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', 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; 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;