diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index aaee2be2feba..44eb56631f42 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -18,6 +18,7 @@ import { InstanceState } from "@/effect/instance-state" import { isOverflow as overflow, usable } from "./overflow" import { makeRuntime } from "@/effect/run-service" import { fn } from "@/util/fn" +import { type Tool as AITool } from "ai" const log = Log.create({ service: "session.compaction" }) @@ -90,6 +91,13 @@ type CompletedCompaction = { summary: string | undefined } +type ResolvedContext = { + agent: Agent.Info + system: string[] + tools: Record + user: MessageV2.User +} + function summaryText(message: MessageV2.WithParts) { const text = message.parts .filter((part): part is MessageV2.TextPart => part.type === "text") @@ -193,6 +201,7 @@ export interface Interface { sessionID: SessionID auto: boolean overflow?: boolean + resolved?: ResolvedContext }) => Effect.Effect<"continue" | "stop"> readonly create: (input: { sessionID: SessionID @@ -346,6 +355,7 @@ export const layer: Layer.Layer< sessionID: SessionID auto: boolean overflow?: boolean + resolved?: ResolvedContext }) { const parent = input.messages.findLast((m) => m.info.id === input.parentID) if (!parent || parent.info.role !== "user") { @@ -379,17 +389,19 @@ export const layer: Layer.Layer< } } - const agent = yield* agents.get("compaction") - const model = agent.model - ? yield* provider.getModel(agent.model.providerID, agent.model.modelID) + const compactionAgent = yield* agents.get("compaction") + const fallbackModel = compactionAgent.model + ? yield* provider.getModel(compactionAgent.model.providerID, compactionAgent.model.modelID) : yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID) + const resolved = input.resolved + const model = fallbackModel const cfg = yield* config.get() const history = compactionPart && messages.at(-1)?.info.id === input.parentID ? messages.slice(0, -1) : messages const prior = completedCompactions(history) - const hidden = new Set(prior.flatMap((item) => [item.userIndex, item.assistantIndex])) const previousSummary = prior.at(-1)?.summary + const hidden = resolved ? undefined : new Set(prior.flatMap((item) => [item.userIndex, item.assistantIndex])) const selected = yield* select({ - messages: history.filter((_, index) => !hidden.has(index)), + messages: hidden ? history.filter((_, index) => !hidden.has(index)) : history, cfg, model, }) @@ -402,10 +414,11 @@ export const layer: Layer.Layer< const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context }) const msgs = structuredClone(selected.head) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) - const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { - stripMedia: true, - toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS, - }) + const modelMessages = yield* MessageV2.toModelMessagesEffect( + msgs, + model, + resolved ? undefined : { stripMedia: true, toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS }, + ) const ctx = yield* InstanceState.context const msg: MessageV2.Assistant = { id: MessageID.ascending(), @@ -439,21 +452,43 @@ export const layer: Layer.Layer< sessionID: input.sessionID, model, }) - const result = yield* processor.process({ - user: userMessage, - agent, - sessionID: input.sessionID, - tools: {}, - system: [], - messages: [ - ...modelMessages, - { - role: "user", - content: [{ type: "text", text: nextPrompt }], - }, - ], - model, - }) + const currentSession = resolved ? yield* session.get(input.sessionID) : undefined + const result = yield* processor.process( + resolved + ? { + user: resolved.user, + agent: resolved.agent, + sessionID: input.sessionID, + permission: currentSession?.permission, + parentSessionID: currentSession?.parentID, + system: resolved.system, + messages: [ + ...modelMessages, + { + role: "user", + content: [{ type: "text", text: nextPrompt }], + }, + ], + tools: resolved.tools, + model, + toolChoice: "none", + } + : { + user: userMessage, + agent: compactionAgent, + sessionID: input.sessionID, + tools: {}, + system: [], + messages: [ + ...modelMessages, + { + role: "user", + content: [{ type: "text", text: nextPrompt }], + }, + ], + model, + }, + ) if (result === "compact") { processor.message.error = new MessageV2.ContextOverflowError({ diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index fb822ff17e8b..85da19db0277 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -363,7 +363,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the model: Provider.Model session: Session.Info tools?: Record - processor: Pick + processor?: Pick bypassAgentCheck: boolean messages: MessageV2.WithParts[] }) { @@ -371,35 +371,40 @@ NOTE: At any point in time through this workflow you should feel free to ask the const tools: Record = {} const run = yield* runner() const promptOps = yield* ops() + // Compaction passes no processor only to build cache-aligned tool definitions. + const processor = input.processor + const messageID = input.processor?.message.id ?? MessageID.ascending() const context = (args: any, options: ToolExecutionOptions): Tool.Context => ({ sessionID: input.session.id, abort: options.abortSignal!, - messageID: input.processor.message.id, + messageID, callID: options.toolCallId, extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps }, agent: input.agent.name, messages: input.messages, - metadata: (val) => - input.processor.updateToolCall(options.toolCallId, (match) => { - if (!["running", "pending"].includes(match.state.status)) return match - return { - ...match, - state: { - title: val.title, - metadata: val.metadata, - status: "running", - input: args, - time: { start: Date.now() }, - }, - } - }), + metadata: processor + ? (val) => + processor.updateToolCall(options.toolCallId, (match) => { + if (!["running", "pending"].includes(match.state.status)) return match + return { + ...match, + state: { + title: val.title, + metadata: val.metadata, + status: "running", + input: args, + time: { start: Date.now() }, + }, + } + }) + : () => Effect.void, ask: (req) => permission .ask({ ...req, sessionID: input.session.id, - tool: { messageID: input.processor.message.id, callID: options.toolCallId }, + tool: { messageID, callID: options.toolCallId }, ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []), }) .pipe(Effect.orDie), @@ -430,7 +435,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the ...attachment, id: PartID.ascending(), sessionID: ctx.sessionID, - messageID: input.processor.message.id, + messageID, })), } yield* plugin.trigger( @@ -438,8 +443,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args }, output, ) - if (options.abortSignal?.aborted) { - yield* input.processor.completeToolCall(options.toolCallId, output) + if (options.abortSignal?.aborted && processor) { + yield* processor.completeToolCall(options.toolCallId, output) } return output }), @@ -513,12 +518,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the ...attachment, id: PartID.ascending(), sessionID: ctx.sessionID, - messageID: input.processor.message.id, + messageID, })), content: result.content, } - if (opts.abortSignal?.aborted) { - yield* input.processor.completeToolCall(opts.toolCallId, output) + if (opts.abortSignal?.aborted && processor) { + yield* processor.completeToolCall(opts.toolCallId, output) } return output }), @@ -529,6 +534,27 @@ NOTE: At any point in time through this workflow you should feel free to ask the return tools }) + const resolveStreamContext = Effect.fn("SessionPrompt.resolveStreamContext")(function* (input: { + agent: Agent.Info + model: Provider.Model + session: Session.Info + processor?: Pick + tools?: Record + bypassAgentCheck: boolean + messages: MessageV2.WithParts[] + }) { + const [resolvedTools, [skills, env, instructions]] = yield* Effect.all([ + resolveTools(input), + Effect.all([ + sys.skills(input.agent), + sys.environment(input.model), + instruction.system().pipe(Effect.orDie), + ]), + ]) + const system = [...env, ...instructions, ...(skills ? [skills] : [])] + return { system, tools: resolvedTools } + }) + const handleSubtask = Effect.fn("SessionPrompt.handleSubtask")(function* (input: { task: MessageV2.SubtaskPart model: Provider.Model @@ -1341,12 +1367,56 @@ NOTE: At any point in time through this workflow you should feel free to ask the } if (task?.type === "compaction") { + const compactionAgentInfo = yield* agents.get("compaction") + const compactionModel = compactionAgentInfo?.model + ? yield* provider.getModel(compactionAgentInfo.model.providerID, compactionAgentInfo.model.modelID) + : model + const originalUser = msgs.findLast( + (m): m is MessageV2.WithParts & { info: MessageV2.User } => + m.info.role === "user" && !m.parts.some((p) => p.type === "compaction"), + ) + const canReusePrefix = + originalUser?.info.format?.type !== "json_schema" && + model.id === compactionModel.id && + model.providerID === compactionModel.providerID + + let resolved: + | { + agent: Agent.Info + system: string[] + tools: Record + user: MessageV2.User + } + | undefined + if (canReusePrefix) { + const originalAgent = originalUser ? yield* agents.get(originalUser.info.agent) : undefined + if (originalAgent && originalUser) { + const bypassAgentCheck = originalUser.parts.some((p) => p.type === "agent") + const ctx = yield* resolveStreamContext({ + agent: originalAgent, + model, + session, + processor: undefined, + tools: originalUser.info.tools, + bypassAgentCheck, + messages: msgs, + }) + resolved = { + agent: originalAgent, + ...ctx, + tools: ctx.tools, + user: originalUser.info, + } + } + } + const result = yield* compaction.process({ messages: msgs, parentID: lastUser.id, sessionID, auto: task.auto, overflow: task.overflow, + resolved, }) if (result === "stop") break continue @@ -1399,28 +1469,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the const lastUserMsg = msgs.findLast((m) => m.info.role === "user") const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false - const tools = yield* resolveTools({ - agent, - session, - model, - tools: lastUser.tools, - processor: handle, - bypassAgentCheck, - messages: msgs, - }) - - if (lastUser.format?.type === "json_schema") { - tools["StructuredOutput"] = createStructuredOutputTool({ - schema: lastUser.format.schema, - onSuccess(output) { - structured = output - }, - }) - } - - if (step === 1) - yield* summary.summarize({ sessionID, messageID: lastUser.id }).pipe(Effect.ignore, Effect.forkIn(scope)) - if (step > 1 && lastFinished) { for (const m of msgs) { if (m.info.role !== "user" || m.info.id <= lastFinished.id) continue @@ -1441,15 +1489,33 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) - const [skills, env, instructions, modelMsgs] = yield* Effect.all([ - sys.skills(agent), - sys.environment(model), - instruction.system().pipe(Effect.orDie), + const [{ tools, system }, modelMsgs] = yield* Effect.all([ + resolveStreamContext({ + agent, + model, + session, + processor: handle, + tools: lastUser.tools, + bypassAgentCheck, + messages: msgs, + }), MessageV2.toModelMessagesEffect(msgs, model), ]) - const system = [...env, ...instructions, ...(skills ? [skills] : [])] + + if (lastUser.format?.type === "json_schema") { + tools["StructuredOutput"] = createStructuredOutputTool({ + schema: lastUser.format.schema, + onSuccess(output) { + structured = output + }, + }) + system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) + } + + if (step === 1) + yield* summary.summarize({ sessionID, messageID: lastUser.id }).pipe(Effect.ignore, Effect.forkIn(scope)) + const format = lastUser.format ?? { type: "text" as const } - if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) const result = yield* handle.process({ user: lastUser, agent, @@ -1775,6 +1841,7 @@ export function createStructuredOutputTool(input: { }, }) } + const bashRegex = /!`([^`]+)`/g // Match [Image N] as single token, quoted strings, or non-space sequences const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi