From e52ef157b2fb81d53277438df5900c074a0146fd Mon Sep 17 00:00:00 2001 From: Ravi Kiran Chirravuri Date: Sat, 4 Apr 2026 02:13:14 +0000 Subject: [PATCH] feat(plugin): allow tool.execute.after hooks to inject AI-visible messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional `inject` field to the `tool.execute.after` hook output type. When a plugin sets `output.inject`, the messages are persisted as synthetic user messages that the AI sees on the next loop iteration. This enables behavioral enforcement skills (e.g. planning-with-files) that need to continuously remind the AI about workflow rules — something that was previously impossible because the AI forgets after compaction. Changes: - packages/plugin/src/index.ts: Add `inject` field to tool.execute.after output type with JSDoc documentation and usage example - packages/opencode/src/session/prompt.ts: Add `flush` helper that creates synthetic user messages from injected entries, called at all three hook sites (registry tools, MCP tools, subtask tools). System-role injections are wrapped in tags. Closes #17412 --- packages/opencode/src/session/prompt.ts | 42 +++++++++++++++++++++++++ packages/plugin/src/index.ts | 20 ++++++++++++ 2 files changed, 62 insertions(+) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 24996c8d4b29..db7fd0cf2f23 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -385,6 +385,33 @@ NOTE: At any point in time through this workflow you should feel free to ask the return input.messages }) + const flush = Effect.fn("SessionPrompt.flush")(function* ( + inject: Array<{ role: "user" | "system"; text: string }> | undefined, + sessionID: SessionID, + user: { agent: string; model: MessageV2.User["model"] }, + ) { + if (!inject?.length) return + for (const entry of inject) { + const msg: MessageV2.User = { + id: MessageID.ascending(), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: user.agent, + model: user.model, + } + yield* sessions.updateMessage(msg) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID, + type: "text", + text: entry.role === "system" ? `\n${entry.text}\n` : entry.text, + synthetic: true, + } satisfies MessageV2.TextPart) + } + }) + const resolveTools = Effect.fn("SessionPrompt.resolveTools")(function* (input: { agent: Agent.Info model: Provider.Model @@ -466,6 +493,11 @@ 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, ) + yield* flush( + (output as Record).inject, + ctx.sessionID, + { agent: input.agent.name, model: { providerID: input.model.providerID, modelID: ModelID.make(input.model.api.id) } }, + ) return output }), ) @@ -498,6 +530,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args }, result, ) + yield* flush( + (result as Record).inject, + ctx.sessionID, + { agent: input.agent.name, model: { providerID: input.model.providerID, modelID: ModelID.make(input.model.api.id) } }, + ) const textParts: string[] = [] const attachments: Omit[] = [] @@ -683,6 +720,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the { tool: "task", sessionID, callID: part.id, args: taskArgs }, result, ) + yield* flush( + (result as Record | undefined)?.inject, + sessionID, + { agent: lastUser.agent, model: lastUser.model }, + ) assistantMessage.finish = "tool-calls" assistantMessage.time.completed = Date.now() diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 473cac8a9bff..4b9ac7c2952d 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -231,12 +231,32 @@ export interface Hooks { input: { cwd: string; sessionID?: string; callID?: string }, output: { env: Record }, ) => Promise + /** + * Called after a tool finishes execution. Plugins can modify the tool + * result (title, output, metadata) before the AI sees it. + * + * Set `inject` to append messages the AI will see on the **next** loop + * iteration. This enables behavioral enforcement — e.g. reminding the + * AI to update planning files after every edit. + * + * ```ts + * "tool.execute.after": async (input, output) => { + * if (input.tool === "edit") { + * output.inject = [ + * { role: "user", text: "Remember to update progress.md." }, + * ] + * } + * } + * ``` + */ "tool.execute.after"?: ( input: { tool: string; sessionID: string; callID: string; args: any }, output: { title: string output: string metadata: any + /** Messages to inject into the conversation after this tool call. */ + inject?: Array<{ role: "user" | "system"; text: string }> }, ) => Promise "experimental.chat.messages.transform"?: (