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:
@@ -164,23 +164,36 @@ const Endpoint4_9 = (raw: RawClient["server.session"]) => (input: Endpoint4_9Inp
|
|||||||
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
|
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
|
||||||
}).pipe(Effect.mapError(mapClientError))
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint4_10Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
type Endpoint4_10Request = Parameters<RawClient["server.session"]["session.synthetic"]>[0]
|
||||||
type Endpoint4_10Input = { readonly sessionID: Endpoint4_10Request["params"]["sessionID"] }
|
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) =>
|
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"] }
|
type Endpoint4_11Input = { readonly sessionID: Endpoint4_11Request["params"]["sessionID"] }
|
||||||
const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11Input) =>
|
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))
|
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||||
type Endpoint4_12Input = {
|
type Endpoint4_13Input = {
|
||||||
readonly sessionID: Endpoint4_12Request["params"]["sessionID"]
|
readonly sessionID: Endpoint4_13Request["params"]["sessionID"]
|
||||||
readonly messageID: Endpoint4_12Request["payload"]["messageID"]
|
readonly messageID: Endpoint4_13Request["payload"]["messageID"]
|
||||||
readonly files?: Endpoint4_12Request["payload"]["files"]
|
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"]({
|
raw["session.revert.stage"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
payload: { messageID: input["messageID"], files: input["files"] },
|
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),
|
Effect.map((value) => value.data),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
type Endpoint4_14Request = 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_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] }
|
type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] }
|
||||||
const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) =>
|
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"] }
|
type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] }
|
||||||
const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) =>
|
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(
|
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.history"]>[0]
|
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.history"]>[0]
|
||||||
type Endpoint4_16Input = {
|
type Endpoint4_17Input = {
|
||||||
readonly sessionID: Endpoint4_16Request["params"]["sessionID"]
|
readonly sessionID: Endpoint4_17Request["params"]["sessionID"]
|
||||||
readonly limit?: Endpoint4_16Request["query"]["limit"]
|
readonly limit?: Endpoint4_17Request["query"]["limit"]
|
||||||
readonly after?: Endpoint4_16Request["query"]["after"]
|
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"]({
|
raw["session.history"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
query: { limit: input["limit"], after: input["after"] },
|
query: { limit: input["limit"], after: input["after"] },
|
||||||
}).pipe(Effect.mapError(mapClientError))
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||||
type Endpoint4_17Input = {
|
type Endpoint4_18Input = {
|
||||||
readonly sessionID: Endpoint4_17Request["params"]["sessionID"]
|
readonly sessionID: Endpoint4_18Request["params"]["sessionID"]
|
||||||
readonly after?: Endpoint4_17Request["query"]["after"]
|
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(
|
Stream.unwrap(
|
||||||
raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe(
|
raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
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_19Request = 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_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
|
type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
|
||||||
const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) =>
|
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))
|
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||||
type Endpoint4_20Input = {
|
type Endpoint4_21Input = {
|
||||||
readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
|
readonly sessionID: Endpoint4_21Request["params"]["sessionID"]
|
||||||
readonly messageID: Endpoint4_20Request["params"]["messageID"]
|
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(
|
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
@@ -264,17 +277,18 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({
|
|||||||
rename: Endpoint4_7(raw),
|
rename: Endpoint4_7(raw),
|
||||||
prompt: Endpoint4_8(raw),
|
prompt: Endpoint4_8(raw),
|
||||||
skill: Endpoint4_9(raw),
|
skill: Endpoint4_9(raw),
|
||||||
compact: Endpoint4_10(raw),
|
synthetic: Endpoint4_10(raw),
|
||||||
wait: Endpoint4_11(raw),
|
compact: Endpoint4_11(raw),
|
||||||
revertStage: Endpoint4_12(raw),
|
wait: Endpoint4_12(raw),
|
||||||
revertClear: Endpoint4_13(raw),
|
revertStage: Endpoint4_13(raw),
|
||||||
revertCommit: Endpoint4_14(raw),
|
revertClear: Endpoint4_14(raw),
|
||||||
context: Endpoint4_15(raw),
|
revertCommit: Endpoint4_15(raw),
|
||||||
history: Endpoint4_16(raw),
|
context: Endpoint4_16(raw),
|
||||||
events: Endpoint4_17(raw),
|
history: Endpoint4_17(raw),
|
||||||
interrupt: Endpoint4_18(raw),
|
events: Endpoint4_18(raw),
|
||||||
background: Endpoint4_19(raw),
|
interrupt: Endpoint4_19(raw),
|
||||||
message: Endpoint4_20(raw),
|
background: Endpoint4_20(raw),
|
||||||
|
message: Endpoint4_21(raw),
|
||||||
})
|
})
|
||||||
|
|
||||||
type Endpoint5_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
|
type Endpoint5_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import type {
|
|||||||
SessionPromptOutput,
|
SessionPromptOutput,
|
||||||
SessionSkillInput,
|
SessionSkillInput,
|
||||||
SessionSkillOutput,
|
SessionSkillOutput,
|
||||||
|
SessionSyntheticInput,
|
||||||
|
SessionSyntheticOutput,
|
||||||
SessionCompactInput,
|
SessionCompactInput,
|
||||||
SessionCompactOutput,
|
SessionCompactOutput,
|
||||||
SessionWaitInput,
|
SessionWaitInput,
|
||||||
@@ -461,6 +463,18 @@ export function make(options: ClientOptions) {
|
|||||||
},
|
},
|
||||||
requestOptions,
|
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) =>
|
compact: (input: SessionCompactInput, requestOptions?: RequestOptions) =>
|
||||||
request<SessionCompactOutput>(
|
request<SessionCompactOutput>(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -584,6 +584,27 @@ export type SessionSkillInput = {
|
|||||||
|
|
||||||
export type SessionSkillOutput = void
|
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 SessionCompactInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||||
|
|
||||||
export type SessionCompactOutput = void
|
export type SessionCompactOutput = void
|
||||||
@@ -934,6 +955,7 @@ export type SessionHistoryOutput = {
|
|||||||
readonly messageID: string
|
readonly messageID: string
|
||||||
readonly text: string
|
readonly text: string
|
||||||
readonly description?: string
|
readonly description?: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -1428,6 +1450,7 @@ export type SessionEventsOutput =
|
|||||||
readonly messageID: string
|
readonly messageID: string
|
||||||
readonly text: string
|
readonly text: string
|
||||||
readonly description?: string
|
readonly description?: string
|
||||||
|
readonly metadata?: { readonly [x: string]: unknown }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -3530,6 +3553,7 @@ export type EventSubscribeOutput =
|
|||||||
readonly messageID: string
|
readonly messageID: string
|
||||||
readonly text: string
|
readonly text: string
|
||||||
readonly description?: string
|
readonly description?: string
|
||||||
|
readonly metadata?: { readonly [x: string]: unknown }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { SkillGuidance } from "./skill/guidance"
|
|||||||
import { Snapshot } from "./snapshot"
|
import { Snapshot } from "./snapshot"
|
||||||
import { SystemContextBuiltIns } from "./system-context/builtins"
|
import { SystemContextBuiltIns } from "./system-context/builtins"
|
||||||
import { SystemContextRegistry } from "./system-context/registry"
|
import { SystemContextRegistry } from "./system-context/registry"
|
||||||
|
import { SessionInstructions } from "./session/instructions"
|
||||||
import { BuiltInTools } from "./tool/builtins"
|
import { BuiltInTools } from "./tool/builtins"
|
||||||
import { McpTool } from "./tool/mcp"
|
import { McpTool } from "./tool/mcp"
|
||||||
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
||||||
@@ -85,6 +86,7 @@ export const locationServices = LayerNode.group([
|
|||||||
ReadToolFileSystem.node,
|
ReadToolFileSystem.node,
|
||||||
BuiltInTools.node,
|
BuiltInTools.node,
|
||||||
McpTool.node,
|
McpTool.node,
|
||||||
|
SessionInstructions.node,
|
||||||
SessionRunnerModel.node,
|
SessionRunnerModel.node,
|
||||||
SessionCompaction.node,
|
SessionCompaction.node,
|
||||||
SessionTitle.node,
|
SessionTitle.node,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { Reference } from "./reference"
|
|||||||
import { SkillV2 } from "./skill"
|
import { SkillV2 } from "./skill"
|
||||||
import { State } from "./state"
|
import { State } from "./state"
|
||||||
import { ToolRegistry } from "./tool/registry"
|
import { ToolRegistry } from "./tool/registry"
|
||||||
|
import { ToolHooks } from "./tool/hooks"
|
||||||
|
|
||||||
export const ID = Plugin.ID
|
export const ID = Plugin.ID
|
||||||
export type ID = typeof ID.Type
|
export type ID = typeof ID.Type
|
||||||
@@ -165,6 +166,7 @@ export const node = makeLocationNode({
|
|||||||
Reference.node,
|
Reference.node,
|
||||||
SkillV2.node,
|
SkillV2.node,
|
||||||
ToolRegistry.toolsNode,
|
ToolRegistry.toolsNode,
|
||||||
|
ToolHooks.node,
|
||||||
PluginRuntime.node,
|
PluginRuntime.node,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Reference } from "../reference"
|
|||||||
import { AbsolutePath, type DeepMutable } from "../schema"
|
import { AbsolutePath, type DeepMutable } from "../schema"
|
||||||
import { SkillV2 } from "../skill"
|
import { SkillV2 } from "../skill"
|
||||||
import { Tools } from "../tool/tools"
|
import { Tools } from "../tool/tools"
|
||||||
|
import { ToolHooks } from "../tool/hooks"
|
||||||
import { WorkspaceV2 } from "../workspace"
|
import { WorkspaceV2 } from "../workspace"
|
||||||
|
|
||||||
const mutable = <T>(value: T) => value as DeepMutable<T>
|
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 reference = yield* Reference.Service
|
||||||
const skill = yield* SkillV2.Service
|
const skill = yield* SkillV2.Service
|
||||||
const tools = yield* Tools.Service
|
const tools = yield* Tools.Service
|
||||||
|
const toolHooks = yield* ToolHooks.Service
|
||||||
const runtime = yield* PluginRuntime.Service
|
const runtime = yield* PluginRuntime.Service
|
||||||
const locationInfo = () =>
|
const locationInfo = () =>
|
||||||
new Location.Info({
|
new Location.Info({
|
||||||
@@ -247,6 +249,47 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||||||
},
|
},
|
||||||
tool: {
|
tool: {
|
||||||
register: (input) => tools.register(input),
|
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: {
|
session: {
|
||||||
create: (input) =>
|
create: (input) =>
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ export interface Interface {
|
|||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
text: string
|
text: string
|
||||||
description?: string
|
description?: string
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
}) => Effect.Effect<void, NotFoundError>
|
}) => Effect.Effect<void, NotFoundError>
|
||||||
readonly revert: {
|
readonly revert: {
|
||||||
readonly stage: (input: {
|
readonly stage: (input: {
|
||||||
@@ -547,6 +548,7 @@ const layer = Layer.effect(
|
|||||||
timestamp: yield* DateTime.now,
|
timestamp: yield* DateTime.now,
|
||||||
text: input.text,
|
text: input.text,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
|
metadata: input.metadata,
|
||||||
})
|
})
|
||||||
yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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],
|
||||||
|
})
|
||||||
@@ -154,6 +154,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||||||
sessionID: event.data.sessionID,
|
sessionID: event.data.sessionID,
|
||||||
text: event.data.text,
|
text: event.data.text,
|
||||||
description: event.data.description,
|
description: event.data.description,
|
||||||
|
metadata: event.data.metadata,
|
||||||
id: event.data.messageID,
|
id: event.data.messageID,
|
||||||
type: "synthetic",
|
type: "synthetic",
|
||||||
time: { created: event.data.timestamp },
|
time: { created: event.data.timestamp },
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
|
|||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
case "synthetic":
|
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":
|
case "skill":
|
||||||
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, metadata: message.metadata })]
|
||||||
case "system":
|
case "system":
|
||||||
|
|||||||
@@ -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: [] })
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
export * as ReadTool from "./read"
|
export * as ReadTool from "./read"
|
||||||
|
|
||||||
|
import { dirname } from "path"
|
||||||
import { ToolFailure } from "@opencode-ai/llm"
|
import { ToolFailure } from "@opencode-ai/llm"
|
||||||
import { Effect, Layer, Schema } from "effect"
|
import { Effect, Layer, Schema } from "effect"
|
||||||
import { makeLocationNode } from "../effect/app-node"
|
import { makeLocationNode } from "../effect/app-node"
|
||||||
import { FileSystem } from "../filesystem"
|
import { FileSystem } from "../filesystem"
|
||||||
|
import { FSUtil } from "../fs-util"
|
||||||
import { Image } from "../image"
|
import { Image } from "../image"
|
||||||
|
import { Location } from "../location"
|
||||||
import { LocationMutation } from "../location-mutation"
|
import { LocationMutation } from "../location-mutation"
|
||||||
import { PermissionV2 } from "../permission"
|
import { PermissionV2 } from "../permission"
|
||||||
|
import { SessionInstructions } from "../session/instructions"
|
||||||
import { AbsolutePath } from "../schema"
|
import { AbsolutePath } from "../schema"
|
||||||
import { ReadToolFileSystem } from "./read-filesystem"
|
import { ReadToolFileSystem } from "./read-filesystem"
|
||||||
import { ToolRegistry } from "./registry"
|
import { ToolRegistry } from "./registry"
|
||||||
@@ -14,6 +18,7 @@ import { Tool } from "./tool"
|
|||||||
import { Tools } from "./tools"
|
import { Tools } from "./tools"
|
||||||
|
|
||||||
export const name = "read"
|
export const name = "read"
|
||||||
|
const FILENAME = "AGENTS.md"
|
||||||
const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"])
|
const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"])
|
||||||
const LocationInput = Schema.Struct({
|
const LocationInput = Schema.Struct({
|
||||||
path: Schema.String,
|
path: Schema.String,
|
||||||
@@ -34,6 +39,9 @@ const layer = Layer.effectDiscard(
|
|||||||
const mutation = yield* LocationMutation.Service
|
const mutation = yield* LocationMutation.Service
|
||||||
const image = yield* Image.Service
|
const image = yield* Image.Service
|
||||||
const permission = yield* PermissionV2.Service
|
const permission = yield* PermissionV2.Service
|
||||||
|
const sessionInstructions = yield* SessionInstructions.Service
|
||||||
|
const fs = yield* FSUtil.Service
|
||||||
|
const location = yield* Location.Service
|
||||||
|
|
||||||
yield* tools
|
yield* tools
|
||||||
.register({
|
.register({
|
||||||
@@ -83,6 +91,20 @@ const layer = Layer.effectDiscard(
|
|||||||
offset: input.offset,
|
offset: input.offset,
|
||||||
limit: input.limit,
|
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)) {
|
if ("encoding" in content && content.encoding === "base64" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
|
||||||
return yield* image
|
return yield* image
|
||||||
.normalize(resource, { ...content, encoding: "base64" })
|
.normalize(resource, { ...content, encoding: "base64" })
|
||||||
@@ -113,5 +135,14 @@ const layer = Layer.effectDiscard(
|
|||||||
export const node = makeLocationNode({
|
export const node = makeLocationNode({
|
||||||
name: "tool/read",
|
name: "tool/read",
|
||||||
layer,
|
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,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ToolOutputStore } from "../tool-output-store"
|
|||||||
import { Wildcard } from "../util/wildcard"
|
import { Wildcard } from "../util/wildcard"
|
||||||
import { definition, permission, registrationEntries, settle, type AnyTool, type RegistrationError } from "./tool"
|
import { definition, permission, registrationEntries, settle, type AnyTool, type RegistrationError } from "./tool"
|
||||||
import { Tools } from "./tools"
|
import { Tools } from "./tools"
|
||||||
|
import { ToolHooks } from "./hooks"
|
||||||
import { makeLocationNode } from "../effect/app-node"
|
import { makeLocationNode } from "../effect/app-node"
|
||||||
|
|
||||||
export type ExecuteInput = {
|
export type ExecuteInput = {
|
||||||
@@ -47,6 +48,7 @@ const registryLayer = Layer.effect(
|
|||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const resources = yield* ToolOutputStore.Service
|
const resources = yield* ToolOutputStore.Service
|
||||||
|
const toolHooks = yield* ToolHooks.Service
|
||||||
type Registration = { readonly identity: object; readonly tool: AnyTool }
|
type Registration = { readonly identity: object; readonly tool: AnyTool }
|
||||||
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
|
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)
|
if (advertised && registration.identity !== advertised)
|
||||||
return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } }
|
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,
|
sessionID: input.sessionID,
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
assistantMessageID: input.assistantMessageID,
|
assistantMessageID: input.assistantMessageID,
|
||||||
@@ -72,15 +84,38 @@ const registryLayer = Layer.effect(
|
|||||||
Effect.succeed({ result: { type: "error" as const, value: failure.message } }),
|
Effect.succeed({ result: { type: "error" as const, value: failure.message } }),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if ("result" in pending) return pending
|
let settlement: Settlement
|
||||||
const output = pending.output
|
if ("result" in pending) {
|
||||||
const bounded = yield* resources.bound({ sessionID: input.sessionID, toolCallID: input.call.id, output })
|
settlement = pending
|
||||||
const result = ToolOutput.toResultValue(bounded.output)
|
} else {
|
||||||
if (result.type === "error")
|
const bounded = yield* resources.bound({ sessionID: input.sessionID, toolCallID: input.call.id, output: pending.output })
|
||||||
return bounded.outputPaths.length > 0 ? { result, outputPaths: bounded.outputPaths } : { result }
|
const result = ToolOutput.toResultValue(bounded.output)
|
||||||
return bounded.outputPaths.length > 0
|
settlement =
|
||||||
? { result, output: bounded.output, outputPaths: bounded.outputPaths }
|
result.type === "error"
|
||||||
: { result, output: bounded.output }
|
? 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({
|
return Service.of({
|
||||||
@@ -143,11 +178,11 @@ function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
|
|||||||
export const node = makeLocationNode({
|
export const node = makeLocationNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer,
|
layer,
|
||||||
deps: [ToolOutputStore.node],
|
deps: [ToolOutputStore.node, ToolHooks.node],
|
||||||
})
|
})
|
||||||
|
|
||||||
export const toolsNode = makeLocationNode({
|
export const toolsNode = makeLocationNode({
|
||||||
service: Tools.Service,
|
service: Tools.Service,
|
||||||
layer,
|
layer,
|
||||||
deps: [ToolOutputStore.node],
|
deps: [ToolOutputStore.node, ToolHooks.node],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Effect, Exit, Fiber, Schema } from "effect"
|
|||||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
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 { Tool } from "@opencode-ai/core/tool/tool"
|
||||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||||
import { testEffect } from "./lib/effect"
|
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: [] })
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { PluginV2 } from "@opencode-ai/core/plugin"
|
|||||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||||
import { Reference } from "@opencode-ai/core/reference"
|
import { Reference } from "@opencode-ai/core/reference"
|
||||||
import { SkillV2 } from "@opencode-ai/core/skill"
|
import { SkillV2 } from "@opencode-ai/core/skill"
|
||||||
|
import { ToolHooks } from "@opencode-ai/core/tool/hooks"
|
||||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { tempLocationLayer } from "../fixture/location"
|
import { tempLocationLayer } from "../fixture/location"
|
||||||
@@ -47,6 +48,7 @@ export const PluginTestLayer = AppNodeBuilder.build(
|
|||||||
PluginRuntime.node,
|
PluginRuntime.node,
|
||||||
Reference.node,
|
Reference.node,
|
||||||
SkillV2.node,
|
SkillV2.node,
|
||||||
|
ToolHooks.node,
|
||||||
ToolRegistry.toolsNode,
|
ToolRegistry.toolsNode,
|
||||||
]),
|
]),
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ export function host(overrides: Overrides = {}): PluginContext {
|
|||||||
},
|
},
|
||||||
tool: overrides.tool ?? {
|
tool: overrides.tool ?? {
|
||||||
register: () => Effect.die("unused tool.register"),
|
register: () => Effect.die("unused tool.register"),
|
||||||
|
execute: {
|
||||||
|
before: () => Effect.die("unused tool.execute.before"),
|
||||||
|
after: () => Effect.die("unused tool.execute.after"),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
session: overrides.session ?? {
|
session: overrides.session ?? {
|
||||||
create: () => Effect.die("unused session.create"),
|
create: () => Effect.die("unused session.create"),
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -248,6 +248,7 @@ describe("SessionProjector", () => {
|
|||||||
messageID: SessionMessage.ID.create(),
|
messageID: SessionMessage.ID.create(),
|
||||||
timestamp: created,
|
timestamp: created,
|
||||||
text: "synthetic context",
|
text: "synthetic context",
|
||||||
|
metadata: { source: "projector-test" },
|
||||||
})
|
})
|
||||||
yield* events.publish(SessionEvent.Shell.Started, {
|
yield* events.publish(SessionEvent.Shell.Started, {
|
||||||
sessionID,
|
sessionID,
|
||||||
@@ -318,6 +319,10 @@ describe("SessionProjector", () => {
|
|||||||
"shell",
|
"shell",
|
||||||
"compaction",
|
"compaction",
|
||||||
])
|
])
|
||||||
|
expect(messages.find((message) => message.type === "synthetic")).toMatchObject({
|
||||||
|
text: "synthetic context",
|
||||||
|
metadata: { source: "projector-test" },
|
||||||
|
})
|
||||||
expect(messages.find((message) => message.type === "shell")).toMatchObject({
|
expect(messages.find((message) => message.type === "shell")).toMatchObject({
|
||||||
output: "/project",
|
output: "/project",
|
||||||
time: { completed: DateTime.makeUnsafe(1) },
|
time: { completed: DateTime.makeUnsafe(1) },
|
||||||
|
|||||||
@@ -126,74 +126,84 @@ export type Endpoint4_9Input = {
|
|||||||
export type Endpoint4_9Output = EffectValue<ReturnType<RawClient["server.session"]["session.skill"]>>
|
export type Endpoint4_9Output = EffectValue<ReturnType<RawClient["server.session"]["session.skill"]>>
|
||||||
export type SessionSkillOperation<E = never> = (input: Endpoint4_9Input) => Effect.Effect<Endpoint4_9Output, E>
|
export type SessionSkillOperation<E = never> = (input: Endpoint4_9Input) => Effect.Effect<Endpoint4_9Output, E>
|
||||||
|
|
||||||
type Endpoint4_10Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
type Endpoint4_10Request = Parameters<RawClient["server.session"]["session.synthetic"]>[0]
|
||||||
export type Endpoint4_10Input = { readonly sessionID: Endpoint4_10Request["params"]["sessionID"] }
|
export type Endpoint4_10Input = {
|
||||||
export type Endpoint4_10Output = EffectValue<ReturnType<RawClient["server.session"]["session.compact"]>>
|
readonly sessionID: Endpoint4_10Request["params"]["sessionID"]
|
||||||
export type SessionCompactOperation<E = never> = (input: Endpoint4_10Input) => Effect.Effect<Endpoint4_10Output, E>
|
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_11Input = { readonly sessionID: Endpoint4_11Request["params"]["sessionID"] }
|
||||||
export type Endpoint4_11Output = EffectValue<ReturnType<RawClient["server.session"]["session.wait"]>>
|
export type Endpoint4_11Output = EffectValue<ReturnType<RawClient["server.session"]["session.compact"]>>
|
||||||
export type SessionWaitOperation<E = never> = (input: Endpoint4_11Input) => Effect.Effect<Endpoint4_11Output, E>
|
export type SessionCompactOperation<E = never> = (input: Endpoint4_11Input) => Effect.Effect<Endpoint4_11Output, E>
|
||||||
|
|
||||||
type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||||
export type Endpoint4_12Input = {
|
export type Endpoint4_12Input = { readonly sessionID: Endpoint4_12Request["params"]["sessionID"] }
|
||||||
readonly sessionID: Endpoint4_12Request["params"]["sessionID"]
|
export type Endpoint4_12Output = EffectValue<ReturnType<RawClient["server.session"]["session.wait"]>>
|
||||||
readonly messageID: Endpoint4_12Request["payload"]["messageID"]
|
export type SessionWaitOperation<E = never> = (input: Endpoint4_12Input) => Effect.Effect<Endpoint4_12Output, E>
|
||||||
readonly files?: Endpoint4_12Request["payload"]["files"]
|
|
||||||
|
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 Endpoint4_13Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.stage"]>>["data"]
|
||||||
export type SessionRevertStageOperation<E = never> = (input: Endpoint4_12Input) => Effect.Effect<Endpoint4_12Output, E>
|
export type SessionRevertStageOperation<E = never> = (input: Endpoint4_13Input) => Effect.Effect<Endpoint4_13Output, E>
|
||||||
|
|
||||||
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
type Endpoint4_14Request = 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]
|
|
||||||
export type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] }
|
export type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] }
|
||||||
export type Endpoint4_14Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.commit"]>>
|
export type Endpoint4_14Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.clear"]>>
|
||||||
export type SessionRevertCommitOperation<E = never> = (input: Endpoint4_14Input) => Effect.Effect<Endpoint4_14Output, E>
|
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_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] }
|
||||||
export type Endpoint4_15Output = EffectValue<ReturnType<RawClient["server.session"]["session.context"]>>["data"]
|
export type Endpoint4_15Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.commit"]>>
|
||||||
export type SessionContextOperation<E = never> = (input: Endpoint4_15Input) => Effect.Effect<Endpoint4_15Output, E>
|
export type SessionRevertCommitOperation<E = never> = (input: Endpoint4_15Input) => Effect.Effect<Endpoint4_15Output, E>
|
||||||
|
|
||||||
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.history"]>[0]
|
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||||
export type Endpoint4_16Input = {
|
export type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] }
|
||||||
readonly sessionID: Endpoint4_16Request["params"]["sessionID"]
|
export type Endpoint4_16Output = EffectValue<ReturnType<RawClient["server.session"]["session.context"]>>["data"]
|
||||||
readonly limit?: Endpoint4_16Request["query"]["limit"]
|
export type SessionContextOperation<E = never> = (input: Endpoint4_16Input) => Effect.Effect<Endpoint4_16Output, E>
|
||||||
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_17Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.history"]>[0]
|
||||||
export type Endpoint4_17Input = {
|
export type Endpoint4_17Input = {
|
||||||
readonly sessionID: Endpoint4_17Request["params"]["sessionID"]
|
readonly sessionID: Endpoint4_17Request["params"]["sessionID"]
|
||||||
|
readonly limit?: Endpoint4_17Request["query"]["limit"]
|
||||||
readonly after?: Endpoint4_17Request["query"]["after"]
|
readonly after?: Endpoint4_17Request["query"]["after"]
|
||||||
}
|
}
|
||||||
export type Endpoint4_17Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.events"]>>>
|
export type Endpoint4_17Output = EffectValue<ReturnType<RawClient["server.session"]["session.history"]>>
|
||||||
export type SessionEventsOperation<E = never> = (input: Endpoint4_17Input) => Stream.Stream<Endpoint4_17Output, E>
|
export type SessionHistoryOperation<E = never> = (input: Endpoint4_17Input) => Effect.Effect<Endpoint4_17Output, E>
|
||||||
|
|
||||||
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||||
export type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] }
|
export type Endpoint4_18Input = {
|
||||||
export type Endpoint4_18Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
|
readonly sessionID: Endpoint4_18Request["params"]["sessionID"]
|
||||||
export type SessionInterruptOperation<E = never> = (input: Endpoint4_18Input) => Effect.Effect<Endpoint4_18Output, E>
|
readonly after?: Endpoint4_18Request["query"]["after"]
|
||||||
|
|
||||||
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"]
|
|
||||||
}
|
}
|
||||||
export type Endpoint4_20Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
|
export type Endpoint4_18Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.events"]>>>
|
||||||
export type SessionMessageOperation<E = never> = (input: Endpoint4_20Input) => Effect.Effect<Endpoint4_20Output, E>
|
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> {
|
export interface SessionApi<E = never> {
|
||||||
readonly list: SessionListOperation<E>
|
readonly list: SessionListOperation<E>
|
||||||
@@ -206,6 +216,7 @@ export interface SessionApi<E = never> {
|
|||||||
readonly rename: SessionRenameOperation<E>
|
readonly rename: SessionRenameOperation<E>
|
||||||
readonly prompt: SessionPromptOperation<E>
|
readonly prompt: SessionPromptOperation<E>
|
||||||
readonly skill: SessionSkillOperation<E>
|
readonly skill: SessionSkillOperation<E>
|
||||||
|
readonly synthetic: SessionSyntheticOperation<E>
|
||||||
readonly compact: SessionCompactOperation<E>
|
readonly compact: SessionCompactOperation<E>
|
||||||
readonly wait: SessionWaitOperation<E>
|
readonly wait: SessionWaitOperation<E>
|
||||||
readonly revertStage: SessionRevertStageOperation<E>
|
readonly revertStage: SessionRevertStageOperation<E>
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ export type { PluginContext } from "./context.js"
|
|||||||
export { define } from "./plugin.js"
|
export { define } from "./plugin.js"
|
||||||
export type { Plugin } from "./plugin.js"
|
export type { Plugin } from "./plugin.js"
|
||||||
export * as Tool from "./tool.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"
|
export type { SessionDomain } from "./runtime.js"
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
export * as Tool from "./tool.js"
|
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 { Agent } from "@opencode-ai/schema/agent"
|
||||||
import { Session } from "@opencode-ai/schema/session"
|
import { Session } from "@opencode-ai/schema/session"
|
||||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
import { Effect, JsonSchema, Schema, type Scope } from "effect"
|
import { Effect, JsonSchema, Schema, type Scope } from "effect"
|
||||||
|
import type { Hooks } from "./registration.js"
|
||||||
|
|
||||||
export interface Context {
|
export interface Context {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
@@ -213,6 +214,28 @@ function toJsonSchema(schema: Schema.Top): JsonSchema.JsonSchema {
|
|||||||
return { ...document.schema, $defs: document.definitions }
|
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 {
|
export interface ToolDomain {
|
||||||
readonly register: (tools: Readonly<Record<string, AnyTool>>) => Effect.Effect<void, RegistrationError, Scope.Scope>
|
readonly register: (tools: Readonly<Record<string, AnyTool>>) => Effect.Effect<void, RegistrationError, Scope.Scope>
|
||||||
|
readonly execute: Hooks<{ before: ToolExecuteBeforeEvent; after: ToolExecuteAfterEvent }>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
.add(
|
||||||
HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", {
|
HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", {
|
||||||
params: { sessionID: Session.ID },
|
params: { sessionID: Session.ID },
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ export const Synthetic = Event.define({
|
|||||||
messageID: SessionMessage.ID,
|
messageID: SessionMessage.ID,
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
description: Schema.String.pipe(optional),
|
description: Schema.String.pipe(optional),
|
||||||
|
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export type Synthetic = typeof Synthetic.Type
|
export type Synthetic = typeof Synthetic.Type
|
||||||
|
|||||||
@@ -239,6 +239,27 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
|||||||
return HttpApiSchema.NoContent.make()
|
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(
|
.handle(
|
||||||
"session.compact",
|
"session.compact",
|
||||||
Effect.fn(function* (ctx) {
|
Effect.fn(function* (ctx) {
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
# V2 Schema Changelog
|
# 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
|
## 2026-06-26: Add Finite Session History
|
||||||
|
|
||||||
- Add `GET /api/session/:sessionID/history` and generated Promise, Effect, and legacy JavaScript client methods.
|
- Add `GET /api/session/:sessionID/history` and generated Promise, Effect, and legacy JavaScript client methods.
|
||||||
|
|||||||
Reference in New Issue
Block a user