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.
This commit is contained in:
Dax Raad 2026-07-01 20:50:10 -04:00
commit d972aa9d83
25 changed files with 893 additions and 116 deletions

View file

@ -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<RawClient["server.session"]["session.compact"]>[0]
type Endpoint4_10Input = { readonly sessionID: Endpoint4_10Request["params"]["sessionID"] }
type Endpoint4_10Request = Parameters<RawClient["server.session"]["session.synthetic"]>[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<RawClient["server.session"]["session.wait"]>[0]
type Endpoint4_11Request = Parameters<RawClient["server.session"]["session.compact"]>[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<RawClient["server.session"]["session.wait"]>[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<RawClient["server.session"]["session.revert.stage"]>[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<RawClient["server.session"]["session.revert.stage"]>[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<RawClient["server.session"]["session.revert.clear"]>[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<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[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<RawClient["server.session"]["session.context"]>[0]
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[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<RawClient["server.session"]["session.context"]>[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<RawClient["server.session"]["session.history"]>[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<RawClient["server.session"]["session.history"]>[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<RawClient["server.session"]["session.events"]>[0]
type Endpoint4_17Input = {
readonly sessionID: Endpoint4_17Request["params"]["sessionID"]
readonly after?: Endpoint4_17Request["query"]["after"]
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.events"]>[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<RawClient["server.session"]["session.interrupt"]>[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<RawClient["server.session"]["session.background"]>[0]
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.interrupt"]>[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<RawClient["server.session"]["session.background"]>[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<RawClient["server.session"]["session.message"]>[0]
type Endpoint4_20Input = {
readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
readonly messageID: Endpoint4_20Request["params"]["messageID"]
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.message"]>[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<RawClient["server.message"]["session.messages"]>[0]

View file

@ -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<SessionSyntheticOutput>(
{
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<SessionCompactOutput>(
{

View file

@ -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 }
}
}
| {

View file

@ -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,

View file

@ -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,
],
})

View file

@ -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 = <T>(value: T) => value as DeepMutable<T>
@ -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) =>

View file

@ -199,6 +199,7 @@ export interface Interface {
sessionID: SessionSchema.ID
text: string
description?: string
metadata?: Record<string, unknown>
}) => Effect.Effect<void, NotFoundError>
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)
}),

View file

@ -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<string>
}) => Effect.Effect<void, MessageDecodeError | FSUtil.Error>
}
export class Service extends Context.Service<Service, Interface>()("@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<Map<SessionSchema.ID, Set<string>>>(new Map())
const load = Effect.fn("SessionInstructions.load")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly paths: ReadonlyArray<string>
}) {
const claimed = yield* Ref.modify(injected, (map) => {
const existing = map.get(input.sessionID) ?? new Set<string>()
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],
})

View file

@ -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 },

View file

@ -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":

View file

@ -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<string>
}
export interface Interface {
readonly hook: {
readonly before: (
callback: (event: BeforeEvent) => Effect.Effect<void> | void,
) => Effect.Effect<State.Registration, never, Scope.Scope>
readonly after: (
callback: (event: AfterEvent) => Effect.Effect<void> | void,
) => Effect.Effect<State.Registration, never, Scope.Scope>
}
readonly runBefore: (event: BeforeEvent) => Effect.Effect<BeforeEvent>
readonly runAfter: (event: AfterEvent) => Effect.Effect<AfterEvent>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolHooks") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
let beforeHooks: ((event: BeforeEvent) => Effect.Effect<void> | void)[] = []
let afterHooks: ((event: AfterEvent) => Effect.Effect<void> | void)[] = []
const register = <Event>(
hooks: () => ((event: Event) => Effect.Effect<void> | void)[],
update: (hooks: ((event: Event) => Effect.Effect<void> | void)[]) => void,
) =>
Effect.fn("ToolHooks.hook")(function* (callback: (event: Event) => Effect.Effect<void> | 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* <Event>(
hooks: readonly ((event: Event) => Effect.Effect<void> | 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: [] })

View file

@ -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,
],
})

View file

@ -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<string, Array<{ readonly token: object; readonly registration: Registration }>>()
@ -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],
})

View file

@ -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: [] })
}),
)
})

View file

@ -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,
]),
[

View file

@ -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"),

View file

@ -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<unknown>
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()
})
})

View file

@ -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) },

View file

@ -126,74 +126,84 @@ export type Endpoint4_9Input = {
export type Endpoint4_9Output = EffectValue<ReturnType<RawClient["server.session"]["session.skill"]>>
export type SessionSkillOperation<E = never> = (input: Endpoint4_9Input) => Effect.Effect<Endpoint4_9Output, E>
type Endpoint4_10Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
export type Endpoint4_10Input = { readonly sessionID: Endpoint4_10Request["params"]["sessionID"] }
export type Endpoint4_10Output = EffectValue<ReturnType<RawClient["server.session"]["session.compact"]>>
export type SessionCompactOperation<E = never> = (input: Endpoint4_10Input) => Effect.Effect<Endpoint4_10Output, E>
type Endpoint4_10Request = Parameters<RawClient["server.session"]["session.synthetic"]>[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<ReturnType<RawClient["server.session"]["session.synthetic"]>>
export type SessionSyntheticOperation<E = never> = (input: Endpoint4_10Input) => Effect.Effect<Endpoint4_10Output, E>
type Endpoint4_11Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint4_11Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
export type Endpoint4_11Input = { readonly sessionID: Endpoint4_11Request["params"]["sessionID"] }
export type Endpoint4_11Output = EffectValue<ReturnType<RawClient["server.session"]["session.wait"]>>
export type SessionWaitOperation<E = never> = (input: Endpoint4_11Input) => Effect.Effect<Endpoint4_11Output, E>
export type Endpoint4_11Output = EffectValue<ReturnType<RawClient["server.session"]["session.compact"]>>
export type SessionCompactOperation<E = never> = (input: Endpoint4_11Input) => Effect.Effect<Endpoint4_11Output, E>
type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[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<RawClient["server.session"]["session.wait"]>[0]
export type Endpoint4_12Input = { readonly sessionID: Endpoint4_12Request["params"]["sessionID"] }
export type Endpoint4_12Output = EffectValue<ReturnType<RawClient["server.session"]["session.wait"]>>
export type SessionWaitOperation<E = never> = (input: Endpoint4_12Input) => Effect.Effect<Endpoint4_12Output, E>
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[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<ReturnType<RawClient["server.session"]["session.revert.stage"]>>["data"]
export type SessionRevertStageOperation<E = never> = (input: Endpoint4_12Input) => Effect.Effect<Endpoint4_12Output, E>
export type Endpoint4_13Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.stage"]>>["data"]
export type SessionRevertStageOperation<E = never> = (input: Endpoint4_13Input) => Effect.Effect<Endpoint4_13Output, E>
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
export type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] }
export type Endpoint4_13Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.clear"]>>
export type SessionRevertClearOperation<E = never> = (input: Endpoint4_13Input) => Effect.Effect<Endpoint4_13Output, E>
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
export type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] }
export type Endpoint4_14Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.commit"]>>
export type SessionRevertCommitOperation<E = never> = (input: Endpoint4_14Input) => Effect.Effect<Endpoint4_14Output, E>
export type Endpoint4_14Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.clear"]>>
export type SessionRevertClearOperation<E = never> = (input: Endpoint4_14Input) => Effect.Effect<Endpoint4_14Output, E>
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.context"]>[0]
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
export type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] }
export type Endpoint4_15Output = EffectValue<ReturnType<RawClient["server.session"]["session.context"]>>["data"]
export type SessionContextOperation<E = never> = (input: Endpoint4_15Input) => Effect.Effect<Endpoint4_15Output, E>
export type Endpoint4_15Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.commit"]>>
export type SessionRevertCommitOperation<E = never> = (input: Endpoint4_15Input) => Effect.Effect<Endpoint4_15Output, E>
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.history"]>[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<ReturnType<RawClient["server.session"]["session.history"]>>
export type SessionHistoryOperation<E = never> = (input: Endpoint4_16Input) => Effect.Effect<Endpoint4_16Output, E>
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.context"]>[0]
export type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] }
export type Endpoint4_16Output = EffectValue<ReturnType<RawClient["server.session"]["session.context"]>>["data"]
export type SessionContextOperation<E = never> = (input: Endpoint4_16Input) => Effect.Effect<Endpoint4_16Output, E>
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.events"]>[0]
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.history"]>[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<EffectValue<ReturnType<RawClient["server.session"]["session.events"]>>>
export type SessionEventsOperation<E = never> = (input: Endpoint4_17Input) => Stream.Stream<Endpoint4_17Output, E>
export type Endpoint4_17Output = EffectValue<ReturnType<RawClient["server.session"]["session.history"]>>
export type SessionHistoryOperation<E = never> = (input: Endpoint4_17Input) => Effect.Effect<Endpoint4_17Output, E>
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
export type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] }
export type Endpoint4_18Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
export type SessionInterruptOperation<E = never> = (input: Endpoint4_18Input) => Effect.Effect<Endpoint4_18Output, E>
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.background"]>[0]
export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
export type Endpoint4_19Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
export type SessionBackgroundOperation<E = never> = (input: Endpoint4_19Input) => Effect.Effect<Endpoint4_19Output, E>
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.message"]>[0]
export type Endpoint4_20Input = {
readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
readonly messageID: Endpoint4_20Request["params"]["messageID"]
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.events"]>[0]
export type Endpoint4_18Input = {
readonly sessionID: Endpoint4_18Request["params"]["sessionID"]
readonly after?: Endpoint4_18Request["query"]["after"]
}
export type Endpoint4_20Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
export type SessionMessageOperation<E = never> = (input: Endpoint4_20Input) => Effect.Effect<Endpoint4_20Output, E>
export type Endpoint4_18Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.events"]>>>
export type SessionEventsOperation<E = never> = (input: Endpoint4_18Input) => Stream.Stream<Endpoint4_18Output, E>
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
export type Endpoint4_19Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
export type SessionInterruptOperation<E = never> = (input: Endpoint4_19Input) => Effect.Effect<Endpoint4_19Output, E>
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.background"]>[0]
export type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] }
export type Endpoint4_20Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
export type SessionBackgroundOperation<E = never> = (input: Endpoint4_20Input) => Effect.Effect<Endpoint4_20Output, E>
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.message"]>[0]
export type Endpoint4_21Input = {
readonly sessionID: Endpoint4_21Request["params"]["sessionID"]
readonly messageID: Endpoint4_21Request["params"]["messageID"]
}
export type Endpoint4_21Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
export type SessionMessageOperation<E = never> = (input: Endpoint4_21Input) => Effect.Effect<Endpoint4_21Output, E>
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
@ -206,6 +216,7 @@ export interface SessionApi<E = never> {
readonly rename: SessionRenameOperation<E>
readonly prompt: SessionPromptOperation<E>
readonly skill: SessionSkillOperation<E>
readonly synthetic: SessionSyntheticOperation<E>
readonly compact: SessionCompactOperation<E>
readonly wait: SessionWaitOperation<E>
readonly revertStage: SessionRevertStageOperation<E>

View file

@ -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"

View file

@ -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<string>
}
export interface ToolDomain {
readonly register: (tools: Readonly<Record<string, AnyTool>>) => Effect.Effect<void, RegistrationError, Scope.Scope>
readonly execute: Hooks<{ before: ToolExecuteBeforeEvent; after: ToolExecuteAfterEvent }>
}

View file

@ -277,6 +277,26 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(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 },

View file

@ -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

View file

@ -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) {

View file

@ -1,5 +1,23 @@
# V2 Schema Changelog
## 2026-07-01: Synthetic Message Metadata And Model-Visible Leak Fix
- Add optional `metadata: Record<string, unknown>` 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.