Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions examples/host-test/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand All @@ -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`. |
Expand Down
40 changes: 21 additions & 19 deletions examples/host-test/src/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
14 changes: 9 additions & 5 deletions examples/host-test/src/dump.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {
agent,
type AgentLineage,
type AgentStateHandle,
type JsonObject,
type JsonValue,
type Observed,
} from '@agent-bundle/runtime';
import { z } from 'zod';

Expand Down Expand Up @@ -155,9 +157,11 @@ export const renderDumpMarkdown = (result: DumpResult): string => {
/** One cell: `depth N · <conversation> ← <parent> (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<AgentLineage>` the capture wrote from `request.lineage`.
const observed = lineage as unknown as Observed<AgentLineage> | { 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})`;
};
44 changes: 42 additions & 2 deletions examples/host-test/tests/route-unit/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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' } },
},
});
Expand All @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion examples/mcp-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
10 changes: 7 additions & 3 deletions examples/mcp-app/evals/graders/status-result.ts
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 2 additions & 2 deletions examples/skills-starter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
22 changes: 14 additions & 8 deletions examples/skills-starter/evals/graders/operations-result.ts
Original file line number Diff line number Diff line change
@@ -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;
20 changes: 13 additions & 7 deletions examples/skills-starter/evals/graders/release-result.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading