Expose semantic execution hooks to libfx hosts

Signed-off-by: Chad Hietala <chad.hietala@vercel.com>
This commit is contained in:
Chad Hietala committed 2026-10-15 09:43:27 -04:00
1 parent 5a3a5f39bf
commit 460142bf26
59 files changed
+3085 -84

No files matched your search

+8
View File
@@ -247,6 +247,8 @@ jobs:
- name: Test libfx Wasm integrations
run: |
node --experimental-wasm-jspi sdk/tests/test-host-tools.mjs wasm
node --experimental-wasm-jspi sdk/tests/test-hooks.mjs wasm
env LIBFX_TEST_HOOKS=1 node --experimental-wasm-jspi sdk/tests/test-agent-steering.mjs wasm
node --experimental-wasm-jspi sdk/tests/test-host-tool-cancel.mjs wasm
node --experimental-wasm-jspi sdk/tests/test-mcp-adapter.mjs http wasm
node --experimental-wasm-jspi sdk/tests/test-skills-adapter.mjs host wasm
@@ -254,6 +256,7 @@ jobs:
- name: Test durable sessions on Wasm
run: |
node --experimental-wasm-jspi sdk/tests/test-durable.mjs memory wasm
env LIBFX_TEST_HOOKS=1 node --experimental-wasm-jspi sdk/tests/test-durable.mjs memory wasm
node --experimental-wasm-jspi sdk/tests/test-durable.mjs local wasm
node --experimental-wasm-jspi sdk/tests/test-durable-provider-crash.mjs wasm
@@ -290,6 +293,8 @@ jobs:
run: |
node sdk/tests/test-minimal-api.mjs
node sdk/tests/test-host-tools.mjs native
node sdk/tests/test-hooks.mjs native
env LIBFX_TEST_HOOKS=1 node sdk/tests/test-agent-steering.mjs native
node sdk/tests/test-host-tool-cancel.mjs native
node sdk/tests/test-mcp-adapter.mjs stdio native
node sdk/tests/test-skills-adapter.mjs disk native
@@ -300,6 +305,7 @@ jobs:
- name: Test durable sessions on N-API
run: |
node sdk/tests/test-durable.mjs memory native
env LIBFX_TEST_HOOKS=1 node sdk/tests/test-durable.mjs memory native
node sdk/tests/test-durable.mjs local native
node sdk/tests/test-durable.mjs world native
node sdk/tests/test-durable-provider-crash.mjs native
@@ -375,6 +381,7 @@ jobs:
echo "936a262061c914694dfd669a543be24573c45d5aa0ff20a8b96b23d01e050e88 $jar" | sha256sum -c -
TLA2TOOLS_JAR="$jar" tests/tla/sdk/check.sh
bun tests/tla/drivers/drive.mjs send --walks 40
bun tests/tla/drivers/drive.mjs send --walks 20 --seed 3 --hooks
bun tests/tla/drivers/drive.mjs lookup --walks 40 --seed 7
bun tests/tla/drivers/drive.mjs send --walks 20 --seed 3 --durability world
bun tests/tla/drivers/drive.mjs lookup --walks 20 --seed 9 --durability world
@@ -386,6 +393,7 @@ jobs:
bun tests/tla/drivers/drive-agents.mjs consume --walks 60 --seed 13
bun tests/tla/drivers/drive-steer.mjs ideal --walks 20 --seed 2
bun tests/tla/drivers/drive-steer.mjs faults --walks 20 --seed 4
bun tests/tla/drivers/drive-steer.mjs faults --walks 10 --seed 4 --hooks
bun tests/tla/drivers/drive-steer.mjs faults --walks 10 --seed 6 --backend wasm
- name: Test libfx on a World
+6 -1
View File
@@ -86,10 +86,12 @@ jobs:
run: |
cp sdk/fx-sdk.js fx-sdk.js
cp sdk/core-output.js core-output.js
cp sdk/tool-observation.js tool-observation.js
cp sdk/wasm-module.js wasm-module.js
cp zig-out/bin/fx-term.wasm fx-term.wasm
sha256sum fx-sdk.js > fx-sdk.js.sha256
sha256sum core-output.js > core-output.js.sha256
sha256sum tool-observation.js > tool-observation.js.sha256
sha256sum wasm-module.js > wasm-module.js.sha256
sha256sum fx-term.wasm > fx-term.wasm.sha256
@@ -102,6 +104,8 @@ jobs:
fx-sdk.js.sha256
core-output.js
core-output.js.sha256
tool-observation.js
tool-observation.js.sha256
wasm-module.js
wasm-module.js.sha256
fx-term.wasm
@@ -155,10 +159,11 @@ jobs:
sha256sum --check fx-sdk.js.sha256
sha256sum --check core-output.js.sha256
sha256sum --check tool-observation.js.sha256
sha256sum --check wasm-module.js.sha256
sha256sum --check fx-term.wasm.sha256
for FILE in fx-sdk.js fx-sdk.js.sha256 core-output.js core-output.js.sha256 wasm-module.js wasm-module.js.sha256 fx-term.wasm fx-term.wasm.sha256; do
for FILE in fx-sdk.js fx-sdk.js.sha256 core-output.js core-output.js.sha256 tool-observation.js tool-observation.js.sha256 wasm-module.js wasm-module.js.sha256 fx-term.wasm fx-term.wasm.sha256; do
CONTENT_TYPE="application/octet-stream"
[[ "$FILE" == *.js ]] && CONTENT_TYPE="text/javascript"
[[ "$FILE" == *.wasm ]] && CONTENT_TYPE="application/wasm"
+1 -1
View File
@@ -284,7 +284,7 @@ jobs:
const names = new Set(report.files.map(({ path }) => path));
const required = [
"package.json", "README.md", "LICENSE", "browser.js", "node.js", "node.cjs", "fx-sdk.js", "wasm-module.js",
"core-output.js", "fetch-cleanup.js", "mcp.js", "skills.js", "skills-node.js", "subscription.js", "oauth.js", "chatgpt.js", "chatgpt-sign-in.js", "grok.js", "grok-sign-in.js", "custom-provider.js",
"core-output.js", "tool-observation.js", "fetch-cleanup.js", "mcp.js", "skills.js", "skills-node.js", "subscription.js", "oauth.js", "chatgpt.js", "chatgpt-sign-in.js", "grok.js", "grok-sign-in.js", "custom-provider.js",
"durable.js", "fx-harness.js", "durable/local.mjs", "durable/vercel.mjs", "durable/world.mjs",
"types/browser.d.ts", "types/chatgpt.d.ts", "types/durable-local.d.ts", "types/durable-vercel.d.ts", "types/durable-world.d.ts", "types/grok.d.ts", "types/libfx.d.cts", "types/mcp.d.ts", "types/node.d.cts", "types/node.d.ts", "types/skills-node.d.ts", "types/skills.d.ts", "types/wasm.d.ts",
"fx-core.wasm", "fx-term.wasm",
+39
View File
@@ -407,6 +407,45 @@ A session stores each prompt as JSON before it runs, so a session prompt takes i
`onEvent` receives diagnostics apart from the model's output: request timing and retries, and session events such as `session.deadline` when a turn stops for a function's time limit. The `FxAgentEvent` type lists them all.
## Instrumentation hooks
Pass `hooks` to `createFxAgent()` or `createFxEngine()` to observe semantic execution on both native and Wasm backends. Your host owns span topology, naming, redaction, and export. libfx doesn't load a tracing library.
```ts
import { createFxAgent, type FxHookEvent } from "libfx";
const observed: FxHookEvent[] = [];
const agent = createFxAgent({
hooks: {
captureContent: true,
onEvent(event) { observed.push(event); },
onError(error) { console.error("Instrumentation callback failed", error); },
},
});
```
`FxHookEvent` includes turn and step boundaries, model requests and completions, provider admission and first output, tool execution and accepted results, permission evaluation, and journal restore/commit/error receipts. Events carry the session ID, logical turn ID, and a fresh execution ID for each prompt or resume. Model-call and step ordinals are scoped to that execution; tool call IDs come from the model and survive recovery. Join a tool to its issuing model through `model.end.data.toolCalls`, without matching tool names or parsing UI events.
`tool.end` reports execution; `tool.result` means the result entered the conversation, not that storage accepted it. `journal.commit` follows a successful append and includes its validated cursor and sequence range. Journal receipts are session-scoped, with null turn and execution IDs. A yielded or paused turn stays resumable; `turn.end.data.handoff` identifies an explicit host handoff. Replaying saved results doesn't emit fresh tool execution events; an authorized rerun emits `tool.start` with `rerun: true` and the original call ID.
`wait.start` and `wait.end` describe actual input requests separately from permission evaluation. Their `kind` is `approval`, `question`, `mcp_form`, or `mcp_url`; request IDs pair outcomes, including denial, cancellation, and failure. Native delegation events carry the child session and durable work ID. Child events retain that work ID, their own session/turn/execution identity, and the root and parent session IDs, so the host can join them even if delivery interleaves.
MCP events include the configured server, original remote tool name, and owning fx call ID. `mcp.request.data.requestIdKind` distinguishes a native JSON-RPC ID, an MCP-v2 engine handle, and an explicitly propagated host correlation token. Hooks don't change remote MCP requests by default: the host adapter emits `mcp.start` and `mcp.end` without adding metadata or emitting a request ID. Opt in with `createMcpAdapter(client, { propagateCorrelation: true })` to send an opaque UUID as `_meta["libfx/correlationId"]` and record it in `mcp.request`. It contains no internal execution/call IDs and doesn't request progress notifications. Give the adapter a `serverName` for a stable local identity; otherwise it uses the client's reported server name when available. Native resource/prompt operations also report their server and action.
`mcp.end.status` uses `success`, `failure`, `cancelled`, `input_required`, `unavailable`, or `unknown`. Native tool/protocol failures map to `failure`; `unavailable` means no operation result was available, and `unknown` means observation couldn't classify a returned host result.
Host tools receive `context.observation` when hooks are enabled. Wrap host-owned approvals or questions with `observation.wait(request, effect)`; the effect still owns the decision, response, cancellation, and error. For a host-created child engine, pass `hooks: context.observation.childHooks()` to retain the parent tool relationship. These helpers are optional, and neither requires a tracing library. Native review, vision, title, and compaction calls use the model event family with an explicit purpose; tool-owned model calls include `parentCallId`.
Always call the host effect when `context.observation` is absent too. Disabling hooks must not skip an approval or question; the observer doesn't make permission decisions.
JavaScript host-tool callbacks apply to the engine's own root session, not native descendant sessions. Descendant hook metadata doesn't authorize a cross-session host-tool request. A host-created child engine has its own root session and can install `childHooks()` for lineage.
Content capture defaults to off. Enabling it includes bounded messages, instructions, tool arguments/results, input prompts/answers, and schemas, which can contain sensitive application content. Each event has a shared 64 KiB text budget. For model requests, the newest messages are kept first from the last 128 messages, followed by instructions, tool descriptions, and schemas. Collections keep at most 128 entries, and tool `inputSchemaJson` fields keep at most 8 KiB each. `contentTruncated` marks omissions. JSON text fields can be prefixes after truncation; `inputSchemaJson` is complete JSON or null. Credentials, request headers, raw image bytes, and opaque provider replay state aren't fields in this protocol. Apply your own redaction before export.
Callbacks run in delivery order, but libfx doesn't await their promises. Callback throws and rejections go to `hooks.onError` and don't fail or retry agent work. A slow synchronous callback still delays delivery, so enqueue events and let the host own buffering and flushing. Hooks are best-effort observations, not a durable trace outbox or exactly-once delivery contract. Existing `onEvent` diagnostics and turn UI streams remain separate.
Auxiliary title-model calls can outlive `turn.end` and report model events with the same turn/execution IDs afterward. Their model-call IDs and `purpose: "title"` distinguish them from the finished agent loop.
## Without sessions
`createFxEngine()` is the engine under the agent: one conversation in the memory of one process, with no queue and no session store. Use it for a single request, a subagent inside a tool call, or a conversation you store yourself.
+71 -4
View File
@@ -4,6 +4,7 @@ import { customProviderDefinition, customProviderSpec } from "./custom-provider.
import { grokSubscription } from "./grok.js";
import { createSubscriptionAuthorizer, credentialMemory } from "./subscription.js";
import { loadModule } from "./wasm-module.js";
import { toolObservation } from "./tool-observation.js";
// Providers that run on a user's plan, authorized by the host's credentials.
const subscriptionProviders = new Set(["codex", "grok"]);
@@ -150,6 +151,25 @@ export function normalizeAgentOptions(value) {
throw new TypeError("createFxEngine() options must be an object");
}
const options = { ...value };
if (options.hooks !== undefined) {
const hooks = options.hooks;
if (!hooks || typeof hooks !== "object" || Array.isArray(hooks) || typeof hooks.onEvent !== "function") {
throw new TypeError("hooks must be an object with onEvent()");
}
if (hooks.onError !== undefined && typeof hooks.onError !== "function") throw new TypeError("hooks.onError must be a function");
if (hooks.captureContent !== undefined && typeof hooks.captureContent !== "boolean") throw new TypeError("hooks.captureContent must be a boolean");
options.hooks = {
captureContent: hooks.captureContent === true,
onEvent: hooks.onEvent.bind(hooks),
...(hooks.onError === undefined ? {} : { onError: hooks.onError.bind(hooks) }),
...(hooks.parent === undefined ? {} : { parent: { ...hooks.parent } }),
};
if (options.hooks.parent !== undefined) {
for (const name of ["rootSessionId", "sessionId", "turnId", "callId"]) {
if (typeof options.hooks.parent[name] !== "string" || options.hooks.parent[name].length === 0 || options.hooks.parent[name].length > 1024) throw new TypeError(`hooks.parent.${name} must be a bounded identity`);
}
}
}
if (Object.hasOwn(options, "env")) {
throw new TypeError("createFxEngine() does not accept env; pass apiKey and model directly");
}
@@ -2245,6 +2265,33 @@ export async function createFxEngine(options = {}) {
const pending = new Map();
let nextId = 1;
let sessionId = null;
const hookScopes = new Map();
// Observation never participates in effects, journal barriers, or retries.
// Contain both throws and rejected promises, including an error handler's.
const hookError = (error) => {
try { Promise.resolve(options.hooks?.onError?.(error)).catch(() => {}); } catch {}
};
const hook = (event) => {
if (!options.hooks) return;
if (options.hooks.parent) event = {
...event, rootSessionId: options.hooks.parent.rootSessionId,
...(event.sessionId === sessionId && event.parentSessionId == null ? {
parentSessionId: options.hooks.parent.sessionId, parentTurnId: options.hooks.parent.turnId, parentCallId: options.hooks.parent.callId,
} : {}),
};
if (event.type === "tool.start") {
const { data, type, ...scope } = event;
hookScopes.set(`${event.sessionId}:${data.call.callId}`, scope);
} else if (event.type === "tool.end") hookScopes.delete(`${event.sessionId}:${event.data.call.callId}`);
else if (event.type === "turn.end") {
for (const [key, scope] of hookScopes) if (scope.executionId === event.executionId) hookScopes.delete(key);
} else if (event.type === "journal.error" && event.data.fenced) hookScopes.clear();
try { Promise.resolve(options.hooks.onEvent(event)).catch(hookError); } catch (error) { hookError(error); }
};
const journalHook = (type, data) => hook({
version: 1, type, sessionId, rootSessionId: sessionId, parentSessionId: null, sourceSessionId: null, workId: null, turnId: null, executionId: null, stepIndex: 0,
timestamp: Date.now(), contentCaptured: false, contentTruncated: false, data,
});
let activeTurn = null;
let closing = false;
let coreExitError = null;
@@ -2279,6 +2326,7 @@ export async function createFxEngine(options = {}) {
journalHead = result.cursor;
if (Number.isSafeInteger(lastSeq)) appendedSeq = lastSeq;
bytesSinceCheckpoint += data.byteLength;
journalHook("journal.commit", { cursor: result.cursor, firstSeq: Number.isSafeInteger(events[0]?.seq) ? events[0].seq : null, lastSeq: Number.isSafeInteger(lastSeq) ? lastSeq : null, eventCount: events.length });
});
// A failed append fails every later one: they would not continue it.
appendTail = appended;
@@ -2422,6 +2470,7 @@ export async function createFxEngine(options = {}) {
if (journalFailure) return;
journalFailure = journalAppendError(error);
emit("journal.error", { error: error instanceof Error ? error.name : "Error" });
journalHook("journal.error", { error: error instanceof Error ? error.name : "Error", fenced: error?.code === "FX_FENCED" || error?.cause?.code === "FX_FENCED" });
rejectInputWaiters([...inputWaiters.keys()], journalFailure);
// Nothing the turn does from here can be saved, and after a fence the
// session has another owner: stop the turn and its effects now.
@@ -2560,7 +2609,10 @@ export async function createFxEngine(options = {}) {
};
const executeHostTool = async (name, input, requestedSessionId, toolCallId) => {
const execute = hostTools.executors.get(name);
const turn = requestedSessionId === undefined || requestedSessionId === sessionId
const issuerSessionId = requestedSessionId ?? sessionId;
const scope = hookScopes.get(`${issuerSessionId}:${toolCallId}`);
// Hook metadata is not authority to execute a foreign session's tool.
const turn = issuerSessionId === sessionId
? activeTurn
: null;
if (!isCurrentTurn(turn)) return { content: "", isError: true, cancelled: true };
@@ -2581,7 +2633,8 @@ export async function createFxEngine(options = {}) {
return execute(input, {
signal: controller.signal,
executionId: String(toolCallId ?? ""),
sessionId,
sessionId: issuerSessionId,
...(options.hooks && scope ? { observation: toolObservation(scope, String(toolCallId), options.hooks, hook, controller.signal) } : {}),
...(options.toolContext === undefined ? {} : { context: options.toolContext }),
});
});
@@ -2695,12 +2748,13 @@ export async function createFxEngine(options = {}) {
// when the host could not. A journaled turn's first progress records its id.
const fx = {
...(followUp ? { inputId: followUp.id, inputAccepted: followUp.accepted } : {}),
...(journal && turnId ? { turnId } : {}),
...((journal || options.hooks) && turnId ? { turnId } : {}),
...stopAt,
};
return request("session/prompt", { sessionId, prompt, ...(Object.keys(fx).length ? { _meta: { fx } } : {}) });
};
runtime.exited.then((code) => {
hookScopes.clear();
closing = true;
// An agent whose core exited writes nothing more.
if (journal) void closeJournal().catch(() => {});
@@ -2712,6 +2766,13 @@ export async function createFxEngine(options = {}) {
emit("runtime.exit", { code });
});
runtime.setLineHandler((message, size) => {
if (message.method === "libfx/hook") {
if (message.params?.rootSessionId === sessionId && message.params?.version === 1) {
if (message.params.type === "turn.end" && handedOff) message.params.data.handoff = true;
hook(message.params);
}
return;
}
emit("acp.receive", { message });
if (message.method === "libfx/journal_append") {
if (journal && message.params?.sessionId === sessionId) {
@@ -2783,8 +2844,12 @@ export async function createFxEngine(options = {}) {
const loaded = persistence ? await loadPersistence(persistence) : null;
const requestedSessionId = options.sessionId ?? null;
const sessionResult = await request("libfx/new", requestedSessionId === null ? {} : { sessionId: requestedSessionId });
const sessionResult = await request("libfx/new", {
...(requestedSessionId === null ? {} : { sessionId: requestedSessionId }),
...(options.hooks ? { hooks: { captureContent: options.hooks.captureContent === true } } : {}),
});
sessionId = sessionResult.sessionId;
if (options.hooks && sessionResult?._meta?.fx?.hooksVersion !== 1) throw new Error("Semantic hooks require a matching libfx core with hooks version 1");
if (initialCheckpoint) {
const [checkpointAttachment] = attachBytes([initialCheckpoint]);
const restored = await request("libfx/restore", { sessionId, checkpointAttachment }).catch((error) => {
@@ -2828,6 +2893,7 @@ export async function createFxEngine(options = {}) {
followUps.push({ id: held.id, text: held.text, accepted: true, held: true });
}
emit("journal.open", { events: events.length, checkpoint: checkpoint !== null, turns: opened?.turns, resumable });
journalHook("journal.restore", { cursor: loaded.head, lastSeq: loaded.lastSeq, resumable, openTurnId, lastTurnId });
}
} catch (error) {
closing = true;
@@ -2980,6 +3046,7 @@ export async function createFxEngine(options = {}) {
return run;
},
async close() {
hookScopes.clear();
if (closing) {
await runtime.exited;
if (journal) await closeJournal();
+11 -2
View File
@@ -33,6 +33,11 @@ export async function createMcpAdapter(client, options = {}) {
throw new TypeError("MCP client must provide listTools() and callTool()");
}
const prefix = options.prefix ?? "";
if (options.propagateCorrelation !== undefined && typeof options.propagateCorrelation !== "boolean") throw new TypeError("MCP propagateCorrelation must be a boolean");
if (options.serverName !== undefined && (typeof options.serverName !== "string" || options.serverName.length === 0 || options.serverName.length > 256)) throw new TypeError("MCP serverName must be a bounded name");
let reportedName;
try { reportedName = client.getServerVersion?.()?.name; } catch {}
const serverName = options.serverName ?? (typeof reportedName === "string" && reportedName.length > 0 && reportedName.length <= 256 ? reportedName : null);
if (typeof prefix !== "string" || !/^[A-Za-z0-9_-]*$/.test(prefix)) {
throw new TypeError("MCP prefix must contain only letters, digits, underscore, or hyphen");
}
@@ -70,8 +75,12 @@ export async function createMcpAdapter(client, options = {}) {
// The server's hints are not trusted: a call is never run twice on its
// own. A host that knows a tool is safe to repeat can set this to true.
idempotent: false,
async execute(input, { signal }) {
const result = await client.callTool({ name: tool.name, arguments: input }, undefined, { signal });
async execute(input, { signal, observation }) {
const correlationId = options.propagateCorrelation === true ? crypto.randomUUID() : undefined;
// Correlation is not progress reporting: progressToken requests remote
// notifications and some clients replace it with their JSON-RPC ID.
const send = () => client.callTool({ name: tool.name, arguments: input, ...(correlationId === undefined ? {} : { _meta: { "libfx/correlationId": correlationId } }) }, undefined, { signal });
const result = observation ? await observation.mcp({ server: serverName, tool: tool.name, correlationId }, send) : await send();
const text = resultText(result);
const images = (Array.isArray(result?.content) ? result.content : []).flatMap((item) => item?.type === "image" ? [item] : item?.type === "resource" && item.resource?.mimeType?.startsWith("image/") && typeof item.resource?.blob === "string" ? [{ type: "image", mimeType: item.resource.mimeType, data: item.resource.blob }] : []).map((item) => ({
type: "image", mimeType: item.mimeType, data: item.data,
+1
View File
@@ -92,6 +92,7 @@
"node.js",
"node.cjs",
"fx-sdk.js",
"tool-observation.js",
"durable.js",
"fx-harness.js",
"durable/local.mjs",
+1
View File
@@ -34,6 +34,7 @@ const files = [
["sdk/fx-harness.js", "fx-harness.js"],
["sdk/wasm-module.js", "wasm-module.js"],
["sdk/core-output.js", "core-output.js"],
["sdk/tool-observation.js", "tool-observation.js"],
["sdk/subscription.js", "subscription.js"],
["sdk/oauth.js", "oauth.js"],
["sdk/chatgpt.js", "chatgpt.js"],
+10 -2
View File
@@ -12,15 +12,17 @@ const browserPath = resolve(repoRoot, "sdk/browser.js");
const sdkPath = resolve(repoRoot, "sdk/fx-sdk.js");
const coreOutputPath = resolve(repoRoot, "sdk/core-output.js");
const wasmModulePath = resolve(repoRoot, "sdk/wasm-module.js");
const toolObservationPath = resolve(repoRoot, "sdk/tool-observation.js");
const wasmPath = resolve(repoRoot, "zig-out/bin/fx-term.wasm");
const [htmlSource, browserBytes, sdkSource, coreOutputBytes, wasmModuleBytes, wasmBytes] = await Promise.all([
const [htmlSource, browserBytes, sdkSource, coreOutputBytes, wasmModuleBytes, wasmBytes, toolObservationBytes] = await Promise.all([
readFile(htmlPath, "utf8"),
readFile(browserPath),
readFile(sdkPath),
readFile(coreOutputPath),
readFile(wasmModulePath),
readFile(wasmPath),
readFile(toolObservationPath),
]);
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
@@ -29,9 +31,12 @@ const coreOutputHash = digest(coreOutputBytes);
const coreOutputName = `core-output.${coreOutputHash}.js`;
const wasmModuleHash = digest(wasmModuleBytes);
const wasmModuleName = `wasm-module.${wasmModuleHash}.js`;
const toolObservationHash = digest(toolObservationBytes);
const toolObservationName = `tool-observation.${toolObservationHash}.js`;
const sdkBytes = Buffer.from(sdkSource.toString()
.replace('from "./core-output.js";', `from "./${coreOutputName}";`)
.replace('from "./wasm-module.js";', `from "./${wasmModuleName}";`));
.replace('from "./wasm-module.js";', `from "./${wasmModuleName}";`)
.replace('from "./tool-observation.js";', `from "./${toolObservationName}";`));
const sdkHash = digest(sdkBytes);
const wasmHash = digest(wasmBytes);
const sdkName = `fx-sdk.${sdkHash}.js`;
@@ -88,6 +93,7 @@ const vercelConfig = {
source: `/${wasmName}`,
headers: [{ key: "Cache-Control", value: "public, max-age=31536000, immutable" }],
},
{ source: `/${toolObservationName}`, headers: [{ key: "Cache-Control", value: "public, max-age=31536000, immutable" }] },
{
source: "/manifest.json",
headers: [{ key: "Cache-Control", value: "no-store" }],
@@ -100,6 +106,7 @@ await Promise.all([
writeFile(resolve(outputDir, sdkName), sdkBytes),
writeFile(resolve(outputDir, coreOutputName), coreOutputBytes),
writeFile(resolve(outputDir, wasmModuleName), wasmModuleBytes),
writeFile(resolve(outputDir, toolObservationName), toolObservationBytes),
writeFile(resolve(outputDir, wasmName), wasmBytes),
writeFile(resolve(outputDir, "vercel.json"), `${JSON.stringify(vercelConfig, null, 2)}\n`),
]);
@@ -110,6 +117,7 @@ const manifest = {
sdk: { file: sdkName, sha256: sdkHash, bytes: sdkBytes.byteLength },
coreOutput: { file: coreOutputName, sha256: coreOutputHash, bytes: coreOutputBytes.byteLength },
wasmModule: { file: wasmModuleName, sha256: wasmModuleHash, bytes: wasmModuleBytes.byteLength },
toolObservation: { file: toolObservationName, sha256: toolObservationHash, bytes: toolObservationBytes.byteLength },
wasm: { file: wasmName, sha256: wasmHash, integrity: integrity(wasmBytes), bytes: wasmBytes.byteLength },
};
await writeFile(resolve(outputDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
+104
View File
@@ -0,0 +1,104 @@
# Semantic observer hooks
libfx exposes execution facts to host-owned instrumentation through `hooks`.
See [the SDK contract](README.md#instrumentation-hooks) for usage and
`sdk/types/libfx.d.cts` for event types.
The JavaScript host owns trace topology, span names, attribute conventions,
capture policy, and export. fx doesn't depend on a tracing library, an
OpenTelemetry SDK, or a collector.
## Separate observation from control
`src/core/hooks/hooks.zig` already owns product hooks such as `PreToolUse` that
can modify or reject work. Instrumentation must not reuse that control contract.
The existing SDK `onEvent` reports diagnostics, and turn iterators report UI
output. Neither is a complete semantic execution stream.
`src/core/agent/runtime/observation.zig` defines the optional borrowed typed sink.
`src/acp/libfx_hooks.zig` projects safe fields onto versioned `libfx/hook`
notifications. Both native and Wasm libfx use that same adapter. The JavaScript
host dispatches them through `hooks.onEvent`; disabled hooks don't serialize or
send semantic events. The standalone CLI doesn't install an observer sink.
An ACP host can opt in on `session/new`, `session/load`, or `session/resume`
through `_meta.fx.hooks`, with the same capture option and event protocol. The
response confirms hooks version 1; omitting the option on a new/load/resume
request disables hooks, including a reload of the active session. This doesn't
change ACP permission policy.
Clients should wait for the prompt response before creating, loading, or resuming
another session. A parent observer captures its setting at prompt start; native
children read the active session's setting when they start. Reconfiguration
during ongoing work isn't an atomic change to the whole parent/child tree.
## Event contract
The envelope carries a protocol version, epoch timestamp, session ID, logical
turn ID, worker execution ID, and execution-local step ordinal. Model-call IDs
are unique within the execution, including retries. Authoritative tool call IDs
link execution and results to the issuing `model.end`. These aren't span IDs.
| Family | Facts reported |
| --- | --- |
| Turn | Start or resume; input; execution disposition; handoff annotation from the host |
| Step | Execution-local ordinal; entry and exit, without claiming successful completion |
| Model | Provider/model, purpose, request messages/instructions/tool definitions, streaming milestones, response metadata, finish reason, usage, and error |
| Tool | Authoritative call ID, arguments, execution start/result/error, conversation acceptance, and explicit rerun flag |
| Permission | Whole admission evaluation and its decision/error, not an inferred human wait |
| Input wait | Actual approval/question/MCP form or URL request, owning call, request identity, bounded content, and outcome |
| MCP | Configured server and original tool/action, owning call, transport/correlation identity, and terminal status |
| Delegation | Accepted dispatch, child session and durable work ID, replay/yield/completion/cancellation |
| Persistence | Restored position and open turn; validated append cursor and sequence range; failure/fencing |
The main model, compaction, review, vision, and title paths report distinct
purposes without synthesizing user turns for auxiliary calls. Review retries
have separate model-call IDs. Title tasks borrow an observer only while their
owner guarantees a join before releasing it. MCP instrumentation stays in fx's
host boundary; the isolated MCP-v2 engine has no tracing imports.
Native child runtimes allocate a fresh execution observer and carry the existing
durable message/work identity. They don't retain a pointer to a parent's
turn-scoped observer. Host-created JavaScript children receive the equivalent
parent relationship through `context.observation.childHooks()`.
## Lifecycle and durability rules
- Execution starts, returned results, and accepted durable results are distinct
phases. An event emitted before a write is not proof that the write committed.
- libfx's persistence owner reports append acceptance only after its existing
`append()` resolves with a valid cursor. A fenced write isn't accepted work.
- Restore and replay are explicit. Loading a completed call must not present it
as a newly executed call. A permitted rerun retains the logical call ID and
gets a distinct attempt identity.
- Yield, handoff, worker deadline, and fencing don't imply a failed logical turn.
A consumer can keep that invocation open and continue it on another worker.
- Observers don't own scheduling, permission decisions, replay policy, or the
durable journal. They must not create a second session or effect log.
- Native event slices are borrowed for the callback. A retaining consumer copies
them. SDK callback payloads are detached from mutable execution state.
- Callback failures are contained and reported through a separate observer-error
channel. They never replace application values/errors, cause a tool/model call
to run again, or become an unhandled rejected promise.
- Per-operation ordering is defined; concurrently executing tools need not have
a total execution order. Durable acceptance follows the journal's order.
- Content is opt-in. Request headers, credentials, opaque provider replay state,
and raw image bytes aren't observation payloads. Content limits and omission
markers are explicit; a consumer's export policy is not a permission policy.
## Proof and remaining boundaries
`sdk/tests/test-hooks.mjs` drives real native and Wasm engines through parallel
tools, errors, content limits, observer failures, durable yield/resume, and fenced
appends. `sdk/tests/test-types.mjs` checks the public types across Node, CommonJS,
browser, and explicit Wasm entry points. The gateway unit fixture exercises model
start/end pairing through success, transport failure, and cancellation.
The focused ACP fixtures exercise real command approval, MCP-v1/v2 calls and
elicitation, and native child execution. Core fixtures cover question outcomes,
review retries, title generation, and batched vision calls. SDK fixtures cover
host-owned input effects, MCP correlation, and child engines on both backends.
The host still owns span topology, asynchronous export, and any trace-state
recovery. No trace continuation state, durable export outbox, or tracing package
is added to fx or its checkpoint format. The standalone CLI doesn't configure
a sink or collector; native runtime consumers can install the typed sink.
+33
View File
@@ -0,0 +1,33 @@
import { strict as assert } from "node:assert";
const enabled = process.env.LIBFX_TEST_HOOKS === "1" || process.argv.includes("--hooks");
const turns = new Map();
const errors = [];
let seen = 0;
/** Opt-in observation for the existing durability/steering conformance drivers. */
export const testHooks = enabled
? {
onEvent(event) {
seen++;
assert.equal(event.version, 1);
assert.equal(typeof event.sessionId, "string");
if (event.executionId !== null) {
assert.equal(typeof event.turnId, "string");
assert.ok(event.turnId.length > 0);
const previous = turns.get(event.executionId);
if (previous !== undefined) assert.equal(event.turnId, previous, event.type);
turns.set(event.executionId, event.turnId);
}
},
onError(error) {
errors.push(error);
},
}
: undefined;
process.once("exit", () => {
if (!enabled) return;
assert.equal(errors.length, 0, errors.map((error) => error.stack ?? String(error)).join("\n"));
assert.ok(seen > 0, "hooks-enabled conformance run observed no events");
});
+2
View File
@@ -5,6 +5,7 @@ import { createServer } from "node:http";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createFxEngine, supportsJspi } from "../node.js";
import { testHooks } from "./hooks-fixture.mjs";
const backend = process.argv[2] || "native";
if (!new Set(["native", "wasm"]).has(backend)) throw new Error("usage: test-agent-steering.mjs [native|wasm]");
@@ -79,6 +80,7 @@ await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen
const { port } = server.address();
const options = (checkpoint) => ({
...(testHooks ? { hooks: testHooks } : {}),
backend,
nativeAddon: addon,
...(wasm ? { wasm } : {}),
+2
View File
@@ -14,6 +14,7 @@ import { createFxAgent, createFxEngine, memory } from "../node.js";
import { createFxEngine as createWasmEngine } from "../fx-sdk.js";
import { foldSessionLog } from "../durable.js";
import { holdWrites } from "./hold-writes.mjs";
import { testHooks } from "./hooks-fixture.mjs";
const durabilityKind = process.argv[2] || "memory";
const engineBackend = process.argv[3] || "native";
@@ -390,6 +391,7 @@ const queueOutlivesProcess = (durability) => {
};
};
const agentOptions = (durability, extra = {}) => ({
...(testHooks ? { hooks: testHooks } : {}),
backend: engineBackend,
nativeAddon: addon,
...(wasm ? { wasm } : {}),
+894
View File
@@ -0,0 +1,894 @@
#!/usr/bin/env node
import { strict as assert } from "node:assert";
import { readFile } from "node:fs/promises";
import { createFxAgent, createFxEngine, createMemoryPersistence, memory } from "../node.js";
import { createMcpAdapter } from "../mcp.js";
import { toolObservation } from "../tool-observation.js";
import { createFxEngine as createSharedEngine } from "../fx-sdk.js";
const backend = process.argv[2] ?? "native";
assert.ok(["native", "wasm"].includes(backend));
const base = {
backend,
nativeAddon: new URL("../../zig-out/lib/libfx.node", import.meta.url),
...(backend === "wasm"
? { wasm: await readFile(new URL("../../zig-out/bin/fx-core.wasm", import.meta.url)) }
: {}),
apiKey: "hook-fixture-credential",
model: "fixture/hooks",
instructions: "hook fixture instructions",
modelCatalog: [
{ id: "fixture/hooks", type: "language", tags: ["tool-use"], context_window: 200000 },
],
};
const finish = (reason = "stop") => ({
type: "finish",
finishReason: { unified: reason, raw: reason },
usage: { inputTokens: { total: 7, cacheRead: 2 }, outputTokens: { total: 3 } },
});
const answer = (text = "answer") => [{ type: "text-delta", delta: text }, finish()];
const call = (id, key) => ({
type: "tool-call",
toolCallId: id,
toolName: "lookup",
input: { key },
});
const sse = (events) =>
new Response(
events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") + "data: [DONE]\n\n",
{
headers: { "content-type": "text/event-stream" },
},
);
function gateway(replies) {
const bodies = [];
return {
bodies,
fetch: async (_url, init) => {
if (init.method === "GET") return Response.json({ data: base.modelCatalog });
bodies.push(JSON.parse(new TextDecoder().decode(init.body)));
const reply = replies[bodies.length - 1];
assert.ok(reply, `unexpected model request ${bodies.length}`);
return typeof reply === "function"
? reply(init)
: reply instanceof Response
? reply
: sse(reply);
},
};
}
const eventsOf = (events, type) => events.filter((event) => event.type === type);
async function drain(turn) {
const updates = [];
for await (const event of turn) updates.push(event);
return { updates, result: await turn.result };
}
// Native semantic boundaries, not UI previews: exact model-to-tool IDs,
// parallel execution and failures, accepted results, and separate new turns.
{
const events = [];
const executed = [];
const mock = gateway([
[call("call-a", "alpha"), call("call-b", "beta"), finish("tool-calls")],
answer(),
answer("second"),
]);
const engine = await createFxEngine({
...base,
fetch: mock.fetch,
hooks: {
captureContent: true,
onEvent: (event) => {
events.push(event);
},
},
tools: [
{
name: "lookup",
description: "Lookup fixture",
inputSchema: { type: "object", properties: { key: { type: "string" } }, required: ["key"] },
async execute({ key }) {
executed.push(key);
if (key === "beta") throw new Error("lookup-fixture-error");
return `value:${key}`;
},
},
],
});
try {
assert.equal(
(await drain(engine.prompt("use lookup", { turnId: "fixture-turn" }))).result.stopReason,
"end_turn",
);
assert.deepEqual(executed.sort(), ["alpha", "beta"]);
assert.equal(mock.bodies.length, 2);
const start = eventsOf(events, "turn.start")[0];
assert.equal(start.turnId, "fixture-turn");
assert.equal(start.data.input, "use lookup");
assert.equal(start.data.resumed, false);
for (const event of events) {
assert.equal(event.version, 1);
assert.equal(event.sessionId, engine.sessionId);
assert.equal(event.turnId, start.turnId);
assert.equal(event.executionId, start.executionId);
assert.ok(Number.isSafeInteger(event.timestamp) && event.timestamp > 0);
}
const modelStarts = eventsOf(events, "model.start");
assert.deepEqual(
modelStarts.map((event) => event.data.modelCallId),
[1, 2],
);
assert.deepEqual(
modelStarts.map((event) => event.stepIndex),
[1, 2],
);
assert.equal(modelStarts[0].data.purpose, "agent");
assert.ok(JSON.stringify(modelStarts[0].data.instructions).includes(base.instructions));
const lookup = modelStarts[0].data.tools.find((tool) => tool.name === "lookup");
assert.equal(JSON.parse(lookup.inputSchemaJson).properties.key.type, "string");
const modelEnds = eventsOf(events, "model.end");
assert.deepEqual(
modelEnds[0].data.toolCalls.map((value) => value.callId),
["call-a", "call-b"],
);
assert.equal(modelEnds[1].data.output, "answer");
assert.equal(modelEnds[1].data.usage.inputTokens, 7);
for (const startEvent of modelStarts) {
const admitted = events.find(
(event) =>
event.type === "model.admitted" && event.data.modelCallId === startEvent.data.modelCallId,
);
const end = modelEnds.find((event) => event.data.modelCallId === startEvent.data.modelCallId);
assert.ok(events.indexOf(startEvent) < events.indexOf(admitted));
assert.ok(events.indexOf(admitted) < events.indexOf(end));
}
const starts = eventsOf(events, "tool.start");
assert.equal(starts.length, 2);
const ends = eventsOf(events, "tool.end");
const results = eventsOf(events, "tool.result");
for (const event of starts) {
const end = ends.find((value) => value.data.call.callId === event.data.call.callId);
const result = results.find((value) => value.data.call.callId === event.data.call.callId);
assert.ok(end && result);
assert.ok(events.indexOf(event) < events.indexOf(end));
assert.ok(events.indexOf(end) < events.indexOf(result));
assert.deepEqual(JSON.parse(event.data.call.argumentsJson), {
key: event.data.call.callId === "call-a" ? "alpha" : "beta",
});
}
assert.equal(
results.find((event) => event.data.call.callId === "call-b").data.status,
"failure",
);
assert.equal(eventsOf(events, "turn.end")[0].data.outcome, "completed");
assert.ok(!JSON.stringify(events).includes(base.apiKey));
await drain(engine.prompt("next", { turnId: "next-turn" }));
const turns = eventsOf(events, "turn.start");
assert.equal(turns[1].turnId, "next-turn");
assert.notEqual(turns[0].executionId, turns[1].executionId);
} finally {
await engine.close();
}
}
// Metadata-only is the default. Rejected observer promises (including the
// error handler's), throws, mutation, and never-settling work cannot change fx.
{
const seen = [];
const errors = [];
const mock = gateway([answer("private-output")]);
const engine = await createFxEngine({
...base,
fetch: mock.fetch,
hooks: {
onEvent(event) {
seen.push(structuredClone(event));
event.data = { corrupted: true };
if (seen.length === 1) throw new Error("observer-throw");
if (seen.length === 2) return Promise.reject(new Error("observer-reject"));
return new Promise(() => {});
},
onError(error) {
errors.push(error.message);
return Promise.reject(new Error("error-handler-reject"));
},
},
});
try {
const { result } = await drain(engine.prompt("private-input"));
assert.equal(result.stopReason, "end_turn");
assert.equal(mock.bodies.length, 1);
assert.deepEqual(errors.sort(), ["observer-reject", "observer-throw"]);
assert.ok(!JSON.stringify(seen).includes("private-input"));
assert.ok(!JSON.stringify(seen).includes("private-output"));
assert.ok(seen.every((event) => !event.contentCaptured));
} finally {
await engine.close();
}
}
// Content is bounded on a UTF-8 boundary without shortening the model's input.
{
const events = [];
const mock = gateway([answer()]);
const input = "界".repeat(24000);
const engine = await createFxEngine({
...base,
fetch: mock.fetch,
hooks: {
captureContent: true,
onEvent: (event) => {
events.push(event);
},
},
});
try {
await drain(engine.prompt(input));
const start = eventsOf(events, "turn.start")[0];
assert.equal(new TextEncoder().encode(start.data.input).byteLength, 65535);
assert.equal(start.contentTruncated, true);
assert.ok(!start.data.input.includes("\ufffd"));
assert.ok(JSON.stringify(mock.bodies).includes(input));
} finally {
await engine.close();
}
}
// Instructions cannot starve the latest model-visible user input.
{
const events = [];
const engine = await createFxEngine({
...base,
instructions: "x".repeat(60000),
fetch: gateway([answer()]).fetch,
hooks: {
captureContent: true,
onEvent: (event) => {
events.push(event);
},
},
});
try {
await drain(engine.prompt("needle"));
assert.ok(eventsOf(events, "model.start")[0].data.messages.at(-1).content.includes("needle"));
} finally {
await engine.close();
}
}
// A receipt follows accepted persistence, not a queued or rejected append.
// Yield/resume keeps the logical turn ID, but not its worker execution ID.
{
const store = createMemoryPersistence();
const events = [];
const mock = gateway([
[call("durable-call", "alpha"), finish("tool-calls")],
answer("resumed"),
answer("reran"),
]);
let executions = 0;
const options = {
...base,
fetch: mock.fetch,
sessionId: "hook-durable-session",
persistence: store,
hooks: {
onEvent: (event) => {
events.push(event);
},
},
tools: [
{
name: "lookup",
description: "Lookup",
inputSchema: { type: "object" },
execute: () => {
executions += 1;
return "durable-result";
},
},
],
};
let engine = await createFxEngine(options);
try {
assert.equal(
(await drain(engine.prompt("lookup", { turnId: "durable-turn", yieldAt: Date.now() - 1 })))
.result.stopReason,
"yielded",
);
assert.equal(eventsOf(events, "turn.end")[0].data.outcome, "yielded");
const first = eventsOf(events, "turn.start")[0];
const receipt = eventsOf(events, "journal.commit").at(-1);
assert.ok(receipt.data.cursor);
assert.equal(receipt.turnId, null);
await engine.close();
engine = await createFxEngine(options);
const restored = eventsOf(events, "journal.restore").at(-1);
assert.equal(restored.data.resumable, true);
assert.equal(restored.data.openTurnId, "durable-turn");
assert.equal((await drain(engine.resume())).result.stopReason, "end_turn");
const resumed = eventsOf(events, "turn.start").at(-1);
assert.equal(resumed.data.resumed, true);
assert.equal(resumed.turnId, first.turnId);
assert.notEqual(resumed.executionId, first.executionId);
for (const event of events.filter((event) => event.executionId === resumed.executionId))
assert.equal(event.turnId, first.turnId, event.type);
assert.equal(
eventsOf(events, "turn.end").find((event) => event.executionId === resumed.executionId)
.turnId,
first.turnId,
);
assert.equal(executions, 1);
assert.equal(eventsOf(events, "tool.start").length, 1);
assert.equal(mock.bodies.length, 2);
// A crash lost everything after the tool intent. Explicit permission to
// rerun is new execution of the same logical call, before a new model step.
const recorded = store.records.flatMap(
(record) => JSON.parse(new TextDecoder().decode(record.data)).events,
);
const intent = recorded.findLastIndex((event) => event.type === "tool_intent");
assert.ok(intent >= 0);
const crashed = createMemoryPersistence();
await crashed.append({
expected: null,
idempotencyKey: "fixture-crash",
data: new TextEncoder().encode(
JSON.stringify({ format: "libfx-journal-v1", events: recorded.slice(0, intent + 1) }),
),
});
await engine.close();
engine = await createFxEngine({ ...options, persistence: crashed });
assert.equal(
(await drain(engine.resume({ onAmbiguous: () => "rerun" }))).result.stopReason,
"end_turn",
);
const rerun = eventsOf(events, "tool.start").at(-1);
const rerunTurn = eventsOf(events, "turn.start").at(-1);
assert.equal(rerun.data.rerun, true);
assert.equal(rerun.data.call.callId, "durable-call");
assert.equal(rerun.turnId, first.turnId);
assert.equal(rerun.executionId, rerunTurn.executionId);
assert.ok(events.indexOf(rerunTurn) < events.indexOf(rerun));
for (const event of events.filter((event) => event.executionId === rerunTurn.executionId))
assert.equal(event.turnId, first.turnId, event.type);
assert.equal(
eventsOf(events, "turn.end").find((event) => event.executionId === rerunTurn.executionId)
.turnId,
first.turnId,
);
assert.equal(eventsOf(events, "turn.start").length, 3);
assert.equal(eventsOf(events, "turn.end").length, 3);
assert.equal(executions, 2);
} finally {
await engine.close();
}
}
for (const wrapped of [false, true]) {
const events = [];
const engine = await createFxEngine({
...base,
fetch: gateway([answer()]).fetch,
persistence: {
load: async () => ({}),
append: async () => {
const fenced = Object.assign(new Error("fenced"), { code: "FX_FENCED" });
throw wrapped ? new Error("wrapped", { cause: fenced }) : fenced;
},
},
hooks: {
onEvent: (event) => {
events.push(event);
},
},
});
try {
await assert.rejects(drain(engine.prompt("never delivered")), /journal|append/i);
assert.equal(eventsOf(events, "journal.commit").length, 0);
assert.equal(eventsOf(events, "model.start").length, 0);
assert.equal(eventsOf(events, "journal.error")[0].data.fenced, true);
} finally {
await engine.close().catch(() => {});
}
}
// No receipt may precede the persistence owner's acknowledgement.
{
const events = [];
const store = createMemoryPersistence();
const entered = Promise.withResolvers();
const accepted = Promise.withResolvers();
const mock = gateway([answer()]);
const engine = await createFxEngine({
...base,
fetch: mock.fetch,
persistence: {
load: store.load,
async append(record) {
entered.resolve();
await accepted.promise;
return store.append(record);
},
},
hooks: {
onEvent: (event) => {
events.push(event);
},
},
});
try {
const running = drain(engine.prompt("wait for storage"));
await entered.promise;
assert.equal(eventsOf(events, "journal.commit").length, 0);
assert.equal(mock.bodies.length, 0);
accepted.resolve();
await running;
assert.ok(eventsOf(events, "journal.commit").length > 0);
} finally {
accepted.resolve();
await engine.close();
}
}
// Streaming cancellation produces one terminal model observation, no retry.
// A handoff is marked separately so the host needn't fail the logical turn.
for (const reason of [undefined, "handoff"]) {
const events = [];
let turn;
const mock = gateway([
(init) =>
new Response(
new ReadableStream({
start(controller) {
init.signal.addEventListener(
"abort",
() => controller.error(new DOMException("Aborted", "AbortError")),
{ once: true },
);
controller.enqueue(
new TextEncoder().encode('data: {"type":"text-delta","delta":"partial"}\n\n'),
);
},
}),
{ headers: { "content-type": "text/event-stream" } },
),
]);
const engine = await createFxEngine({
...base,
fetch: mock.fetch,
...(reason === "handoff" ? { persistence: createMemoryPersistence() } : {}),
hooks: {
onEvent(event) {
events.push(event);
if (event.type === "model.output_start") turn.cancel(reason ? { reason } : {});
},
},
});
try {
turn = engine.prompt("cancel the stream");
await drain(turn);
assert.equal(mock.bodies.length, 1);
assert.equal(eventsOf(events, "model.start").length, 1);
assert.equal(eventsOf(events, "model.end").length, 1);
assert.equal(eventsOf(events, "model.end")[0].data.cancelled, true, JSON.stringify(events));
assert.ok(["cancelled", "interrupted"].includes(eventsOf(events, "turn.end")[0].data.outcome));
assert.equal(eventsOf(events, "turn.end")[0].data.handoff === true, reason === "handoff");
} finally {
await engine.close();
}
}
// Hook transport and content capture are absent unless explicitly requested.
{
const diagnostics = [];
const engine = await createFxEngine({
...base,
fetch: gateway([answer()]).fetch,
onEvent: (event) => {
diagnostics.push(event);
},
});
try {
await drain(engine.prompt("hooks disabled"));
const created = diagnostics.find(
(event) => event.type === "acp.send" && event.message?.method === "libfx/new",
);
assert.equal(created.message.params.hooks, undefined);
assert.ok(!diagnostics.some((event) => event.message?.method === "libfx/hook"));
} finally {
await engine.close();
}
}
// Durable agents forward the same observer into the engine that runs the turn.
{
const events = [];
const hooks = new (class {
#events = events;
onEvent(event) {
this.#events.push(event);
}
})();
const agent = createFxAgent({
...base,
durability: memory(),
fetch: gateway([answer("durable agent")]).fetch,
hooks,
});
try {
const session = agent.session("hook-factory-session");
const turn = session.prompt("observe the durable engine");
await drain(turn);
assert.equal(eventsOf(events, "turn.start").length, 1);
assert.equal(eventsOf(events, "turn.end").length, 1);
assert.equal(eventsOf(events, "turn.start")[0].sessionId, session.id);
assert.ok(eventsOf(events, "journal.commit").length > 0);
} finally {
await agent.close();
}
}
// Input waits are real host effects; observer failures cannot change decisions,
// answers, or thrown errors. Child engines carry their caller's tool identity.
{
const events = [];
const mock = gateway([
[call("parent-call", "inputs"), finish("tool-calls")],
answer("parent finished"),
]);
let effects = 0;
const engine = await createFxEngine({
...base,
fetch: mock.fetch,
hooks: {
captureContent: true,
onEvent: (event) => {
events.push(event);
},
},
tools: [
{
name: "lookup",
description: "Inputs and child",
inputSchema: { type: "object" },
async execute(_input, context) {
assert.ok(context.observation);
const accepted = { outcome: "accepted", answers: ["approve"], value: 42 };
assert.equal(
await context.observation.wait({ kind: "approval", prompt: "Allow this?" }, () => {
effects++;
return accepted;
}),
accepted,
);
const denied = { outcome: "denied", answers: ["no"] };
assert.equal(
await context.observation.wait(
{
kind: "question",
questions: [{ question: "Continue?", options: [{ label: "no" }] }],
},
() => {
effects++;
return denied;
},
),
denied,
);
const failure = new Error("input-fixture-failure");
await assert.rejects(
context.observation.wait({ kind: "question", prompt: "Fail" }, () => {
effects++;
throw failure;
}),
(error) => error === failure,
);
const child = await createFxEngine({
...base,
hooks: context.observation.childHooks(),
fetch: gateway([answer("child finished")]).fetch,
});
try {
await drain(child.prompt("child input", { turnId: "child-turn" }));
} finally {
await child.close();
}
return "inputs handled";
},
},
],
});
try {
await drain(engine.prompt("run", { turnId: "parent-turn" }));
assert.equal(effects, 3);
const starts = eventsOf(events, "wait.start");
const ends = eventsOf(events, "wait.end");
assert.deepEqual(
starts.map((event) => event.data.kind),
["approval", "question", "question"],
);
assert.deepEqual(
ends.map((event) => event.data.outcome),
["accepted", "denied", "failed"],
);
assert.equal(new Set(starts.map((event) => event.data.requestId)).size, 3);
for (const event of starts) assert.equal(event.data.callId, "parent-call");
const child = eventsOf(events, "turn.start").find((event) => event.turnId === "child-turn");
assert.ok(child);
assert.notEqual(child.sessionId, engine.sessionId);
assert.equal(child.rootSessionId, engine.sessionId);
assert.equal(child.parentSessionId, engine.sessionId);
assert.equal(child.parentTurnId, "parent-turn");
assert.equal(child.parentCallId, "parent-call");
} finally {
await engine.close();
}
}
// MCP metadata is taken from the adapter's registered server and original tool,
// never guessed from its sanitized model name. Remote correlation is opt-in.
for (const propagateCorrelation of [false, true]) {
const events = [];
const remote = [];
const adapter = await createMcpAdapter(
{
listTools: async () => ({
tools: [{ name: "remote.echo", description: "Echo", inputSchema: { type: "object" } }],
}),
async callTool(params) {
remote.push(params);
return { content: [{ type: "text", text: "mcp result" }] };
},
},
{ prefix: "fixture_", serverName: "fixture-server", propagateCorrelation },
);
const modelTool = adapter.tools[0].name;
const mock = gateway([
[
{ type: "tool-call", toolCallId: "mcp-call", toolName: modelTool, input: {} },
finish("tool-calls"),
],
answer(),
]);
const engine = await createFxEngine({
...base,
fetch: mock.fetch,
tools: adapter.tools,
hooks: {
onEvent: (event) => {
events.push(event);
},
},
});
try {
await drain(engine.prompt("call MCP"));
const started = eventsOf(events, "mcp.start")[0];
assert.equal(started.data.server, "fixture-server");
assert.equal(started.data.tool, "remote.echo");
assert.equal(started.data.callId, "mcp-call");
const request = eventsOf(events, "mcp.request")[0];
if (propagateCorrelation) {
const token = remote[0]._meta["libfx/correlationId"];
assert.match(token, /^[0-9a-f-]{36}$/);
assert.ok(!token.includes(started.executionId));
assert.ok(!token.includes("mcp-call"));
assert.equal(remote[0]._meta.progressToken, undefined);
assert.equal(request.data.requestIdKind, "correlation");
assert.equal(request.data.requestId, token);
} else {
assert.equal(remote[0]._meta, undefined);
assert.equal(request, undefined);
}
assert.equal(eventsOf(events, "mcp.end")[0].data.operationId, started.data.operationId);
assert.equal(eventsOf(events, "mcp.end")[0].data.status, "success");
assert.equal(remote.length, 1);
} finally {
await engine.close();
await adapter.close();
}
}
// A host-created child adds its outer parent only to its own root session.
// Descendant events already carry their native parent relationship.
{
const events = [];
const contexts = [];
const promptStarted = Promise.withResolvers();
let handler, exit, executeHostTool, promptMessage;
const runtime = {
exited: new Promise((resolve) => {
exit = resolve;
}),
setLineHandler(value) {
handler = value;
},
write(data) {
const message = JSON.parse(data);
if (message.method === "session/prompt") {
promptMessage = message;
promptStarted.resolve();
return;
}
if (message.id !== undefined)
queueMicrotask(() =>
handler({
id: message.id,
result:
message.method === "libfx/new"
? { sessionId: "child-root", _meta: { fx: { hooksVersion: 1 } } }
: {},
}),
);
},
abortHostEffects() {},
closeStdin() {
exit(0);
},
};
const outer = toolObservation(
{
sessionId: "outer",
rootSessionId: "outer-root",
turnId: "outer-turn",
executionId: "outer-execution",
stepIndex: 1,
},
"outer-call",
{
onEvent: (event) => {
events.push(event);
},
},
() => {},
new AbortController().signal,
);
const engine = await createSharedEngine({
apiKey: "fixture-key",
runtimeFactory: (options) => {
executeHostTool = options.hostToolExecutor;
return runtime;
},
hooks: outer.childHooks(),
tools: [
{
name: "lookup",
description: "Lookup",
inputSchema: { type: "object" },
execute(_input, context) {
contexts.push(context);
return "ok";
},
},
],
});
try {
const frame = {
version: 1,
type: "turn.start",
sessionId: "child-root",
rootSessionId: "child-root",
parentSessionId: null,
turnId: "child-turn",
executionId: "child-execution",
data: { resumed: false },
};
handler({ method: "libfx/hook", params: frame });
handler({
method: "libfx/hook",
params: {
...frame,
sessionId: "native-grandchild",
parentSessionId: "native-child-parent",
parentTurnId: "native-parent-turn",
parentCallId: "native-parent-call",
},
});
assert.equal(events[0].parentSessionId, "outer");
assert.equal(events[0].parentCallId, "outer-call");
assert.equal(events[1].rootSessionId, "outer-root");
assert.equal(events[1].parentSessionId, "native-child-parent");
assert.equal(events[1].parentTurnId, "native-parent-turn");
assert.equal(events[1].parentCallId, "native-parent-call");
const turn = engine.prompt("scope collision fixture");
await promptStarted.promise;
const emit = (overrides) =>
handler({ method: "libfx/hook", params: { ...frame, ...overrides } });
const call = { callId: "same", name: "lookup", provenance: "fx_local" };
emit({ type: "tool.start", executionId: "root-scope", data: { call, rerun: false } });
emit({
type: "tool.start",
sessionId: "native-grandchild",
parentSessionId: "native-child-parent",
executionId: "grandchild-scope",
data: { call, rerun: false },
});
assert.equal(
(await executeHostTool("lookup", {}, "native-grandchild", "same")).cancelled,
true,
);
assert.equal(contexts.length, 0, "native descendant metadata cannot authorize a host tool");
emit({
type: "turn.end",
sessionId: "native-grandchild",
parentSessionId: "native-child-parent",
executionId: "grandchild-scope",
data: { outcome: "completed", error: null },
});
await executeHostTool("lookup", {}, "child-root", "same");
assert.ok(contexts[0].observation, "ending a descendant must retain another execution's scope");
assert.equal(contexts[0].sessionId, "child-root");
assert.equal(contexts[0].observation.childHooks().parent.sessionId, "child-root");
emit({
type: "turn.end",
executionId: "root-scope",
data: { outcome: "completed", error: null },
});
await executeHostTool("lookup", {}, "child-root", "same");
assert.equal(
contexts[1].observation,
undefined,
"turn.end must remove scopes without tool.end",
);
emit({ type: "tool.start", executionId: "fenced-scope", data: { call, rerun: false } });
emit({ type: "journal.error", data: { error: "Error", fenced: true } });
await executeHostTool("lookup", {}, "child-root", "same");
assert.equal(contexts[2].observation, undefined, "a fence must discard abandoned scopes");
handler({ id: promptMessage.id, result: { stopReason: "end_turn", usage: {} } });
await drain(turn);
} finally {
await engine.close();
}
}
// Cancellation is an observation, not a replacement for the host's response.
// A late answer cannot emit a second terminal event or change the returned value.
{
const events = [];
const controller = new AbortController();
const answer = Promise.withResolvers();
const scope = {
version: 1,
sessionId: "fixture",
rootSessionId: "fixture",
parentSessionId: null,
sourceSessionId: null,
workId: null,
turnId: "turn",
executionId: "execution",
stepIndex: 1,
};
const observer = toolObservation(
scope,
"call",
{ captureContent: true },
(event) => events.push(event),
controller.signal,
);
let calls = 0;
const pending = observer.wait({ kind: "approval", prompt: "Approve?" }, () => {
calls++;
return answer.promise;
});
controller.abort();
assert.equal(eventsOf(events, "wait.end")[0].data.outcome, "cancelled");
const accepted = { outcome: "accepted", answers: ["late answer"] };
answer.resolve(accepted);
assert.equal(await pending, accepted);
assert.equal(calls, 1);
assert.equal(eventsOf(events, "wait.end").length, 1);
}
for (const hooks of [
null,
{},
{ onEvent: true },
{ onEvent() {}, captureContent: "yes" },
{ onEvent() {}, onError: true },
]) {
await assert.rejects(createFxEngine({ ...base, hooks }), /hooks/);
}
console.log(
`${backend} semantic hooks passed: model/tool boundaries, content, observer isolation, durable resume, and fencing`,
);
+7 -1
View File
@@ -31,7 +31,13 @@ try {
const vercel = JSON.parse(await readFile(join(temp, "vercel.json"), "utf8"));
const header = vercel.headers.find(({ source }) => source === `/${manifest.wasmModule.file}`);
assert.deepEqual(header?.headers, [{ key: "Cache-Control", value: "public, max-age=31536000, immutable" }]);
console.log("terminal demo package passed: shared Wasm module is hashed, rewritten, and immutable");
const observation = await readFile(join(temp, manifest.toolObservation.file));
assert.equal(digest(observation), manifest.toolObservation.sha256);
assert.equal(observation.byteLength, manifest.toolObservation.bytes);
assert.match(sdk, new RegExp(`from "\\./${manifest.toolObservation.file.replaceAll(".", "\\.")}";`));
assert.doesNotMatch(sdk, /from "\.\/tool-observation\.js";/);
assert.deepEqual(vercel.headers.find(({ source }) => source === `/${manifest.toolObservation.file}`)?.headers, [{ key: "Cache-Control", value: "public, max-age=31536000, immutable" }]);
console.log("terminal demo package passed: shared modules are hashed, rewritten, and immutable");
} finally {
await rm(temp, { recursive: true, force: true });
}
+36
View File
@@ -16,6 +16,8 @@ import {
type FxEngineTurn,
type FxEngineTurnResult,
type FxHostTool,
type FxHooks,
type FxHookEvent,
type FxToolResult,
type JournalRecord,
type Persistence,
@@ -27,6 +29,40 @@ function assertType<T extends true>(): void {}
declare const process: { env: Record<string, string | undefined> };
declare const apiKey: string;
const hooks: FxHooks = {
captureContent: true,
onEvent(event: FxHookEvent) {
if (event.type === "model.start") {
assertType<Equal<typeof event.data.modelCallId, number>>();
assertType<Equal<typeof event.data.messages, import("libfx").FxHookMessage[] | undefined>>();
}
if (event.type === "tool.result") {
assertType<Equal<typeof event.data.call.callId, string>>();
// @ts-expect-error accepted tool results have no transport response headers
void event.data.headers;
}
},
async onError(_error: unknown) {},
};
void createFxEngine({ apiKey, hooks });
void createFxAgent({ apiKey, hooks });
const interactiveTool: FxHostTool = {
name: "confirm",
description: "Ask the host",
inputSchema: { type: "object" },
async execute(_input, context) {
if (!context.observation) return "no observer";
const response = await context.observation.wait<number>({ kind: "approval", prompt: "Continue?" }, async () => ({ outcome: "accepted", value: 42 }));
assertType<Equal<typeof response.value, number | undefined>>();
const childHooks: import("libfx/browser").FxHooks = context.observation.childHooks();
const wasmObserver: import("libfx/wasm").FxToolObservation = context.observation;
void childHooks;
void wasmObserver;
return response.outcome;
},
};
void interactiveTool;
declare const model: string;
declare const file: File;
declare const pngBytes: Uint8Array;
+185
View File
@@ -0,0 +1,185 @@
// Observations of host-owned effects. Helpers call the effect exactly once,
// preserve its value/error, and never await an instrumentation callback.
export function toolObservation(scope, callId, hooks, emit, signal) {
const report = (error) => {
try {
Promise.resolve(hooks.onError?.(error)).catch(() => {});
} catch {}
};
let next = 0;
const id = () => `host:${scope.executionId}:${callId}:${++next}`;
const publish = (type, data, content = {}) =>
emit({
...scope,
type,
data,
timestamp: Date.now(),
contentCaptured: hooks.captureContent === true,
contentTruncated: content.truncated === true,
});
const capture = () => {
let remaining = 64 * 1024;
return {
truncated: false,
text(value) {
if (hooks.captureContent !== true || value == null) return undefined;
value = String(value);
let prefix = value.slice(0, remaining);
if (prefix.length && /[\uD800-\uDBFF]/.test(prefix.at(-1))) prefix = prefix.slice(0, -1);
const bytes = new TextEncoder().encode(prefix);
let end = Math.min(bytes.length, remaining);
while (end > 0 && end < bytes.length && (bytes[end] & 0xc0) === 0x80) end--;
remaining -= end;
this.truncated ||= prefix.length < value.length || end < bytes.length;
return new TextDecoder().decode(bytes.subarray(0, end));
},
};
};
function terminal(end) {
let ended = false;
const finish = (...args) => {
if (ended) return;
ended = true;
signal.removeEventListener("abort", abort);
try {
end(...args);
} catch (error) {
report(error);
}
};
const abort = () => finish("cancelled", undefined);
signal.addEventListener("abort", abort, { once: true });
if (signal.aborted) abort();
return finish;
}
return {
async wait(request, run) {
if (
!request ||
!["approval", "question", "mcp_form", "mcp_url"].includes(request.kind) ||
typeof run !== "function"
)
throw new TypeError("wait needs an input request and an effect");
if (signal.aborted) return run();
const requestId = id();
const content = capture();
content.truncated ||=
(request.questions?.length ?? 0) > 128 ||
(request.questions ?? [])
.slice(0, 128)
.some((question) => (question.options?.length ?? 0) > 128);
publish(
"wait.start",
{
requestId,
kind: request.kind,
callId,
externalId: request.externalId ?? null,
server: request.server ?? null,
schemaJson: content.text(request.schemaJson),
prompt: content.text(request.prompt),
questionCount: request.questions?.length ?? 0,
...(hooks.captureContent === true
? {
questions: (request.questions ?? []).slice(0, 128).map((question) => ({
question: content.text(question.question),
options: (question.options ?? []).slice(0, 128).map((option) => ({
label: content.text(option.label),
description: content.text(option.description),
})),
})),
}
: {}),
},
content,
);
const finish = terminal((outcome, result, error) => {
const content = capture();
content.truncated ||= (result?.answers?.length ?? 0) > 128;
publish(
"wait.end",
{
requestId,
outcome,
error: error?.name ?? null,
...(hooks.captureContent === true
? {
answers: (result?.answers ?? [])
.slice(0, 128)
.map((answer) => content.text(answer)),
}
: {}),
},
content,
);
});
try {
const result = await run();
let outcome = "accepted";
try {
outcome = result?.outcome ?? outcome;
} catch (error) {
report(error);
}
finish(outcome, result);
return result;
} catch (error) {
finish(
signal.aborted || error?.name === "AbortError"
? "cancelled"
: error?.name === "TimeoutError"
? "timed_out"
: "failed",
undefined,
error,
);
throw error;
}
},
async mcp(identity, run) {
if (signal.aborted) return run({ correlationId: identity.correlationId });
const operationId = id();
publish("mcp.start", {
operationId,
callId,
server: identity.server ?? null,
tool: identity.tool,
transport: null,
});
if (identity.correlationId !== undefined)
publish("mcp.request", {
operationId,
requestId: identity.correlationId,
requestIdKind: "correlation",
});
const finish = terminal((status, error) =>
publish("mcp.end", { operationId, status, error: error?.name ?? null }),
);
try {
const result = await run({ correlationId: identity.correlationId });
let status = "unknown";
try {
status = result?.isError ? "failure" : "success";
} catch (error) {
report(error);
}
finish(status);
return result;
} catch (error) {
finish(signal.aborted || error?.name === "AbortError" ? "cancelled" : "failure", error);
throw error;
}
},
childHooks() {
return {
...hooks,
parent: {
rootSessionId: scope.rootSessionId,
sessionId: scope.sessionId,
turnId: scope.turnId,
callId,
},
};
},
};
}
+7
View File
@@ -53,6 +53,10 @@ export type {
FxGatewayProvider,
FxGrokProvider,
FxHostTool,
FxHooks,
FxHookEvent,
FxHookMessage,
FxHookToolCall,
FxIdleResult,
FxImageBlock,
FxJsonSchema,
@@ -92,6 +96,9 @@ export type {
FxTextBlock,
FxTool,
FxToolContext,
FxToolObservation,
FxWaitRequest,
FxWaitResponse,
FxToolImage,
FxToolResult,
FxTools,
+125
View File
@@ -144,6 +144,32 @@ export interface FxToolContext {
sessionId: string;
/** The JSON passed as `context` to the `session()` call whose prompt or steer started the turn. */
context?: JsonValue;
/** Present when hooks are installed. Observe host-owned input waits and child engines. */
observation?: FxToolObservation;
}
export interface FxWaitRequest {
kind: "approval" | "question" | "mcp_form" | "mcp_url";
externalId?: string | number;
server?: string;
prompt?: string;
schemaJson?: string;
questions?: { question: string; options?: { label: string; description?: string }[] }[];
}
export interface FxWaitResponse<T = unknown> {
outcome: "accepted" | "denied" | "cancelled" | "failed" | "timed_out";
answers?: string[];
value?: T;
}
export interface FxToolObservation {
/** Calls the host effect once and preserves its response/error. Never makes a permission decision. */
wait<T>(request: FxWaitRequest, run: () => FxWaitResponse<T> | Promise<FxWaitResponse<T>>): Promise<FxWaitResponse<T>>;
/** Used by the MCP adapter to bind a remote call to this tool invocation. */
mcp<T>(identity: { server: string | null; tool: string; correlationId?: string | undefined }, run: (request: { correlationId: string | undefined }) => T | Promise<T>): Promise<T>;
/** Install these hooks on a host-created child engine to retain the parent tool relationship. */
childHooks(): FxHooks;
}
/** An image in a typed tool result: base64 `data`, or a `sourceRef` with no `data`. */
@@ -305,8 +331,107 @@ export type FxAgentEvent = FxEngineEvent | FxSessionEvent;
// ---------------------------------------------------------------------------
// Options shared by agents and engines
/** Semantic observation, independent of diagnostics, UI events, permissions, and tracing libraries. */
export interface FxHooks {
/** Parent of a host-created child engine. Normally supplied by `context.observation.childHooks()`. */
parent?: { rootSessionId: string; sessionId: string; turnId: string; callId: string };
/** Include bounded messages, instructions, tool arguments/results, and schemas. Default false. */
captureContent?: boolean;
/** Called in event order, but returned promises are not awaited. The host owns buffering and flushing. */
onEvent(event: FxHookEvent): void | Promise<void>;
/** Receives callback throws and rejected promises. Errors here are contained too. */
onError?(error: unknown): void | Promise<void>;
}
/** An authoritative model call identity, with no provider replay or credentials. */
export interface FxHookToolCall {
callId: string;
name: string;
provenance: "fx_local" | "provider_executed";
/** Present only with content capture. May be a truncated JSON string. */
argumentsJson?: string | null;
}
export interface FxHookMessage {
role: "system" | "user" | "assistant" | "tool";
content?: string | null;
toolCallId: string | null;
toolName: string | null;
toolResultStatus: "success" | "failure" | null;
imageCount: number;
toolCalls: FxHookToolCall[];
}
interface FxHookEnvelope {
version: 1;
sessionId: string;
rootSessionId: string;
parentSessionId: string | null;
sourceSessionId: string | null;
workId: string | null;
parentTurnId?: string;
parentCallId?: string;
/** Stable host turn ID; null for session-scoped journal receipts. */
turnId: string | null;
/** Fresh for each execution/resume, not a trace or durable operation ID. */
executionId: string | null;
/** One-based within this execution, or 0 outside a step. */
stepIndex: number;
/** Milliseconds since the epoch, sampled at the semantic boundary. */
timestamp: number;
contentCaptured: boolean;
/** A content budget or collection limit omitted information. */
contentTruncated: boolean;
}
/** Versioned execution facts. The host chooses topology, conventions, redaction, and export. */
export type FxHookEvent = FxHookEnvelope & (
| { type: "turn.start"; data: { resumed: boolean; input?: string | null } }
| { type: "turn.end"; data: { outcome: "completed" | "interrupted" | "failed" | "paused" | "yielded" | "cancelled" | "fenced"; error: string | null; handoff?: boolean } }
| { type: "step.start" | "step.end"; data: Record<string, never> }
| { type: "model.start"; data: {
/** Scoped to executionId. Retries have distinct IDs. */
modelCallId: number;
parentCallId: string | null;
purpose: "agent" | "compaction" | "vision" | "title" | "permission_review" | "auxiliary";
model: string; toolChoice: string; maxOutputTokens: number | null; messageCount: number;
instructions?: FxHookMessage[]; messages?: FxHookMessage[];
toolsJson?: string | null;
tools: { name: string; description?: string | null; inputSchemaJson?: string | null }[];
} }
| { type: "model.admitted" | "model.output_start"; data: { modelCallId: number } }
| { type: "model.end"; data: {
modelCallId: number; error: string | null; cancelled: boolean; output?: string | null;
finishReason?: string | null; generationId?: string | null; provider?: string | null;
usage?: { inputTokens: number | null; outputTokens: number | null; cacheReadTokens: number | null; cacheWriteTokens: number | null; reasoningTokens: number | null };
toolCalls?: FxHookToolCall[];
} }
| { type: "tool.start"; data: { call: FxHookToolCall; rerun: boolean } }
| { type: "permission.start"; data: { call: FxHookToolCall } }
| { type: "tool.end"; data: {
call: FxHookToolCall; output?: string | null; status: "success" | "failure" | "cancelled"; error: string | null;
usage?: { inputTokens: number; outputTokens: number; webSearchRequests: number };
} }
/** Acceptance into conversation memory, not proof of durable storage. */
| { type: "tool.result"; data: { call: FxHookToolCall; output?: string | null; status: "success" | "failure" } }
/** Permission evaluation can complete without a human prompt. */
| { type: "permission.end"; data: { call: FxHookToolCall; decision: "once" | "always" | "deny" | "policy_denied" | "permission_required" | null; error: string | null } }
| { type: "wait.start"; data: { requestId: number | string; kind: FxWaitRequest["kind"]; callId: string | null; externalId: string | number | null; server: string | null; prompt?: string | null; schemaJson?: string | null; questionCount: number; questions?: { question: string; submission?: "none" | "input" | "choice"; options: { label: string; description?: string | null }[] }[] } }
| { type: "wait.end"; data: { requestId: number | string; outcome: FxWaitResponse["outcome"]; error: string | null; answers?: string[] } }
| { type: "mcp.start"; data: { operationId: number | string; callId: string | null; server: string | null; tool: string; transport: string | null } }
| { type: "mcp.request"; data: { operationId: number | string; requestId: number | string; requestIdKind: "jsonrpc" | "engine" | "correlation" } }
| { type: "mcp.end"; data: { operationId: number | string; status: "success" | "failure" | "cancelled" | "input_required" | "unavailable" | "unknown"; error: string | null } }
| { type: "delegation"; data: { callId: string; childSessionId: string; workId: string; operationId: string | null; phase: "dispatched" | "completed" | "yielded" | "replayed" | "failed" | "cancelled" | "feedback_queued" | "feedback_applied" | "feedback_not_applied" } }
/** Emitted only after append resolves and its cursor validates. */
| { type: "journal.commit"; data: { cursor: string; firstSeq: number | null; lastSeq: number | null; eventCount: number } }
| { type: "journal.restore"; data: { cursor: string | null; lastSeq: number; resumable: boolean; openTurnId: string | null; lastTurnId: string | null } }
| { type: "journal.error"; data: { error: string; fenced: boolean } }
);
/** Options `createFxAgent()` and `createFxEngine()` share. */
export interface FxSharedOptions<Event> {
/** Host-owned instrumentation of native and Wasm execution. Disabled by default. */
hooks?: FxHooks;
/** The complete host-owned system context, up to 64 KiB of UTF-8. An array is joined with blank lines. */
instructions?: string | readonly string[];
tools?: FxTools;
+6 -1
View File
@@ -10,7 +10,8 @@ export interface McpClient {
/** Resolves to the tools array or `{ tools, nextCursor? }`. */
listTools(params?: { cursor: string }): unknown;
/** Called with the tool's original name, the model's arguments, and the turn's `signal` in `options`. */
callTool(params: { name: string; arguments?: unknown }, resultSchema?: unknown, options?: { signal?: AbortSignal }): unknown;
callTool(params: { name: string; arguments?: unknown; _meta?: { "libfx/correlationId": string } }, resultSchema?: unknown, options?: { signal?: AbortSignal }): unknown;
getServerVersion?(): { name?: string } | undefined;
/** Required when `resources` is given. */
readResource?(params: { uri: string }): unknown;
/** Required when `prompts` is given. */
@@ -20,6 +21,10 @@ export interface McpClient {
/** Options for `createMcpAdapter()`. */
export interface McpAdapterOptions {
/** Local server identity for hook events. Defaults to the client's reported server name, or null. */
serverName?: string;
/** Send an opaque UUID as `_meta["libfx/correlationId"]`. Default false; doesn't request MCP progress notifications. */
propagateCorrelation?: boolean;
/** Prepended to each tool name: letters, digits, `_`, or `-`. */
prefix?: string;
/** Resource URIs whose text becomes instructions. */
+7
View File
@@ -62,6 +62,10 @@ export type {
FxGatewayProvider,
FxGrokProvider,
FxHostTool,
FxHooks,
FxHookEvent,
FxHookMessage,
FxHookToolCall,
FxIdleResult,
FxImageBlock,
FxJsonSchema,
@@ -103,6 +107,9 @@ export type {
FxTextBlock,
FxTool,
FxToolContext,
FxToolObservation,
FxWaitRequest,
FxWaitResponse,
FxToolImage,
FxToolResult,
FxTools,
+7
View File
@@ -35,6 +35,10 @@ export type {
FxGatewayProvider,
FxGrokProvider,
FxHostTool,
FxHooks,
FxHookEvent,
FxHookMessage,
FxHookToolCall,
FxImageBlock,
FxJsonSchema,
FxKeyedTool,
@@ -62,6 +66,9 @@ export type {
FxTextBlock,
FxTool,
FxToolContext,
FxToolObservation,
FxWaitRequest,
FxWaitResponse,
FxToolImage,
FxToolResult,
FxTools,
+599
View File
@@ -0,0 +1,599 @@
//! The native/Wasm adapter for the shared semantic observer. This deliberately
//! projects safe fields instead of serializing requests, history, or replay state.
const std = @import("std");
const observation = @import("../core/agent/runtime/observation.zig");
const types = @import("../core/shared/types.zig");
const limits = @import("../core/config/context_limits.zig");
const schemas = @import("../core/tooling/model_tool_schema.zig");
const io_mod = @import("../core/shared/io.zig");
const debug_trace = @import("../core/shared/debug_trace.zig");
const jsonrpc = @import("jsonrpc.zig");
const acp_types = @import("types.zig");
const journal = @import("../core/agent/runtime/journal.zig");
pub const Config = struct { capture_content: bool = false };
pub fn parseConfig(alloc: std.mem.Allocator, raw: ?[]const u8) !?Config {
const parsed = try std.json.parseFromSlice(std.json.Value, alloc, raw orelse "{}", .{});
defer parsed.deinit();
if (parsed.value != .object) return error.InvalidHooks;
const hooks = parsed.value.object.get("hooks") orelse acp_types.fxMetaField(parsed.value.object, "hooks") orelse return null;
if (hooks != .object) return error.InvalidHooks;
const capture = hooks.object.get("captureContent") orelse return Config{};
if (capture != .bool) return error.InvalidHooks;
return .{ .capture_content = capture.bool };
}
pub const Bridge = struct {
enabled: bool = false,
alloc: std.mem.Allocator,
writer: *jsonrpc.Writer,
session_id: []const u8,
root_session_id: []const u8,
parent_session_id: ?[]const u8 = null,
source_session_id: ?[]const u8 = null,
work_id: ?[]const u8 = null,
turn_id: []const u8,
turn_id_storage: [@max(journal.max_turn_id_bytes, 32)]u8,
session_id_storage: [255]u8,
execution_id: acp_types.MessageIdBuffer,
config: Config,
observer: observation.Observer,
pub fn init(self: *Bridge, alloc: std.mem.Allocator, writer: *jsonrpc.Writer, session_id: []const u8, turn_id: ?[]const u8, config: Config) void {
self.* = .{
.alloc = alloc,
.writer = writer,
.session_id = "",
.root_session_id = "",
.turn_id = "",
.turn_id_storage = undefined,
.session_id_storage = undefined,
.execution_id = undefined,
.config = config,
.observer = .{ .sink = .{ .context = self, .emit_fn = emit } },
};
const turn_len = if (turn_id) |id| id.len else self.execution_id.len;
if (session_id.len > self.session_id_storage.len or turn_len > self.turn_id_storage.len) {
debug_trace.logf("hooks", "observation disabled: identity length exceeds capacity session_bytes={d} turn_bytes={d}", .{ session_id.len, turn_len });
return;
}
_ = acp_types.generateMessageId(&self.execution_id);
@memcpy(self.session_id_storage[0..session_id.len], session_id);
self.session_id = self.session_id_storage[0..session_id.len];
self.root_session_id = self.session_id;
const id = turn_id orelse &self.execution_id;
@memcpy(self.turn_id_storage[0..id.len], id);
self.turn_id = self.turn_id_storage[0..id.len];
self.enabled = true;
}
fn emit(raw: *anyopaque, step: usize, event: observation.Event) void {
const self: *Bridge = @ptrCast(@alignCast(raw));
if (!self.enabled) return;
var out: std.Io.Writer.Allocating = .init(self.alloc);
defer out.deinit();
std.json.Stringify.value(WireEvent{ .bridge = self, .step = step, .event = event }, .{}, &out.writer) catch |err| {
debug_trace.logf("hooks", "event encoding failed err={s}", .{@errorName(err)});
return;
};
self.writer.writeNotification(self.alloc, "libfx/hook", out.written()) catch |err| {
debug_trace.logf("hooks", "event delivery failed err={s}", .{@errorName(err)});
};
}
};
const max_content_bytes = 64 * 1024;
const max_entries = 128;
const MessageAllotment = struct { content: usize, arguments: []const usize };
const ModelAllotment = struct {
messages: []const MessageAllotment,
instructions: []const MessageAllotment,
descriptions: []const usize,
schemas: []const ?[]const u8,
tools_json: ?[]const u8,
};
const Content = struct {
alloc: std.mem.Allocator,
enabled: bool,
remaining: usize = max_content_bytes,
truncated: bool = false,
fn allot(self: *Content, value: ?[]const u8) usize {
if (!self.enabled) return 0;
const text_value = value orelse return 0;
const count = limits.utf8PrefixLength(text_value, self.remaining);
self.remaining -= count;
self.truncated = self.truncated or count < text_value.len;
return count;
}
fn text(self: *Content, value: ?[]const u8, max: usize) ?[]const u8 {
if (!self.enabled) return null;
const text_value = value orelse return null;
const count = limits.utf8PrefixLength(text_value, max);
self.truncated = self.truncated or count < text_value.len;
return text_value[0..count];
}
fn writeText(self: *Content, writer: *std.json.Stringify, name: []const u8, value: ?[]const u8) !void {
return self.writeAllottedText(writer, name, value, self.allot(value));
}
fn writeAllottedText(self: *Content, writer: *std.json.Stringify, name: []const u8, value: ?[]const u8, max: usize) !void {
if (!self.enabled) return;
try field(writer, name, self.text(value, max));
}
fn calls(self: *Content, writer: *std.json.Stringify, calls_value: []const types.ToolCall) !void {
try writer.beginArray();
for (calls_value[0..@min(calls_value.len, max_entries)]) |value| try self.call(writer, value);
self.truncated = self.truncated or calls_value.len > max_entries;
try writer.endArray();
}
fn call(self: *Content, writer: *std.json.Stringify, value: types.ToolCall) !void {
return self.allottedCall(writer, value, self.allot(value.arguments_json));
}
fn allottedCall(self: *Content, writer: *std.json.Stringify, value: types.ToolCall, max: usize) !void {
try writer.beginObject();
try field(writer, "callId", value.id);
try field(writer, "name", value.name);
try field(writer, "provenance", @tagName(value.provenance));
try self.writeAllottedText(writer, "argumentsJson", value.arguments_json, max);
try writer.endObject();
}
fn messages(self: *Content, writer: *std.json.Stringify, name: []const u8, values: []const types.ChatMessage, allotments: []const MessageAllotment) !void {
if (!self.enabled) return;
try writer.objectField(name);
try writer.beginArray();
for (values[values.len -| max_entries..], allotments) |message, allotted| {
try writer.beginObject();
try field(writer, "role", @tagName(message.role));
try self.writeAllottedText(writer, "content", message.content, allotted.content);
try field(writer, "toolCallId", message.tool_call_id);
try field(writer, "toolName", message.tool_name);
try field(writer, "toolResultStatus", message.tool_result_status);
try field(writer, "imageCount", message.images.len);
try writer.objectField("toolCalls");
try writer.beginArray();
for (message.tool_calls[0..allotted.arguments.len], allotted.arguments) |tool_call, max| try self.allottedCall(writer, tool_call, max);
self.truncated = self.truncated or message.tool_calls.len > allotted.arguments.len;
try writer.endArray();
try writer.endObject();
}
self.truncated = self.truncated or values.len > max_entries;
try writer.endArray();
}
fn schema(self: *Content, alloc: std.mem.Allocator, value: anytype) !?[]const u8 {
// A schema is either complete JSON or null, never a JSON prefix posing
// as a usable schema. Bound its serialization before allocating a copy.
var storage: [8192]u8 = undefined;
var out = std.Io.Writer.fixed(&storage);
const success = if (@TypeOf(value) == schemas.ObjectSchema) blk: {
schemas.writeObjectSchema(self.alloc, &out, value) catch break :blk false;
break :blk true;
} else blk: {
std.json.Stringify.value(value, .{}, &out) catch break :blk false;
break :blk true;
};
if (!success or out.buffered().len > self.remaining) {
self.truncated = true;
return null;
}
self.remaining -= out.buffered().len;
return try alloc.dupe(u8, out.buffered());
}
fn planMessages(self: *Content, alloc: std.mem.Allocator, values: []const types.ChatMessage, newest_first: bool) ![]const MessageAllotment {
const tail = values[values.len -| max_entries..];
self.truncated = self.truncated or values.len > tail.len;
const planned = try alloc.alloc(MessageAllotment, tail.len);
for (0..tail.len) |offset| {
const index = if (newest_first) tail.len - 1 - offset else offset;
const message = tail[index];
const arguments = try alloc.alloc(usize, @min(message.tool_calls.len, max_entries));
planned[index] = .{ .content = self.allot(message.content), .arguments = arguments };
for (message.tool_calls[0..arguments.len], arguments) |tool_call, *max| max.* = self.allot(tool_call.arguments_json);
}
return planned;
}
fn planModel(self: *Content, alloc: std.mem.Allocator, value: anytype) !ModelAllotment {
// Reserve by relevance, then serialize in the original wire order.
const messages_plan = try self.planMessages(alloc, value.request.messages, true);
const instructions_plan = try self.planMessages(alloc, value.request.instructions, false);
const tool_count = @min(max_entries, value.request.tools.advertised_functions.len + value.request.tools.additional_functions.len + value.request.tools.selected_dynamic.len);
const descriptions = try alloc.alloc(usize, tool_count);
const schema_values = try alloc.alloc(?[]const u8, tool_count);
var index: usize = 0;
inline for (.{ value.request.tools.advertised_functions, value.request.tools.additional_functions, value.request.tools.selected_dynamic }) |tools| {
for (tools) |tool| {
if (index == tool_count) break;
descriptions[index] = self.allot(tool.description);
index += 1;
}
}
index = 0;
inline for (.{ value.request.tools.advertised_functions, value.request.tools.additional_functions, value.request.tools.selected_dynamic }) |tools| {
for (tools) |tool| {
if (index == tool_count) break;
schema_values[index] = try self.schema(alloc, tool.input_schema);
index += 1;
}
}
var tools_json: ?[]const u8 = null;
if (value.tools_json) |json| {
if (json.len <= self.remaining) {
self.remaining -= json.len;
tools_json = json;
} else self.truncated = true;
}
return .{ .messages = messages_plan, .instructions = instructions_plan, .descriptions = descriptions, .schemas = schema_values, .tools_json = tools_json };
}
};
fn field(writer: *std.json.Stringify, name: []const u8, value: anytype) !void {
try writer.objectField(name);
try writer.write(value);
}
const WireEvent = struct {
bridge: *const Bridge,
step: usize,
event: observation.Event,
pub fn jsonStringify(self: WireEvent, writer: *std.json.Stringify) !void {
const names = .{
.turn_start = "turn.start",
.turn_end = "turn.end",
.step_start = "step.start",
.step_end = "step.end",
.model_start = "model.start",
.model_admitted = "model.admitted",
.model_output_start = "model.output_start",
.model_end = "model.end",
.tool_start = "tool.start",
.tool_end = "tool.end",
.tool_result = "tool.result",
.permission_start = "permission.start",
.permission_end = "permission.end",
.wait_start = "wait.start",
.wait_end = "wait.end",
.mcp_start = "mcp.start",
.mcp_request = "mcp.request",
.mcp_end = "mcp.end",
.delegation = "delegation",
};
try writer.beginObject();
try field(writer, "version", @as(u8, 1));
try field(writer, "sessionId", self.bridge.session_id);
try field(writer, "rootSessionId", self.bridge.root_session_id);
try field(writer, "parentSessionId", self.bridge.parent_session_id);
try field(writer, "sourceSessionId", self.bridge.source_session_id);
try field(writer, "workId", self.bridge.work_id);
try field(writer, "turnId", self.bridge.turn_id);
try field(writer, "executionId", &self.bridge.execution_id);
try field(writer, "stepIndex", self.step);
try field(writer, "timestamp", io_mod.milliTimestamp());
inline for (@typeInfo(observation.Event).@"union".field_names) |name| {
if (self.event == @field(std.meta.Tag(observation.Event), name)) try field(writer, "type", @field(names, name));
}
var content = Content{ .alloc = self.bridge.alloc, .enabled = self.bridge.config.capture_content };
try writer.objectField("data");
try writer.beginObject();
switch (self.event) {
.turn_start => |value| {
try field(writer, "resumed", value.resumed);
try content.writeText(writer, "input", value.input);
},
.turn_end => |value| {
try field(writer, "outcome", @tagName(value.outcome));
try field(writer, "error", value.error_name);
},
.step_start, .step_end => {},
.model_start => |value| {
var scratch = std.heap.ArenaAllocator.init(self.bridge.alloc);
defer scratch.deinit();
const plan: ModelAllotment = if (content.enabled) content.planModel(scratch.allocator(), value) catch return error.WriteFailed else .{ .messages = &.{}, .instructions = &.{}, .descriptions = &.{}, .schemas = &.{}, .tools_json = null };
try field(writer, "modelCallId", value.id);
try field(writer, "parentCallId", value.parent_call_id);
try field(writer, "purpose", @tagName(value.purpose));
if (content.enabled and value.tools_json != null) try field(writer, "toolsJson", plan.tools_json);
try field(writer, "model", value.request.model);
try field(writer, "toolChoice", @tagName(value.request.tool_choice));
try field(writer, "maxOutputTokens", value.request.max_output_tokens);
try field(writer, "messageCount", value.request.messages.len);
try content.messages(writer, "instructions", value.request.instructions, plan.instructions);
try content.messages(writer, "messages", value.request.messages, plan.messages);
try writer.objectField("tools");
try writer.beginArray();
var count: usize = 0;
inline for (.{ value.request.tools.advertised_functions, value.request.tools.additional_functions, value.request.tools.selected_dynamic }) |tools| {
for (tools) |tool| {
if (count == max_entries) {
content.truncated = true;
break;
}
try writer.beginObject();
try field(writer, "name", tool.name);
if (content.enabled) {
try content.writeAllottedText(writer, "description", tool.description, plan.descriptions[count]);
try field(writer, "inputSchemaJson", plan.schemas[count]);
}
count += 1;
try writer.endObject();
}
}
try writer.endArray();
},
.model_admitted, .model_output_start => |id| try field(writer, "modelCallId", id),
.model_end => |value| {
try field(writer, "modelCallId", value.id);
try field(writer, "error", value.error_name);
try field(writer, "cancelled", value.cancelled);
if (value.completion) |completion| {
try content.writeText(writer, "output", completion.content);
try field(writer, "finishReason", if (completion.finish_reason) |reason| reason.label() else @as(?[]const u8, null));
try field(writer, "generationId", completion.generation_id);
try field(writer, "provider", completion.resolved_provider);
try field(writer, "usage", .{
.inputTokens = completion.usage.input_tokens,
.outputTokens = completion.usage.output_tokens,
.cacheReadTokens = completion.usage.cache_read_tokens,
.cacheWriteTokens = completion.usage.cache_write_tokens,
.reasoningTokens = completion.usage.reasoning_tokens,
});
try writer.objectField("toolCalls");
try content.calls(writer, completion.tool_calls);
}
},
.tool_start => |value| {
try writer.objectField("call");
try content.call(writer, value.call);
try field(writer, "rerun", value.rerun);
},
.permission_start => |call| {
try writer.objectField("call");
try content.call(writer, call);
},
.tool_end => |value| {
try writer.objectField("call");
try content.call(writer, value.call);
try content.writeText(writer, "output", value.output);
try field(writer, "status", value.status);
try field(writer, "error", value.error_name);
if (value.usage) |usage| try field(writer, "usage", .{ .inputTokens = usage.input_tokens, .outputTokens = usage.output_tokens, .webSearchRequests = usage.web_search_requests });
},
.tool_result => |value| {
try writer.objectField("call");
try content.call(writer, value.call);
try content.writeText(writer, "output", value.output);
try field(writer, "status", @tagName(value.status));
},
.permission_end => |value| {
try writer.objectField("call");
try content.call(writer, value.call);
try field(writer, "decision", value.decision);
try field(writer, "error", value.error_name);
},
.wait_start => |value| {
try field(writer, "requestId", value.id);
try field(writer, "kind", value.request.kind);
try field(writer, "callId", value.request.call_id);
try field(writer, "externalId", value.request.external_id);
try field(writer, "server", value.request.server_name);
try content.writeText(writer, "prompt", value.request.prompt);
try content.writeText(writer, "schemaJson", value.request.schema_json);
try field(writer, "questionCount", value.request.questions.len);
if (content.enabled) {
try writer.objectField("questions");
try writer.beginArray();
for (value.request.questions[0..@min(value.request.questions.len, max_entries)]) |question| {
try writer.beginObject();
try content.writeText(writer, "question", question.question);
try field(writer, "submission", question.submission);
try writer.objectField("options");
try writer.beginArray();
for (question.options[0..@min(question.options.len, max_entries)]) |option| {
try writer.beginObject();
try content.writeText(writer, "label", option.label);
try content.writeText(writer, "description", option.description);
try writer.endObject();
}
content.truncated = content.truncated or question.options.len > max_entries;
try writer.endArray();
try writer.endObject();
}
content.truncated = content.truncated or value.request.questions.len > max_entries;
try writer.endArray();
}
},
.wait_end => |value| {
try field(writer, "requestId", value.id);
try field(writer, "outcome", value.outcome);
try field(writer, "error", value.error_name);
if (content.enabled) {
try writer.objectField("answers");
try writer.beginArray();
for (value.answers[0..@min(value.answers.len, max_entries)]) |answer| try writer.write(content.text(answer, content.allot(answer)));
content.truncated = content.truncated or value.answers.len > max_entries;
try writer.endArray();
}
},
.mcp_start => |value| {
try field(writer, "operationId", value.id);
try field(writer, "callId", value.identity.call_id);
try field(writer, "server", value.identity.server);
try field(writer, "tool", value.identity.tool);
try field(writer, "transport", value.identity.transport);
},
.mcp_request => |value| {
try field(writer, "operationId", value.id);
try field(writer, "requestId", value.request_id);
try field(writer, "requestIdKind", value.kind);
},
.mcp_end => |value| {
try field(writer, "operationId", value.id);
try field(writer, "status", value.status);
try field(writer, "error", value.error_name);
},
.delegation => |value| {
try field(writer, "callId", value.call_id);
try field(writer, "childSessionId", value.child_id);
try field(writer, "workId", value.work_id);
try field(writer, "operationId", value.operation_id);
try field(writer, "phase", value.phase);
},
}
try writer.endObject();
try field(writer, "contentCaptured", content.enabled);
try field(writer, "contentTruncated", content.truncated);
try writer.endObject();
}
};
test "libfx hook content is opt in bounded and excludes opaque provider state" {
const alloc = std.testing.allocator;
const secret_message = types.ChatMessage{ .role = .assistant, .content = "visible", .provider_replay = .{ .source = .{ .provider = .gateway, .model = "fixture" }, .parts_json = "opaque-secret" } };
var bridge: Bridge = undefined;
// Only the serialization fields are used by this test.
bridge.session_id = "fixture-session";
bridge.root_session_id = "fixture-session";
bridge.parent_session_id = null;
bridge.source_session_id = null;
bridge.work_id = null;
bridge.alloc = alloc;
bridge.turn_id = "fixture-turn";
@memset(&bridge.execution_id, 'a');
for ([_]bool{ false, true }) |capture| {
bridge.config = .{ .capture_content = capture };
const wire = WireEvent{ .bridge = &bridge, .step = 1, .event = .{ .model_start = .{ .id = 1, .purpose = .agent, .request = .{
.model = "fixture",
.messages = &.{secret_message},
.tool_choice = .none,
.provider_options = .{},
} } } };
const encoded = try std.json.Stringify.valueAlloc(alloc, wire, .{});
defer alloc.free(encoded);
try std.testing.expect(std.mem.find(u8, encoded, "opaque-secret") == null);
try std.testing.expect((std.mem.find(u8, encoded, "visible") != null) == capture);
}
const long = try alloc.alloc(u8, max_content_bytes + 4);
defer alloc.free(long);
@memset(long, 'x');
var budget = Content{ .alloc = alloc, .enabled = true };
try std.testing.expectEqual(@as(usize, max_content_bytes), budget.text(long, budget.allot(long)).?.len);
try std.testing.expect(budget.truncated);
try std.testing.expectEqual(@as(usize, 0), budget.text("extra", budget.allot("extra")).?.len);
}
test "libfx model capture reserves the newest messages before instructions" {
const alloc = std.testing.allocator;
const Capture = struct {
fn write(_: ?*anyopaque, _: []const u8) !void {}
};
var writer = jsonrpc.Writer.initCallback(null, Capture.write);
var bridge: Bridge = undefined;
bridge.init(alloc, &writer, "fixture-session", "fixture-turn", .{ .capture_content = true });
const instructions = try alloc.alloc(u8, max_content_bytes);
defer alloc.free(instructions);
@memset(instructions, 'x');
const messages = try alloc.alloc(types.ChatMessage, max_entries + 1);
defer alloc.free(messages);
for (messages) |*message| message.* = .{ .role = .user, .content = "history" };
messages[0].content = "obsolete";
messages[max_entries].content = "needle";
const wire = WireEvent{ .bridge = &bridge, .step = 1, .event = .{ .model_start = .{ .id = 1, .purpose = .agent, .request = .{
.model = "fixture",
.instructions = &.{.{ .role = .system, .content = instructions }},
.messages = messages,
.tool_choice = .none,
.provider_options = .{},
} } } };
const encoded = try std.json.Stringify.valueAlloc(alloc, wire, .{});
defer alloc.free(encoded);
const parsed = try std.json.parseFromSlice(std.json.Value, alloc, encoded, .{});
defer parsed.deinit();
const captured = parsed.value.object.get("data").?.object.get("messages").?.array.items;
try std.testing.expectEqual(max_entries, captured.len);
try std.testing.expectEqualStrings("needle", captured[captured.len - 1].object.get("content").?.string);
try std.testing.expect(std.mem.find(u8, encoded, "obsolete") == null);
var total: usize = 0;
for (captured) |message| total += message.object.get("content").?.string.len;
for (parsed.value.object.get("data").?.object.get("instructions").?.array.items) |message| total += message.object.get("content").?.string.len;
try std.testing.expectEqual(max_content_bytes, total);
try std.testing.expect(parsed.value.object.get("contentTruncated").?.bool);
}
test "libfx hook bridge owns session and turn identities" {
const Capture = struct {
fn write(_: ?*anyopaque, _: []const u8) !void {}
};
var writer = jsonrpc.Writer.initCallback(null, Capture.write);
var session = [_]u8{ 'c', 'h', 'i', 'l', 'd' };
var turn = [_]u8{ 't', 'u', 'r', 'n' };
var bridge: Bridge = undefined;
bridge.init(std.testing.allocator, &writer, &session, &turn, .{});
@memset(&session, 0xaa);
@memset(&turn, 0xaa);
try std.testing.expectEqualStrings("child", bridge.session_id);
try std.testing.expectEqualStrings("child", bridge.root_session_id);
try std.testing.expectEqualStrings("turn", bridge.turn_id);
}
test "libfx hook bridge disables observation for overlong identities in every build mode" {
const Capture = struct {
writes: usize = 0,
fn write(raw: ?*anyopaque, _: []const u8) !void {
const self: *@This() = @ptrCast(@alignCast(raw.?));
self.writes += 1;
}
};
var capture: Capture = .{};
var writer = jsonrpc.Writer.initCallback(&capture, Capture.write);
var session: [256]u8 = @splat('s');
var turn: [journal.max_turn_id_bytes + 1]u8 = @splat('t');
var bridge: Bridge = undefined;
bridge.init(std.testing.allocator, &writer, &session, "turn", .{});
try std.testing.expect(!bridge.enabled);
bridge.observer.beginTurn("input", false);
bridge.observer.endTurn(.completed, null);
bridge.init(std.testing.allocator, &writer, "session", &turn, .{});
try std.testing.expect(!bridge.enabled);
bridge.observer.beginTurn("input", false);
bridge.observer.endTurn(.completed, null);
try std.testing.expectEqual(@as(usize, 0), capture.writes);
bridge.init(std.testing.allocator, &writer, session[0..255], turn[0..journal.max_turn_id_bytes], .{});
try std.testing.expect(bridge.enabled);
}
test "libfx hooks are disabled by default and reject invalid capture configuration" {
const alloc = std.testing.allocator;
try std.testing.expect(try parseConfig(alloc, null) == null);
try std.testing.expect(try parseConfig(alloc, "{}") == null);
try std.testing.expect(!(try parseConfig(alloc, "{\"hooks\":{}}")).?.capture_content);
try std.testing.expect((try parseConfig(alloc, "{\"hooks\":{\"captureContent\":true}}")).?.capture_content);
try std.testing.expectError(error.InvalidHooks, parseConfig(alloc, "{\"hooks\":true}"));
try std.testing.expectError(error.InvalidHooks, parseConfig(alloc, "{\"hooks\":{\"captureContent\":\"true\"}}"));
}
test "libfx hook encoding failure cannot fail the execution callback" {
const Capture = struct {
fn write(_: ?*anyopaque, _: []const u8) !void {
return error.UnexpectedWrite;
}
};
var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 0 });
var writer = jsonrpc.Writer.initCallback(null, Capture.write);
var bridge: Bridge = undefined;
bridge.init(failing.allocator(), &writer, "fixture-session", "fixture-turn", .{});
bridge.observer.beginTurn("input", false);
bridge.observer.endTurn(.completed, null);
try std.testing.expect(bridge.observer.turn_ended);
}
+6 -3
View File
@@ -3,6 +3,7 @@
//! opens from the events the host stored through `libfx/journal_open`.
//! The events themselves are `core/agent/runtime/journal.zig`'s.
const std = @import("std");
const observation = @import("../core/agent/runtime/observation.zig");
const jsonrpc = @import("jsonrpc.zig");
const journal = @import("../core/agent/runtime/journal.zig");
const session_runtime = @import("../core/session/session.zig");
@@ -601,6 +602,7 @@ pub fn rerunCalls(
checkpoint: *session_codec.RecoveryCheckpoint,
step_index: usize,
runner: anytype,
observer: ?*observation.Observer,
) !usize {
const steps = checkpoint.execution.tool_steps;
if (step_index >= steps.len) return 0;
@@ -616,6 +618,7 @@ pub fn rerunCalls(
const replacement = try persistedResult(alloc, call, outcome);
types.freePersistedToolResult(alloc, result.*);
result.* = replacement;
if (observer) |sink| sink.emit(.{ .tool_result = .{ .call = call, .output = replacement.output, .status = replacement.status } });
reran += 1;
debug_trace.logf("session", "event=libfx_resume_rerun call={s} tool={s} status={s}", .{ call.id, call.name, @tagName(replacement.status) });
}
@@ -845,7 +848,7 @@ test "a resumed turn runs again the calls the host asks for and leaves the rest
defer ran.deinit(alloc);
const ids = [_][]const u8{ "call-1", "call-3" };
const reran = try rerunCalls(alloc, &answered, 0, TestRerunner{ .ids = &ids, .ran = &ran });
const reran = try rerunCalls(alloc, &answered, 0, TestRerunner{ .ids = &ids, .ran = &ran }, null);
try std.testing.expectEqual(@as(usize, 2), reran);
try std.testing.expectEqual(@as(usize, 2), ran.items.len);
const results = answered.execution.tool_steps[0].tool_results;
@@ -871,12 +874,12 @@ test "a resume cancelled during a rerun keeps that call and the later ones answe
defer ran.deinit(alloc);
const ids = [_][]const u8{ "call-1", "call-2", "call-3" };
const reran = try rerunCalls(alloc, &answered, 0, TestRerunner{ .ids = &ids, .stop_at = "call-2", .ran = &ran });
const reran = try rerunCalls(alloc, &answered, 0, TestRerunner{ .ids = &ids, .stop_at = "call-2", .ran = &ran }, null);
try std.testing.expectEqual(@as(usize, 1), reran);
const results = answered.execution.tool_steps[0].tool_results;
try std.testing.expectEqualStrings("ran again as call-1", results[0].output);
try std.testing.expectEqualStrings(session_adapter.unfinished_tool_output, results[1].output);
try std.testing.expectEqualStrings(session_adapter.unfinished_tool_output, results[2].output);
// A step index past the turn's steps runs nothing.
try std.testing.expectEqual(@as(usize, 0), try rerunCalls(alloc, &answered, 5, TestRerunner{ .ids = &ids, .ran = &ran }));
try std.testing.expectEqual(@as(usize, 0), try rerunCalls(alloc, &answered, 5, TestRerunner{ .ids = &ids, .ran = &ran }, null));
}
+73 -9
View File
@@ -1,4 +1,5 @@
const std = @import("std");
const observation = @import("../core/agent/runtime/observation.zig");
const testing_allocator = @import("../core/shared/testing_allocator.zig");
const skill_contract = @import("../core/skills/skill_contract.zig");
const std_builtin = @import("builtin");
@@ -89,6 +90,7 @@ const types = @import("../core/shared/types.zig");
const history_range = @import("../core/shared/history_range.zig");
const worker_runtime = @import("../core/agent/worker_runtime.zig");
const agent_stream_provider = @import("../core/agent/stream_provider.zig");
const libfx_hooks = @import("libfx_hooks.zig");
const runtime_gateway_step = @import("../core/agent/runtime/gateway_step.zig");
const Allocator = std.mem.Allocator;
@@ -213,6 +215,7 @@ const AcpContext = struct {
captured_mode: ?[]const u8 = null,
captured_permission_mode: ?PermissionMode = null,
current_prompt_input: ?*ParsedPromptInput = null,
observer: ?*observation.Observer = null,
fn deinitPublishedToolCalls(self: *AcpContext) void {
var keys = self.published_tool_calls.keyIterator();
@@ -430,6 +433,7 @@ const AcpContext = struct {
});
}
var tc: tool_runtime.Context = .{
.observer = self.observer,
.workspace_root = self.state.workspace_root,
.access_scope = self.state.workspace_access.scope(self.state.workspace_root),
.ignored_list_entries = self.state.cfg.ignored_list_entries,
@@ -469,6 +473,8 @@ const AcpContext = struct {
.subagent_caller_id = session.session_id,
.worker = &self.state.worker,
.permission_prompter = if (self.state.initialized) .{
.observer = self.observer,
.cancel_flag = &session.cancel_flag,
.context = @ptrCast(self),
.request_fn = requestAcpPermission,
.retain_grant_fn = retainAcpGrant,
@@ -567,9 +573,15 @@ const ResumeRerun = struct {
debug_trace.logf("session", "event=libfx_resume_rerun_unpublished call={s} err={s}", .{ call.id, @errorName(err) });
break :blk "call_unknown";
};
const outcome = provider.call(alloc, call.name, call.id, call.arguments_json, self.max_result_bytes, self.cancel_flag) catch |err| switch (err) {
error.Cancelled => return null,
else => |other| return other,
if (self.ctx.observer) |observer| observer.emit(.{ .tool_start = .{ .call = call, .rerun = true } });
const outcome = provider.call(alloc, call.name, call.id, call.arguments_json, self.max_result_bytes, self.cancel_flag) catch |err| {
if (self.ctx.observer) |observer| observer.emit(.{ .tool_end = .{
.call = call,
.status = if (err == error.Cancelled) "cancelled" else "failure",
.error_name = @errorName(err),
} });
if (err == error.Cancelled) return null;
return err;
};
const failed = switch (outcome) {
.success => false,
@@ -581,6 +593,11 @@ const ResumeRerun = struct {
.rich => |content| content.text,
};
const shown = tool_call_presentation.toolUpdateContentText(failed, text);
if (self.ctx.observer) |observer| observer.emit(.{ .tool_end = .{
.call = call,
.status = if (failed) "failure" else "success",
.output = text,
} });
const sent = if (failed)
self.ctx.sendToolCallErrorWithCommandResult(acp_id, shown, null)
else
@@ -924,6 +941,14 @@ pub fn handlePrompt(
};
defer ctx.deinitPublishedToolCalls();
var hook_bridge: libfx_hooks.Bridge = undefined;
if (session.observer_hooks) |hook_config| {
const turn_id = prompt_input.turn_id orelse if (session.journal) |*journal| journal.open_turn_id.slice() else null;
hook_bridge.init(alloc, &state.writer, session.session_id, turn_id, hook_config);
if (hook_bridge.enabled) ctx.observer = &hook_bridge.observer;
}
defer if (ctx.observer) |observer| observer.endTurn(if (session.cancel_flag.load(.seq_cst)) .cancelled else .failed, null);
var recovery_checkpoint: ?session_codec.RecoveryCheckpoint = null;
defer if (recovery_checkpoint) |*checkpoint| checkpoint.deinit(alloc);
if (session.journal) |*journal| {
@@ -959,12 +984,13 @@ pub fn handlePrompt(
// so before the turn continues, and their results replace the answer
// that they may have partly run.
if (answered_step) |step| if (prompt_input.rerun_call_ids.len > 0) {
if (ctx.observer) |observer| observer.beginTurn(recovery_checkpoint.?.user.text, true);
_ = try libfx_journal.rerunCalls(alloc, &recovery_checkpoint.?, step, ResumeRerun{
.ctx = &ctx,
.cancel_flag = &session.cancel_flag,
.max_result_bytes = session.max_tool_result_bytes,
.call_ids = prompt_input.rerun_call_ids,
});
}, ctx.observer);
};
} else if (prompt_input.continue_recovery) {
const writable = if (session.writable) |*value| value else return .{
@@ -1108,7 +1134,7 @@ pub fn handlePrompt(
.custom_tool_guidance = tool_projection.custom_guidance,
}, current_prompt_is_root_authority);
agent_config.session_child_capability = sessionChildCapability(session);
maybeStartAcpTitleTask(state, session, owned_prompt, recovery_checkpoint != null);
maybeStartAcpTitleTask(state, session, owned_prompt, recovery_checkpoint != null, ctx.observer);
defer if (session.title_task != null) completeAcpTitleTask(state, session, alloc);
agent_runtime.processAgentPrompt(&session.session_rt.agent, &deps, null, .{
.view = state.lifecycle_view,
@@ -1167,6 +1193,7 @@ fn maybeStartAcpTitleTask(
session: *server.ActiveSessionState,
prompt_text: []const u8,
recovery: bool,
observer: ?*observation.Observer,
) void {
// Unit tests share the real provider bundles; never spawn network side
// calls from a test process. Wiring is covered by e2e mock servers.
@@ -1182,6 +1209,7 @@ fn maybeStartAcpTitleTask(
const excerpt = session_title_generation.promptExcerpt(prompt_text) orelse return;
if (session.credential_source != .host_managed and session.api_key.len == 0) return;
const task = session_title_generation.Task.create(.{
.observer = observer,
.session_id = session_id,
.model = title_model,
.prompt_excerpt = excerpt,
@@ -1263,6 +1291,15 @@ pub fn runSubagentChild(
.captured_permission_mode = admission.permission_mode,
};
defer ctx.deinitPublishedToolCalls();
var child_bridge: libfx_hooks.Bridge = undefined;
if (active.observer_hooks) |hook_config| {
child_bridge.init(alloc, &state.writer, turn.child_id orelse session_id, message.id, hook_config);
child_bridge.root_session_id = session_id;
child_bridge.parent_session_id = admission.parent_id;
child_bridge.source_session_id = message.source_id;
child_bridge.work_id = child_bridge.turn_id;
if (child_bridge.enabled) ctx.observer = &child_bridge.observer;
}
var child_projection = state.cfg.mode_registry.buildModelToolProjection(
alloc,
builtin_tools.advertisement_set,
@@ -1870,6 +1907,7 @@ fn agentRuntimeDeps(ctx: *AcpContext) agent_runtime.AgentRuntimeDeps {
const session = if (ctx.state.active_session) |*active| active else unreachable;
return .{
.ctx = @ptrCast(ctx),
.observer = ctx.observer,
.agent_stream_provider = server.streamProviderFor(ctx.state, ctx.state.active_session.?.provider),
.confirm_session_writer = confirmSessionWriter,
.report_usage = reportUsage,
@@ -3413,6 +3451,18 @@ fn requestAcpElicitation(
.{},
);
defer alloc.free(params);
const wait = if (responder.acp.observer) |observer| observer.beginWait(.{
.kind = if (input_request.mode == .url) .mcp_url else .mcp_form,
.call_id = responder.tool_call_id,
.external_id = outbound_id,
.prompt = display_message,
.schema_json = input_request.requested_schema_json,
.server_name = origin.server_name,
}) else null;
var wait_finished = false;
var wait_outcome: observation.WaitOutcome = .failed;
var wait_error: ?[]const u8 = null;
defer if (!wait_finished) if (wait) |value| value.end(if (responder.operation_cancel_flag != null and responder.operation_cancel_flag.?.load(.seq_cst)) .cancelled else wait_outcome, &.{}, wait_error);
try state.writer.writeRequest(
alloc,
.{ .integer = @intCast(outbound_id) },
@@ -3420,16 +3470,28 @@ fn requestAcpElicitation(
params,
);
const maybe_response = try awaitAcpElicitationResponse(
const maybe_response = awaitAcpElicitationResponse(
state,
outbound_id,
origin,
responder.operation_cancel_flag,
);
) catch |err| {
wait_outcome = if (err == error.Cancelled) .cancelled else if (err == error.Timeout or err == error.McpOperationTimedOut) .timed_out else .failed;
wait_error = @errorName(err);
return err;
};
awaiting = false;
var response = maybe_response orelse return error.Cancelled;
var response = maybe_response orelse {
const explicitly_cancelled = (origin.lifecycle_cancel_flag != null and origin.lifecycle_cancel_flag.?.load(.acquire)) or
(responder.operation_cancel_flag != null and responder.operation_cancel_flag.?.load(.acquire));
wait_outcome = if (!explicitly_cancelled and acpAwakeMillis() >= origin.deadline_ms) .timed_out else .cancelled;
return error.Cancelled;
};
defer response.deinit(state.alloc);
if (response.cancelled) return error.Cancelled;
if (response.cancelled) {
wait_outcome = .cancelled;
return error.Cancelled;
}
if (response.error_json != null) return error.McpInputRequired;
const response_json = response.result_json orelse return error.McpInputRequired;
@@ -3494,6 +3556,8 @@ fn requestAcpElicitation(
try responder.accepted_url_ids.append(state.alloc, retained_id);
}
}
if (wait) |value| value.end(if (action == .accept) .accepted else if (action == .cancel) .cancelled else .denied, if (input_request.mode == .form) &.{canonical_response} else &.{}, null);
wait_finished = true;
return canonical_response;
}
+1
View File
@@ -220,6 +220,7 @@ const PendingLegacyUrl = struct {
};
pub const ActiveSessionState = struct {
observer_hooks: ?@import("libfx_hooks.zig").Config = null,
session_id: []u8,
store: ?session_store.Store = null,
writable: ?session_store.LoadedWritableSession = null,
+83 -8
View File
@@ -5,6 +5,7 @@ const debug_trace = @import("../core/shared/debug_trace.zig");
const mem_utils = @import("../core/shared/mem_utils.zig");
const text_utils = @import("../core/shared/text_utils.zig");
const jsonrpc = @import("jsonrpc.zig");
const libfx_hooks = @import("libfx_hooks.zig");
const acp_types = @import("types.zig");
const mcp_servers = @import("mcp_servers.zig");
const client_instructions = @import("client_instructions.zig");
@@ -76,6 +77,47 @@ fn requestedLibfxSessionId(alloc: Allocator, params_raw: ?[]const u8) error{ Inv
}
}
test "hook configuration OOM is not converted to an ACP parameter error" {
const Fixture = struct {
fail_index: usize = 0,
alloc_index: usize = 0,
writes: usize = 0,
fn allocate(raw: *anyopaque, len: usize, alignment: std.mem.Alignment, ret: usize) ?[*]u8 {
const self: *@This() = @ptrCast(@alignCast(raw));
const index = self.alloc_index;
self.alloc_index += 1;
if (index == self.fail_index) return null;
return std.testing.allocator.rawAlloc(len, alignment, ret);
}
fn free(_: *anyopaque, memory: []u8, alignment: std.mem.Alignment, ret: usize) void {
std.testing.allocator.rawFree(memory, alignment, ret);
}
fn write(raw: ?*anyopaque, _: []const u8) !void {
const self: *@This() = @ptrCast(@alignCast(raw.?));
self.writes += 1;
}
};
const Handler = *const fn (*server.ServerState, Allocator, *jsonrpc.Message) anyerror!void;
const handlers = if (comptime host_target.is_wasm) [_]Handler{ handleNewLibfxSession, handleNewWasmSession, handleLoadWasmSession } else [_]Handler{ handleNewLibfxSession, handleNewSession, handleLoadSession, handleResumeSession };
for (handlers) |handler| {
var fixture: Fixture = .{};
const alloc: Allocator = .{ .ptr = &fixture, .vtable = &.{ .alloc = Fixture.allocate, .resize = Allocator.noResize, .remap = Allocator.noRemap, .free = Fixture.free } };
if (handler == handleNewLibfxSession) {
// That handler parses the optional session ID first, even for {}.
// Fail the first hooks allocation, not an allocation in that parse.
fixture.fail_index = std.math.maxInt(usize);
try std.testing.expect(try requestedLibfxSessionId(alloc, "{}") == null);
fixture.fail_index = fixture.alloc_index;
fixture.alloc_index = 0;
}
var state: server.ServerState = undefined;
state.writer = jsonrpc.Writer.initCallback(&fixture, Fixture.write);
var message: jsonrpc.Message = .{ .params_raw = "{}" };
try std.testing.expectError(error.OutOfMemory, handler(&state, alloc, &message));
try std.testing.expectEqual(@as(usize, 0), fixture.writes);
}
}
pub fn handleNewLibfxSession(
state: *server.ServerState,
alloc: Allocator,
@@ -92,6 +134,10 @@ pub fn handleNewLibfxSession(
};
var requested_owned = requested != null;
defer if (requested_owned) alloc.free(requested.?);
const observer_hooks = libfx_hooks.parseConfig(alloc, msg.params_raw) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
else => return state.writer.writeError(alloc, msg.id, .{ .code = ErrorCode.invalid_params, .message = "Invalid hooks" }),
};
try server.releaseActiveSession(state);
requested_owned = false;
const session_id = requested orelse try session_store.generateSessionId(alloc);
@@ -106,6 +152,7 @@ pub fn handleNewLibfxSession(
const start = server.loadStartingMode(state, alloc);
state.active_session = .{
.observer_hooks = observer_hooks,
.session_id = session_id,
.model = model,
.provider = state.provider,
@@ -129,10 +176,14 @@ pub fn handleNewLibfxSession(
session_id_owned = false;
model_owned = false;
session_rt_owned = false;
try writeNewSessionResponse(state, alloc, msg, session_id);
try writeNewSessionResponse(state, alloc, msg, session_id, observer_hooks);
}
pub fn handleNewWasmSession(state: *server.ServerState, alloc: Allocator, msg: *jsonrpc.Message) !void {
const observer_hooks = libfx_hooks.parseConfig(alloc, msg.params_raw) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
else => return state.writer.writeError(alloc, msg.id, .{ .code = ErrorCode.invalid_params, .message = "Invalid hooks" }),
};
try server.releaseActiveSession(state);
var durable = try freshAcpState(state, alloc, state.workspace_root);
@@ -186,7 +237,7 @@ pub fn handleNewWasmSession(state: *server.ServerState, alloc: Allocator, msg: *
model_owned = false;
session_rt_owned = false;
try writeNewSessionResponse(state, alloc, msg, session_id);
try writeNewSessionResponse(state, alloc, msg, session_id, observer_hooks);
}
pub fn commitWasmSessionLocked(alloc: Allocator, session: *server.ActiveSessionState) !void {
@@ -337,6 +388,10 @@ test "ACP ultrafast WASM saves preserve baselines and failed preference writes r
}
pub fn handleNewSession(state: *server.ServerState, alloc: Allocator, msg: *jsonrpc.Message) !void {
const observer_hooks = libfx_hooks.parseConfig(alloc, msg.params_raw) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
else => return state.writer.writeError(alloc, msg.id, .{ .code = ErrorCode.invalid_params, .message = "Invalid hooks" }),
};
// Validate the client system prompt before any session side effect.
var client_system_prompt = client_instructions.parse(alloc, msg.params_raw) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
@@ -406,6 +461,7 @@ pub fn handleNewSession(state: *server.ServerState, alloc: Allocator, msg: *json
session_mcp_owned = false;
session_host_owned = false;
return startV2Session(state, alloc, msg, v2_store, .{
.observer_hooks = observer_hooks,
.mcp = session_mcp,
.mcp_host = session_host,
.workspace = if (workspace) |*binding| binding else null,
@@ -495,11 +551,12 @@ pub fn handleNewSession(state: *server.ServerState, alloc: Allocator, msg: *json
session_mcp_owned = false;
session_host_owned = false;
try writeNewSessionResponse(state, alloc, msg, session_id);
try writeNewSessionResponse(state, alloc, msg, session_id, observer_hooks);
}
/// What `session/new` hands `startV2Session`.
const V2Start = struct {
observer_hooks: ?libfx_hooks.Config = null,
/// Owned; the session takes it over.
mcp: ?*mcp_runtime.McpRuntime,
/// Owned like `mcp`.
@@ -592,7 +649,7 @@ fn startV2Session(
session_rt_owned = false;
session_mcp_owned = false;
session_host_owned = false;
try writeNewSessionResponse(state, alloc, msg, session_id);
try writeNewSessionResponse(state, alloc, msg, session_id, start.observer_hooks);
}
fn writeNewSessionResponse(
@@ -600,7 +657,9 @@ fn writeNewSessionResponse(
alloc: Allocator,
msg: *jsonrpc.Message,
session_id: []const u8,
observer_hooks: ?libfx_hooks.Config,
) !void {
state.active_session.?.observer_hooks = observer_hooks;
try server.refreshModelCatalogForOptions(state);
var out: std.Io.Writer.Allocating = .init(alloc);
defer out.deinit();
@@ -639,7 +698,9 @@ fn writeNewSessionResponse(
try writeJsonStr(state.active_session.?.mode, &out.writer);
try out.writer.writeAll(",\"availableModes\":");
try writeModesArray(&out.writer, state.cfg.mode_registry);
try out.writer.writeAll("}}");
try out.writer.writeByte('}');
if (state.active_session.?.observer_hooks != null) try out.writer.writeAll(",\"_meta\":{\"fx\":{\"hooksVersion\":1}}");
try out.writer.writeByte('}');
try state.writer.writeResponse(alloc, msg.id, out.writer.buffered());
@@ -647,6 +708,10 @@ fn writeNewSessionResponse(
}
pub fn handleLoadWasmSession(state: *server.ServerState, alloc: Allocator, msg: *jsonrpc.Message) !void {
const observer_hooks = libfx_hooks.parseConfig(alloc, msg.params_raw) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
else => return state.writer.writeError(alloc, msg.id, .{ .code = ErrorCode.invalid_params, .message = "Invalid hooks" }),
};
const params = msg.params_raw orelse return state.writer.writeError(alloc, msg.id, .{
.code = ErrorCode.invalid_params,
.message = "Missing params",
@@ -666,7 +731,7 @@ pub fn handleLoadWasmSession(state: *server.ServerState, alloc: Allocator, msg:
if (sameSessionId(active.session_id, session_id)) {
for (active.session_rt.agent.history.items) |turn| try sendHistoryTurnAsUpdates(state, alloc, session_id, turn);
try sendActiveSessionInfoUpdate(state, alloc);
return writeLoadSessionResponse(state, alloc, msg, active.model);
return writeLoadSessionResponse(state, alloc, msg, active.model, observer_hooks);
}
}
@@ -729,7 +794,7 @@ pub fn handleLoadWasmSession(state: *server.ServerState, alloc: Allocator, msg:
session_rt_owned = false;
for (state.active_session.?.session_rt.agent.history.items) |turn| try sendHistoryTurnAsUpdates(state, alloc, session_id, turn);
try sendActiveSessionInfoUpdate(state, alloc);
try writeLoadSessionResponse(state, alloc, msg, state.active_session.?.model);
try writeLoadSessionResponse(state, alloc, msg, state.active_session.?.model, observer_hooks);
}
pub fn handleListWasmSessions(state: *server.ServerState, alloc: Allocator, msg: *jsonrpc.Message) !void {
@@ -795,6 +860,10 @@ fn handleRestoreSession(
msg: *jsonrpc.Message,
kind: RestoreKind,
) !void {
const observer_hooks = libfx_hooks.parseConfig(alloc, msg.params_raw) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
else => return state.writer.writeError(alloc, msg.id, .{ .code = ErrorCode.invalid_params, .message = "Invalid hooks" }),
};
const params = msg.params_raw orelse return state.writer.writeError(alloc, msg.id, .{
.code = ErrorCode.invalid_params,
.message = "Missing params",
@@ -918,6 +987,7 @@ fn handleRestoreSession(
alloc,
msg,
active.model,
observer_hooks,
);
}
}
@@ -1084,6 +1154,7 @@ fn handleRestoreSession(
alloc,
msg,
state.active_session.?.model,
observer_hooks,
);
}
@@ -1263,7 +1334,9 @@ fn writeLoadSessionResponse(
alloc: Allocator,
msg: *jsonrpc.Message,
model: []const u8,
observer_hooks: ?libfx_hooks.Config,
) !void {
state.active_session.?.observer_hooks = observer_hooks;
try server.refreshModelCatalogForOptions(state);
var out: std.Io.Writer.Allocating = .init(alloc);
defer out.deinit();
@@ -1299,7 +1372,9 @@ fn writeLoadSessionResponse(
try writeJsonStr(state.active_session.?.mode, &out.writer);
try out.writer.writeAll(",\"availableModes\":");
try writeModesArray(&out.writer, state.cfg.mode_registry);
try out.writer.writeAll("}}");
try out.writer.writeByte('}');
if (observer_hooks != null) try out.writer.writeAll(",\"_meta\":{\"fx\":{\"hooksVersion\":1}}");
try out.writer.writeByte('}');
try state.writer.writeResponse(alloc, msg.id, out.writer.buffered());
}
+2
View File
@@ -47,6 +47,8 @@ pub const classifyProviderExecutedResultStatus = runtime_execution_memory.classi
pub const normalizeAssistantTextForDisplay = runtime_assistant_stream.normalizeAssistantTextForDisplay;
test {
_ = @import("runtime/observation.zig");
_ = @import("runtime/gateway_step.zig");
_ = @import("stream_provider.zig");
_ = @import("../compactor/compactor.zig");
_ = @import("runtime/parallel_execution.zig");
+23
View File
@@ -1,4 +1,5 @@
const std = @import("std");
const observation = @import("observation.zig");
const skill_contract = @import("../../skills/skill_contract.zig");
const agent_stream_provider = @import("../stream_provider.zig");
const auth_runtime = @import("../../auth/auth_runtime.zig");
@@ -200,6 +201,7 @@ pub const DiffMarkerStyles = struct {
pub const AgentRuntimeDeps = struct {
ctx: *anyopaque,
observer: ?*observation.Observer = null,
agent_stream_provider: agent_stream_provider.Provider = agent_stream_provider.unavailable_provider,
flush_assistant_stream_per_content_chunk: bool = false,
render_assistant_text: bool = true,
@@ -302,6 +304,27 @@ pub const AgentRuntimeDeps = struct {
if (self.push_context_notice) |push| return push(self.ctx, text);
return self.push_system_notice(self.ctx, text);
}
pub fn executeToolCall(self: *const AgentRuntimeDeps, request: ToolExecutionRequest) !ToolExecutionResult {
if (self.observer) |observer| observer.emit(.{ .tool_start = .{ .call = request.call } });
var observed_request = request;
observed_request.observer = self.observer orelse request.observer;
const result = self.execute_tool_call(self.ctx, observed_request) catch |err| {
if (self.observer) |observer| observer.emit(.{ .tool_end = .{
.call = request.call,
.status = if (err == error.Cancelled) "cancelled" else "failure",
.error_name = @errorName(err),
} });
return err;
};
if (self.observer) |observer| observer.emit(.{ .tool_end = .{
.call = request.call,
.output = result.model_output,
.status = if (result.cancelled) "cancelled" else @tagName(result.status),
.usage = result.inner_usage,
} });
return result;
}
};
fn terminalLeaseCleanupUnavailable(_: *anyopaque, _: []const u8) !void {
+47 -1
View File
@@ -6,6 +6,7 @@ const debug_trace = @import("../../shared/debug_trace.zig");
const io_mod = @import("../../shared/io.zig");
const runtime_telemetry = @import("telemetry.zig");
const runtime_tool_contracts = @import("tool_contracts.zig");
const observation = @import("observation.zig");
const Allocator = std.mem.Allocator;
const TraceContext = debug_trace.TraceContext;
@@ -23,6 +24,7 @@ const InvocationAdmission = struct {
model: []const u8,
caller_admission: agent_stream_provider.Admission,
admitted: bool = false,
observed: ?*observation.ModelCall = null,
fn admit(raw: *anyopaque) !void {
const self: *@This() = @ptrCast(@alignCast(raw));
@@ -32,6 +34,7 @@ const InvocationAdmission = struct {
try self.caller_admission.admit();
}
self.attempt_evidence.provider_admitted = true;
if (self.observed) |observed| observed.emit(.{ .model_admitted = observed.id });
debug_trace.eventf(
"agent",
"provider_admitted",
@@ -51,13 +54,17 @@ pub fn streamModelCompletion(
return agent_stream_provider.failResult(error.Cancelled);
}
const started_at_ms = io_mod.milliTimestamp();
var observed = if (request_value.observer) |observer| observer.beginModelForTool(request_value.data(), request_value.purpose, request_value.parent_call_id) else null;
var observed_events = ObservedEvents{ .upstream = request_value.events, .observed = if (observed) |*call| call else null };
var admission = InvocationAdmission{
.attempt_evidence = request_value.attempt_evidence,
.trace_ctx = request_value.trace_ctx,
.model = request_value.model,
.caller_admission = request_value.admission,
.observed = if (observed) |*call| call else null,
};
var request = request_value;
if (observed != null) request.events = .{ .context = &observed_events, .emit_fn = ObservedEvents.emit };
request.admission = .{ .context = &admission, .admit_fn = InvocationAdmission.admit };
// Only the owned result escapes. HTTP and parser scratch is released after
// every attempt, including transport failure and cancellation.
@@ -65,11 +72,19 @@ pub fn streamModelCompletion(
defer scratch.deinit();
const request_alloc = scratch.allocator();
var result = provider.stream(request_alloc, request) catch |err| {
if (observed) |call| call.emit(.{ .model_end = .{ .id = call.id, .error_name = @errorName(err), .cancelled = err == error.Cancelled or request.cancel_flag.load(.seq_cst) } });
runtime_telemetry.recordGatewayCallMetric(request.model, started_at_ms, 0, 0, 0, 0, request.trace_ctx.turn_id, request.trace_ctx.step_id, request.trace_ctx.subagent_id, @errorName(err), "");
return err;
};
defer result.deinit(request_alloc);
if (!admission.admitted) return agent_stream_provider.failResult(error.ProviderAdmissionMissing);
if (!admission.admitted) {
if (observed) |call| call.emit(.{ .model_end = .{ .id = call.id, .error_name = "ProviderAdmissionMissing" } });
return agent_stream_provider.failResult(error.ProviderAdmissionMissing);
}
if (observed) |call| call.emit(.{ .model_end = switch (result) {
.completed => |value| .{ .id = call.id, .completion = value.completion, .cancelled = request.cancel_flag.load(.seq_cst) },
.failed => |value| .{ .id = call.id, .error_name = @tagName(value.kind), .cancelled = request.cancel_flag.load(.seq_cst) },
} });
recordProviderResultMetric(request.model, started_at_ms, result, request.trace_ctx);
return switch (result) {
@@ -78,6 +93,22 @@ pub fn streamModelCompletion(
};
}
const ObservedEvents = struct {
upstream: agent_stream_provider.EventSink,
observed: ?*observation.ModelCall,
fn emit(raw: *anyopaque, event: agent_stream_provider.Event) void {
const self: *@This() = @ptrCast(@alignCast(raw));
if (self.observed) |call| switch (event) {
.content_delta, .reasoning_delta => |chunk| if (chunk.len > 0) {
call.output();
},
.tool_started => call.output(),
.tool_input_delta => {},
};
self.upstream.emit(event);
}
};
fn recordProviderResultMetric(
model: []const u8,
started_at_ms: i64,
@@ -129,6 +160,17 @@ fn recordProviderResultMetric(
test "gateway request scratch is released across success failure cancellation and retries" {
const Fake = struct {
mode: usize = 0,
observed_starts: usize = 0,
observed_ends: usize = 0,
fn observe(raw: *anyopaque, _: usize, event: observation.Event) void {
const self: *@This() = @ptrCast(@alignCast(raw));
switch (event) {
.model_start => self.observed_starts += 1,
.model_end => self.observed_ends += 1,
else => {},
}
}
fn stream(raw: ?*anyopaque, alloc: Allocator, request: agent_stream_provider.ModelRequest) !StreamResult {
const self: *@This() = @ptrCast(@alignCast(raw.?));
@@ -150,12 +192,14 @@ test "gateway request scratch is released across success failure cancellation an
var turn = std.heap.ArenaAllocator.init(std.testing.allocator);
defer turn.deinit();
var fake: Fake = .{};
var observer = observation.Observer{ .sink = .{ .context = &fake, .emit_fn = Fake.observe } };
var cancel: std.atomic.Value(bool) = .init(false);
for (0..1000) |attempt| {
fake.mode = attempt % 4;
var delivery = DeliveryCertainty.init();
var evidence: AttemptEvidence = .{};
const result = streamModelCompletion(.{ .context = &fake, .stream_fn = Fake.stream }, turn.allocator(), .{
.observer = &observer,
.credential = .{ .direct = .{ .secret_bytes = "fixture-key", .source = .ai_gateway_api_key } },
.model = "fixture-model",
.retry_count = 1,
@@ -185,6 +229,8 @@ test "gateway request scratch is released across success failure cancellation an
}
try std.testing.expect(turn.queryCapacity() < 128 * 1024);
}
try std.testing.expectEqual(@as(usize, 1000), fake.observed_starts);
try std.testing.expectEqual(fake.observed_starts, fake.observed_ends);
}
fn failureMetricCode(kind: agent_stream_provider.FailureKind) u16 {
@@ -1,4 +1,5 @@
const std = @import("std");
const observation = @import("observation.zig");
const agent_stream_provider = @import("../stream_provider.zig");
const image_attachments = @import("../../images/image_attachments.zig");
const types = @import("../../shared/types.zig");
@@ -11,6 +12,8 @@ const ChatMessage = types.ChatMessage;
const model = "google/gemini-2.5-flash";
pub const Request = struct {
observer: ?*observation.Observer = null,
parent_call_id: ?[]const u8 = null,
stream_provider: agent_stream_provider.Provider,
api_key: []const u8,
credential_source: ?types.CredentialSource = null,
@@ -54,6 +57,9 @@ pub fn inspect(
request.stream_provider,
alloc,
.{
.observer = request.observer,
.parent_call_id = request.parent_call_id,
.purpose = .vision,
.credential = if (request.credential_source == .host_managed)
.host_managed
else
+194
View File
@@ -0,0 +1,194 @@
//! Semantic execution facts for host-owned instrumentation. No trace schema,
//! exporter, or durable state lives here. All event payloads are borrowed for
//! the synchronous callback; a retaining sink must copy them and be thread safe.
const std = @import("std");
const types = @import("../../shared/types.zig");
const provider = @import("../stream_provider.zig");
pub const ModelPurpose = enum { agent, compaction, vision, title, permission_review, auxiliary };
pub const TurnOutcome = enum { completed, interrupted, failed, paused, yielded, cancelled, fenced };
pub const WaitKind = enum { approval, question, mcp_form, mcp_url };
pub const WaitOutcome = enum { accepted, denied, cancelled, failed, timed_out };
pub const McpStatus = enum { success, failure, cancelled, input_required, unavailable, unknown };
pub const WaitRequest = struct {
kind: WaitKind,
call_id: ?[]const u8 = null,
external_id: ?u64 = null,
prompt: ?[]const u8 = null,
schema_json: ?[]const u8 = null,
questions: []const types.QuestionBatchEntry = &.{},
server_name: ?[]const u8 = null,
};
pub const McpIdentity = struct { call_id: ?[]const u8, server: []const u8, tool: []const u8, transport: ?[]const u8 };
pub const Event = union(enum) {
turn_start: struct { input: []const u8, resumed: bool },
turn_end: struct { outcome: TurnOutcome, error_name: ?[]const u8 = null },
step_start: void,
step_end: void,
model_start: struct { id: usize, purpose: ModelPurpose, request: provider.RequestData, parent_call_id: ?[]const u8 = null, tools_json: ?[]const u8 = null },
model_admitted: usize,
model_output_start: usize,
model_end: struct {
id: usize,
completion: ?types.ModelCompletion = null,
error_name: ?[]const u8 = null,
cancelled: bool = false,
},
tool_start: struct { call: types.ToolCall, rerun: bool = false },
tool_end: struct {
call: types.ToolCall,
output: ?[]const u8 = null,
status: []const u8,
error_name: ?[]const u8 = null,
usage: ?types.ToolUsage = null,
},
/// Accepted into the engine's conversation, not a durable-write receipt.
tool_result: struct { call: types.ToolCall, output: []const u8, status: types.PersistedToolStatus },
/// The whole permission evaluation, not necessarily a human wait.
permission_start: types.ToolCall,
permission_end: struct { call: types.ToolCall, decision: ?types.ToolPermissionDecision = null, error_name: ?[]const u8 = null },
wait_start: struct { id: usize, request: WaitRequest },
wait_end: struct { id: usize, outcome: WaitOutcome, answers: []const []const u8 = &.{}, error_name: ?[]const u8 = null },
mcp_start: struct { id: usize, identity: McpIdentity },
mcp_request: struct { id: usize, request_id: u64, kind: enum { jsonrpc, engine } = .jsonrpc },
mcp_end: struct { id: usize, status: McpStatus, error_name: ?[]const u8 = null },
delegation: struct {
call_id: []const u8,
child_id: []const u8,
work_id: []const u8,
operation_id: ?[]const u8 = null,
phase: enum { dispatched, completed, yielded, replayed, failed, cancelled, feedback_queued, feedback_applied, feedback_not_applied },
},
};
pub const Sink = struct {
context: *anyopaque,
emit_fn: *const fn (*anyopaque, usize, Event) void,
};
pub const Observer = struct {
// One observer per worker execution, including a resumed execution.
sink: Sink,
/// Execution-local ordinals, never debug counters or durable operation IDs.
step_index: std.atomic.Value(usize) = .init(0),
next_model: std.atomic.Value(usize) = .init(1),
next_operation: std.atomic.Value(usize) = .init(1),
turn_started: bool = false,
turn_ended: bool = false,
pub fn beginTurn(self: *Observer, input: []const u8, resumed: bool) void {
if (self.turn_started) return;
self.turn_started = true;
self.emit(.{ .turn_start = .{ .input = input, .resumed = resumed } });
}
pub fn endTurn(self: *Observer, outcome: TurnOutcome, error_name: ?[]const u8) void {
if (!self.turn_started or self.turn_ended) return;
self.turn_ended = true;
self.emit(.{ .turn_end = .{ .outcome = outcome, .error_name = error_name } });
}
pub fn emit(self: *Observer, event: Event) void {
self.sink.emit_fn(self.sink.context, self.step_index.load(.acquire), event);
}
pub fn beginStep(self: *Observer, index: usize) void {
self.step_index.store(index, .release);
self.emit(.step_start);
}
pub fn endStep(self: *Observer) void {
self.emit(.step_end);
self.step_index.store(0, .release);
}
pub fn beginModel(self: *Observer, request: provider.RequestData, purpose: ModelPurpose) ModelCall {
return self.beginModelForTool(request, purpose, null);
}
pub fn beginModelForTool(self: *Observer, request: provider.RequestData, purpose: ModelPurpose, parent_call_id: ?[]const u8) ModelCall {
return self.beginModelWithSchemas(request, purpose, parent_call_id, null);
}
pub fn beginModelWithSchemas(self: *Observer, request: provider.RequestData, purpose: ModelPurpose, parent_call_id: ?[]const u8, tools_json: ?[]const u8) ModelCall {
const call = ModelCall{ .observer = self, .id = self.next_model.fetchAdd(1, .monotonic), .step = self.step_index.load(.acquire) };
call.emit(.{ .model_start = .{ .id = call.id, .purpose = purpose, .request = request, .parent_call_id = parent_call_id, .tools_json = tools_json } });
return call;
}
pub fn beginWait(self: *Observer, request: WaitRequest) Wait {
const wait = Wait{ .observer = self, .id = self.next_operation.fetchAdd(1, .monotonic), .step = self.step_index.load(.acquire) };
self.sink.emit_fn(self.sink.context, wait.step, .{ .wait_start = .{ .id = wait.id, .request = request } });
return wait;
}
pub fn beginMcp(self: *Observer, identity: McpIdentity) McpCall {
const call = McpCall{ .observer = self, .id = self.next_operation.fetchAdd(1, .monotonic), .step = self.step_index.load(.acquire) };
self.sink.emit_fn(self.sink.context, call.step, .{ .mcp_start = .{ .id = call.id, .identity = identity } });
return call;
}
};
pub const Wait = struct {
observer: *Observer,
id: usize,
step: usize,
pub fn end(self: Wait, outcome: WaitOutcome, answers: []const []const u8, error_name: ?[]const u8) void {
self.observer.sink.emit_fn(self.observer.sink.context, self.step, .{ .wait_end = .{ .id = self.id, .outcome = outcome, .answers = answers, .error_name = error_name } });
}
};
pub const McpCall = struct {
observer: *Observer,
id: usize,
step: usize,
pub fn request(self: McpCall, request_id: u64) void {
self.observer.sink.emit_fn(self.observer.sink.context, self.step, .{ .mcp_request = .{ .id = self.id, .request_id = request_id } });
}
pub fn engineRequest(self: McpCall, request_id: u64) void {
self.observer.sink.emit_fn(self.observer.sink.context, self.step, .{ .mcp_request = .{ .id = self.id, .request_id = request_id, .kind = .engine } });
}
pub fn end(self: McpCall, status: McpStatus, error_name: ?[]const u8) void {
self.observer.sink.emit_fn(self.observer.sink.context, self.step, .{ .mcp_end = .{ .id = self.id, .status = status, .error_name = error_name } });
}
};
pub const ModelCall = struct {
observer: *Observer,
id: usize,
step: usize,
output_started: bool = false,
pub fn emit(self: ModelCall, event: Event) void {
self.observer.sink.emit_fn(self.observer.sink.context, self.step, event);
}
pub fn output(self: *ModelCall) void {
if (self.output_started) return;
self.output_started = true;
self.emit(.{ .model_output_start = self.id });
}
};
test "semantic observer model identities are independent of debug IDs and capture their step" {
const Capture = struct {
steps: [4]usize = undefined,
len: usize = 0,
fn emit(raw: *anyopaque, step: usize, _: Event) void {
const self: *@This() = @ptrCast(@alignCast(raw));
self.steps[self.len] = step;
self.len += 1;
}
};
var capture: Capture = .{};
var observer = Observer{ .sink = .{ .context = &capture, .emit_fn = Capture.emit } };
observer.beginStep(1);
const data = provider.RequestData{ .model = "fixture", .messages = &.{}, .tool_choice = .none, .provider_options = .{} };
var call = observer.beginModel(data, .agent);
try std.testing.expectEqual(@as(usize, 1), call.id);
observer.beginStep(2);
call.output();
call.output();
try std.testing.expectEqualSlices(usize, &.{ 1, 1, 2, 1 }, &capture.steps);
}
@@ -0,0 +1,43 @@
const std = @import("std");
const observation = @import("observation.zig");
/// Unit-test capture copies only scalar facts, never borrowed event payloads.
pub const Capture = struct {
mutex: std.Io.Mutex = .init,
counts: [@typeInfo(observation.Event).@"union".field_names.len]usize = @splat(0),
purposes: [@typeInfo(observation.ModelPurpose).@"enum".field_names.len]usize = @splat(0),
outcomes: [@typeInfo(observation.WaitOutcome).@"enum".field_names.len]usize = @splat(0),
expected_call: ?[]const u8 = null,
invalid: bool = false,
saw_mcp_request: bool = false,
pub fn observer(self: *Capture) observation.Observer {
return .{ .sink = .{ .context = self, .emit_fn = emit } };
}
pub fn count(self: *const Capture, tag: std.meta.Tag(observation.Event)) usize {
return self.counts[@backingInt(tag)];
}
fn emit(raw: *anyopaque, _: usize, event: observation.Event) void {
const self: *Capture = @ptrCast(@alignCast(raw));
self.mutex.lockUncancelable(std.testing.io);
defer self.mutex.unlock(std.testing.io);
self.counts[@backingInt(std.meta.activeTag(event))] += 1;
switch (event) {
.model_start => |value| {
self.purposes[@backingInt(value.purpose)] += 1;
if (self.expected_call) |expected| self.invalid = self.invalid or value.parent_call_id == null or !std.mem.eql(u8, expected, value.parent_call_id.?);
},
.wait_start => |value| if (self.expected_call) |expected| {
self.invalid = self.invalid or value.request.call_id == null or !std.mem.eql(u8, expected, value.request.call_id.?);
},
.wait_end => |value| self.outcomes[@backingInt(value.outcome)] += 1,
.mcp_start => |value| if (self.expected_call) |expected| {
self.invalid = self.invalid or value.identity.call_id == null or !std.mem.eql(u8, expected, value.identity.call_id.?);
},
.mcp_request => self.saw_mcp_request = true,
else => {},
}
}
};
+31 -3
View File
@@ -65,6 +65,7 @@ const runtime_tool_admission = @import("tool_admission.zig");
const runtime_interruption = @import("interruption.zig");
const runtime_parallel_execution = @import("parallel_execution.zig");
const runtime_tool_batch = @import("tool_batch.zig");
const observation = @import("observation.zig");
const model_response_recovery = @import("model_response_recovery.zig");
const response_language = @import("response_language.zig");
const tool_mcp_runtime = @import("../../tooling/tool_mcp_runtime.zig");
@@ -3179,7 +3180,7 @@ fn materializeConfirmedProviderTools(
novel_calls,
try deps.agent_stream_provider.projectReplay(arena, if (completion.provider_state_json) |parts| .{ .source = selection, .parts_json = parts } else null, novel_calls, false, true),
);
var batch: runtime_tool_batch.StepBatchState = .{};
var batch: runtime_tool_batch.StepBatchState = .{ .observer = deps.observer };
reportProviderExecutedUsage(deps, novel_calls);
for (novel_calls) |call| {
try appendProviderExecutedToolResult(
@@ -5442,9 +5443,28 @@ pub fn processAgentPrompt(
);
defer finalization.deinit();
var observed_error: ?anyerror = null;
if (deps.observer) |observer| observer.beginTurn(job.prompt, job.recovery_checkpoint != null);
defer if (deps.observer) |observer| {
const outcome: observation.TurnOutcome = if (observed_error) |err| switch (err) {
error.TurnYielded => .yielded,
error.Cancelled => .cancelled,
error.SessionWriterChanged, error.SessionWriterParked => .fenced,
else => .failed,
} else if (finalization.outcome) |outcome| switch (outcome) {
.completed => .completed,
.interrupted => .interrupted,
.failed => .failed,
.paused => .paused,
} else .failed;
observer.endTurn(outcome, if (observed_error) |err| @errorName(err) else null);
};
processQueuedPromptInner(deps, semantic_presentation, effective_lifecycle, effective_config, effective_job, &finalization, agent) catch |err| {
observed_error = err;
if (finalization.state == .open) {
finalization.finish(.failed, null, null) catch |finalization_err| {
observed_error = finalization_err;
if (deps.compaction_failure) |out| out.* = null;
return finalization_err;
};
@@ -5452,7 +5472,10 @@ pub fn processAgentPrompt(
return err;
};
if (finalization.state == .open) {
try finalization.finish(.completed, null, null);
finalization.finish(.completed, null, null) catch |err| {
observed_error = err;
return err;
};
}
}
@@ -6993,6 +7016,8 @@ fn processQueuedPromptLoop(
var step: usize = 0;
agent_steps_loop: while (agent_steps.allowsStep(config.agent_step_limit, step)) : (step += 1) {
current_step_index = step + 1;
if (deps.observer) |observer| observer.beginStep(current_step_index);
defer if (deps.observer) |observer| observer.endStep();
const step_ctx: TraceContext = .{ .turn_id = turn_id, .step_id = debug_trace.nextStepId(), .subagent_id = config.subagent_id };
const presentation_group_id = runtime_tool_presentation.presentationGroupForStep(
active_presentation_group_id,
@@ -7496,6 +7521,7 @@ fn processQueuedPromptLoop(
.trace_ctx = step_ctx,
};
var summary_model: runtime_text_completion.CompactorCaller = .{
.observer = deps.observer,
.stream_provider = deps.agent_stream_provider,
.cooperative_transport_pulse = deps.cooperative_transport_pulse,
.provider = job.provider,
@@ -7654,6 +7680,7 @@ fn processQueuedPromptLoop(
.pending_status = &pending_auto_retry_status,
};
var model_request = agent_stream_provider.ModelRequest{
.observer = deps.observer,
.credential = if (job.credential_source == .host_managed)
.host_managed
else
@@ -9958,6 +9985,7 @@ fn processQueuedPromptLoop(
}
var step_batch = runtime_tool_batch.StepBatchState{
.observer = deps.observer,
.identical_failure_escalation = &identical_failure_escalation,
};
terminal_validation_retry.beginBatch();
@@ -11701,7 +11729,7 @@ fn processQueuedPromptLoop(
const execution_lifecycle_id = types.ToolLifecycleId{ .turn_id = turn_id, .call_id = execution_call.id };
const execution_is_command = runtime_tool_presentation.activityKindForCall(arena, deps.tool_registry, tool_call) == .command;
var execution_error: ?anyerror = null;
var execution = deps.execute_tool_call(deps.ctx, .{
var execution = deps.executeToolCall(.{
.skill_locations = if (skills.catalog) |catalog| &catalog.locations else null,
.call_allocator = call_allocator,
.result_allocator = arena,
@@ -355,7 +355,7 @@ fn cancelRequested(cancel_flag: ?*std.atomic.Value(bool)) bool {
pub fn parallelHookExecute(ctx: *anyopaque, alloc: Allocator, call: ToolCall, index: usize) !ToolExecutionResult {
const exec_ctx: *ParallelHookExecContext = @ptrCast(@alignCast(ctx));
return exec_ctx.hooks.execute_tool_call(exec_ctx.hooks.ctx, .{
return exec_ctx.hooks.executeToolCall(.{
.skill_locations = exec_ctx.skill_locations,
.call_allocator = alloc,
.result_allocator = alloc,
@@ -4,6 +4,7 @@
//! fx-compactor as its model caller.
const std = @import("std");
const observation = @import("observation.zig");
const agent_stream_provider = @import("../stream_provider.zig");
const runtime_gateway_step = @import("gateway_step.zig");
const compactor = @import("../../compactor/compactor.zig");
@@ -16,6 +17,8 @@ const types = @import("../../shared/types.zig");
const Allocator = std.mem.Allocator;
pub const Request = struct {
observer: ?*observation.Observer = null,
purpose: observation.ModelPurpose = .auxiliary,
stream_provider: agent_stream_provider.Provider,
cooperative_pulse: ?agent_stream_provider.CooperativePulse = null,
credential: agent_stream_provider.CredentialLease,
@@ -83,6 +86,8 @@ pub fn complete(alloc: Allocator, request: Request) Error!Outcome {
request.stream_provider,
alloc,
.{
.observer = request.observer,
.purpose = request.purpose,
.credential = request.credential,
.session_id = request.session_id,
.model = request.model,
@@ -179,6 +184,7 @@ fn onEvent(raw: *anyopaque, event: agent_stream_provider.Event) void {
/// and its own output limit. A request after the conversation keeps the
/// conversation's options except Ultrafast, which is reserved for agent turns.
pub const CompactorCaller = struct {
observer: ?*observation.Observer = null,
stream_provider: agent_stream_provider.Provider,
cooperative_transport_pulse: ?agent_stream_provider.CooperativePulse = null,
provider: model_provider.ProviderId,
@@ -239,6 +245,8 @@ pub const CompactorCaller = struct {
options.ultrafast = false;
var usage: types.Usage = .{};
const outcome = try complete(alloc, .{
.observer = self.observer,
.purpose = .compaction,
.stream_provider = self.stream_provider,
.cooperative_pulse = self.cooperative_transport_pulse,
.credential = if (self.credential_source == .host_managed) .host_managed else .{ .direct = .{
+12 -2
View File
@@ -888,9 +888,14 @@ pub fn requestToolPermissionTraced(
}
break :schema null;
};
const outcome = hooks.request_tool_permission(hooks.ctx, arena, call, review_turn, mode, local_grants, live_authority, revalidation, advertised_dynamic_tool_names, review_schema) catch |err| {
if (hooks.observer) |observer| observer.emit(.{ .permission_start = call });
var observed_review = review_turn;
observed_review.observer = hooks.observer;
const outcome = hooks.request_tool_permission(hooks.ctx, arena, call, observed_review, mode, local_grants, live_authority, revalidation, advertised_dynamic_tool_names, review_schema) catch |err| {
if (hooks.observer) |observer| observer.emit(.{ .permission_end = .{ .call = call, .error_name = @errorName(err) } });
return permissionErrorOutcome(arena, call, mode, err, target_class, ctx);
};
if (hooks.observer) |observer| observer.emit(.{ .permission_end = .{ .call = call, .decision = outcome.decision } });
tracePermissionOutcome(call, mode, local_grants.len, target_class, ctx, outcome);
return outcome;
}
@@ -920,9 +925,14 @@ pub fn requestPreparedFileMutationPermissionTraced(
ctx,
);
};
const outcome = request(hooks.ctx, arena, call, prepared, review_turn, mode, local_grants, live_authority, advertised_dynamic_tool_names) catch |err| {
if (hooks.observer) |observer| observer.emit(.{ .permission_start = call });
var observed_review = review_turn;
observed_review.observer = hooks.observer;
const outcome = request(hooks.ctx, arena, call, prepared, observed_review, mode, local_grants, live_authority, advertised_dynamic_tool_names) catch |err| {
if (hooks.observer) |observer| observer.emit(.{ .permission_end = .{ .call = call, .error_name = @errorName(err) } });
return permissionErrorOutcome(arena, call, mode, err, target_class, ctx);
};
if (hooks.observer) |observer| observer.emit(.{ .permission_end = .{ .call = call, .decision = outcome.decision } });
tracePermissionOutcome(call, mode, local_grants.len, target_class, ctx, outcome);
return outcome;
}
+12
View File
@@ -1,4 +1,5 @@
const std = @import("std");
const observation = @import("observation.zig");
const types = @import("../../shared/types.zig");
const debug_trace = @import("../../shared/debug_trace.zig");
const diff = @import("../../output/diff.zig");
@@ -23,6 +24,7 @@ const AgentRuntimeDeps = runtime_deps.AgentRuntimeDeps;
const ToolExecutionResult = runtime_tool_contracts.ToolExecutionResult;
pub const StepBatchState = struct {
observer: ?*observation.Observer = null,
step_error_count: usize = 0,
step_total_count: usize = 0,
step_had_writes: bool = false,
@@ -99,6 +101,11 @@ pub fn appendToolResultContent(
if (accounting.record_completion) {
try runtime_interruption.recordCompletedToolName(arena, completed_tool_names, tool_call.name);
}
if (batch.observer) |observer| observer.emit(.{ .tool_result = .{
.call = tool_call,
.output = content,
.status = accounting.status orelse if (accounting.increment_error) .failure else .success,
} });
}
pub fn appendPermissionFeedback(
@@ -376,6 +383,11 @@ pub fn processCommittedFileResult(
),
.tool_result_memory = prepared_memory,
});
if (batch.observer) |observer| observer.emit(.{ .tool_result = .{
.call = tool_call,
.output = model_output,
.status = runtime_execution_memory.persistedStatusForCurrentFxLocalResult(execution.status, model_output),
} });
debug_trace.eventf(
"tool",
"committed_result_appended",
@@ -1,4 +1,5 @@
const std = @import("std");
const observation = @import("observation.zig");
const skill_contract = @import("../../skills/skill_contract.zig");
const command_admission = @import("../../permissions/command_admission.zig");
const types = @import("../../shared/types.zig");
@@ -147,6 +148,7 @@ pub fn unavailableHostToolResult(alloc: Allocator) Allocator.Error!ToolExecution
}
pub const ToolExecutionRequest = struct {
observer: ?*observation.Observer = null,
skill_locations: ?*const skill_contract.Locations = null,
call_allocator: Allocator,
result_allocator: Allocator,
@@ -1,4 +1,5 @@
const std = @import("std");
const observation = @import("observation.zig");
const builtin = @import("builtin");
const agent_stream_provider = @import("../stream_provider.zig");
const context_limits = @import("../../config/context_limits.zig");
@@ -25,6 +26,8 @@ const system_prompt =
"Extract exactly one record for every requested image ID, in requested order.";
pub const Config = struct {
observer: ?*observation.Observer = null,
parent_call_id: ?[]const u8 = null,
stream_provider: agent_stream_provider.Provider,
api_key: []const u8,
credential_source: ?types.CredentialSource = null,
@@ -287,6 +290,8 @@ fn runBatchAttempt(
verified_images,
.{
.stream_provider = config.stream_provider,
.observer = config.observer,
.parent_call_id = config.parent_call_id,
.api_key = config.api_key,
.credential_source = config.credential_source,
.gateway_team = config.gateway_team,
+4
View File
@@ -1,4 +1,5 @@
const std = @import("std");
const observation = @import("runtime/observation.zig");
const testing_allocator = @import("../shared/testing_allocator.zig");
const model_capabilities = @import("../config/model_capabilities.zig");
const image_attachments = @import("../images/image_attachments.zig");
@@ -217,6 +218,9 @@ pub fn validate_prompt_lanes(
/// Borrowed typed request. Providers own validation, wire serialization,
/// endpoint selection, headers, HTTP, and stream reduction.
pub const ModelRequest = struct {
observer: ?*observation.Observer = null,
parent_call_id: ?[]const u8 = null,
purpose: observation.ModelPurpose = .agent,
credential: types.CredentialLease,
session_id: ?[]const u8 = null,
model: []const u8,
+11 -3
View File
@@ -2321,16 +2321,24 @@ pub const McpRuntime = struct {
try tools_feature.validateArguments(arena, arguments_json, .{});
return try self.toolOperations().callToolFromSnapshot(
var observed = if (options.observer) |observer| observer.beginMcp(.{ .call_id = options.call_id, .server = snapshot.server_name, .tool = snapshot.original_name, .transport = @tagName(match.server.config.transport) }) else null;
var observed_options = options;
observed_options.mcp_observation = if (observed) |*call| call else null;
const result = self.toolOperations().callToolFromSnapshot(
arena,
match.server,
&snapshot,
arguments_json,
max_tool_result_bytes,
options,
observed_options,
0,
null,
);
) catch |err| {
if (observed) |call| call.end(if (err == error.Cancelled) .cancelled else .failure, @errorName(err));
return err;
};
if (observed) |call| call.end(result.status.observed(), null);
return result;
}
fn serverForSnapshotLocked(
+3
View File
@@ -592,6 +592,7 @@ pub const Operations = struct {
responderCapabilities(options.input_responder),
);
defer alloc.free(request_body);
if (options.mcp_observation) |call| call.request(request_id);
var tool_precommit = ToolPrecommit{
.alloc = runtime.lifecycle.alloc,
@@ -793,6 +794,7 @@ pub const Operations = struct {
};
var transport_precommit = tool_precommit.transport();
const request_id = try server.reserveRequestId();
if (options.mcp_observation) |call| call.request(request_id);
if (server.legacy_http) |client| {
if (!client.acquireUse()) return error.McpConnectionClosed;
const client_version = client.version;
@@ -1153,6 +1155,7 @@ pub const Operations = struct {
const client = server.legacy_sse orelse return error.McpConnectionClosed;
const request_id = try server.reserveRequestId();
if (options.mcp_observation) |call| call.request(request_id);
const request_body = try buildToolCallRequestForProtocol(
alloc,
request_id,
+18 -5
View File
@@ -132,25 +132,32 @@ pub const Catalog = struct {
if (options.expected_binding) |b| if (b.runtime_generation != c.generation) return error.McpAdvertisedToolChanged;
if (options.expected_runtime_generation) |g| if (g != c.generation) return error.McpAuthorityChanged;
try c.ensure(name, options.cancel_flag);
const server, const tool = found: {
const server, const tool, const transport = found: {
c.mutex.lockUncancelable(c.io);
defer c.mutex.unlock(c.io);
const found = c.findLocked(name) orelse return null;
if (options.expected_binding) |b| {
if (!b.sameDefinition(c.bindingLocked(found.server, found.tool.*))) return error.McpAdvertisedToolChanged;
}
break :found .{ c.runtime.servers()[found.server].name, try arena.dupe(u8, found.tool.original_name) };
break :found .{ c.runtime.servers()[found.server].name, try arena.dupe(u8, found.tool.original_name), @tagName(c.runtime.servers()[found.server].transport) };
};
var asking: Asking = .{ .responder = options.input_responder orelse undefined, .tool = name, .cancel = options.cancel_flag, .io = c.io };
const observed = if (options.observer) |observer| observer.beginMcp(.{ .call_id = options.call_id, .server = server, .tool = tool, .transport = transport }) else null;
const asker: ?runtime_mod.Asker = if (options.input_responder != null) .{ .context = &asking, .ask = Asking.ask } else null;
const answer = c.runtime.call(arena, server, tool, arguments_json, options.cancel_flag, asker) catch |err| {
const answer = c.runtime.callObserved(arena, server, tool, arguments_json, options.cancel_flag, asker, observed) catch |err| {
if (observed) |call| call.end(if (err == error.Cancelled) .cancelled else .failure, @errorName(err));
asking.finish(arena, .abandoned);
return err;
};
// The questions asked end with the call: an editor hears that a URL it
// opened was used (ACP `elicitation/complete`).
asking.finish(arena, if (answer == .result) .completed else .abandoned);
return try render(arena, server, name, answer, max_tool_result_bytes);
const result = render(arena, server, name, answer, max_tool_result_bytes) catch |err| {
if (observed) |call| call.end(.failure, @errorName(err));
return err;
};
if (observed) |call| call.end(if (result) |value| value.status.observed() else .unavailable, null);
return result;
}
pub fn searchTools(
@@ -712,7 +719,13 @@ test "an MCP-v2 server's tools reach the model under fx's names" {
try testing.expectEqual(c.generation, (try c.validateTool(a, "mcp_fake_echo", "{}", .unrestricted)).valid);
try testing.expect((try c.validateTool(a, "mcp_fake_echo", "[", .unrestricted)) == .invalid);
const result = (try c.callTool(a, "mcp_fake_echo", "{}", 4096, .{ .expected_binding = binding })).?;
var capture: @import("../agent/runtime/observation_testing.zig").Capture = .{ .expected_call = "mcp-call" };
var observer = capture.observer();
const result = (try c.callTool(a, "mcp_fake_echo", "{}", 4096, .{ .expected_binding = binding, .observer = &observer, .call_id = "mcp-call" })).?;
try testing.expectEqual(@as(usize, 1), capture.count(.mcp_start));
try testing.expectEqual(capture.count(.mcp_start), capture.count(.mcp_end));
try testing.expect(capture.saw_mcp_request);
try testing.expect(!capture.invalid);
try testing.expectEqual(tool_mcp_runtime.CallStatus.success, result.status);
try testing.expect(std.mem.find(u8, result.model_output, "hi") != null);
var stale = binding;
+8 -1
View File
@@ -10,6 +10,7 @@
//! `tests/tla/mcp_host/ElicitationRouting.tla`.
const std = @import("std");
const observation = @import("../agent/runtime/observation.zig");
const mcp = @import("../../mcp/mcp.zig");
const mcp_contract = @import("../mcp/mcp_contract.zig");
const io_mod = @import("../shared/io.zig");
@@ -158,6 +159,7 @@ pub const Error = Allocator.Error || error{
};
const Request = struct {
observation: ?observation.McpCall = null,
kind: Kind,
/// Results are allocated with it; the caller doesn't use it while waiting.
alloc: Allocator,
@@ -409,7 +411,11 @@ pub const Runtime = struct {
cancel: ?*const std.atomic.Value(bool),
asker: ?Asker,
) Error!CallResult {
var req: Request = .{ .kind = .{ .call = .{ .server = server, .tool = tool, .arguments = arguments } }, .alloc = alloc, .asker = asker };
return r.callObserved(alloc, server, tool, arguments, cancel, asker, null);
}
pub fn callObserved(r: *Runtime, alloc: Allocator, server: []const u8, tool: []const u8, arguments: ?[]const u8, cancel: ?*const std.atomic.Value(bool), asker: ?Asker, observed: ?observation.McpCall) Error!CallResult {
var req: Request = .{ .kind = .{ .call = .{ .server = server, .tool = tool, .arguments = arguments } }, .alloc = alloc, .asker = asker, .observation = observed };
try r.submit(&req, cancel);
return switch (req.result) {
.call => |c| c,
@@ -581,6 +587,7 @@ pub const Runtime = struct {
// Room first: a call the engine took must have its waiter.
try r.calls.ensureUnusedCapacity(r.gpa, 1);
const handle_ = try r.engine.call(r.resolved.servers[i].name, c.tool, c.arguments);
if (req.observation) |call_| call_.engineRequest(handle_);
r.calls.putAssumeCapacity(handle_, req);
},
.cancel => |target| {
+45 -16
View File
@@ -1,4 +1,5 @@
const std = @import("std");
const observation = @import("../agent/runtime/observation.zig");
const auto_classifier_context = @import("auto_classifier_context.zig");
const debug_trace = @import("../shared/debug_trace.zig");
const diff_mod = @import("../output/diff.zig");
@@ -225,6 +226,7 @@ const ReviewView = enum {
/// Borrowed view of the successful model turn. Every referenced slice must
/// remain valid until `Reviewer.review` returns.
pub const ReviewTurnContext = struct {
observer: ?*observation.Observer = null,
model: []const u8,
pending_assistant: types.ChatMessage,
target_call_id: []const u8,
@@ -521,27 +523,41 @@ pub const Reviewer = struct {
"event=auto_review_send attempt={d} max_attempts=3 target_call_id={s}",
.{ attempt, review_turn.target_call_id },
);
const observed = if (review_turn.observer) |observer| observer.beginModelWithSchemas(.{
.model = self.model,
.instructions = &instructions,
.messages = &messages,
.tool_choice = .required,
.provider_options = .{},
}, .permission_review, review_turn.target_call_id, tools_json) else null;
var transport_outcome = transport.send(
alloc,
self.model,
payload,
send_deadline,
cancel_flag,
) catch |err| switch (err) {
error.OutOfMemory, error.Cancelled => return err,
else => {
if (!transport_retry_available) return .{ .invalid = .transport_call_failed };
transport_retry_available = false;
attempt += 1;
send_deadline = reviewAttemptDeadline(self.timeout_ms);
debug_trace.logf(
"permission",
"event=auto_review_transport_retry reason=call_failed target_call_id={s}",
.{review_turn.target_call_id},
);
continue;
},
) catch |err| failed: {
if (observed) |call| call.emit(.{ .model_end = .{ .id = call.id, .error_name = @errorName(err), .cancelled = err == error.Cancelled } });
break :failed switch (err) {
error.OutOfMemory, error.Cancelled => return err,
else => {
if (!transport_retry_available) return .{ .invalid = .transport_call_failed };
transport_retry_available = false;
attempt += 1;
send_deadline = reviewAttemptDeadline(self.timeout_ms);
debug_trace.logf(
"permission",
"event=auto_review_transport_retry reason=call_failed target_call_id={s}",
.{review_turn.target_call_id},
);
continue;
},
};
};
if (observed) |call| call.emit(.{ .model_end = switch (transport_outcome) {
.completion => |owned| .{ .id = call.id, .completion = owned.completion, .cancelled = cancel_flag.load(.seq_cst) },
else => .{ .id = call.id, .error_name = @tagName(transport_outcome), .cancelled = transport_outcome == .cancelled },
} });
switch (transport_outcome) {
.cancelled => return error.Cancelled,
.timed_out, .transient_failure => {
@@ -1855,6 +1871,8 @@ test "review response retries malformed completion once on the same deadline" {
}
};
var fixture = Fixture{};
var capture: @import("../agent/runtime/observation_testing.zig").Capture = .{ .expected_call = "pending" };
var observer = capture.observer();
defer if (fixture.first_payload) |payload| std.testing.allocator.free(payload);
var result = try Reviewer.withTransport(.{
.context = &fixture,
@@ -1862,6 +1880,7 @@ test "review response retries malformed completion once on the same deadline" {
.build_fn = buildTestReviewPayload,
}, null, 1000).review(std.testing.allocator, .{
.review_turn = .{
.observer = &observer,
.model = "test/main",
.target_call_id = "pending",
.origin = .root,
@@ -1879,6 +1898,9 @@ test "review response retries malformed completion once on the same deadline" {
try std.testing.expectEqual(std.meta.Tag(ParseOutcome).valid, std.meta.activeTag(result));
try std.testing.expectEqual(Decision.clear, result.valid.decision);
try std.testing.expectEqual(@as(usize, 2), fixture.sends);
try std.testing.expectEqual(@as(usize, 2), capture.count(.model_start));
try std.testing.expectEqual(capture.count(.model_start), capture.count(.model_end));
try std.testing.expect(!capture.invalid);
}
test "review response recovery is bounded and releases every completion" {
@@ -1948,11 +1970,15 @@ test "review response recovery is bounded and releases every completion" {
};
for (0..cycles) |_| {
var fixture = Fixture{ .mode = mode };
var capture: @import("../agent/runtime/observation_testing.zig").Capture = .{ .expected_call = "pending" };
var observer = capture.observer();
var observed_request = request;
observed_request.review_turn.observer = &observer;
const reviewer = Reviewer.withTransport(.{ .context = &fixture, .send_fn = Fixture.send, .build_fn = buildTestReviewPayload }, &fixture.cancel, if (mode == .expired) 20 else 1000);
if (mode == .cancel_between or mode == .cancel_second) {
try std.testing.expectError(error.Cancelled, reviewer.review(std.testing.allocator, request));
try std.testing.expectError(error.Cancelled, reviewer.review(std.testing.allocator, observed_request));
} else {
var outcome = try reviewer.review(std.testing.allocator, request);
var outcome = try reviewer.review(std.testing.allocator, observed_request);
defer outcome.deinit(std.testing.allocator);
switch (mode) {
.recover_clear, .transport_recover, .transport_then_invalid, .transport_throws => {
@@ -1985,6 +2011,9 @@ test "review response recovery is bounded and releases every completion" {
};
try std.testing.expectEqual(expected_sends, fixture.sends);
try std.testing.expectEqual(expected_released, fixture.released);
try std.testing.expectEqual(fixture.sends, capture.count(.model_start));
try std.testing.expectEqual(capture.count(.model_start), capture.count(.model_end));
try std.testing.expect(!capture.invalid);
}
}
}
+39 -2
View File
@@ -1,4 +1,5 @@
const std = @import("std");
const observation = @import("../agent/runtime/observation.zig");
const diff_mod = @import("../output/diff.zig");
const permission_request = @import("permission_request.zig");
const types = @import("../shared/types.zig");
@@ -9,6 +10,8 @@ const Allocator = std.mem.Allocator;
/// grant construction stay in tool admission; transports present the request,
/// return the selected response, and may retain grants in session-owned state.
pub const Prompter = struct {
observer: ?*observation.Observer = null,
cancel_flag: ?*const std.atomic.Value(bool) = null,
context: *anyopaque,
request_fn: *const fn (
*anyopaque,
@@ -28,14 +31,20 @@ pub const Prompter = struct {
review: ?*const diff_mod.FileReview,
grant_offer: ?[]const types.PermissionGrant,
) anyerror!permission_request.OwnedPermissionResponse {
return self.request_fn(
const wait = if (self.observer) |observer| observer.beginWait(.{ .kind = .approval, .call_id = call.id, .external_id = if (request_value.id == 0) null else request_value.id, .prompt = request_value.label }) else null;
const response = self.request_fn(
self.context,
alloc,
request_value,
call,
review,
grant_offer,
);
) catch |err| {
if (wait) |value| value.end(if (err == error.Cancelled) .cancelled else .failed, &.{}, @errorName(err));
return err;
};
if (wait) |value| value.end(if (self.cancel_flag != null and self.cancel_flag.?.load(.seq_cst)) .cancelled else if (response.decision.isDenied()) .denied else .accepted, if (response.feedback) |feedback| &.{feedback} else &.{}, null);
return response;
}
pub fn retainGrant(self: Prompter, tool_name: []const u8, target_path: []const u8) anyerror!bool {
@@ -44,3 +53,31 @@ pub const Prompter = struct {
return true;
}
};
test "approval observer pairs actual prompt attempts without changing their decisions" {
const Fixture = struct {
decision: types.ToolPermissionDecision = .once,
failure: bool = false,
fn request(raw: *anyopaque, alloc: Allocator, _: permission_request.PermissionRequest, _: types.ToolCall, _: ?*const diff_mod.FileReview, _: ?[]const types.PermissionGrant) !permission_request.OwnedPermissionResponse {
const self: *@This() = @ptrCast(@alignCast(raw));
if (self.failure) return error.Cancelled;
return permission_request.OwnedPermissionResponse.init(alloc, self.decision, null);
}
};
var capture: @import("../agent/runtime/observation_testing.zig").Capture = .{ .expected_call = "approval-call" };
var observer = capture.observer();
var fixture: Fixture = .{};
const prompter = Prompter{ .context = &fixture, .request_fn = Fixture.request, .observer = &observer };
const call = types.ToolCall{ .id = "approval-call", .name = "shell", .arguments_json = "{}" };
for ([_]types.ToolPermissionDecision{ .once, .deny }) |decision| {
fixture.decision = decision;
var response = try prompter.request(std.testing.allocator, .{ .id = 42, .label = "Allow?" }, call, null, null);
defer response.deinit();
try std.testing.expectEqual(decision, response.decision);
}
fixture.failure = true;
try std.testing.expectError(error.Cancelled, prompter.request(std.testing.allocator, .{ .label = "Allow?" }, call, null, null));
try std.testing.expectEqual(@as(usize, 3), capture.count(.wait_start));
try std.testing.expectEqual(capture.count(.wait_start), capture.count(.wait_end));
try std.testing.expect(!capture.invalid);
}
@@ -1,4 +1,5 @@
const std = @import("std");
const observation = @import("../agent/runtime/observation.zig");
const debug_trace = @import("../shared/debug_trace.zig");
const io_mod = @import("../shared/io.zig");
const session_display_metadata = @import("session_display_metadata.zig");
@@ -94,6 +95,7 @@ fn capUtf8(text: []const u8, max_bytes: usize) []const u8 {
}
pub const Request = struct {
observer: ?*observation.Observer = null,
stream_provider: stream_provider.Provider,
model: []const u8,
api_key: []const u8,
@@ -163,6 +165,8 @@ pub fn run(alloc: Allocator, request: Request) !Outcome {
request.stream_provider,
alloc,
.{
.observer = request.observer,
.purpose = .title,
.credential = credential,
.session_id = request.session_id,
.model = request.model,
@@ -220,6 +224,8 @@ pub const Task = struct {
/// Terminal state of the attempt, readable once `done` is set.
pub const Status = enum { pending, generated, unavailable };
/// Borrowed; a caller installing this must join the task before releasing it.
observer: ?*observation.Observer = null,
thread: ?std.Thread = null,
done: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
cancel_requested: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
@@ -241,6 +247,7 @@ pub const Task = struct {
failure_detail: []const u8 = "",
pub const Init = struct {
observer: ?*observation.Observer = null,
session_id: []const u8,
model: []const u8,
prompt_excerpt: []const u8,
@@ -270,6 +277,7 @@ pub const Task = struct {
const task = try alloc.create(Task);
task.* = .{
.observer = init.observer,
.session_id = session_id,
.model = model,
.prompt_excerpt = prompt_excerpt,
@@ -333,6 +341,7 @@ pub const Task = struct {
defer self.done.store(true, .release);
const alloc = std.heap.c_allocator;
const outcome = run(alloc, .{
.observer = self.observer,
.stream_provider = self.stream_provider,
.model = self.model,
.api_key = self.api_key orelse "",
@@ -562,7 +571,10 @@ test "run sanitizes provider content into a generated title" {
}
};
var cancelled = std.atomic.Value(bool).init(false);
var capture: @import("../agent/runtime/observation_testing.zig").Capture = .{};
var observer = capture.observer();
const outcome = try run(std.testing.allocator, .{
.observer = &observer,
.stream_provider = .{ .stream_fn = Fake.stream },
.model = "test/title",
.api_key = "key",
@@ -575,6 +587,9 @@ test "run sanitizes provider content into a generated title" {
};
defer std.testing.allocator.free(title);
try std.testing.expectEqualStrings("Fix the renderer", title);
try std.testing.expectEqual(@as(usize, 1), capture.count(.model_start));
try std.testing.expectEqual(capture.count(.model_start), capture.count(.model_end));
try std.testing.expectEqual(@as(usize, 1), capture.purposes[@backingInt(observation.ModelPurpose.title)]);
}
test "run treats unsanitizable output as unavailable" {
+1
View File
@@ -413,6 +413,7 @@ fn withoutSubagentFunctions(
fn runtimeDeps(context: *Context) agent_runtime.AgentRuntimeDeps {
return .{
.ctx = context,
.observer = context.config.tool_context.observer,
.agent_stream_provider = context.config.tool_context.agent_stream_provider,
.tool_registry = context.config.tool_context.tool_registry,
.context_registry = context.config.context_registry,
+40 -8
View File
@@ -1,4 +1,5 @@
const std = @import("std");
const execution_observation = @import("../agent/runtime/observation.zig");
const testing_allocator = @import("../shared/testing_allocator.zig");
const approval_registry = @import("approval_registry.zig");
const authority = @import("authority.zig");
@@ -61,6 +62,15 @@ fn unavailableChildRun(
return error.ProviderFailed;
}
test "managed execution receipts retain the pending contract" {
var runtime: Runtime = undefined;
for ([_]bool{ false, true }) |pending| {
const result = try runtime.encodeManaged(std.testing.allocator, .{ .ok = true, .pending = pending });
defer std.testing.allocator.free(result.body);
try std.testing.expectEqual(pending, result.pending);
}
}
pub const ProgressSink = struct {
context: *anyopaque,
publish_fn: *const fn (*anyopaque, types.SubagentStatus) void,
@@ -91,6 +101,7 @@ pub const ModelOverrideResolver = struct {
};
pub const ExecuteOptions = struct {
observer: ?*execution_observation.Observer = null,
caller_id: []const u8,
invocation_id: []const u8,
parent_permission_mode: types.PermissionMode = .yolo,
@@ -109,6 +120,7 @@ pub const ExecuteOptions = struct {
};
pub const ManagedExecutionResult = struct {
pending: bool = false,
success: bool,
body: []u8,
/// The final status model is owned by the result allocator.
@@ -341,6 +353,17 @@ pub const Runtime = struct {
};
switch (result) {
.receipt => |delivery| {
if (options.observer) |observer| observer.emit(.{ .delegation = .{
.call_id = options.invocation_id,
.child_id = target.child_id,
.work_id = target.work_id,
.operation_id = operation_id,
.phase = switch (delivery) {
.queued => .feedback_queued,
.applied => .feedback_applied,
.not_applied => .feedback_not_applied,
},
} });
if (delivery == .queued) {
const queued = queued_receipt.?;
queued_receipt = null;
@@ -371,7 +394,8 @@ pub const Runtime = struct {
});
},
.ready => |ready| {
const result = try self.observeManagedState(
if (options.observer) |observer| observer.emit(.{ .delegation = .{ .call_id = options.invocation_id, .child_id = ready.child_id, .work_id = operation_id, .phase = .dispatched } });
const result = self.observeManagedState(
alloc,
ready.child_id,
operation_id,
@@ -380,15 +404,22 @@ pub const Runtime = struct {
options.cancel_flag,
options.steering_worker,
options.model_capability_resolver,
);
) catch |err| {
if (options.observer) |observer| observer.emit(.{ .delegation = .{ .call_id = options.invocation_id, .child_id = ready.child_id, .work_id = operation_id, .phase = if (err == error.Cancelled) .cancelled else .failed } });
return err;
};
if (options.observer) |observer| observer.emit(.{ .delegation = .{ .call_id = options.invocation_id, .child_id = ready.child_id, .work_id = operation_id, .phase = if (result.pending) .yielded else if (result.success) .completed else .failed } });
break :blk result;
},
.completed => |completed| break :blk self.completeManagedResult(
alloc,
completed.child_id,
operation_id,
completed.observation,
),
.completed => |completed| {
if (options.observer) |observer| observer.emit(.{ .delegation = .{ .call_id = options.invocation_id, .child_id = completed.child_id, .work_id = operation_id, .phase = .replayed } });
break :blk self.completeManagedResult(
alloc,
completed.child_id,
operation_id,
completed.observation,
);
},
}
}
},
@@ -1049,6 +1080,7 @@ pub const Runtime = struct {
_ = self;
return .{
.success = result.ok,
.pending = result.pending,
.body = try model_contract.encodeResultAlloc(alloc, result),
};
}
+2
View File
@@ -1,4 +1,5 @@
const std = @import("std");
const observation = @import("../agent/runtime/observation.zig");
const testing_allocator = @import("../shared/testing_allocator.zig");
const skill_contract = @import("../skills/skill_contract.zig");
const command_admission = @import("../permissions/command_admission.zig");
@@ -294,6 +295,7 @@ pub const DispatchContext = struct {
vision_provider: ?VisionProvider = null,
host_tool_provider: ?HostToolProvider = null,
ask_question_ctx: ?*anyopaque = null,
observer: ?*observation.Observer = null,
ask_question_batch: ?AskQuestionBatchFn = null,
tool_capabilities: ToolCapabilities = .{},
web_fetch_runtime: ?*web_fetch_runtime.Runtime = null,
@@ -139,12 +139,15 @@ pub fn call(
return semanticFailure(ctx.allocator, "No MCP runtime is available.");
const call_feature = ctx.mcp_call_feature orelse
return semanticFailure(ctx.allocator, "MCP resources and prompts are unavailable.");
const result = call_feature(runtime_context, ctx.allocator, erased.as(Input).request, .{
const feature = erased.as(Input).request;
const observed = if (ctx.observer) |observer| observer.beginMcp(.{ .call_id = ctx.tool_call_id, .server = feature.server_name, .tool = @tagName(feature.action), .transport = null }) else null;
const result = call_feature(runtime_context, ctx.allocator, feature, .{
.cancel_flag = ctx.cancel_flag,
.input_responder = ctx.mcp_input_responder,
.output_limit_bytes = ctx.max_tool_result_bytes,
.access = ctx.mcp_access,
}) catch |err| {
if (observed) |call_| call_.end(if (err == error.Cancelled) .cancelled else .failure, @errorName(err));
if (err == error.OutOfMemory) return error.OutOfMemory;
if (err == error.McpInputRequired or
err == error.UnsupportedMode or
@@ -158,6 +161,7 @@ pub fn call(
err,
) };
};
if (observed) |call_| call_.end(.success, null);
if (result.images.len > 0) return .{ .rich = .{ .text = result.model_output, .images = result.images } };
return .{ .success = result.model_output };
}
+19
View File
@@ -1,4 +1,5 @@
const std = @import("std");
const observation = @import("../agent/runtime/observation.zig");
const capability_retrieval = @import("capability_retrieval.zig");
const types = @import("../shared/types.zig");
const context_limits = @import("../config/context_limits.zig");
@@ -86,6 +87,9 @@ pub const SnapshotToolFn = *const fn (*anyopaque, Allocator, []const u8, Binding
pub const Binding = types.McpToolBinding;
pub const CallOptions = struct {
observer: ?*observation.Observer = null,
call_id: ?[]const u8 = null,
mcp_observation: ?*observation.McpCall = null,
expected_binding: ?Binding = null,
expected_runtime_generation: ?u64 = null,
cancel_flag: ?*std.atomic.Value(bool) = null,
@@ -279,8 +283,23 @@ pub const CallStatus = enum {
tool_failure,
protocol_failure,
input_required,
pub fn observed(self: CallStatus) observation.McpStatus {
return switch (self) {
.success => .success,
.tool_failure, .protocol_failure => .failure,
.input_required => .input_required,
};
}
};
test "MCP hook statuses normalize tool and protocol failures" {
try std.testing.expectEqual(observation.McpStatus.success, CallStatus.success.observed());
try std.testing.expectEqual(observation.McpStatus.failure, CallStatus.tool_failure.observed());
try std.testing.expectEqual(observation.McpStatus.failure, CallStatus.protocol_failure.observed());
try std.testing.expectEqual(observation.McpStatus.input_required, CallStatus.input_required.observed());
}
pub const CallResult = struct {
model_output: []const u8,
images: []types.ToolImage = &.{},
+24
View File
@@ -1,4 +1,5 @@
const std = @import("std");
const observation = @import("../agent/runtime/observation.zig");
const testing_allocator = @import("../shared/testing_allocator.zig");
const skill_contract = @import("../skills/skill_contract.zig");
const builtin = @import("builtin");
@@ -112,6 +113,7 @@ test {
}
pub const Context = struct {
observer: ?*observation.Observer = null,
workspace_root: []const u8,
access_scope: ?workspace_access.AccessScope = null,
ignored_list_entries: []const []const u8,
@@ -240,6 +242,7 @@ pub const Context = struct {
/// Projects only the borrowed capabilities consumed by admission.
pub fn admissionInput(self: Context) tool_admission.Input {
const observer = self.observer orelse if (self.permission_review_turn) |review| review.observer else null;
var input: tool_admission.Input = .{
.workspace_root = self.workspace_root,
.access_scope = self.access_scope,
@@ -265,6 +268,11 @@ pub const Context = struct {
if (self.permission_state_override != null) {
input.session_permission_state_provider = null;
}
if (input.permission_prompter) |*prompter| {
prompter.observer = observer orelse prompter.observer;
prompter.cancel_flag = self.cancel_flag orelse prompter.cancel_flag;
}
if (input.permission_review_turn) |*review| review.observer = observer;
return input;
}
@@ -389,6 +397,7 @@ pub fn executeToolCallAuthorized(
request.command_replay_capture.?.abort(request.result_allocator);
};
var execution_ctx = ctx;
execution_ctx.observer = request.observer orelse ctx.observer;
execution_ctx.skill_locations = request.skill_locations orelse ctx.skill_locations;
if (request.permission_mode) |permission_mode| {
execution_ctx.permission_mode = permission_mode;
@@ -602,6 +611,7 @@ pub fn executeHostToolCallAuthorized(
if (spec.executor_kind != .host) return error.InvalidToolArguments;
var execution_ctx = ctx;
execution_ctx.observer = request.observer orelse ctx.observer;
if (request.permission_mode) |permission_mode| {
execution_ctx.permission_mode = permission_mode;
}
@@ -849,6 +859,8 @@ fn executeRegisteredTool(
dispatch_ctx.result_commit_sink = &result_commit_token;
dispatch_ctx.execution_authority = authority;
dispatch_ctx.mcp_call_options = .{
.observer = ctx.observer,
.call_id = call.id,
.expected_runtime_generation = ctx.expected_mcp_runtime_generation,
.expected_binding = ctx.expected_mcp_binding,
.cancel_flag = dispatch_ctx.cancel_flag,
@@ -1154,6 +1166,7 @@ fn typedDispatchContext(ctx: Context, arena: Allocator) tool_dispatch.DispatchCo
ctx.web_search_runtime_ready and ctx.web_search_backend != null;
return .{
.allocator = arena,
.observer = ctx.observer,
.permission_mode = ctx.permission_mode,
.workspace_root = ctx.workspace_root,
.access_scope = ctx.access_scope,
@@ -1329,6 +1342,8 @@ fn executeVisionRequest(
authority: command_admission.ToolExecutionAuthority,
) !ToolExecutionResult {
const config: vision_executor.Config = .{
.observer = state.runtime.observer,
.parent_call_id = if (state.runtime.output_chunk_lifecycle_id) |id| id.call_id else null,
.stream_provider = state.runtime.agent_stream_provider,
.api_key = state.runtime.api_key,
.credential_source = state.runtime.credential_source,
@@ -2173,6 +2188,7 @@ fn executeSubagentProvider(
const identity_epoch = host.issueOperationIdentity(invocation_id);
var progress_bridge = SubagentProgressBridge.init(arena, ctx, state.call);
const output = host.executeManaged(arena, request, .{
.observer = ctx.observer,
.caller_id = caller_id,
.invocation_id = invocation_id,
.parent_permission_mode = ctx.permission_mode,
@@ -2442,6 +2458,7 @@ fn testReviewTurn() permission_auto_classifier.ReviewTurnContext {
}
const TestRuntime = struct {
observer: ?*observation.Observer = null,
agent_stream_provider: agent_stream_provider.Provider = agent_stream_provider.unavailable_provider,
tool_registry: tool_dispatch.Registry = test_tool_registry,
worker: WorkerRuntime = .{},
@@ -2517,6 +2534,7 @@ const TestRuntime = struct {
.max_tool_result_bytes = self.max_tool_result_bytes,
.api_key = self.api_key,
.agent_stream_provider = self.agent_stream_provider,
.observer = self.observer,
.gateway_team = self.gateway_team,
.provider = self.provider,
.provider_capabilities = self.provider_capabilities,
@@ -7560,7 +7578,10 @@ test "vision runtime resolves historical authorized images and batches twenty as
};
var fixture = VisionGatewayFixture{ .alloc = alloc, .responses = &responses };
defer fixture.deinit();
var capture: @import("../agent/runtime/observation_testing.zig").Capture = .{};
var observer = capture.observer();
var rt = TestRuntime{
.observer = &observer,
.agent_stream_provider = fixture.provider(),
.tool_registry = .{ .tools = vision_test_registry_tools[0..] },
.api_key = "gateway-key",
@@ -7582,6 +7603,9 @@ test "vision runtime resolves historical authorized images and batches twenty as
try std.testing.expectEqual(tool_contracts.ToolExecutionStatus.success, result.status);
try std.testing.expectEqual(@as(usize, 3), fixture.call_count);
try std.testing.expectEqual(@as(usize, 3), capture.count(.model_start));
try std.testing.expectEqual(capture.count(.model_start), capture.count(.model_end));
try std.testing.expectEqual(@as(usize, 3), capture.purposes[@backingInt(observation.ModelPurpose.vision)]);
try std.testing.expectEqual(@as(usize, 8), std.mem.count(u8, fixture.payloads.items[0], "\"type\":\"file\""));
try std.testing.expectEqual(@as(usize, 8), std.mem.count(u8, fixture.payloads.items[1], "\"type\":\"file\""));
try std.testing.expectEqual(@as(usize, 4), std.mem.count(u8, fixture.payloads.items[2], "\"type\":\"file\""));
+2
View File
@@ -4837,6 +4837,8 @@ test {
_ = @import("gateway/chat_completions_protocol.zig");
_ = provider_runtime;
_ = @import("acp/prompt.zig");
_ = @import("acp/libfx_hooks.zig");
_ = @import("core/mcp_host/model.zig");
_ = @import("core/output/activity_status.zig");
_ = @import("core/agent/agent_runtime.zig");
_ = @import("core/agent/execution_memory.zig");
+53 -1
View File
@@ -1,4 +1,5 @@
const std = @import("std");
const observation = @import("../../core/agent/runtime/observation.zig");
const question_answer = @import("../../core/agent/question_answer.zig");
const core_types = @import("../../core/shared/types.zig");
const text_utils = @import("../../core/shared/text_utils.zig");
@@ -44,6 +45,8 @@ pub const Input = struct {
};
pub const Requester = struct {
observer: ?*observation.Observer = null,
call_id: ?[]const u8 = null,
ctx: ?*anyopaque,
response_alloc: Allocator,
request: tool_dispatch.AskQuestionBatchFn,
@@ -94,6 +97,8 @@ pub fn call(ctx: tool_dispatch.DispatchContext, erased: tool_dispatch.ToolInput)
};
const input = erased.as(Input);
const output = executeWithRequester(ctx.allocator, input.args_json, .{
.observer = ctx.observer,
.call_id = ctx.tool_call_id,
.ctx = ctx.ask_question_ctx,
.response_alloc = ctx.allocator,
.request = request,
@@ -110,7 +115,12 @@ pub fn executeWithRequester(result_alloc: Allocator, args_json: []const u8, requ
const parse_arena = parse_arena_state.allocator();
const entries = parseQuestionBatch(parse_arena, args_json) catch |err| return invalidArgsBody(result_alloc, err);
const answers_opt = try requester.request(requester.ctx, requester.response_alloc, entries);
const wait = if (requester.observer) |observer| observer.beginWait(.{ .kind = .question, .call_id = requester.call_id, .questions = entries }) else null;
const answers_opt = requester.request(requester.ctx, requester.response_alloc, entries) catch |err| {
if (wait) |value| value.end(.failed, &.{}, @errorName(err));
return err;
};
if (wait) |value| value.end(if (answers_opt == null) .cancelled else .accepted, answers_opt orelse &.{}, null);
if (answers_opt) |answers| {
defer freeAnswers(requester.response_alloc, answers);
return question_answer.encodeJson(result_alloc, entries, answers);
@@ -118,6 +128,48 @@ pub fn executeWithRequester(result_alloc: Allocator, args_json: []const u8, requ
return result_alloc.dupe(u8, cancel_sentinel);
}
test "question observer records answers cancellation and failure at the real requester" {
const Fixture = struct {
cancelled: bool = false,
failure: bool = false,
fn request(raw: ?*anyopaque, alloc: Allocator, _: []const core_types.QuestionBatchEntry) Allocator.Error!?[][]u8 {
const self: *@This() = @ptrCast(@alignCast(raw.?));
if (self.failure) return error.OutOfMemory;
if (self.cancelled) return null;
const answers = try alloc.alloc([]u8, 1);
errdefer alloc.free(answers);
answers[0] = try alloc.dupe(u8, "Yes");
return answers;
}
};
var fixture: Fixture = .{};
var capture: @import("../../core/agent/runtime/observation_testing.zig").Capture = .{ .expected_call = "question-call" };
var observer = capture.observer();
for ([_]bool{ false, true }) |cancelled| {
fixture.cancelled = cancelled;
const output = try executeWithRequester(std.testing.allocator, "{\"questions\":[{\"question\":\"Continue?\",\"options\":[{\"label\":\"Yes\"},{\"label\":\"No\"}]}]}", .{
.ctx = &fixture,
.response_alloc = std.testing.allocator,
.request = Fixture.request,
.observer = &observer,
.call_id = "question-call",
});
defer std.testing.allocator.free(output);
try std.testing.expect(if (cancelled) std.mem.eql(u8, output, cancel_sentinel) else std.mem.find(u8, output, "Yes") != null);
}
fixture.failure = true;
try std.testing.expectError(error.OutOfMemory, executeWithRequester(std.testing.allocator, "{\"questions\":[{\"question\":\"Continue?\",\"options\":[{\"label\":\"Yes\"},{\"label\":\"No\"}]}]}", .{
.ctx = &fixture,
.response_alloc = std.testing.allocator,
.request = Fixture.request,
.observer = &observer,
.call_id = "question-call",
}));
try std.testing.expectEqual(@as(usize, 3), capture.count(.wait_start));
try std.testing.expectEqual(capture.count(.wait_start), capture.count(.wait_end));
try std.testing.expect(!capture.invalid);
}
fn parseQuestionBatch(arena: Allocator, args_json: []const u8) QuestionBatchParseError![]const core_types.QuestionBatchEntry {
var parsed = std.json.parseFromSlice(std.json.Value, arena, args_json, .{}) catch {
return error.InvalidArguments;
+79 -6
View File
@@ -704,6 +704,10 @@ class AcpClient {
return Buffer.concat(this._stderrChunks).toString();
}
get hookEvents(): any[] {
return this.rawLines.map(line => JSON.parse(line)).filter(message => message.method === "libfx/hook").map(message => message.params);
}
get closed(): boolean {
return this._closed;
}
@@ -868,6 +872,33 @@ class AcpClient {
}
}
test("ACP load and resume negotiate hooks and clear them when omitted", async () => {
const fixture = createConfiguredProviderFixture();
const client = await AcpClient.create({ cwd: fixture.workspace, env: fixture.env });
try {
await client.request("initialize", { protocolVersion: 1, clientCapabilities: {} });
const created = await client.request("session/new", { cwd: fixture.workspace, mcpServers: [] }) as any;
expect(created.error).toBeUndefined();
const sessionId = created.result.sessionId;
for (const method of ["session/load", "session/resume"]) {
const enabled = await client.request(method, { sessionId, mcpServers: [], _meta: { fx: { hooks: { captureContent: true } } } }) as any;
expect(enabled.error).toBeUndefined();
expect(enabled.result._meta.fx.hooksVersion).toBe(1);
await runPrompt(client, "observe this prompt");
expect(client.hookEvents.some(event => event.type === "turn.end")).toBe(true);
const count = client.hookEvents.length;
const disabled = await client.request(method, { sessionId, mcpServers: [] }) as any;
expect(disabled.error).toBeUndefined();
expect(disabled.result._meta).toBeUndefined();
await runPrompt(client, "do not observe this prompt");
expect(client.hookEvents.length).toBe(count);
}
client.endStdin();
expect(await client.waitForExit()).toBe(0);
expect(client.stderr).toBe("");
} finally { await client.close(); fixture.close(); }
}, 30000);
test("configured provider ACP preserves reasoning between prompts", async () => {
const fixture = createConfiguredProviderFixture(async body => {
const response = configuredCompletion(body.model, "reasoned answer");
@@ -1229,9 +1260,9 @@ function sendSteeringPrompt(client: AcpClient, id: number, text: string): void {
});
}
async function startAutoSession(client: AcpClient) {
async function startAutoSession(client: AcpClient, hooks = false) {
await client.request("initialize", { protocolVersion: 1 }, 1);
const created = await client.request("session/new", { mcpServers: [] }, 2) as any;
const created = await client.request("session/new", { mcpServers: [], ...(hooks ? { _meta: { fx: { hooks: { captureContent: true } } } } : {}) }, 2) as any;
await client.readLine(); // consume session/update notification
await client.request("session/set_mode", { modeId: "auto" }, 3);
return created.result.sessionId as string;
@@ -1479,6 +1510,7 @@ describe("acp: model-independent", () => {
"session/new",
{
cwd: root.workspace,
_meta: { fx: { hooks: { captureContent: true } } },
mcpServers: [acpStdioServer(
"UNUSED",
pidPath,
@@ -1509,6 +1541,8 @@ describe("acp: model-independent", () => {
expect(wire).toContain('"method":"resources/read"');
expect(wire).toContain('"method":"prompts/get"');
expect(wire.match(/"method":"completion\/complete"/g)).toHaveLength(2);
expect(client.hookEvents.some(event => event.type === "mcp.start" && event.data.server === "fixture" && event.data.tool === "resource_read")).toBe(true);
expect(client.hookEvents.some(event => event.type === "mcp.start" && event.data.server === "fixture" && event.data.tool === "prompt_get")).toBe(true);
expect(client.stderr).toBe("");
client.endStdin();
expect(await client.waitForExit()).toBe(0);
@@ -2966,6 +3000,7 @@ describe("acp: model-independent", () => {
"session/new",
{
cwd: root.workspace,
_meta: { fx: { hooks: { captureContent: true } } },
mcpServers: [{
type: "http",
name: "fixture",
@@ -3022,6 +3057,12 @@ describe("acp: model-independent", () => {
expect(entry.headers["x-workspace"]).toBe("acp");
}
expect(httpFixture.requests[2]?.headers["mcp-param-text"]).toBe("acp");
const mcpStart = client.hookEvents.find(event => event.type === "mcp.start");
expect(mcpStart.data).toMatchObject({ callId: "call_http", server: "fixture", tool: "echo", transport: "http" });
const mcpRequest = client.hookEvents.find(event => event.type === "mcp.request");
expect(mcpRequest.data.requestId).toBe(httpFixture.requests[2]?.message.id);
expect(mcpRequest.data.requestIdKind).toBe("jsonrpc");
expect(client.hookEvents.find(event => event.type === "mcp.end").data.operationId).toBe(mcpStart.data.operationId);
expect(client.stderr).toBe("");
} finally {
await client?.close();
@@ -3063,6 +3104,7 @@ describe("acp: model-independent", () => {
"session/new",
{
cwd: root.workspace,
_meta: { fx: { hooks: { captureContent: true } } },
mcpServers: [{
type: "http",
name: "fixture",
@@ -3092,6 +3134,9 @@ describe("acp: model-independent", () => {
expect(toolCall("search_http")?._meta.fx.toolCall).toEqual({ internal: true });
expect(toolCall("select_http")?._meta.fx.toolCall).toEqual({ internal: true });
expect(toolCall("call_http")?.name).toBe(MCP_TOOL_NAME);
expect(client.hookEvents.find(event => event.type === "mcp.start").data).toMatchObject({ callId: "call_http", server: "fixture", tool: "echo", transport: "http" });
expect(client.hookEvents.find(event => event.type === "mcp.request").data.requestIdKind).toBe("engine");
expect(client.hookEvents.filter(event => event.type === "mcp.end")).toHaveLength(1);
const initialPrompt = acpGatewayRequest(gateway.requests[0]!.body).prompt
.map((message) => acpContentText(message.content))
@@ -5560,6 +5605,7 @@ describe("acp: model-independent", () => {
"session/new",
{
cwd: root.workspace,
_meta: { fx: { hooks: { captureContent: true } } },
mcpServers: [acpStdioServer(
"unused",
pidPath,
@@ -5601,6 +5647,10 @@ describe("acp: model-independent", () => {
expect(prompt.promptResult.result.stopReason).toBe("end_turn");
expect(gateway.requests).toHaveLength(3);
expect(directRequests).toHaveLength(1);
const wait = client.hookEvents.find(event => event.type === "wait.start");
expect(wait.data).toMatchObject({ kind: "mcp_form", callId: "call_form", server: "fixture" });
expect(client.hookEvents.find(event => event.type === "wait.end").data).toMatchObject({ requestId: wait.data.requestId, outcome: "accepted" });
expect(client.hookEvents.filter(event => event.type === "mcp.request")).toHaveLength(2);
expect(directRequests[0]).toMatchObject({
sessionId,
toolCallId: "call_form",
@@ -5668,6 +5718,7 @@ describe("acp: model-independent", () => {
"session/new",
{
cwd: root.workspace,
_meta: { fx: { hooks: { captureContent: true } } },
mcpServers: [acpStdioServer("unused", pidPath, "mrtr_input_required", {
FX_MCP_WIRE_LOG: wirePath,
FX_MCP_EXPECT_ELICITATION: "form",
@@ -5688,6 +5739,9 @@ describe("acp: model-independent", () => {
expect(prompt.promptResult.result.stopReason).toBe("end_turn");
expect(gateway.requests).toHaveLength(3);
expect(directRequests).toHaveLength(1);
const wait = client.hookEvents.find(event => event.type === "wait.start");
expect(wait.data).toMatchObject({ kind: "mcp_form", callId: "call_form", server: "fixture" });
expect(client.hookEvents.find(event => event.type === "wait.end").data).toMatchObject({ requestId: wait.data.requestId, outcome: "accepted" });
expect(directRequests[0]).toMatchObject({
toolCallId: "call_form",
mode: "form",
@@ -5742,6 +5796,7 @@ describe("acp: model-independent", () => {
"session/new",
{
cwd: root.workspace,
_meta: { fx: { hooks: { captureContent: true } } },
mcpServers: [acpStdioServer(
"unused",
pidPath,
@@ -5761,7 +5816,7 @@ describe("acp: model-independent", () => {
sendPrompt(client, 4, "Start an MCP elicitation and wait for the client.");
let sawDirectRequest = false;
for (let index = 0; index < 20 && !sawDirectRequest; index += 1) {
for (let index = 0; index < 60 && !sawDirectRequest; index += 1) {
const message = await client.readLine(TIMEOUT) as any;
sawDirectRequest = message.method === "elicitation/create";
}
@@ -5770,6 +5825,7 @@ describe("acp: model-independent", () => {
client.endStdin();
expect(await client.waitForExit(TIMEOUT)).toBe(0);
expect(client.hookEvents.find(event => event.type === "wait.end")?.data.outcome).toBe("cancelled");
await expectMcpProcessExited(pidPath);
} finally {
await client?.close();
@@ -5898,6 +5954,7 @@ describe("acp: model-independent", () => {
"session/new",
{
cwd: root.workspace,
_meta: { fx: { hooks: { captureContent: true } } },
mcpServers: [acpStdioServer(
"unused",
pidPath,
@@ -5927,6 +5984,9 @@ describe("acp: model-independent", () => {
);
expect(prompt.promptResult.result.stopReason).toBe("end_turn");
expect(directRequests).toHaveLength(1);
const wait = client.hookEvents.find(event => event.type === "wait.start");
expect(wait.data).toMatchObject({ kind: "mcp_url", callId: "call_url", server: "fixture" });
expect(client.hookEvents.find(event => event.type === "wait.end").data).toMatchObject({ requestId: wait.data.requestId, outcome: "accepted" });
const direct = directRequests[0]!;
expect(direct).toMatchObject({
sessionId,
@@ -9780,11 +9840,20 @@ describe("acp: model-independent", () => {
cwd: root.workspace,
env: fakeGatewayEnv(root, gateway),
});
await startAutoSession(client);
const parentSession = await startAutoSession(client, true);
const result = await runPrompt(client, "Delegate workspace inspection.", TIMEOUT);
expect(result.promptResult.result.stopReason).toBe("end_turn");
expect(gateway.requests).toHaveLength(3);
expect(gateway.requests[0]!.body).toContain('"name":"subagent"');
const dispatched = client.hookEvents.find(event => event.type === "delegation" && event.data.phase === "dispatched");
expect(dispatched.data.callId).toBe(createId);
const child = client.hookEvents.find(event => event.type === "turn.start" && event.sessionId === dispatched.data.childSessionId);
expect(child.rootSessionId).toBe(parentSession);
expect(child.parentSessionId).toBe(parentSession);
expect(child.workId).toBe(dispatched.data.workId);
expect(child.turnId).toBe(dispatched.data.workId);
expect(client.hookEvents.some(event => event.type === "model.start" && event.sessionId === child.sessionId)).toBe(true);
expect(client.hookEvents.some(event => event.type === "delegation" && event.data.phase === "completed")).toBe(true);
expect(client.stderr).toBe("");
} finally {
await client?.close();
@@ -9813,7 +9882,7 @@ describe("acp: model-independent", () => {
cwd: root.workspace,
env: fakeGatewayEnv(root, gateway),
});
await startAutoSession(client);
await startAutoSession(client, true);
const promptId = 6810;
const cancelId = 6811;
@@ -9848,6 +9917,7 @@ describe("acp: model-independent", () => {
}
expect(responses.get(cancelId)?.result).toBeNull();
expect(responses.get(promptId)?.result?.stopReason).toBe("cancelled");
expect(client.hookEvents.some(event => event.type === "delegation" && event.data.phase === "cancelled")).toBe(true);
heldChild.resolve(finalText("late child completion"));
const followUp = await runPrompt(
@@ -9888,9 +9958,12 @@ describe("acp: model-independent", () => {
cwd: root.workspace,
env: fakeGatewayEnv(root, gateway),
});
await startAutoSession(client);
await startAutoSession(client, true);
client.setPermissionOption("allow_once");
const result = await runPrompt(client, "Run the approved command.", TIMEOUT);
const wait = client.hookEvents.find(event => event.type === "wait.start" && event.data.kind === "approval");
expect(wait.data.callId).toBe("approved_command_1");
expect(client.hookEvents.find(event => event.type === "wait.end").data).toMatchObject({ requestId: wait.data.requestId, outcome: "accepted" });
const permission = result.messages.find(
(message: any) => message.method === "session/request_permission",
);
+3 -1
View File
@@ -4,7 +4,7 @@
// checks after every step that the code is in a state the model allows.
//
// bun tests/tla/drivers/drive-steer.mjs [ideal|faults] [--walks N] [--seed S]
// [--backend native|wasm] [--durability local|world] [--verbose]
// [--backend native|wasm] [--durability local|world] [--verbose] [--hooks]
//
// `tests/tla/sdk/check.sh` model-checks the spec and writes its
// graphs/steer-<graph>.dot. The
@@ -27,6 +27,7 @@ import { fileURLToPath } from "node:url";
import { createFxAgent } from "../../../sdk/node.js";
import { local } from "../../../sdk/durable/local.mjs";
import { loadGraph } from "./graph.mjs";
import { testHooks } from "../../../sdk/tests/hooks-fixture.mjs";
const args = process.argv.slice(2);
const option = (name, fallback) => {
@@ -139,6 +140,7 @@ async function harness() {
const stored = durabilityKind === "world" ? await appWorldAt(dir) : local({ dir });
const durability = gatedDeliveries(stored, gate);
const agent = createFxAgent({
...(testHooks ? { hooks: testHooks } : {}),
backend,
nativeAddon: addon,
...(wasm ? { wasm } : {}),
+3 -1
View File
@@ -4,7 +4,7 @@
// checks after every step that the code is in a state the model allows.
//
// bun tests/tla/drivers/drive.mjs [send|lookup|write|cutoff|leased|queue] [--walks N] [--seed S] [--backend native|wasm]
// [--durability local|world|vercel]
// [--durability local|world|vercel] [--hooks]
//
// The lookup and cutoff graphs run a lookup, which starts before its intent
// is stored: the driver holds that intent's record, so the call runs while
@@ -52,6 +52,7 @@ import { createFxAgent } from "../../../sdk/node.js";
import { local } from "../../../sdk/durable/local.mjs";
import { holdWrites } from "../../../sdk/tests/hold-writes.mjs";
import { loadGraph } from "./graph.mjs";
import { testHooks } from "../../../sdk/tests/hooks-fixture.mjs";
const args = process.argv.slice(2);
const option = (name, fallback) => {
@@ -232,6 +233,7 @@ async function harness() {
});
writes[w] = held;
agents[w] = createFxAgent({
...(testHooks ? { hooks: testHooks } : {}),
backend,
nativeAddon: addon,
...(wasm ? { wasm } : {}),