From 2f71cb16b2aa34c90c4ef0bb52a3a67115adf34a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 22:59:17 +0000 Subject: [PATCH 1/3] chore(examples/host-test): record request.lineage through the public context member request.lineage is a first-class Observed on AgentRequestContext since #444, so the probe reads it directly instead of casting for an optional member and records it on every line (available or unavailable with reason). renderLineage types the serialized value against the public AgentLineage and Observed types. The route-unit suite asserts a mounted lineage is recorded and rendered. --- examples/host-test/README.md | 4 +- examples/host-test/src/capture.ts | 40 +++++++++-------- examples/host-test/src/dump.ts | 14 +++--- .../host-test/tests/route-unit/routes.test.ts | 44 ++++++++++++++++++- 4 files changed, 74 insertions(+), 28 deletions(-) diff --git a/examples/host-test/README.md b/examples/host-test/README.md index 0357b585b..55f0fac4e 100644 --- a/examples/host-test/README.md +++ b/examples/host-test/README.md @@ -3,7 +3,7 @@ A probing plugin. Install it into a Claude Code, Codex, or Cursor home, drive one agent session, and read back exactly what that host sent to every plugin hook and MCP call — the raw envelope, the framework request context each -handler saw, and (once the framework resolves it) the conversation lineage. +handler saw, and the conversation lineage the runtime resolved for it. It is the acceptance vehicle for `request.lineage` and the evidence source for `docs/audits/*-host-lineage-matrix.md`. @@ -25,7 +25,7 @@ bounded summary into the durable state kernel (`src/state.ts`, | --- | --- | | `event.native` | The complete host payload, byte for byte, with secret-looking values replaced by `[redacted]`. | | `event.canonical` | The framework's canonical identity (`event`, `idempotencyKey`, `observedAt`, `provenance`). | -| `request` | `(await agent())` as the route saw it: `invocation`, `host`, `session`, `actor`, `workspace`, `capabilities`, provider keys, whether state and notices were mounted, and `lineage` when the runtime supplies it. | +| `request` | `(await agent())` as the route saw it: `invocation`, `host`, `session`, `actor`, `workspace`, `capabilities`, `lineage`, provider keys, and whether state and notices were mounted. `lineage` is always present: `available` with the resolved tree position, or `unavailable` with the runtime's per-host reason. | | `ids` | Every identity-shaped native field (`conversation_id`, `generation_id`, `session_id`, `subagent_id`, `tool_call_id`, `agent_id`, `turn_id`, `user_email`, …) lifted out for filtering. | | `process` | `pid`, `ppid`, `cwd`, `execPath`, entry file, uptime — of the process that ran the route. | | `runtime` | `shared-runtime` when the hook reached the warm MCP-hosted runtime, `standalone-hook` when it fell back to the hook process, `mcp-server`, or `cli`. | diff --git a/examples/host-test/src/capture.ts b/examples/host-test/src/capture.ts index 93bc5c77e..9ef820e69 100644 --- a/examples/host-test/src/capture.ts +++ b/examples/host-test/src/capture.ts @@ -135,25 +135,27 @@ const detectRuntime = (context: AgentRequestContext, argv: readonly string[]): C } }; -/** The framework request context as the route observed it, minus non-data members. */ -export const snapshotRequest = (context: AgentRequestContext): JsonObject => { - const lineage = (context as AgentRequestContext & { readonly lineage?: unknown }).lineage; - return { - actor: asJson(context.actor), - capabilities: asJson(context.capabilities), - hasNotices: context.notices !== undefined, - hasState: context.state !== undefined, - host: asJson(context.host), - invocation: asJson(context.invocation), - ...(lineage === undefined ? {} : { lineage: asJson(lineage) }), - providers: { - keys: Object.keys(context.providers).sort((left, right) => left.localeCompare(right)), - processLifetime: asJson(context.providers.processLifetime), - }, - session: asJson(context.session), - workspace: asJson(context.workspace), - }; -}; +/** + * The framework request context as the route observed it, minus non-data + * members. `lineage` is a first-class `Observed` member of the context, so it + * is recorded on every line: `available` with the resolved tree position, or + * `unavailable` with the runtime's per-host reason. + */ +export const snapshotRequest = (context: AgentRequestContext): JsonObject => ({ + actor: asJson(context.actor), + capabilities: asJson(context.capabilities), + hasNotices: context.notices !== undefined, + hasState: context.state !== undefined, + host: asJson(context.host), + invocation: asJson(context.invocation), + lineage: asJson(context.lineage), + providers: { + keys: Object.keys(context.providers).sort((left, right) => left.localeCompare(right)), + processLifetime: asJson(context.providers.processLifetime), + }, + session: asJson(context.session), + workspace: asJson(context.workspace), +}); export interface CaptureInput { readonly event?: AgentEventRouteProps; diff --git a/examples/host-test/src/dump.ts b/examples/host-test/src/dump.ts index cc5abb705..4086a02f3 100644 --- a/examples/host-test/src/dump.ts +++ b/examples/host-test/src/dump.ts @@ -1,8 +1,10 @@ import { agent, + type AgentLineage, type AgentStateHandle, type JsonObject, type JsonValue, + type Observed, } from '@agent-bundle/runtime'; import { z } from 'zod'; @@ -155,9 +157,11 @@ export const renderDumpMarkdown = (result: DumpResult): string => { /** One cell: `depth N · (resolution)` or the typed unavailable reason. */ export const renderLineage = (lineage: JsonValue | undefined): string => { if (lineage === undefined || lineage === null || typeof lineage !== 'object' || Array.isArray(lineage)) return 'not recorded'; - const observed = lineage as { readonly state?: string; readonly reason?: string; readonly value?: JsonValue }; - if (observed.state !== 'available') return `unavailable · ${observed.reason ?? 'unknown'}`; - const value = (observed.value ?? {}) as { readonly conversation?: string; readonly depth?: number; readonly parent?: string; readonly resolution?: string; readonly root?: string }; - const parent = value.parent === undefined ? '' : ` ← ${value.parent}`; - return `depth ${String(value.depth ?? '?')} · ${value.conversation ?? '?'}${parent} (${value.resolution ?? '?'})`; + // The serialized `Observed` the capture wrote from `request.lineage`. + const observed = lineage as unknown as Observed | { readonly state?: undefined }; + if (observed.state !== 'available') { + return `unavailable · ${observed.state === 'unavailable' ? observed.reason : 'unknown'}`; + } + const { conversation, depth, parent, resolution } = observed.value; + return `depth ${String(depth)} · ${conversation}${parent === undefined ? '' : ` ← ${parent}`} (${resolution})`; }; diff --git a/examples/host-test/tests/route-unit/routes.test.ts b/examples/host-test/tests/route-unit/routes.test.ts index 01d3a26ac..b80116c80 100644 --- a/examples/host-test/tests/route-unit/routes.test.ts +++ b/examples/host-test/tests/route-unit/routes.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, expect, it } from '@rstest/core'; -import { available } from '@agent-bundle/runtime'; +import { available, type AgentLineage } from '@agent-bundle/runtime'; import { createGeneratedRuntimeState, type GeneratedRuntimeState, @@ -45,12 +45,19 @@ const eventInput = ( native, }); -const render = async (route: string, input: unknown, sessionId = 'root-session', host = 'claude') => { +const render = async ( + route: string, + input: unknown, + sessionId = 'root-session', + host = 'claude', + lineage?: AgentLineage, +) => { const bindings = await runtimeState.requestBindings(); try { return await renderRoute(route, { context: { host: available({ name: host }, 'native'), + ...(lineage === undefined ? {} : { lineage: available(lineage, 'native') }), noticeLedger: bindings.noticeLedger, session: available({ sessionId }, 'native'), state: bindings.state, @@ -124,6 +131,9 @@ it('records the complete native envelope, the request context, and env names for hasState: true, host: { state: 'available', value: { name: 'claude' } }, invocation: { kind: 'event' }, + // `request.lineage` is recorded on every line; the route-unit context + // mounts none, so the runtime's unavailable reason is the evidence. + lineage: { state: 'unavailable' }, session: { state: 'available', value: { sessionId: 'root-session' } }, }, }); @@ -132,6 +142,36 @@ it('records the complete native envelope, the request context, and env names for expect(JSON.stringify(record)).not.toContain(logDir.replace('captures.ndjson', 'value-should-not-appear')); }); +it('records the mounted request.lineage verbatim and renders it in the dump table', async () => { + const lineage: AgentLineage = { + conversation: 'agent-1', + depth: 1, + parent: 'root-session', + resolution: 'registry', + root: 'root-session', + subagent: { id: 'agent-1' }, + }; + await render('event:tool/before', eventInput('tool/before', { + agent_id: 'agent-1', + cwd: '/repo', + hook_event_name: 'PreToolUse', + session_id: 'root-session', + tool_input: { command: 'pwd' }, + tool_name: 'Bash', + tool_use_id: 'toolu_02', + }), 'root-session', 'claude', lineage); + + const [record] = await readLogLines(); + expect(record).toMatchObject({ request: { lineage: { source: 'native', state: 'available', value: lineage } } }); + + const dumped = await render('tool:host-test/dump', { conversation: 'agent-1' }); + expect(dumped.document.value).toMatchObject({ + matched: 1, + records: [expect.objectContaining({ lineage: { source: 'native', state: 'available', value: lineage } })], + }); + expectDocument(dumped).toContainMarkdown('depth 1 · agent-1 ← root-session (registry)'); +}); + it('redacts secret-looking native values but keeps ids intact', async () => { await render('event:tool/before', eventInput('tool/before', { cwd: '/repo', From 8d0e59f6032c7f2544fc27eed8631501289330e3 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 23:01:58 +0000 Subject: [PATCH 2/3] chore(examples/skills-starter): type outcome graders against agent-bundle/eval Both graders declare themselves as the public EvalGraderFunction instead of restating the grader context and outcome shapes by hand. The README names the build output directory the CLI actually writes (artifact/, not dist/). --- examples/skills-starter/README.md | 4 ++-- .../evals/graders/operations-result.ts | 22 ++++++++++++------- .../evals/graders/release-result.ts | 20 +++++++++++------ 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/examples/skills-starter/README.md b/examples/skills-starter/README.md index 6b6232fac..0b7f38110 100644 --- a/examples/skills-starter/README.md +++ b/examples/skills-starter/README.md @@ -64,6 +64,6 @@ pnpm dev Use `pnpm check` when you want validation and a build without starting the Workbench; run the deterministic eval command separately when you need the -release-readiness verdict. Generated output is written to `dist/`; its root -contract is `dist/agent-bundle.manifest.json`. The `.agent-bundle/` directory +release-readiness verdict. Generated output is written to `artifact/`; its root +contract is `artifact/agent-bundle.manifest.json`. The `.agent-bundle/` directory contains development state and is not source material. diff --git a/examples/skills-starter/evals/graders/operations-result.ts b/examples/skills-starter/evals/graders/operations-result.ts index 1717a4e31..faff9546b 100644 --- a/examples/skills-starter/evals/graders/operations-result.ts +++ b/examples/skills-starter/evals/graders/operations-result.ts @@ -1,18 +1,24 @@ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; -export default async ({ fixturePath }: { readonly fixturePath: string }) => { - const result = JSON.parse(await readFile(join(fixturePath, 'result.json'), 'utf8')) as { - readonly evidence?: unknown; - readonly outcome?: string; - readonly rollbackOrStopCondition?: string; - }; +import type { EvalGraderFunction } from 'agent-bundle/eval'; + +interface OperationsResult { + readonly evidence?: unknown; + readonly outcome?: string; + readonly rollbackOrStopCondition?: string; +} + +const grade: EvalGraderFunction = async ({ fixturePath }) => { + const result = JSON.parse(await readFile(join(fixturePath, 'result.json'), 'utf8')) as OperationsResult; const complete = result.outcome === 'ready' && Array.isArray(result.evidence) && result.evidence.length >= 2 && typeof result.rollbackOrStopCondition === 'string' && result.rollbackOrStopCondition.length > 0; return complete - ? { detail: 'The operational handoff includes evidence and a rollback or stop condition.', outcome: 'pass' as const } - : { detail: 'The operational handoff is missing evidence or a rollback or stop condition.', outcome: 'fail' as const }; + ? { detail: 'The operational handoff includes evidence and a rollback or stop condition.', outcome: 'pass' } + : { detail: 'The operational handoff is missing evidence or a rollback or stop condition.', outcome: 'fail' }; }; + +export default grade; diff --git a/examples/skills-starter/evals/graders/release-result.ts b/examples/skills-starter/evals/graders/release-result.ts index af48da399..b02f67ac1 100644 --- a/examples/skills-starter/evals/graders/release-result.ts +++ b/examples/skills-starter/evals/graders/release-result.ts @@ -1,12 +1,18 @@ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; -export default async ({ fixturePath }: { readonly fixturePath: string }) => { - const result = JSON.parse(await readFile(join(fixturePath, 'result.json'), 'utf8')) as { - readonly blockers?: unknown; - readonly verdict?: string; - }; +import type { EvalGraderFunction } from 'agent-bundle/eval'; + +interface ReleaseResult { + readonly blockers?: unknown; + readonly verdict?: string; +} + +const grade: EvalGraderFunction = async ({ fixturePath }) => { + const result = JSON.parse(await readFile(join(fixturePath, 'result.json'), 'utf8')) as ReleaseResult; return result.verdict === 'ready' && Array.isArray(result.blockers) && result.blockers.length === 0 - ? { detail: 'The release artifact is ready with no blockers.', outcome: 'pass' as const } - : { detail: 'The release artifact is not ready or has unresolved blockers.', outcome: 'fail' as const }; + ? { detail: 'The release artifact is ready with no blockers.', outcome: 'pass' } + : { detail: 'The release artifact is not ready or has unresolved blockers.', outcome: 'fail' }; }; + +export default grade; From 571bb1eed7c77d00cac5412d6a56957f1da9de64 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 23:01:58 +0000 Subject: [PATCH 3/3] chore(examples/mcp-app): type the outcome grader against agent-bundle/eval The status grader declares itself as the public EvalGraderFunction instead of restating the grader context and outcome shapes. The README's mcp run example reuses the build output the CLI actually writes (--artifact artifact). --- examples/mcp-app/README.md | 2 +- examples/mcp-app/evals/graders/status-result.ts | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/examples/mcp-app/README.md b/examples/mcp-app/README.md index f72811d96..11404f0b2 100644 --- a/examples/mcp-app/README.md +++ b/examples/mcp-app/README.md @@ -84,7 +84,7 @@ pnpm exec agent-bundle mcp run --server status --target portable ``` The command resolves the generated entry from the portable target's MCP -manifest, building a temporary artifact first; pass `--artifact dist` to +manifest, building a temporary artifact first; pass `--artifact artifact` to reuse the `pnpm build` output instead. Closing stdin exits 0 and Ctrl-C exits 130, and per-server state persists under `.agent-bundle/mcp-run/portable/status`. diff --git a/examples/mcp-app/evals/graders/status-result.ts b/examples/mcp-app/evals/graders/status-result.ts index 3922eb3a7..ec34e7a89 100644 --- a/examples/mcp-app/evals/graders/status-result.ts +++ b/examples/mcp-app/evals/graders/status-result.ts @@ -1,11 +1,15 @@ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; +import type { EvalGraderFunction } from 'agent-bundle/eval'; + import { isHealthyCompilerFixture } from '../../src/compiler-status-contract.ts'; -export default async ({ fixturePath }: { readonly fixturePath: string }) => { +const grade: EvalGraderFunction = async ({ fixturePath }) => { const result = JSON.parse(await readFile(join(fixturePath, 'result.json'), 'utf8')) as unknown; return isHealthyCompilerFixture(result) - ? { detail: 'The compiler service is healthy.', outcome: 'pass' as const } - : { detail: 'The compiler service did not report a healthy status.', outcome: 'fail' as const }; + ? { detail: 'The compiler service is healthy.', outcome: 'pass' } + : { detail: 'The compiler service did not report a healthy status.', outcome: 'fail' }; }; + +export default grade;