From ae85921747cc272ce5478691defc1c8ef2f1a7e0 Mon Sep 17 00:00:00 2001 From: Alex <56285544+doomsday616@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:18:13 +0800 Subject: [PATCH 1/3] feat: add inject field to tool.execute.after hook output --- packages/plugin/src/index.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index a264cf5aaf94..9208bcfda64a 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -220,12 +220,33 @@ 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. + * + * Use `inject` to append extra messages that the AI will see on the + * **next** loop iteration. This is the primary mechanism for behavioral + * enforcement — e.g. reminding the AI to update planning files after + * every edit, or warning about security policy violations. + * + * ```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"?: ( From 9f00e28a7a9f20d80d94471a8474db9a809828b1 Mon Sep 17 00:00:00 2001 From: Alex <56285544+doomsday616@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:18:36 +0800 Subject: [PATCH 2/3] feat: flush injected messages from tool.execute.after hooks --- packages/opencode/src/session/prompt.ts | 57 +++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index dd74b83f50f2..9d4cdb6d2614 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -481,6 +481,13 @@ export namespace SessionPrompt { }, result, ) + if ((result as any)?.inject?.length) { + await flushInjectedMessages( + (result as any).inject, + sessionID, + { agent: lastUser.agent, model: lastUser.model }, + ) + } assistantMessage.finish = "tool-calls" assistantMessage.time.completed = Date.now() await Session.updateMessage(assistantMessage) @@ -768,6 +775,42 @@ export namespace SessionPrompt { return Provider.defaultModel() } + /** + * Persist injected messages from a `tool.execute.after` hook so the AI + * sees them on the next loop iteration. Each entry becomes a synthetic + * user message with a single text part, mirroring the existing pattern + * used for subtask summary messages (see loop body). + */ + async function flushInjectedMessages( + inject: Array<{ role: "user" | "system"; text: string }> | undefined, + sessionID: string, + lastUser: { agent: string; model: { providerID: string; modelID: string } }, + ) { + if (!inject?.length) return + for (const msg of inject) { + const userMsg: MessageV2.User = { + id: MessageID.ascending(), + sessionID: SessionID.make(sessionID), + role: "user", + time: { created: Date.now() }, + agent: lastUser.agent, + model: lastUser.model, + } + await Session.updateMessage(userMsg) + await Session.updatePart({ + id: PartID.ascending(), + messageID: userMsg.id, + sessionID: SessionID.make(sessionID), + type: "text", + text: + msg.role === "system" + ? `\n${msg.text}\n` + : msg.text, + synthetic: true, + } satisfies MessageV2.TextPart) + } + } + /** @internal Exported for testing */ export async function resolveTools(input: { agent: Agent.Info @@ -858,6 +901,13 @@ export namespace SessionPrompt { }, output, ) + if ((output as any).inject?.length) { + await flushInjectedMessages( + (output as any).inject, + ctx.sessionID, + { agent: input.agent.name, model: { providerID: input.model.providerID, modelID: input.model.api.id } }, + ) + } return output }, }) @@ -905,6 +955,13 @@ export namespace SessionPrompt { }, result, ) + if ((result as any).inject?.length) { + await flushInjectedMessages( + (result as any).inject, + ctx.sessionID, + { agent: input.agent.name, model: { providerID: input.model.providerID, modelID: input.model.api.id } }, + ) + } const textParts: string[] = [] const attachments: Omit[] = [] From 4e19d1474fd793e1e79876e16e9a5cc84fdd9b24 Mon Sep 17 00:00:00 2001 From: Alex <56285544+doomsday616@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:22:55 +0800 Subject: [PATCH 3/3] fix: use branded ModelID type in flushInjectedMessages call sites --- packages/opencode/src/session/prompt.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 9d4cdb6d2614..4e660850d1b1 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -784,7 +784,7 @@ export namespace SessionPrompt { async function flushInjectedMessages( inject: Array<{ role: "user" | "system"; text: string }> | undefined, sessionID: string, - lastUser: { agent: string; model: { providerID: string; modelID: string } }, + lastUser: { agent: string; model: MessageV2.User["model"] }, ) { if (!inject?.length) return for (const msg of inject) { @@ -905,7 +905,7 @@ export namespace SessionPrompt { await flushInjectedMessages( (output as any).inject, ctx.sessionID, - { agent: input.agent.name, model: { providerID: input.model.providerID, modelID: input.model.api.id } }, + { agent: input.agent.name, model: { providerID: input.model.providerID, modelID: ModelID.make(input.model.api.id) } }, ) } return output @@ -959,7 +959,7 @@ export namespace SessionPrompt { await flushInjectedMessages( (result as any).inject, ctx.sessionID, - { agent: input.agent.name, model: { providerID: input.model.providerID, modelID: input.model.api.id } }, + { agent: input.agent.name, model: { providerID: input.model.providerID, modelID: ModelID.make(input.model.api.id) } }, ) }