From d972aa9d83121186b5ec3d63092351d1296913a0 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 1 Jul 2026 20:50:10 -0400 Subject: [PATCH] feat(core): tool execute hooks, session instructions, synthetic endpoint - V2 tool execute hooks: add `ctx.tool.execute.before/after` plugin API, core ToolHooks service, and registry wiring so hosted/local tool calls run registered before/after hooks; provider-executed calls are excluded. - Session synthetic endpoint: add `POST /api/session/:sessionID/synthetic` with text/description/metadata plus regenerated Promise/Effect/JS client surfaces. - SessionInstructions service: read tool discovers nearby AGENTS.md walking up to the Location root (exclusive) and injects them as durable synthetic instructions, with lazy history dedup of prior claims. - to-llm-message: stop forwarding synthetic message metadata to the model so bookkeeping annotations stay model-hidden. - schema changelog entry for synthetic metadata and the metadata leak fix. --- .../client/src/generated-effect/client.ts | 116 +++++---- packages/client/src/generated/client.ts | 14 + packages/client/src/generated/types.ts | 24 ++ packages/core/src/location-services.ts | 2 + packages/core/src/plugin.ts | 2 + packages/core/src/plugin/host.ts | 43 +++ packages/core/src/session.ts | 2 + packages/core/src/session/instructions.ts | 101 ++++++++ packages/core/src/session/message-updater.ts | 1 + .../core/src/session/runner/to-llm-message.ts | 2 +- packages/core/src/tool/hooks.ts | 92 +++++++ packages/core/src/tool/read.ts | 33 ++- packages/core/src/tool/registry.ts | 59 ++++- packages/core/test/plugin.test.ts | 66 +++++ packages/core/test/plugin/fixture.ts | 2 + packages/core/test/plugin/host.ts | 4 + .../core/test/session-instructions.test.ts | 245 ++++++++++++++++++ packages/core/test/session-projector.test.ts | 5 + .../plugin/src/v2/effect/generated/api.ts | 115 ++++---- packages/plugin/src/v2/effect/index.ts | 2 +- packages/plugin/src/v2/effect/tool.ts | 25 +- packages/protocol/src/groups/session.ts | 20 ++ packages/schema/src/session-event.ts | 1 + packages/server/src/handlers/session.ts | 21 ++ specs/v2/schema-changelog.md | 18 ++ 25 files changed, 896 insertions(+), 119 deletions(-) create mode 100644 packages/core/src/session/instructions.ts create mode 100644 packages/core/src/tool/hooks.ts create mode 100644 packages/core/test/session-instructions.test.ts diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index d05a82cb20..80f38d287c 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -164,23 +164,36 @@ const Endpoint4_9 = (raw: RawClient["server.session"]) => (input: Endpoint4_9Inp payload: { id: input["id"], skill: input["skill"], resume: input["resume"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_10Request = Parameters[0] -type Endpoint4_10Input = { readonly sessionID: Endpoint4_10Request["params"]["sessionID"] } +type Endpoint4_10Request = Parameters[0] +type Endpoint4_10Input = { + readonly sessionID: Endpoint4_10Request["params"]["sessionID"] + readonly text: Endpoint4_10Request["payload"]["text"] + readonly description?: Endpoint4_10Request["payload"]["description"] + readonly metadata?: Endpoint4_10Request["payload"]["metadata"] +} const Endpoint4_10 = (raw: RawClient["server.session"]) => (input: Endpoint4_10Input) => - raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + raw["session.synthetic"]({ + params: { sessionID: input["sessionID"] }, + payload: { text: input["text"], description: input["description"], metadata: input["metadata"] }, + }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_11Request = Parameters[0] +type Endpoint4_11Request = Parameters[0] type Endpoint4_11Input = { readonly sessionID: Endpoint4_11Request["params"]["sessionID"] } const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11Input) => + raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint4_12Request = Parameters[0] +type Endpoint4_12Input = { readonly sessionID: Endpoint4_12Request["params"]["sessionID"] } +const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) => raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_12Request = Parameters[0] -type Endpoint4_12Input = { - readonly sessionID: Endpoint4_12Request["params"]["sessionID"] - readonly messageID: Endpoint4_12Request["payload"]["messageID"] - readonly files?: Endpoint4_12Request["payload"]["files"] +type Endpoint4_13Request = Parameters[0] +type Endpoint4_13Input = { + readonly sessionID: Endpoint4_13Request["params"]["sessionID"] + readonly messageID: Endpoint4_13Request["payload"]["messageID"] + readonly files?: Endpoint4_13Request["payload"]["files"] } -const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) => +const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) => raw["session.revert.stage"]({ params: { sessionID: input["sessionID"] }, payload: { messageID: input["messageID"], files: input["files"] }, @@ -189,42 +202,42 @@ const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12I Effect.map((value) => value.data), ) -type Endpoint4_13Request = Parameters[0] -type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] } -const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) => - raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) - -type Endpoint4_14Request = Parameters[0] +type Endpoint4_14Request = Parameters[0] type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] } const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) => - raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_15Request = Parameters[0] +type Endpoint4_15Request = Parameters[0] type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] } const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) => + raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint4_16Request = Parameters[0] +type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] } +const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) => raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint4_16Request = Parameters[0] -type Endpoint4_16Input = { - readonly sessionID: Endpoint4_16Request["params"]["sessionID"] - readonly limit?: Endpoint4_16Request["query"]["limit"] - readonly after?: Endpoint4_16Request["query"]["after"] +type Endpoint4_17Request = Parameters[0] +type Endpoint4_17Input = { + readonly sessionID: Endpoint4_17Request["params"]["sessionID"] + readonly limit?: Endpoint4_17Request["query"]["limit"] + readonly after?: Endpoint4_17Request["query"]["after"] } -const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) => +const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) => raw["session.history"]({ params: { sessionID: input["sessionID"] }, query: { limit: input["limit"], after: input["after"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_17Request = Parameters[0] -type Endpoint4_17Input = { - readonly sessionID: Endpoint4_17Request["params"]["sessionID"] - readonly after?: Endpoint4_17Request["query"]["after"] +type Endpoint4_18Request = Parameters[0] +type Endpoint4_18Input = { + readonly sessionID: Endpoint4_18Request["params"]["sessionID"] + readonly after?: Endpoint4_18Request["query"]["after"] } -const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) => +const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) => Stream.unwrap( raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe( Effect.mapError(mapClientError), @@ -232,22 +245,22 @@ const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17I ), ) -type Endpoint4_18Request = Parameters[0] -type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] } -const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) => - raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) - -type Endpoint4_19Request = Parameters[0] +type Endpoint4_19Request = Parameters[0] type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] } const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) => + raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint4_20Request = Parameters[0] +type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] } +const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) => raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_20Request = Parameters[0] -type Endpoint4_20Input = { - readonly sessionID: Endpoint4_20Request["params"]["sessionID"] - readonly messageID: Endpoint4_20Request["params"]["messageID"] +type Endpoint4_21Request = Parameters[0] +type Endpoint4_21Input = { + readonly sessionID: Endpoint4_21Request["params"]["sessionID"] + readonly messageID: Endpoint4_21Request["params"]["messageID"] } -const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) => +const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) => raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), @@ -264,17 +277,18 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({ rename: Endpoint4_7(raw), prompt: Endpoint4_8(raw), skill: Endpoint4_9(raw), - compact: Endpoint4_10(raw), - wait: Endpoint4_11(raw), - revertStage: Endpoint4_12(raw), - revertClear: Endpoint4_13(raw), - revertCommit: Endpoint4_14(raw), - context: Endpoint4_15(raw), - history: Endpoint4_16(raw), - events: Endpoint4_17(raw), - interrupt: Endpoint4_18(raw), - background: Endpoint4_19(raw), - message: Endpoint4_20(raw), + synthetic: Endpoint4_10(raw), + compact: Endpoint4_11(raw), + wait: Endpoint4_12(raw), + revertStage: Endpoint4_13(raw), + revertClear: Endpoint4_14(raw), + revertCommit: Endpoint4_15(raw), + context: Endpoint4_16(raw), + history: Endpoint4_17(raw), + events: Endpoint4_18(raw), + interrupt: Endpoint4_19(raw), + background: Endpoint4_20(raw), + message: Endpoint4_21(raw), }) type Endpoint5_0Request = Parameters[0] diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index 9f03e0be4f..d1e7225bed 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -25,6 +25,8 @@ import type { SessionPromptOutput, SessionSkillInput, SessionSkillOutput, + SessionSyntheticInput, + SessionSyntheticOutput, SessionCompactInput, SessionCompactOutput, SessionWaitInput, @@ -461,6 +463,18 @@ export function make(options: ClientOptions) { }, requestOptions, ), + synthetic: (input: SessionSyntheticInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/synthetic`, + body: { text: input["text"], description: input["description"], metadata: input["metadata"] }, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), compact: (input: SessionCompactInput, requestOptions?: RequestOptions) => request( { diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index d2e68c13b5..1999704bcc 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -584,6 +584,27 @@ export type SessionSkillInput = { export type SessionSkillOutput = void +export type SessionSyntheticInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly text: { + readonly text: string + readonly description?: string | null + readonly metadata?: { readonly [x: string]: JsonValue } + }["text"] + readonly description?: { + readonly text: string + readonly description?: string | null + readonly metadata?: { readonly [x: string]: JsonValue } + }["description"] + readonly metadata?: { + readonly text: string + readonly description?: string | null + readonly metadata?: { readonly [x: string]: JsonValue } + }["metadata"] +} + +export type SessionSyntheticOutput = void + export type SessionCompactInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } export type SessionCompactOutput = void @@ -934,6 +955,7 @@ export type SessionHistoryOutput = { readonly messageID: string readonly text: string readonly description?: string + readonly metadata?: { readonly [x: string]: JsonValue } } } | { @@ -1428,6 +1450,7 @@ export type SessionEventsOutput = readonly messageID: string readonly text: string readonly description?: string + readonly metadata?: { readonly [x: string]: unknown } } } | { @@ -3530,6 +3553,7 @@ export type EventSubscribeOutput = readonly messageID: string readonly text: string readonly description?: string + readonly metadata?: { readonly [x: string]: unknown } } } | { diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 9cc3848544..0d913c10fb 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -38,6 +38,7 @@ import { SkillGuidance } from "./skill/guidance" import { Snapshot } from "./snapshot" import { SystemContextBuiltIns } from "./system-context/builtins" import { SystemContextRegistry } from "./system-context/registry" +import { SessionInstructions } from "./session/instructions" import { BuiltInTools } from "./tool/builtins" import { McpTool } from "./tool/mcp" import { ReadToolFileSystem } from "./tool/read-filesystem" @@ -85,6 +86,7 @@ export const locationServices = LayerNode.group([ ReadToolFileSystem.node, BuiltInTools.node, McpTool.node, + SessionInstructions.node, SessionRunnerModel.node, SessionCompaction.node, SessionTitle.node, diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 29ab721b83..dd80647a73 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -18,6 +18,7 @@ import { Reference } from "./reference" import { SkillV2 } from "./skill" import { State } from "./state" import { ToolRegistry } from "./tool/registry" +import { ToolHooks } from "./tool/hooks" export const ID = Plugin.ID export type ID = typeof ID.Type @@ -165,6 +166,7 @@ export const node = makeLocationNode({ Reference.node, SkillV2.node, ToolRegistry.toolsNode, + ToolHooks.node, PluginRuntime.node, ], }) diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 6c1d6a67ae..1ab46b794d 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -17,6 +17,7 @@ import { Reference } from "../reference" import { AbsolutePath, type DeepMutable } from "../schema" import { SkillV2 } from "../skill" import { Tools } from "../tool/tools" +import { ToolHooks } from "../tool/hooks" import { WorkspaceV2 } from "../workspace" const mutable = (value: T) => value as DeepMutable @@ -31,6 +32,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int const reference = yield* Reference.Service const skill = yield* SkillV2.Service const tools = yield* Tools.Service + const toolHooks = yield* ToolHooks.Service const runtime = yield* PluginRuntime.Service const locationInfo = () => new Location.Info({ @@ -247,6 +249,47 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }, tool: { register: (input) => tools.register(input), + execute: { + before: (callback) => + toolHooks.hook.before((event) => { + const output = { + tool: event.tool, + sessionID: event.sessionID, + agent: event.agent, + assistantMessageID: event.assistantMessageID, + toolCallID: event.toolCallID, + input: event.input, + } + const result = callback(output) + return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( + Effect.tap(() => Effect.sync(() => (event.input = output.input))), + ) + }), + after: (callback) => + toolHooks.hook.after((event) => { + const output = { + tool: event.tool, + sessionID: event.sessionID, + agent: event.agent, + assistantMessageID: event.assistantMessageID, + toolCallID: event.toolCallID, + input: event.input, + result: event.result, + output: event.output, + outputPaths: event.outputPaths, + } + const result = callback(output) + return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( + Effect.tap(() => + Effect.sync(() => { + event.result = output.result + event.output = output.output + event.outputPaths = output.outputPaths + }), + ), + ) + }), + }, }, session: { create: (input) => diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 739cc1467e..bbd6e2cc4b 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -199,6 +199,7 @@ export interface Interface { sessionID: SessionSchema.ID text: string description?: string + metadata?: Record }) => Effect.Effect readonly revert: { readonly stage: (input: { @@ -547,6 +548,7 @@ const layer = Layer.effect( timestamp: yield* DateTime.now, text: input.text, description: input.description, + metadata: input.metadata, }) yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid) }), diff --git a/packages/core/src/session/instructions.ts b/packages/core/src/session/instructions.ts new file mode 100644 index 0000000000..3f5b8455fe --- /dev/null +++ b/packages/core/src/session/instructions.ts @@ -0,0 +1,101 @@ +export * as SessionInstructions from "./instructions" + +import { Context, DateTime, Effect, Layer, Option, Ref, Schema } from "effect" +import { makeLocationNode } from "../effect/app-node" +import { EventV2 } from "../event" +import { FSUtil } from "../fs-util" +import { SessionEvent } from "./event" +import { MessageDecodeError } from "./error" +import { SessionMessage } from "./message" +import { SessionSchema } from "./schema" +import { SessionStore } from "./store" + +const InjectedMetadata = Schema.Struct({ + instruction: Schema.Struct({ paths: Schema.Array(Schema.String) }), +}) + +export interface Interface { + readonly load: (input: { + readonly sessionID: SessionSchema.ID + readonly paths: ReadonlyArray + }) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/SessionInstructions") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const events = yield* EventV2.Service + const fs = yield* FSUtil.Service + const store = yield* SessionStore.Service + // Same-turn parallel reads settle concurrently, so an in-memory claim guards each + // Session/path pair before any filesystem work. The durable history check below covers + // paths injected in earlier turns after this Location layer was reopened. + const injected = yield* Ref.make>>(new Map()) + + const load = Effect.fn("SessionInstructions.load")(function* (input: { + readonly sessionID: SessionSchema.ID + readonly paths: ReadonlyArray + }) { + const claimed = yield* Ref.modify(injected, (map) => { + const existing = map.get(input.sessionID) ?? new Set() + const newlyClaimed = input.paths.filter((path) => !existing.has(path)) + if (newlyClaimed.length === 0) return [newlyClaimed, map] + const next = new Map(map) + next.set(input.sessionID, new Set([...existing, ...newlyClaimed])) + return [newlyClaimed, next] + }) + if (claimed.length === 0) return + const alreadyInjected = yield* previouslyInjected(store, input.sessionID) + const toInject = claimed.filter((path) => !alreadyInjected.has(path)) + if (toInject.length === 0) return + const files = yield* Effect.forEach( + toInject, + (path) => + fs.readFileStringSafe(path).pipe( + Effect.map((content) => (content === undefined ? undefined : { path, content })), + ), + { concurrency: "unbounded" }, + ) + const readable = files.filter((file): file is { path: string; content: string } => file !== undefined) + if (readable.length === 0) return + // Publish directly rather than through SessionV2.synthetic: a Location-scoped layer + // cannot depend on SessionV2 (it routes through LocationServiceMap, forming a type + // cycle with this node). The durable publish is what makes the synthetic visible on + // the next projected history reload. The dedup ledger lives on the synthetic message + // metadata so it survives across Location layer restarts. + yield* events.publish(SessionEvent.Synthetic, { + sessionID: input.sessionID, + messageID: SessionMessage.ID.create(), + timestamp: yield* DateTime.now, + text: readable.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n"), + description: `Loaded ${readable.map((file) => file.path).join(", ")}`, + metadata: { instruction: { paths: readable.map((file) => file.path) } }, + }) + }) + + return Service.of({ load }) + }), +) + +function previouslyInjected(store: SessionStore.Interface, sessionID: SessionSchema.ID) { + return Effect.gen(function* () { + const history = yield* store.context(sessionID) + return new Set( + history + .filter((message): message is SessionMessage.Synthetic => message.type === "synthetic") + .flatMap( + (message) => + Option.getOrUndefined(Schema.decodeUnknownOption(InjectedMetadata)(message.metadata))?.instruction.paths ?? + [], + ), + ) + }) +} + +export const node = makeLocationNode({ + name: "session-instructions", + layer, + deps: [EventV2.node, FSUtil.node, SessionStore.node], +}) diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 5b63031c95..e12c24162c 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -154,6 +154,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { sessionID: event.data.sessionID, text: event.data.text, description: event.data.description, + metadata: event.data.metadata, id: event.data.messageID, type: "synthetic", time: { created: event.data.timestamp }, diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index 17e13dc767..8ddd7cce5c 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -130,7 +130,7 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] }), ] case "synthetic": - return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })] + return [Message.make({ id: message.id, role: "user", content: message.text })] case "skill": return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })] case "system": diff --git a/packages/core/src/tool/hooks.ts b/packages/core/src/tool/hooks.ts new file mode 100644 index 0000000000..d71be015dc --- /dev/null +++ b/packages/core/src/tool/hooks.ts @@ -0,0 +1,92 @@ +export * as ToolHooks from "./hooks" + +import { makeLocationNode } from "../effect/app-node" +import { AgentV2 } from "../agent" +import { SessionMessage } from "../session/message" +import { SessionSchema } from "../session/schema" +import { State } from "../state" +import { Context, Effect, Layer, Scope } from "effect" +import type { ToolOutput, ToolResultValue } from "@opencode-ai/llm" + +export interface BeforeEvent { + readonly tool: string + readonly sessionID: SessionSchema.ID + readonly agent: AgentV2.ID + readonly assistantMessageID: SessionMessage.ID + readonly toolCallID: string + input: unknown +} + +export interface AfterEvent { + readonly tool: string + readonly sessionID: SessionSchema.ID + readonly agent: AgentV2.ID + readonly assistantMessageID: SessionMessage.ID + readonly toolCallID: string + readonly input: unknown + result: ToolResultValue + output?: ToolOutput + outputPaths?: ReadonlyArray +} + +export interface Interface { + readonly hook: { + readonly before: ( + callback: (event: BeforeEvent) => Effect.Effect | void, + ) => Effect.Effect + readonly after: ( + callback: (event: AfterEvent) => Effect.Effect | void, + ) => Effect.Effect + } + readonly runBefore: (event: BeforeEvent) => Effect.Effect + readonly runAfter: (event: AfterEvent) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/ToolHooks") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + let beforeHooks: ((event: BeforeEvent) => Effect.Effect | void)[] = [] + let afterHooks: ((event: AfterEvent) => Effect.Effect | void)[] = [] + + const register = ( + hooks: () => ((event: Event) => Effect.Effect | void)[], + update: (hooks: ((event: Event) => Effect.Effect | void)[]) => void, + ) => + Effect.fn("ToolHooks.hook")(function* (callback: (event: Event) => Effect.Effect | void) { + const scope = yield* Scope.Scope + let active = true + update([...hooks(), callback]) + const dispose = Effect.sync(() => { + if (!active) return + active = false + update(hooks().filter((item) => item !== callback)) + }) + yield* Scope.addFinalizer(scope, dispose) + return { dispose } + }) + + const run = Effect.fnUntraced(function* ( + hooks: readonly ((event: Event) => Effect.Effect | void)[], + event: Event, + ) { + for (const hook of hooks) { + const result = hook(event) + if (Effect.isEffect(result)) yield* result + } + return event + }) + + return Service.of({ + hook: { + before: register(() => beforeHooks, (next) => (beforeHooks = next)), + after: register(() => afterHooks, (next) => (afterHooks = next)), + }, + runBefore: (event) => run(beforeHooks, event), + runAfter: (event) => run(afterHooks, event), + }) + }), +) + +export const node = makeLocationNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 6961a86091..090387ddda 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -1,12 +1,16 @@ export * as ReadTool from "./read" +import { dirname } from "path" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Layer, Schema } from "effect" import { makeLocationNode } from "../effect/app-node" import { FileSystem } from "../filesystem" +import { FSUtil } from "../fs-util" import { Image } from "../image" +import { Location } from "../location" import { LocationMutation } from "../location-mutation" import { PermissionV2 } from "../permission" +import { SessionInstructions } from "../session/instructions" import { AbsolutePath } from "../schema" import { ReadToolFileSystem } from "./read-filesystem" import { ToolRegistry } from "./registry" @@ -14,6 +18,7 @@ import { Tool } from "./tool" import { Tools } from "./tools" export const name = "read" +const FILENAME = "AGENTS.md" const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]) const LocationInput = Schema.Struct({ path: Schema.String, @@ -34,6 +39,9 @@ const layer = Layer.effectDiscard( const mutation = yield* LocationMutation.Service const image = yield* Image.Service const permission = yield* PermissionV2.Service + const sessionInstructions = yield* SessionInstructions.Service + const fs = yield* FSUtil.Service + const location = yield* Location.Service yield* tools .register({ @@ -83,6 +91,20 @@ const layer = Layer.effectDiscard( offset: input.offset, limit: input.limit, }) + // After a successful file content read (not directory listings), discover + // nearby AGENTS.md walking up to the Location root exclusive and inject them + // as durable synthetic instructions. Discovery failures never fail the read. + yield* Effect.gen(function* () { + if (target.externalDirectory !== undefined) return + const resolved = FSUtil.resolve(target.canonical) + const root = FSUtil.resolve(location.directory) + // up() searches its stop directory, so the Location-root AGENTS.md (already + // supplied by the core/instructions baseline) is dropped by the dirname filter. + const discovered = yield* fs.up({ targets: [FILENAME], start: dirname(resolved), stop: root }) + const candidates = discovered.map(FSUtil.resolve).filter((file) => dirname(file) !== root) + if (candidates.length === 0) return + yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates }) + }).pipe(Effect.catch(() => Effect.void), Effect.catchDefect(() => Effect.void)) if ("encoding" in content && content.encoding === "base64" && SUPPORTED_IMAGE_MIMES.has(content.mime)) { return yield* image .normalize(resource, { ...content, encoding: "base64" }) @@ -113,5 +135,14 @@ const layer = Layer.effectDiscard( export const node = makeLocationNode({ name: "tool/read", layer, - deps: [ToolRegistry.node, ReadToolFileSystem.node, LocationMutation.node, Image.node, PermissionV2.node], + deps: [ + ToolRegistry.node, + ReadToolFileSystem.node, + LocationMutation.node, + Image.node, + PermissionV2.node, + SessionInstructions.node, + FSUtil.node, + Location.node, + ], }) diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 1014270a74..1dc7478c11 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -10,6 +10,7 @@ import { ToolOutputStore } from "../tool-output-store" import { Wildcard } from "../util/wildcard" import { definition, permission, registrationEntries, settle, type AnyTool, type RegistrationError } from "./tool" import { Tools } from "./tools" +import { ToolHooks } from "./hooks" import { makeLocationNode } from "../effect/app-node" export type ExecuteInput = { @@ -47,6 +48,7 @@ const registryLayer = Layer.effect( Service, Effect.gen(function* () { const resources = yield* ToolOutputStore.Service + const toolHooks = yield* ToolHooks.Service type Registration = { readonly identity: object; readonly tool: AnyTool } const local = new Map>() @@ -61,7 +63,17 @@ const registryLayer = Layer.effect( } if (advertised && registration.identity !== advertised) return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } } - const pending = yield* settle(registration.tool, input.call, { + // Hooks fire only for hosted/local tools; provider-executed calls never reach settleWith. + const beforeEvent: ToolHooks.BeforeEvent = { + tool: input.call.name, + sessionID: input.sessionID, + agent: input.agent, + assistantMessageID: input.assistantMessageID, + toolCallID: input.call.id, + input: input.call.input, + } + yield* toolHooks.runBefore(beforeEvent) + const pending = yield* settle(registration.tool, { ...input.call, input: beforeEvent.input }, { sessionID: input.sessionID, agent: input.agent, assistantMessageID: input.assistantMessageID, @@ -72,15 +84,38 @@ const registryLayer = Layer.effect( Effect.succeed({ result: { type: "error" as const, value: failure.message } }), ), ) - if ("result" in pending) return pending - const output = pending.output - const bounded = yield* resources.bound({ sessionID: input.sessionID, toolCallID: input.call.id, output }) - const result = ToolOutput.toResultValue(bounded.output) - if (result.type === "error") - return bounded.outputPaths.length > 0 ? { result, outputPaths: bounded.outputPaths } : { result } - return bounded.outputPaths.length > 0 - ? { result, output: bounded.output, outputPaths: bounded.outputPaths } - : { result, output: bounded.output } + let settlement: Settlement + if ("result" in pending) { + settlement = pending + } else { + const bounded = yield* resources.bound({ sessionID: input.sessionID, toolCallID: input.call.id, output: pending.output }) + const result = ToolOutput.toResultValue(bounded.output) + settlement = + result.type === "error" + ? bounded.outputPaths.length > 0 + ? { result, outputPaths: bounded.outputPaths } + : { result } + : bounded.outputPaths.length > 0 + ? { result, output: bounded.output, outputPaths: bounded.outputPaths } + : { result, output: bounded.output } + } + const afterEvent: ToolHooks.AfterEvent = { + tool: input.call.name, + sessionID: input.sessionID, + agent: input.agent, + assistantMessageID: input.assistantMessageID, + toolCallID: input.call.id, + input: beforeEvent.input, + result: settlement.result, + output: settlement.output, + outputPaths: settlement.outputPaths, + } + yield* toolHooks.runAfter(afterEvent) + return { + result: afterEvent.result, + ...(afterEvent.output !== undefined ? { output: afterEvent.output } : {}), + ...(afterEvent.outputPaths !== undefined ? { outputPaths: afterEvent.outputPaths } : {}), + } }) return Service.of({ @@ -143,11 +178,11 @@ function whollyDisabled(action: string, rules: PermissionV2.Ruleset) { export const node = makeLocationNode({ service: Service, layer, - deps: [ToolOutputStore.node], + deps: [ToolOutputStore.node, ToolHooks.node], }) export const toolsNode = makeLocationNode({ service: Tools.Service, layer, - deps: [ToolOutputStore.node], + deps: [ToolOutputStore.node, ToolHooks.node], }) diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index bbd332a124..27f1d04061 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -3,6 +3,8 @@ import { Effect, Exit, Fiber, Schema } from "effect" import { define } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" import { PluginV2 } from "@opencode-ai/core/plugin" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionMessage } from "@opencode-ai/core/session/message" import { Tool } from "@opencode-ai/core/tool/tool" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { testEffect } from "./lib/effect" @@ -102,4 +104,68 @@ describe("PluginV2", () => { ) }), ) + + it.effect("fires before/after tool hooks with mutable events around settlement", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const registry = yield* ToolRegistry.Service + const executed: unknown[] = [] + const seen: { + before?: unknown + after?: { input: unknown; result: unknown; output: unknown } + } = {} + + const plugin = define({ + id: "tool-hooks", + effect: (ctx) => + Effect.gen(function* () { + yield* ctx.tool + .register({ + echo: Tool.make({ + description: "Echo", + input: Schema.Struct({ text: Schema.String }), + output: Schema.Struct({ text: Schema.String }), + execute: ({ text }) => Effect.sync(() => executed.push({ text })).pipe(Effect.as({ text })), + }), + }) + .pipe(Effect.orDie) + + yield* ctx.tool.execute + .before((event) => { + seen.before = event.input + event.input = { text: "before-mutated" } + }) + .pipe(Effect.asVoid) + + yield* ctx.tool.execute + .after((event) => { + seen.after = { input: event.input, result: event.result, output: event.output } + event.result = { type: "text", value: "after-mutated" } + event.output = { structured: { rewritten: true }, content: [] } + }) + .pipe(Effect.asVoid) + }), + }) + + yield* plugins.add(PluginV2.ID.make(plugin.id), plugin.effect) + + const materialized = yield* registry.materialize({ model: testModel }) + const settlement = yield* materialized.settle({ + sessionID: SessionV2.ID.make("ses_hooks"), + agent: AgentV2.ID.make("build"), + assistantMessageID: SessionMessage.ID.make("msg_hooks"), + call: { type: "tool-call", id: "call-hooks", name: "echo", input: { text: "original" } }, + }) + + expect(seen.before).toEqual({ text: "original" }) + expect(executed).toEqual([{ text: "before-mutated" }]) + expect(seen.after).toEqual({ + input: { text: "before-mutated" }, + result: { type: "json", value: { text: "before-mutated" } }, + output: { structured: { text: "before-mutated" }, content: [] }, + }) + expect(settlement.result).toEqual({ type: "text", value: "after-mutated" }) + expect(settlement.output).toEqual({ structured: { rewritten: true }, content: [] }) + }), + ) }) diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index 900e042b4b..ea6e65bb64 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -16,6 +16,7 @@ import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" import { Reference } from "@opencode-ai/core/reference" import { SkillV2 } from "@opencode-ai/core/skill" +import { ToolHooks } from "@opencode-ai/core/tool/hooks" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { Effect, Layer } from "effect" import { tempLocationLayer } from "../fixture/location" @@ -47,6 +48,7 @@ export const PluginTestLayer = AppNodeBuilder.build( PluginRuntime.node, Reference.node, SkillV2.node, + ToolHooks.node, ToolRegistry.toolsNode, ]), [ diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index bca8012920..e572d9b624 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -52,6 +52,10 @@ export function host(overrides: Overrides = {}): PluginContext { }, tool: overrides.tool ?? { register: () => Effect.die("unused tool.register"), + execute: { + before: () => Effect.die("unused tool.execute.before"), + after: () => Effect.die("unused tool.execute.after"), + }, }, session: overrides.session ?? { create: () => Effect.die("unused session.create"), diff --git a/packages/core/test/session-instructions.test.ts b/packages/core/test/session-instructions.test.ts new file mode 100644 index 0000000000..f044bbadc0 --- /dev/null +++ b/packages/core/test/session-instructions.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { DateTime, Effect, Layer } from "effect" +import { Message, Model } from "@opencode-ai/llm" +import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat" +import { AgentV2 } from "@opencode-ai/core/agent" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Config } from "@opencode-ai/core/config" +import { Database } from "@opencode-ai/core/database/database" +import { EventV2 } from "@opencode-ai/core/event" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Global } from "@opencode-ai/core/global" +import { Image } from "@opencode-ai/core/image" +import { Location } from "@opencode-ai/core/location" +import { LocationMutation } from "@opencode-ai/core/location-mutation" +import { ModelV2 } from "@opencode-ai/core/model" +import { PermissionV2 } from "@opencode-ai/core/permission" +import { ProjectV2 } from "@opencode-ai/core/project" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ReadTool } from "@opencode-ai/core/tool/read" +import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem" +import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionExecution } from "@opencode-ai/core/session/execution" +import { SessionInstructions } from "@opencode-ai/core/session/instructions" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionStore } from "@opencode-ai/core/session/store" +import { SessionV2 } from "@opencode-ai/core/session" +import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message" +import { ToolHooks } from "@opencode-ai/core/tool/hooks" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { tempLocationLayer } from "./fixture/location" +import { testEffect } from "./lib/effect" +import { settleTool, testModel } from "./lib/tool" + +const projects = Layer.succeed( + ProjectV2.Service, + ProjectV2.Service.of({ + resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }), + directories: () => Effect.succeed([]), + commit: () => Effect.void, + }), +) +const permission = Layer.succeed( + PermissionV2.Service, + PermissionV2.Service.of({ + assert: () => Effect.void, + ask: () => Effect.die("unused"), + reply: () => Effect.die("unused"), + get: () => Effect.die("unused"), + forSession: () => Effect.die("unused"), + list: () => Effect.die("unused"), + }), +) +const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })) +const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]]) + +const testLayer = AppNodeBuilder.build( + LayerNode.group([ + Database.node, + EventV2.node, + SessionProjector.node, + SessionStore.node, + SessionV2.node, + Location.node, + FSUtil.node, + LocationMutation.node, + ReadToolFileSystem.node, + ReadTool.node, + ToolRegistry.node, + ToolRegistry.toolsNode, + ToolHooks.node, + SessionInstructions.node, + Global.node, + ToolOutputStore.node, + Image.node, + ]), + [ + [ProjectV2.node, projects], + [SessionExecution.node, SessionExecution.noopLayer], + [Location.node, tempLocationLayer], + [PermissionV2.node, permission], + [Config.node, config], + [Image.node, imageLayer], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ], +) as unknown as Layer.Layer + +const it = testEffect(testLayer) + +const identity = { + agent: AgentV2.ID.make("build"), + assistantMessageID: SessionMessage.ID.make("msg_nearby"), +} +const readCall = (sessionID: SessionV2.ID, id: string, readPath: string): ToolRegistry.ExecuteInput => ({ + sessionID, + ...identity, + call: { type: "tool-call", id, name: "read", input: { path: readPath } }, +}) + +const writeAgents = (file: string, content: string) => Effect.promise(() => fs.writeFile(file, content)) +const mkdir = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true })) + +const synthetics = (sessionID: SessionV2.ID) => + Effect.gen(function* () { + const store = yield* SessionStore.Service + return (yield* store.context(sessionID)).filter((message) => message.type === "synthetic") + }) + +// Seed a prior synthetic message with an instruction dedup ledger, simulating a prior turn +// after the Location layer was reopened (in-memory set empty). +const seedSynthetic = (sessionID: SessionV2.ID, paths: string[]) => + Effect.gen(function* () { + const events = yield* EventV2.Service + yield* events.publish(SessionEvent.Synthetic, { + sessionID, + messageID: SessionMessage.ID.create(), + timestamp: yield* DateTime.now, + text: `Instructions from: ${paths[0]}\nprior`, + description: `Loaded ${paths[0]}`, + metadata: { instruction: { paths } }, + }) + }) + +describe("SessionInstructions", () => { + it.effect("injects AGENTS.md files above a read, excludes the Location root, and dedups across reads", () => + Effect.gen(function* () { + const location = yield* Location.Service + const dir = location.directory + const rootPath = path.resolve(dir, "AGENTS.md") + const subPath = path.resolve(dir, "sub", "AGENTS.md") + const deepPath = path.resolve(dir, "sub", "deep", "AGENTS.md") + const otherPath = path.resolve(dir, "sub", "other", "AGENTS.md") + yield* mkdir(path.dirname(deepPath)) + yield* mkdir(path.dirname(otherPath)) + yield* writeAgents(rootPath, "root-instructions") + yield* writeAgents(subPath, "sub-instructions") + yield* writeAgents(deepPath, "deep-instructions") + yield* writeAgents(otherPath, "other-instructions") + yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "deep", "file.txt"), "file content")) + yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "other", "file2.txt"), "file content 2")) + + const session = yield* SessionV2.Service + const registry = yield* ToolRegistry.Service + const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id + + // A read deep under sub/ discovers deep and sub AGENTS.md, walking up to but + // excluding the Location root (already supplied by the core/instructions baseline). + yield* settleTool(registry, readCall(sessionID, "call-deep", "sub/deep/file.txt")) + + const firstInjected = yield* synthetics(sessionID) + expect(firstInjected).toHaveLength(1) + expect(firstInjected[0]!.text).toBe( + `Instructions from: ${deepPath}\ndeep-instructions\n\nInstructions from: ${subPath}\nsub-instructions`, + ) + expect(firstInjected[0]!.description).toBe(`Loaded ${deepPath}, ${subPath}`) + // The synthetic's metadata carries the durable dedup ledger. + expect(firstInjected[0]!.metadata).toEqual({ instruction: { paths: [deepPath, subPath] } }) + expect(firstInjected[0]!.text).not.toContain("root-instructions") + + // A sibling read under sub/other discovers only the new AGENTS.md; sub is already + // injected for this session so it is not re-emitted, and the root is still excluded. + yield* settleTool(registry, readCall(sessionID, "call-other", "sub/other/file2.txt")) + + const secondInjected = yield* synthetics(sessionID) + expect(secondInjected).toHaveLength(2) + expect(secondInjected[1]!.text).toBe(`Instructions from: ${otherPath}\nother-instructions`) + expect(secondInjected[1]!.description).toBe(`Loaded ${otherPath}`) + expect(secondInjected[1]!.metadata).toEqual({ instruction: { paths: [otherPath] } }) + expect(secondInjected.some((message) => message.text.includes("root-instructions"))).toBe(false) + }), + ) + + it.effect("does not re-inject paths already recorded in durable session history", () => + Effect.gen(function* () { + const location = yield* Location.Service + const dir = location.directory + const rootPath = path.resolve(dir, "AGENTS.md") + const subPath = path.resolve(dir, "sub", "AGENTS.md") + yield* mkdir(path.resolve(dir, "sub")) + yield* writeAgents(rootPath, "root-instructions") + yield* writeAgents(subPath, "sub-instructions") + yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "file.txt"), "content")) + + const session = yield* SessionV2.Service + const registry = yield* ToolRegistry.Service + const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id + + // Seed the durable history with a prior synthetic that already claims sub's AGENTS.md + // via the instruction metadata ledger. + yield* seedSynthetic(sessionID, [subPath]) + expect((yield* synthetics(sessionID))).toHaveLength(1) + + yield* settleTool(registry, readCall(sessionID, "call-sub", "sub/file.txt")) + + // The durable claim on the prior synthetic prevents re-injection; no new synthetic. + expect((yield* synthetics(sessionID))).toHaveLength(1) + }), + ) + + it.effect("loads instructions directly without a read", () => + Effect.gen(function* () { + const location = yield* Location.Service + const dir = location.directory + const subPath = path.resolve(dir, "sub", "AGENTS.md") + yield* mkdir(path.resolve(dir, "sub")) + yield* writeAgents(subPath, "sub-instructions") + + const session = yield* SessionV2.Service + const sessionInstructions = yield* SessionInstructions.Service + const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id + + yield* sessionInstructions.load({ sessionID, paths: [subPath] }) + + const injected = yield* synthetics(sessionID) + expect(injected).toHaveLength(1) + expect(injected[0]!.text).toBe(`Instructions from: ${subPath}\nsub-instructions`) + expect(injected[0]!.description).toBe(`Loaded ${subPath}`) + expect(injected[0]!.metadata).toEqual({ instruction: { paths: [subPath] } }) + }), + ) + + test("toLLMMessages does not forward synthetic metadata to the provider", () => { + const created = DateTime.makeUnsafe(0) + const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route }) + const synthetic = SessionMessage.Synthetic.make({ + id: SessionMessage.ID.make("msg_synthetic"), + type: "synthetic", + sessionID: SessionV2.ID.make("ses_test"), + text: "Instructions from: /repo/sub/AGENTS.md\ncontent", + description: "Loaded /repo/sub/AGENTS.md", + metadata: { instruction: { paths: ["/repo/sub/AGENTS.md"] } }, + time: { created }, + }) + const messages = toLLMMessages([synthetic], model) + expect(messages).toHaveLength(1) + expect(messages[0]!.role).toBe("user") + expect(messages[0]!.content).toEqual([{ type: "text", text: "Instructions from: /repo/sub/AGENTS.md\ncontent" }]) + // Metadata is bookkeeping for the dedup ledger; the model must not see it. + expect(messages[0]!.metadata).toBeUndefined() + }) +}) diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index 6648ee43c3..c78abd17d5 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -248,6 +248,7 @@ describe("SessionProjector", () => { messageID: SessionMessage.ID.create(), timestamp: created, text: "synthetic context", + metadata: { source: "projector-test" }, }) yield* events.publish(SessionEvent.Shell.Started, { sessionID, @@ -318,6 +319,10 @@ describe("SessionProjector", () => { "shell", "compaction", ]) + expect(messages.find((message) => message.type === "synthetic")).toMatchObject({ + text: "synthetic context", + metadata: { source: "projector-test" }, + }) expect(messages.find((message) => message.type === "shell")).toMatchObject({ output: "/project", time: { completed: DateTime.makeUnsafe(1) }, diff --git a/packages/plugin/src/v2/effect/generated/api.ts b/packages/plugin/src/v2/effect/generated/api.ts index 86a7eb60d8..74b1eb671d 100644 --- a/packages/plugin/src/v2/effect/generated/api.ts +++ b/packages/plugin/src/v2/effect/generated/api.ts @@ -126,74 +126,84 @@ export type Endpoint4_9Input = { export type Endpoint4_9Output = EffectValue> export type SessionSkillOperation = (input: Endpoint4_9Input) => Effect.Effect -type Endpoint4_10Request = Parameters[0] -export type Endpoint4_10Input = { readonly sessionID: Endpoint4_10Request["params"]["sessionID"] } -export type Endpoint4_10Output = EffectValue> -export type SessionCompactOperation = (input: Endpoint4_10Input) => Effect.Effect +type Endpoint4_10Request = Parameters[0] +export type Endpoint4_10Input = { + readonly sessionID: Endpoint4_10Request["params"]["sessionID"] + readonly text: Endpoint4_10Request["payload"]["text"] + readonly description?: Endpoint4_10Request["payload"]["description"] + readonly metadata?: Endpoint4_10Request["payload"]["metadata"] +} +export type Endpoint4_10Output = EffectValue> +export type SessionSyntheticOperation = (input: Endpoint4_10Input) => Effect.Effect -type Endpoint4_11Request = Parameters[0] +type Endpoint4_11Request = Parameters[0] export type Endpoint4_11Input = { readonly sessionID: Endpoint4_11Request["params"]["sessionID"] } -export type Endpoint4_11Output = EffectValue> -export type SessionWaitOperation = (input: Endpoint4_11Input) => Effect.Effect +export type Endpoint4_11Output = EffectValue> +export type SessionCompactOperation = (input: Endpoint4_11Input) => Effect.Effect -type Endpoint4_12Request = Parameters[0] -export type Endpoint4_12Input = { - readonly sessionID: Endpoint4_12Request["params"]["sessionID"] - readonly messageID: Endpoint4_12Request["payload"]["messageID"] - readonly files?: Endpoint4_12Request["payload"]["files"] +type Endpoint4_12Request = Parameters[0] +export type Endpoint4_12Input = { readonly sessionID: Endpoint4_12Request["params"]["sessionID"] } +export type Endpoint4_12Output = EffectValue> +export type SessionWaitOperation = (input: Endpoint4_12Input) => Effect.Effect + +type Endpoint4_13Request = Parameters[0] +export type Endpoint4_13Input = { + readonly sessionID: Endpoint4_13Request["params"]["sessionID"] + readonly messageID: Endpoint4_13Request["payload"]["messageID"] + readonly files?: Endpoint4_13Request["payload"]["files"] } -export type Endpoint4_12Output = EffectValue>["data"] -export type SessionRevertStageOperation = (input: Endpoint4_12Input) => Effect.Effect +export type Endpoint4_13Output = EffectValue>["data"] +export type SessionRevertStageOperation = (input: Endpoint4_13Input) => Effect.Effect -type Endpoint4_13Request = Parameters[0] -export type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] } -export type Endpoint4_13Output = EffectValue> -export type SessionRevertClearOperation = (input: Endpoint4_13Input) => Effect.Effect - -type Endpoint4_14Request = Parameters[0] +type Endpoint4_14Request = Parameters[0] export type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] } -export type Endpoint4_14Output = EffectValue> -export type SessionRevertCommitOperation = (input: Endpoint4_14Input) => Effect.Effect +export type Endpoint4_14Output = EffectValue> +export type SessionRevertClearOperation = (input: Endpoint4_14Input) => Effect.Effect -type Endpoint4_15Request = Parameters[0] +type Endpoint4_15Request = Parameters[0] export type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] } -export type Endpoint4_15Output = EffectValue>["data"] -export type SessionContextOperation = (input: Endpoint4_15Input) => Effect.Effect +export type Endpoint4_15Output = EffectValue> +export type SessionRevertCommitOperation = (input: Endpoint4_15Input) => Effect.Effect -type Endpoint4_16Request = Parameters[0] -export type Endpoint4_16Input = { - readonly sessionID: Endpoint4_16Request["params"]["sessionID"] - readonly limit?: Endpoint4_16Request["query"]["limit"] - readonly after?: Endpoint4_16Request["query"]["after"] -} -export type Endpoint4_16Output = EffectValue> -export type SessionHistoryOperation = (input: Endpoint4_16Input) => Effect.Effect +type Endpoint4_16Request = Parameters[0] +export type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] } +export type Endpoint4_16Output = EffectValue>["data"] +export type SessionContextOperation = (input: Endpoint4_16Input) => Effect.Effect -type Endpoint4_17Request = Parameters[0] +type Endpoint4_17Request = Parameters[0] export type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] + readonly limit?: Endpoint4_17Request["query"]["limit"] readonly after?: Endpoint4_17Request["query"]["after"] } -export type Endpoint4_17Output = StreamValue>> -export type SessionEventsOperation = (input: Endpoint4_17Input) => Stream.Stream +export type Endpoint4_17Output = EffectValue> +export type SessionHistoryOperation = (input: Endpoint4_17Input) => Effect.Effect -type Endpoint4_18Request = Parameters[0] -export type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] } -export type Endpoint4_18Output = EffectValue> -export type SessionInterruptOperation = (input: Endpoint4_18Input) => Effect.Effect - -type Endpoint4_19Request = Parameters[0] -export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] } -export type Endpoint4_19Output = EffectValue> -export type SessionBackgroundOperation = (input: Endpoint4_19Input) => Effect.Effect - -type Endpoint4_20Request = Parameters[0] -export type Endpoint4_20Input = { - readonly sessionID: Endpoint4_20Request["params"]["sessionID"] - readonly messageID: Endpoint4_20Request["params"]["messageID"] +type Endpoint4_18Request = Parameters[0] +export type Endpoint4_18Input = { + readonly sessionID: Endpoint4_18Request["params"]["sessionID"] + readonly after?: Endpoint4_18Request["query"]["after"] } -export type Endpoint4_20Output = EffectValue>["data"] -export type SessionMessageOperation = (input: Endpoint4_20Input) => Effect.Effect +export type Endpoint4_18Output = StreamValue>> +export type SessionEventsOperation = (input: Endpoint4_18Input) => Stream.Stream + +type Endpoint4_19Request = Parameters[0] +export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] } +export type Endpoint4_19Output = EffectValue> +export type SessionInterruptOperation = (input: Endpoint4_19Input) => Effect.Effect + +type Endpoint4_20Request = Parameters[0] +export type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] } +export type Endpoint4_20Output = EffectValue> +export type SessionBackgroundOperation = (input: Endpoint4_20Input) => Effect.Effect + +type Endpoint4_21Request = Parameters[0] +export type Endpoint4_21Input = { + readonly sessionID: Endpoint4_21Request["params"]["sessionID"] + readonly messageID: Endpoint4_21Request["params"]["messageID"] +} +export type Endpoint4_21Output = EffectValue>["data"] +export type SessionMessageOperation = (input: Endpoint4_21Input) => Effect.Effect export interface SessionApi { readonly list: SessionListOperation @@ -206,6 +216,7 @@ export interface SessionApi { readonly rename: SessionRenameOperation readonly prompt: SessionPromptOperation readonly skill: SessionSkillOperation + readonly synthetic: SessionSyntheticOperation readonly compact: SessionCompactOperation readonly wait: SessionWaitOperation readonly revertStage: SessionRevertStageOperation diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts index c1694e533e..295c948766 100644 --- a/packages/plugin/src/v2/effect/index.ts +++ b/packages/plugin/src/v2/effect/index.ts @@ -2,5 +2,5 @@ export type { PluginContext } from "./context.js" export { define } from "./plugin.js" export type { Plugin } from "./plugin.js" export * as Tool from "./tool.js" -export type { ToolDomain } from "./tool.js" +export type { ToolDomain, ToolExecuteBeforeEvent, ToolExecuteAfterEvent } from "./tool.js" export type { SessionDomain } from "./runtime.js" diff --git a/packages/plugin/src/v2/effect/tool.ts b/packages/plugin/src/v2/effect/tool.ts index 223de7a8b8..c25f79c58b 100644 --- a/packages/plugin/src/v2/effect/tool.ts +++ b/packages/plugin/src/v2/effect/tool.ts @@ -1,10 +1,11 @@ export * as Tool from "./tool.js" -import { ToolDefinition, ToolFailure, ToolOutput, type ToolCall } from "@opencode-ai/llm" +import { ToolDefinition, ToolFailure, ToolOutput, type ToolCall, type ToolResultValue } from "@opencode-ai/llm" import { Agent } from "@opencode-ai/schema/agent" import { Session } from "@opencode-ai/schema/session" import { SessionMessage } from "@opencode-ai/schema/session-message" import { Effect, JsonSchema, Schema, type Scope } from "effect" +import type { Hooks } from "./registration.js" export interface Context { readonly sessionID: Session.ID @@ -213,6 +214,28 @@ function toJsonSchema(schema: Schema.Top): JsonSchema.JsonSchema { return { ...document.schema, $defs: document.definitions } } +export interface ToolExecuteBeforeEvent { + readonly tool: string + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly assistantMessageID: SessionMessage.ID + readonly toolCallID: string + input: unknown +} + +export interface ToolExecuteAfterEvent { + readonly tool: string + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly assistantMessageID: SessionMessage.ID + readonly toolCallID: string + readonly input: unknown + result: ToolResultValue + output?: ToolOutput + outputPaths?: ReadonlyArray +} + export interface ToolDomain { readonly register: (tools: Readonly>) => Effect.Effect + readonly execute: Hooks<{ before: ToolExecuteBeforeEvent; after: ToolExecuteAfterEvent }> } diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 7228596141..643bf6f4a9 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -277,6 +277,26 @@ export const makeSessionGroup = (sessionLo }), ), ) + .add( + HttpApiEndpoint.post("session.synthetic", "/api/session/:sessionID/synthetic", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ + text: Schema.String, + description: Schema.String.pipe(Schema.optional), + metadata: SessionMessage.Synthetic.fields.metadata, + }), + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.synthetic", + summary: "Add synthetic message", + description: "Append a synthetic message to a session and resume execution.", + }), + ), + ) .add( HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", { params: { sessionID: Session.ID }, diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index fde5dd6014..5013dddee9 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -138,6 +138,7 @@ export const Synthetic = Event.define({ messageID: SessionMessage.ID, text: Schema.String, description: Schema.String.pipe(optional), + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional), }, }) export type Synthetic = typeof Synthetic.Type diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 15788fd6ec..a61571e9a5 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -239,6 +239,27 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl return HttpApiSchema.NoContent.make() }), ) + .handle( + "session.synthetic", + Effect.fn(function* (ctx) { + yield* session.synthetic({ + sessionID: ctx.params.sessionID, + text: ctx.payload.text, + description: ctx.payload.description, + metadata: ctx.payload.metadata, + }).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) .handle( "session.compact", Effect.fn(function* (ctx) { diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index e9ea22e522..dec23ebae7 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -1,5 +1,23 @@ # V2 Schema Changelog +## 2026-07-01: Synthetic Message Metadata And Model-Visible Leak Fix + +- Add optional `metadata: Record` to the durable `session.next.synthetic.1` event data so synthetic messages can carry a durable ledger (e.g. lazy-instruction dedup paths). +- Add optional `metadata` to the `SessionV2.synthetic` method and the `POST /api/session/:sessionID/synthetic` HTTP endpoint payload. +- Stop forwarding `SessionMessage.Synthetic.metadata` (inherited from `Base.metadata`) to the provider message in `to-llm-message`. Synthetic metadata is bookkeeping; the model must not see it. + +Change: + +- Give durable synthetic messages an optional metadata channel so Location-scoped services can stamp durable, model-hidden annotations (e.g. lazy-instruction dedup claims) without depending on `SessionV2`. +- `to-llm-message` no longer includes `metadata` on the lowered synthetic user message. Previously `Base.metadata` was forwarded to the provider for every synthetic message; it is now withheld so the model only sees the synthetic text. + +Compatibility: + +- The added durable-event field is optional so previously recorded experimental events remain decodable; no durable-event version bump. +- Existing projected synthetic messages decode without `metadata`; the lazy-instruction dedup treats absent metadata as no prior claim. +- No database migration is required. +- Provider-visible behavior changes: the model no longer receives synthetic message metadata. Existing sessions that relied on synthetic metadata being model-visible should move that information into the synthetic text. + ## 2026-06-26: Add Finite Session History - Add `GET /api/session/:sessionID/history` and generated Promise, Effect, and legacy JavaScript client methods.