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
13 changes: 13 additions & 0 deletions .changeset/typed-handler-request-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@agent-bundle/runtime": minor
"agent-bundle": minor
---

Expose the transport-installed `AgentRequestContext` as optional
`context.request` to `defineOperation` handlers while preserving the same
request handle returned by `agent()`. Identity axes remain honest `Observed`
values with typed unavailable reasons when a transport cannot know them.

Document `await agent()` as the route-component context contract and the
`renderRoute(..., { context })` identity-injection seam for tests. Business
input cannot override host, session, actor, workspace, or capability context.
48 changes: 48 additions & 0 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,54 @@ unavailable-shaped value instead of throwing. `processLifetime` is reserved
for the framework-owned process identity and hit counter, so provider filenames
must not derive that key.

### Handler request context

Conventional route components receive only their surface props, such as
`{ input, signal }`. They read transport-owned request context with
`await agent()` from `@agent-bundle/runtime`. The handle exposes the
invocation plus `host`, `session`, `actor`, and `workspace` identity axes.
Each identity axis is `Observed`: transports publish an `available` value and
source when they know it, or `unavailable` with a typed reason when they do
not. Generated event scopes currently mount no actor principal, so event
routes observe actor as unavailable rather than receiving a fabricated value.

Handlers authored with `defineOperation` receive the same handle as optional
`context.request` in the second `execute` argument:

```ts
const status = defineOperation({
// ...
execute: async (input, context) => {
const request = context.request;
// request is the identical handle returned by await agent() in this invocation.
return inspect(input, request);
},
});
```

The runtime supplies `context.request` inside `runAgentRequest`; direct
operation calls outside a request scope leave it absent. Transport context is
separate from validated business input, so fields named `host`, `session`, or
similar inside `input` cannot override request identity.

Route-unit tests inject identity through the harness context seam:

```ts
import { available } from '@agent-bundle/runtime';
import { renderRoute } from 'agent-bundle/test';

await renderRoute('tool:curator/status', {
context: {
host: available({ name: 'test-host' }, 'native'),
session: available({ sessionId: 'test-session' }, 'native'),
},
input: { subject: 'library' },
});
```

The same seam accepts `actor`, `workspace`, and `capabilities`; tests can use
`unavailable(...)` to pin a transport's honest absence semantics.

### Migration nudges

Source validation reports **informational** nudges (never errors — migrations
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Agent, agent } from '@agent-bundle/runtime';
import { Agent, agent, type JsonValue } from '@agent-bundle/runtime';
import type { AgentEventRouteProps } from 'agent-bundle';

export default async function AfterTool({ canonical }: AgentEventRouteProps) {
Expand All @@ -8,8 +8,11 @@ export default async function AfterTool({ canonical }: AgentEventRouteProps) {
id: notice.id,
message: notice.content.root.kind === 'text' ? notice.content.root.text : '',
}));
const actor: JsonValue = context.actor.state === 'available'
? { source: context.actor.source, state: context.actor.state, value: { id: context.actor.value.id } }
: { reason: context.actor.reason, state: context.actor.state };
return (
<Agent.Result>
<Agent.Result value={{ actor }}>
<Agent.Markdown>{`Observed ${canonical.event} from ${canonical.provenance.host}.`}</Agent.Markdown>
{notices.map((notice) => (
<Agent.Context key={notice.id}>{`notice ${notice.id}: ${notice.message}`}</Agent.Context>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Agent, agent, type JsonValue } from '@agent-bundle/runtime';
import { z } from 'zod';

export const config = {
description: 'Returns the request identity axes observed by this route.',
title: 'Context',
};

export const inputSchema = z.object({
host: z.string().optional(),
session: z.string().optional(),
}).strict();

export const resultSchema = z.object({
actor: z.unknown(),
host: z.unknown(),
session: z.unknown(),
workspace: z.unknown(),
}).strict();

export default async function Context() {
const context = await agent();
const actor: JsonValue = context.actor.state === 'available'
? { source: context.actor.source, state: context.actor.state, value: { id: context.actor.value.id } }
: { reason: context.actor.reason, state: context.actor.state };
const host: JsonValue = context.host.state === 'available'
? { source: context.host.source, state: context.host.state, value: { name: context.host.value.name } }
: { reason: context.host.reason, state: context.host.state };
const session: JsonValue = context.session.state === 'available'
? { source: context.session.source, state: context.session.state, value: { sessionId: context.session.value.sessionId } }
: { reason: context.session.reason, state: context.session.state };
const workspace: JsonValue = context.workspace.state === 'available'
? { source: context.workspace.source, state: context.workspace.state, value: { root: context.workspace.value.root } }
: { reason: context.workspace.reason, state: context.workspace.state };
const result = {
actor,
host,
session,
workspace,
};
return (
<Agent.Result value={result}>
<Agent.Text>Request context observed.</Agent.Text>
</Agent.Result>
);
}
28 changes: 25 additions & 3 deletions packages/agent-bundle/src/routes/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,15 @@ export interface AgentEventCanonicalIdentity {
/** Complete host envelope after the adapter's schema and byte-bound validation. */
export type AgentEventNativePayload = Readonly<Record<string, unknown>>;

/** Props received by an event route's async default Server Component. */
/**
* Props received by an event route's async default Server Component.
*
* Read transport-owned request identity with `await agent()` from
* `@agent-bundle/runtime`. The invocation, host, session, actor, and workspace
* axes are `Observed`, including typed unavailable reasons when the host
* cannot know an axis. Business payload fields cannot override them.
* Generated event scopes currently expose actor as unavailable.
*/
export interface AgentEventRouteProps {
readonly canonical: AgentEventCanonicalIdentity;
readonly native: AgentEventNativePayload;
Expand Down Expand Up @@ -71,7 +79,14 @@ export interface AgentEventRouteConfig {
readonly tools?: readonly string[];
}

/** Props received by every executable MCP route's async default Server Component. */
/**
* Props received by every executable MCP route's async default Server Component.
*
* Read transport-owned invocation, host, session, actor, and workspace axes
* with `await agent()` from `@agent-bundle/runtime`. Every identity axis is
* `Observed`; unavailable axes carry a typed reason, and `input` cannot
* override request identity.
*/
export interface ToolRouteProps<InputSchema extends RouteSchema> {
readonly input: RouteSchemaOutput<InputSchema>;
readonly signal: AbortSignal;
Expand Down Expand Up @@ -127,7 +142,14 @@ export interface CliRouteConfig {
readonly positionals?: readonly string[];
}

/** Props received by every routed CLI command's async default function. */
/**
* Props received by every routed CLI command's async default function.
*
* Read transport-owned invocation, host, session, actor, and workspace axes
* with `await agent()` from `@agent-bundle/runtime`. Every identity axis is
* `Observed`; unavailable axes carry a typed reason, and parsed command input
* cannot override request identity.
*/
export interface CliRouteProps<InputSchema extends RouteSchema> {
readonly input: RouteSchemaOutput<InputSchema>;
readonly signal: AbortSignal;
Expand Down
8 changes: 7 additions & 1 deletion packages/agent-bundle/src/test/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,13 @@ import type {
TestableRouteDescriptor,
} from './types.ts';

/** Request-scoped overrides for one rendered route, over the runtime's own request contract. */
/**
* Request-scoped overrides for one rendered route, over the runtime's own
* request contract. `host`, `session`, `actor`, `workspace`, and
* `capabilities` are the identity-injection seam for context-dependent route
* tests; construct observed values with `available` or `unavailable` from
* `@agent-bundle/runtime`.
*/
export type RenderRouteContext = Omit<AgentRequestInit, 'invocation' | 'progress' | 'signal'> & {
readonly invocation?: Omit<AgentInvocationInput, 'kind'>;
readonly progress?: AgentProgressReporter;
Expand Down
23 changes: 17 additions & 6 deletions packages/agent-bundle/tests/generated-route-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,11 @@ it('lists and calls a generated filesystem tool through final-only Flight', { re
"import { z } from 'zod';",
"export const config = { annotations: { readOnlyHint: true }, description: 'Inspect one source.' };",
"export const inputSchema = z.object({ source: z.string() }).strict();",
"export const resultSchema = z.object({ invocationKind: z.literal('tool'), source: z.string() }).strict();",
"export const resultSchema = z.object({ actor: z.unknown(), host: z.unknown(), invocationKind: z.literal('tool'), session: z.unknown(), source: z.string(), workspace: z.unknown() }).strict();",
'export default async function Inspect({ input, signal }) {',
" if (signal.aborted) throw new DOMException('aborted', 'AbortError');",
' const context = await agent();',
' const result = { invocationKind: context.invocation.kind, source: input.source };',
' const result = { actor: context.actor, host: context.host, invocationKind: context.invocation.kind, session: context.session, source: input.source, workspace: context.workspace };',
' return (',
' <Agent.Result value={result}>',
' <Agent.Markdown>{`Inspected **${input.source}**.`}</Agent.Markdown>',
Expand Down Expand Up @@ -162,10 +162,17 @@ it('lists and calls a generated filesystem tool through final-only Flight', { re
await expect(client.listTools()).resolves.toMatchObject({
tools: [{ annotations: { readOnlyHint: true }, description: 'Inspect one source.', name: 'inspect' }],
});
await expect(client.callTool({ arguments: { source: 'library' }, name: 'inspect' }, { signal: AbortSignal.timeout(10_000) })).resolves.toMatchObject({
const inspected = await client.callTool({ arguments: { source: 'library' }, name: 'inspect' }, { signal: AbortSignal.timeout(10_000) });
expect(inspected).toMatchObject({
content: [{ text: 'Inspected **library**.', type: 'text' }],
structuredContent: { invocationKind: 'tool', source: 'library' },
});
expect(inspected.structuredContent).toMatchObject({
actor: { reason: 'not-provided', state: 'unavailable' },
host: { reason: 'not-provided', state: 'unavailable' },
session: { reason: 'not-provided', state: 'unavailable' },
workspace: { reason: 'not-provided', state: 'unavailable' },
});
const resources = await client.listResources();
expect(resources).toMatchObject({ resources: [
expect.objectContaining({ uri: 'catalog://books' }),
Expand Down Expand Up @@ -524,7 +531,11 @@ it('renders one tool/after event route through two native thin clients', { retry
' const context = await agent();',
' const requestValue = context.providers.requestValue as { kind: string };',
' const tool = typeof native.tool_name === "string" ? native.tool_name : "unknown";',
' return createElement(Agent.Result, null, createElement(Agent.Context, null, `${canonical.provenance.host}:${tool}:${requestValue.kind}:${String(Object.isFrozen(context.providers))}`));',
" const actor = context.actor.state === 'unavailable' ? `unavailable:${context.actor.reason}` : `available:${context.actor.value.id}`;",
" const host = context.host.state === 'unavailable' ? `unavailable:${context.host.reason}` : `available:${context.host.source}:${context.host.value.name}`;",
" const session = context.session.state === 'unavailable' ? `unavailable:${context.session.reason}` : `available:${context.session.source}:${context.session.value.sessionId}`;",
" const workspace = context.workspace.state === 'unavailable' ? `unavailable:${context.workspace.reason}` : `available:${context.workspace.source}:${context.workspace.value.root}`;",
' return createElement(Agent.Result, null, createElement(Agent.Context, null, `${canonical.provenance.host}:${tool}:${requestValue.kind}:${String(Object.isFrozen(context.providers))}:host:${host}:session:${session}:workspace:${workspace}:actor:${actor}`));',
'}',
'',
].join('\n')),
Expand Down Expand Up @@ -576,10 +587,10 @@ it('renders one tool/after event route through two native thin clients', { retry
};
const response = await runHook(hook.output, native);
expect(response).toEqual(target === 'cursor'
? { additional_context: 'cursor:Write:event:true' }
? { additional_context: `cursor:Write:event:true:host:available:native:cursor:session:available:native:session-1:workspace:available:native:${root}:actor:unavailable:not-provided` }
: {
hookSpecificOutput: {
additionalContext: 'claude:Write:event:true',
additionalContext: `claude:Write:event:true:host:available:native:claude:session:available:native:session-1:workspace:available:native:${root}:actor:unavailable:not-provided`,
hookEventName: 'PostToolUse',
},
});
Expand Down
14 changes: 13 additions & 1 deletion packages/agent-bundle/tests/projection/mcp-in-memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ describe('the in-memory MCP projection level', () => {
it('registers every compiled route kind on the real generated server', async () => {
const surface = await listMcpSurface();

expect(surface.tools).toEqual(['catalog', 'echo', 'journal', 'publish-notice', 'strict-report', 'ticket', 'unavailable', 'wait']);
expect(surface.tools).toEqual(['catalog', 'context', 'echo', 'journal', 'publish-notice', 'strict-report', 'ticket', 'unavailable', 'wait']);
expect(surface.prompts).toEqual(['summarize']);
expect(surface.resources).toEqual(['harness://notes']);
expect(surface.provenance).toMatchObject({
Expand All @@ -39,6 +39,7 @@ describe('the in-memory MCP projection level', () => {
'prompt:harness/summarize',
'resource:harness/notes',
'tool:harness/catalog',
'tool:harness/context',
'tool:harness/echo',
'tool:harness/journal',
'tool:harness/publish-notice',
Expand Down Expand Up @@ -70,6 +71,17 @@ describe('the in-memory MCP projection level', () => {
expect(invocation.provenance.proofLevel).toBe('mcp-in-memory');
});

it('reports the identity axes the in-memory projection actually installs', async () => {
const invocation = await invokeMcpTool('context');

expect(invocation.structuredContent).toEqual({
actor: { reason: 'not-provided', state: 'unavailable' },
host: { reason: 'not-provided', state: 'unavailable' },
session: { reason: 'not-provided', state: 'unavailable' },
workspace: { reason: 'not-provided', state: 'unavailable' },
});
});

it('carries a represented error to the protocol as isError rather than a transport failure', async () => {
const invocation = await invokeMcpTool('unavailable');

Expand Down
45 changes: 44 additions & 1 deletion packages/agent-bundle/tests/route-unit/render-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { renderRoute } from '../../src/test/render.ts';
import { testManifest } from '../../src/test/registry.ts';

const workspace = { source: 'native', state: 'available', value: { root: '/tmp/harness-library' } } as never;
const notProvided = { reason: 'not-provided', state: 'unavailable' };

/** The harness error one render rejected with; a resolved render is itself a failure. */
const rejection = async (render: Promise<unknown>): Promise<AgentTestError> => {
Expand Down Expand Up @@ -66,6 +67,48 @@ describe('renderRoute through the real renderer', () => {
});
});

it('reports typed unavailable identity axes when the harness receives no context injection', async () => {
const rendered = await renderRoute('tool:harness/context');

expect(rendered.result).toEqual({
actor: notProvided,
host: notProvided,
session: notProvided,
workspace: notProvided,
});
});

it('preserves injected identity values and their observation sources', async () => {
const rendered = await renderRoute('tool:harness/context', {
context: {
actor: { source: 'receipt', state: 'available', value: { id: 'actor-route-unit' } },
host: { source: 'native', state: 'available', value: { name: 'route-unit-host' } },
session: { source: 'native', state: 'available', value: { sessionId: 'route-unit-session' } },
workspace: { source: 'derived', state: 'available', value: { root: '/tmp/route-unit' } },
},
});

expect(rendered.result).toEqual({
actor: { source: 'receipt', state: 'available', value: { id: 'actor-route-unit' } },
host: { source: 'native', state: 'available', value: { name: 'route-unit-host' } },
session: { source: 'native', state: 'available', value: { sessionId: 'route-unit-session' } },
workspace: { source: 'derived', state: 'available', value: { root: '/tmp/route-unit' } },
});
});

it('does not treat lookalike business input as request identity', async () => {
const rendered = await renderRoute('tool:harness/context', {
input: { host: 'spoofed-host', session: 'spoofed-session' },
});

expect(rendered.result).toEqual({
actor: notProvided,
host: notProvided,
session: notProvided,
workspace: notProvided,
});
});

it('auto-mounts isolated declared state into each route-unit render', async () => {
const first = await renderRoute('tool:harness/journal', { input: { note: 'route-unit proof' } });
const second = await renderRoute('tool:harness/journal');
Expand Down Expand Up @@ -214,7 +257,7 @@ describe('renderRoute through the real renderer', () => {
expectDocument(rendered)
.toHaveStatus('success')
.toContainMarkdown('Observed tool/after from claude.')
.toHaveValue(undefined);
.toHaveValue({ actor: notProvided });
});

it('renders a route module handed in directly, without the compiled manifest', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const routeHarnessContractFixtures = (): Record<string, ContractRouteFixt
'prompt:harness/summarize': { input: { note: 'chapter one' } },
'resource:harness/notes': {},
'tool:harness/catalog': { input: { genre: 'mystery' }, resultCompat: 'additive' },
'tool:harness/context': { resultCompat: 'closed' },
'tool:harness/echo': { input: { message: 'contract matrix' }, resultCompat: 'additive' },
'tool:harness/journal': { input: { note: 'matrix proof' }, resultCompat: 'closed' },
'tool:harness/publish-notice': {
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-bundle/tests/test-harness-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ describe('the compiled test manifest', () => {
'prompt:harness/summarize',
'resource:harness/notes',
'tool:harness/catalog',
'tool:harness/context',
'tool:harness/echo',
'tool:harness/journal',
'tool:harness/publish-notice',
Expand Down Expand Up @@ -237,6 +238,7 @@ describe('the generated route registry', () => {
const loaders = /loaders: \{\n(?<body>[\s\S]*?)\n {2}\},/u.exec(source)?.groups?.body ?? '';

expect(loaders).toContain('"event:tool/after": () => import(');
expect(loaders).toContain('"tool:harness/context": () => import(');
expect(loaders).toContain('"tool:harness/echo": () => import(');
expect(loaders).toContain('"tool:harness/journal": () => import(');
expect(loaders).toContain('"tool:harness/unavailable": () => import(');
Expand Down
5 changes: 5 additions & 0 deletions packages/rsc-runtime/src/agent-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,11 @@ const currentLease = (): Lease => {
return lease;
};

export const currentAgentRequest = (): AgentRequestContext | undefined => {
const lease = getStore().storage.getStore();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid initializing the global store from the context probe

When defineOperation.execute runs outside an invocation, this probe now calls getStore(), which both creates the realm-wide Symbol.for('@agent-bundle/runtime/request-store') store and throws if another installed runtime version already owns it. Consequently, an otherwise context-free direct operation can either fail with store-version-conflict in a mixed-version process or claim the symbol and cause the other runtime to fail later, even though direct execution previously did not touch request storage. Make the non-throwing probe inspect an existing compatible store without creating or rejecting on an unrelated version.

Useful? React with 👍 / 👎.

return lease === undefined || lease.closed ? undefined : lease.handle;
};

export const agent = async (): Promise<AgentRequestContext> => {
const lease = currentLease();
open(lease);
Expand Down
Loading
Loading