diff --git a/.changeset/459-provider-request-context.md b/.changeset/459-provider-request-context.md new file mode 100644 index 000000000..d84615a35 --- /dev/null +++ b/.changeset/459-provider-request-context.md @@ -0,0 +1,6 @@ +--- +"@agent-bundle/runtime": patch +"agent-bundle": patch +--- + +Hand `src/providers/` factories the request they run for: `AgentProviderContext` gains `host`, `session`, `workspace`, and `lineage` (live `tree` included) exactly as the route observes them on `await agent()`, plus read-only `state` (`lifetime`, `read()`) and `notices` (`inbox()`) views of the mounted handles; `dispatch`, `publish`, and `acknowledge` stay route-only, and `agent()`/`useAgent()` inside a factory throw `outside-invocation`. Every generated request scope (Flight worker, rendered CLI/script worker, plain routed CLI) and the `agent-bundle/test` harness now run providers as the request's own resolver — after `runAgentRequest` freezes the identity axes and opens the notice lease, before the route — and `runAgentRequest` accepts `providers` as an `AgentProviderResolver` `(request: AgentProviderRequest) => values` beside the plain record. New exports: `AgentProviderObserved`, `AgentProviderLineage`, `AgentProviderLineageTree`, `AgentProviderLineagePeer`, `AgentProviderLineageSubagent`, `AgentProviderLineageResolution`, `AgentProviderStateHandle`, `AgentProviderStateSnapshot`, `AgentProviderNotice`, `AgentProviderNoticesHandle` from `agent-bundle`; `AgentProviderRequest`, `AgentProviderResolver`, `AgentProviderStateHandle`, `AgentProviderNoticesHandle` from `@agent-bundle/runtime`. Factories that destructure `{ invocation, signal }` are unchanged. (#556) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 1d7d725a7..c95da21db 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -82,7 +82,7 @@ entries carry `provenance.kind: 'conventional'` in the normalized model. | `src/cli/**/*.{ts,tsx}` | Routed CLI commands compiled into one collision-checked command graph and one generated package executable named after `plugin.name` (superseding the `src/cli.ts` bin convention for the project), plus the same executable as `bin/.mjs` in every selected host artifact whose target publishes the `cli` capability (all built-in targets). Nesting is identity: `src/cli/library/audit.ts` runs as ` library audit`. Plain `.ts` commands execute directly and print one canonical JSON line; `.tsx` commands render through the dispatcher with the four output modes. | `bin: false`, `routes.cli: 'conventional'`, or prefix a path segment with `_` | | `src/events//.{ts,tsx}`, `src/events/stop.{ts,tsx}` | Semantic event route: the path is the canonical event family (`src/events/tool/after.tsx` is `tool/after`; `stop` is the one top-level family) and must be one of the admitted `canonicalAgentEvents`. The optional static `config` (`AgentEventRouteConfig`: `targets`, `tools`, `runtime: 'shared' \| 'standalone'`, `fallback`, `delivery`, `timeoutMs`) restricts hosts and selects the execution mode; the async default Server Component receives `AgentEventRouteProps` (`{ canonical, native, signal }`) and returns `Agent.*` output that the selected host adapter encodes into its native hook envelope. `canonical.payload` is the family's cross-host reading of the envelope (#466) — the fields at least two hosts report (`toolName`, `toolInput`, `toolResponse`, `sessionId`, `transcriptPath`, `cwd`, `prompt`, `agentId`/`agentType`, `reentry`, …), each as `{ value, nativeKey }` naming the host key it came from and absent when the host did not send it; `E` narrows it to the route's family. The per-family field table is `agentEventPayloadFields` and the per-host key table `agentEventPayloadNativeKeys` (`routes/events.ts`), mirrored under `hooks.eventRoutes..payload` in each pinned capability table so the generated events reference documents the mapping per host. Application code never branches on host JSON or emits native hook documents; per-host support is a capability state (`supported`/`degraded`/`unavailable`/`prohibited`) surfaced by `inspect` and enforced at build time (`AB4817`, `AB4823`–`AB4825`). | Restrict `config.targets`, or prefix a path segment with `_` | | `src/state.ts` | Project state definition: default-exports `defineState({ ... })`; generated MCP, routed-CLI, and rendered-script request scopes mount `(await agent()).state` and `.notices`. | `state: false`, or rename the file to `_state.ts` | -| `src/providers/.{ts,tsx}` | Request context provider: default-exports a factory receiving `{ invocation, plugin, signal }`; its value is mounted at `(await agent()).providers.` for generated MCP and event routes, projected MCP commands, plain and rendered routed CLI commands, and rendered scripts. | Prefix the file with `_` | +| `src/providers/.{ts,tsx}` | Request context provider: default-exports a factory receiving `{ invocation, signal, host, session, workspace, lineage, plugin, state?, notices? }` — the request's observed identity, lineage, and plugin root plus read-only views of the mounted state (`read`) and notice (`inbox`) handles; its value is mounted at `(await agent()).providers.` for generated MCP and event routes, projected MCP commands, plain and rendered routed CLI commands, and rendered scripts. | Prefix the file with `_` | | `src/layout.{ts,tsx}` | Shared document layout: default-exports one component receiving `{ children, route, signal }` that renders `Agent.Result` around every rendered route — generated MCP tools, resources, and prompts, rendered routed CLI commands, projected MCP commands, and rendered scripts. Event routes are never wrapped. | Rename to `_layout.tsx` | | `src/mcp//layout.{ts,tsx}` | Per-server layout nested inside the root layout for that generated server's routes. | Rename to `_layout.tsx`, or set `routes.servers.` to a non-generated mode | @@ -261,22 +261,56 @@ an otherwise valid migration. Each direct child of `src/providers/` derives its key by camel-casing the file stem: for example, `src/providers/project-auth.ts` mounts at `(await agent()).providers.projectAuth`. Every module default-exports a factory -with the contract `(context: { invocation, plugin, signal }) => value | -Promise`, where `invocation` is the current route invocation, `plugin` -is the observed plugin root the request will publish as -`(await agent()).plugin` (#468), and `signal` is its request abort signal. +with the contract `(context: AgentProviderContext) => value | Promise`: + +```ts +interface AgentProviderContext { + invocation: AgentProviderInvocation; // the surface-specific route invocation + signal: AbortSignal; // the request abort signal + host: Observed<{ name }>; // exactly what the route reads on `await agent()` + session: Observed<{ sessionId }>; + workspace: Observed<{ root }>; + lineage: Observed; // own chain plus the live `tree` (#457) + plugin: Observed<{ root, stateRoot }>; // the plugin root the request publishes (#468) + state?: { lifetime; read(options?) }; // the mounted state handle, `read` only + notices?: { inbox() }; // the request's notice handle, `inbox` only +} +``` + +`host`, `session`, `workspace`, `lineage`, and `plugin` are the same observed +values the route will read, unavailable reasons included. `state` is present +for projects that declare `src/state.ts` and `notices` for projects whose scope +mounts the notice ledger; both are the real request handles narrowed by +construction to their read paths (#459), so a provider can expose a derived +view of shared state — a topology, a summary, a peers list — but never dispatch +a state event or publish, acknowledge, or withdraw a notice: those stay +route-only. Providers also run outside the request's async context, so +`agent()` and `useAgent()` inside a factory throw `outside-invocation` rather +than handing it the full handle. The types ship from `agent-bundle` +(`AgentProviderContext`, `AgentProviderStateHandle`, +`AgentProviderNoticesHandle`, `AgentProviderLineage`, …) without a runtime +import; at run time the handles are the runtime's own. Every generated request scope — the shared Flight worker behind generated MCP and event routes, the react-server worker behind rendered routed CLI commands and rendered scripts, and the routed-CLI executable itself for plain `.ts` -commands — executes providers once per request, sequentially in deterministic -key order, before entering `runAgentRequest`. The returned values join the -request's provider map. A thrown or rejected factory fails the request closed; +commands — executes providers once per request as the request's own provider +resolver: `runAgentRequest` freezes the identity axes, opens the notice lease +(so `notices.inbox()` is real), then runs the factories sequentially in +deterministic key order, and only then runs the route. That ordering — state +and notices mounted before providers, rather than a lazy handle that resolves +later — is what keeps the generated loop and the harness's `executeProviders` +one simple function: a provider awaits real handles, and a factory that reads +`inbox()` eagerly cannot deadlock on a lease that has not opened yet. The +returned values join the request's provider map. A thrown or rejected factory +fails the request closed, exactly as a route that throws after admission does; expected degradation should return an honest unavailable-shaped value instead of throwing. `invocation.kind` stays surface-specific (`tool`, `event`, `cli`, `script`), so a provider can branch on the entry surface deliberately. `processLifetime` is reserved for the framework-owned process identity and hit -counter, so provider filenames must not derive that key. +counter, so provider filenames must not derive that key. A custom host calling +`runAgentRequest` directly may pass `providers` as the resolved record or as +the same resolver function `(request: AgentProviderRequest) => values`. The `agent-bundle/test` harness mounts the same providers, in the same order and with the same fail-closed semantics, for every manifest-backed helper diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 05d79d380..e0da719ce 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -147,9 +147,13 @@ export default async function library({ invocation }: AgentProviderContext): Pro } ``` -Providers run once per request in deterministic key order before the request -scope opens; a thrown factory fails that request closed, so return an honest -unavailable-shaped value for expected degradation. The compiler validates the +Providers run once per request in deterministic key order as the request's own +resolver: after `runAgentRequest` freezes the identity axes and opens the +notice lease, before the route runs, so the factory context carries `host`, +`session`, `workspace`, `lineage`, and `plugin` as the route will read them plus +the read-only `state` (`read`) and `notices` (`inbox`) handles (#459; see +`entry-conventions.md`). A thrown factory fails that request closed, so return +an honest unavailable-shaped value for expected degradation. The compiler validates the default export (`AB4940`), unique keys (`AB4941`), and the reserved framework-owned `processLifetime` key (`AB4942`). diff --git a/examples/worktree-proximity/README.md b/examples/worktree-proximity/README.md index 4d3123b3f..43dcf9f14 100644 --- a/examples/worktree-proximity/README.md +++ b/examples/worktree-proximity/README.md @@ -50,13 +50,16 @@ The application has four planes: `resolution`) and, through `lineage.value.tree`, who else is alive: `siblings` (every other live conversation under the same root, the root included), `children`, and other live `roots`, each with the registry's own - `resolution` for its placement. `agentTree()` in `src/event-support.ts` + `resolution` for its placement. `agentTreeOf()` in `src/event-support.ts` turns that into the coordinator's report; `liveConversations()` turns it into the liveness the domain uses. - **Providers** — `git-worktree` derives repository, branch, commit, common Git directory, and linked-worktree identity without throwing for expected - degradation. `agent-topology` reports that its snapshot is unavailable - because providers receive no request lineage. + degradation. `agent-topology` assembles the coordinator's snapshot once per + request from the request view every provider receives: the agent tree + resolved on `context.lineage` (own chain plus the live tree) and a read of + the mounted intent state through `context.state.read()`; each half carries + its own availability, and the provider can only read. - **Events** — canonical shared-runtime routes bind actors to worktrees, record or clear intent, detect conflicts, render current-actor context, release stopped actors, and publish or admit notices. @@ -75,13 +78,14 @@ lineage journal over that same driver. The application never opens a second store from Git identity data; `gitWorktree.commonDir` remains identity evidence only. -The issue sketch places the agent tree at `providers.agentTopology`. The tree -is on the request (`request.lineage.tree`, -[#457](https://github.com/scriptedalchemy/agent-bundle/issues/457)), but a -provider factory receives only `{ invocation, signal }` — not the request's -`lineage` ([#459](https://github.com/scriptedalchemy/agent-bundle/issues/459)) — -so this provider reports an honest unavailable result and routes read the -tree from `(await agent()).lineage` instead. +`providers.agentTopology` is that snapshot: a provider factory receives the +request's `host`, `session`, `workspace`, and `lineage` — with the live tree +([#457](https://github.com/scriptedalchemy/agent-bundle/issues/457)) — plus +read-only `state` (`read()`) and `notices` (`inbox()`) handles +([#459](https://github.com/scriptedalchemy/agent-bundle/issues/459)), so the +coordinator `status` tool reads `providers.agentTopology` and performs no +second read of its own. Event routes still use the mounted +`(await agent()).state` handle, because they dispatch. `worktree()` in `src/api.ts` is the issue-mandated custom Promise API over the provider value. `useWorktree()` is the hook-shaped variant for Server diff --git a/examples/worktree-proximity/src/event-support.ts b/examples/worktree-proximity/src/event-support.ts index 42c9292a9..b489bd002 100644 --- a/examples/worktree-proximity/src/event-support.ts +++ b/examples/worktree-proximity/src/event-support.ts @@ -64,12 +64,21 @@ export type AgentTreeView = | { readonly reason: string; readonly state: 'unavailable' }; /** - * The whole-tree view the coordinator reports, read from `request.lineage` - * and nothing else. A lineage with no `tree` (a payload that proved only its - * own chain, or a standalone hook) is reported as unavailable rather than as - * an empty tree. + * An observed lineage as a route reads it (`Observed`) or as a + * provider receives it (`AgentProviderContext['lineage']`, the same shape + * spelled without a runtime import); both are assignable here. */ -export const agentTreeOf = (lineage: Observed): AgentTreeView => { +type ObservedLineage = + | { readonly state: 'available'; readonly value: AgentLineage } + | { readonly reason: string; readonly state: 'unavailable' }; + +/** + * The whole-tree view the coordinator reports, read from the request's + * lineage and nothing else. A lineage with no `tree` (a payload that proved + * only its own chain, or a standalone hook) is reported as unavailable rather + * than as an empty tree. + */ +export const agentTreeOf = (lineage: ObservedLineage): AgentTreeView => { if (lineage.state !== 'available') { return { reason: `lineage unavailable (${lineage.reason})`, state: 'unavailable' }; } @@ -90,8 +99,6 @@ export const agentTreeOf = (lineage: Observed): AgentTreeView => { }; }; -export const agentTree = async (): Promise => agentTreeOf(await requestLineage()); - /** * The subagent a request speaks for, when the runtime's `request.lineage` * places it below the root. The runtime resolves the same shape on every diff --git a/examples/worktree-proximity/src/mcp/coordinator/tools/status.tsx b/examples/worktree-proximity/src/mcp/coordinator/tools/status.tsx index 1f8c5cc99..f8a2668f9 100644 --- a/examples/worktree-proximity/src/mcp/coordinator/tools/status.tsx +++ b/examples/worktree-proximity/src/mcp/coordinator/tools/status.tsx @@ -1,11 +1,11 @@ -import { Agent, type AgentNoticeState, type JsonValue } from '@agent-bundle/runtime'; +import { Agent, agent, type AgentNoticeState, type JsonValue } from '@agent-bundle/runtime'; import { AGENT_NOTICE_STATES } from '@agent-bundle/runtime/notices'; import type { ToolConfig, ToolRouteProps } from 'agent-bundle'; import React from 'react'; import { z } from 'zod'; -import { withIntent, withNotices } from '../../../coordination.js'; -import { agentTree } from '../../../event-support.js'; +import { withNotices } from '../../../coordination.js'; +import type { AgentTopologyProviderValue } from '../../../providers/agent-topology.js'; import { ActivitySchema, BindingSchema } from '../../../state.js'; export const config = { @@ -115,11 +115,22 @@ const publishedNotices = async (): Promise => { return { ...counts, state: 'available', total: result.value.length }; }; +/** + * The topology snapshot the `agent-topology` provider assembled for this + * request (agent-bundle#459): the agent tree the runtime resolved for the call + * and a read of the intent state, each with its own availability. A fixture + * that omits the provider is reported, never worked around with a second read. + */ +const topologyOf = (providers: { readonly agentTopology?: AgentTopologyProviderValue }): AgentTopologyProviderValue => + providers.agentTopology ?? { + agents: { reason: 'agent-topology provider not mounted', state: 'unavailable' }, + intent: { reason: 'Intent state unavailable: the agent-topology provider is not mounted.', state: 'unavailable' }, + }; + export default async function Status({ input, }: ToolRouteProps) { - const agents = await agentTree(); - const intentResult = await withIntent(async (store) => store.read()); + const { agents, intent: intentResult } = topologyOf((await agent()).providers); const notices = await publishedNotices(); let result: StatusResult; if (intentResult.state === 'unavailable') { @@ -135,7 +146,7 @@ export default async function Status({ state: 'unavailable', }; } else { - const { revision, state: intent } = intentResult.value; + const { revision, value: intent } = intentResult.value; const bindings = input.actorId === undefined ? intent.bindings : intent.bindings.filter((binding) => binding.actorId === input.actorId); diff --git a/examples/worktree-proximity/src/providers/agent-topology.ts b/examples/worktree-proximity/src/providers/agent-topology.ts index ba2335799..c0d3d1033 100644 --- a/examples/worktree-proximity/src/providers/agent-topology.ts +++ b/examples/worktree-proximity/src/providers/agent-topology.ts @@ -1,22 +1,50 @@ -export interface AgentTopologyProviderValue { - readonly reason: string; - readonly state: 'unavailable'; -} +import type { AgentProviderContext } from 'agent-bundle'; + +import type { CapabilityResult } from '../coordination.js'; +import { agentTreeOf, type AgentTreeView } from '../event-support.js'; +import { IntentStateSchema, type IntentState } from '../state.js'; /** - * The issue sketch places the agent tree at `providers.agentTopology`. The - * tree itself is now on the request — `(await agent()).lineage.value.tree` - * lists the live siblings, children, and other roots the runtime's registry - * holds (agent-bundle#457) — but a conventional provider factory still - * receives only `{ invocation, signal }`, not the request's `lineage` - * (agent-bundle#459), so this provider cannot derive that view. Routes read - * `request.lineage` directly (`agentTree()` in `event-support.ts`); this - * provider reports the gap honestly rather than inventing a tree. + * The whole-tree view the coordinator reports, assembled once per request + * from what the framework hands a provider (agent-bundle#459): the agent tree + * the runtime's lineage registry resolved for this request — own chain plus + * the live siblings, children, and other roots (agent-bundle#457) — and a + * read of the mounted intent state (worktree bindings, activities, refusals). + * Each half carries its own availability; nothing here is guessed, and the + * provider can only read: `state.dispatch` and `notices.publish` are not on + * the provider context. */ -export default function agentTopologyProvider(): AgentTopologyProviderValue { - return { - reason: - 'The agent tree is on request.lineage.tree; providers receive no request lineage (agent-bundle#459), so read it from (await agent()).lineage in a route.', - state: 'unavailable', - }; +export interface AgentTopologyProviderValue { + readonly agents: AgentTreeView; + readonly intent: CapabilityResult<{ readonly revision: number; readonly value: IntentState }>; +} + +export default async function agentTopologyProvider( + context: AgentProviderContext, +): Promise { + const agents = agentTreeOf(context.lineage); + if (context.state === undefined) { + return { + agents, + intent: { reason: 'Intent state unavailable: this surface mounts no state handle.', state: 'unavailable' }, + }; + } + try { + const snapshot = await context.state.read({ signal: context.signal }); + const parsed = IntentStateSchema.safeParse(snapshot.state); + return { + agents, + intent: parsed.success + ? { state: 'available', value: { revision: snapshot.revision, value: parsed.data } } + : { reason: 'Intent state unavailable: the mounted state is not the worktree-proximity intent definition.', state: 'unavailable' }, + }; + } catch (error) { + return { + agents, + intent: { + reason: `Intent state unavailable: ${error instanceof Error ? error.message : String(error)}`, + state: 'unavailable', + }, + }; + } } diff --git a/examples/worktree-proximity/tests/route-unit/routes.test.ts b/examples/worktree-proximity/tests/route-unit/routes.test.ts index ee489e6a2..a2b19176f 100644 --- a/examples/worktree-proximity/tests/route-unit/routes.test.ts +++ b/examples/worktree-proximity/tests/route-unit/routes.test.ts @@ -17,7 +17,7 @@ import { } from 'agent-bundle/test'; import BeforeTool from '../../src/events/tool/before.js'; -import agentTopologyProvider from '../../src/providers/agent-topology.js'; +import agentTopologyProvider, { type AgentTopologyProviderValue } from '../../src/providers/agent-topology.js'; import type { IntentEvents, IntentState } from '../../src/state.js'; const manifest = testManifest(); @@ -46,13 +46,32 @@ const provider = (root: string) => ({ // The generated `.agent-bundle/routes.d.ts` (in this project's tsconfig program) // makes every declared provider key required on an explicit map, so the fixture -// carries `agentTopology` too; its factory is pure and reports the same honest -// unavailable value the harness would mount. -const providers = (root: string) => ({ - agentTopology: agentTopologyProvider(), +// carries `agentTopology` too. Event routes never read it, so they get an +// honest unavailable snapshot; the coordinator tests run the real factory over +// the request view the harness would hand it (#459). +const noTopology: AgentTopologyProviderValue = { + agents: { reason: 'fixture: not resolved', state: 'unavailable' }, + intent: { reason: 'fixture: not read', state: 'unavailable' }, +}; +const providers = (root: string, agentTopology: AgentTopologyProviderValue = noTopology) => ({ + agentTopology, gitWorktree: provider(root), }); +/** Runs the real `agent-topology` factory over the read-only request view a generated scope hands it. */ +const topologyFor = ( + lineage: Observed = { reason: 'not-provided', state: 'unavailable' }, +) => agentTopologyProvider({ + host: { reason: 'not-provided', state: 'unavailable' }, + invocation: { kind: 'tool', props: { input: {}, operationId: 'tool:coordinator/status' } }, + lineage, + plugin: { reason: 'not-provided', state: 'unavailable' }, + session: { reason: 'not-provided', state: 'unavailable' }, + signal: new AbortController().signal, + state: mounted.state, + workspace: { reason: 'not-provided', state: 'unavailable' }, +}); + // The harness projects the envelope into `canonical.payload` exactly as the // artifact does; the journeys keep their own readable idempotency keys and // clock. `validate: false` admits the deliberately partial envelopes below. @@ -497,7 +516,7 @@ describe('worktree proximity journeys', () => { const rendered = await renderRoute('tool:coordinator/status', { context: { ...mounted.context(), - providers: providers(worktrees.root), + providers: providers(worktrees.root, await topologyFor()), }, input: {}, }); @@ -524,11 +543,12 @@ describe('worktree proximity journeys', () => { }); it('renders the live agent tree the runtime resolved for the call, never a tree of its own (#457)', async () => { + const lineage = childLineageWithTree('agent-a', ['agent-b']); const rendered = await renderRoute('tool:coordinator/status', { context: { ...mounted.context(), - lineage: childLineageWithTree('agent-a', ['agent-b']), - providers: providers(worktrees.a), + lineage, + providers: providers(worktrees.a, await topologyFor(lineage)), }, input: {}, }); @@ -625,12 +645,12 @@ describe('worktree proximity journeys', () => { // An MCP tool call from the same agent: the client name, MCP session id, // and server cwd all differ from the hook that published — the lineage // conversation is what identifies the publisher. - const statusFor = (conversation: string) => renderRoute('tool:coordinator/status', { + const statusFor = async (conversation: string) => renderRoute('tool:coordinator/status', { context: { ...mounted.context(), host: available({ name: 'claude-code' }, 'native'), lineage: childLineage(conversation), - providers: providers(worktrees.root), + providers: providers(worktrees.root, await topologyFor(childLineage(conversation))), session: available({ sessionId: 'mcp-session' }, 'native'), workspace: available({ root: worktrees.root }, 'derived'), }, @@ -674,6 +694,32 @@ describe('worktree proximity journeys', () => { expect((await noticesOf('agent-b', worktrees.b)).inbox).toEqual([]); }); + it('assembles the agent-topology provider value from the request view: lineage tree plus a read of the intent state (#459)', async () => { + await bindActors(); + await recordIntent('agent-a', worktrees.a, 'src/shared.ts', 'intent:a'); + const topology = await topologyFor(childLineageWithTree('agent-b', ['agent-a'])); + expect(topology.agents).toMatchObject({ conversation: 'agent-b', siblings: [rootPeer, childPeer('agent-a')], state: 'available' }); + expect(topology.intent.state).toBe('available'); + if (topology.intent.state !== 'available') throw new Error('unreachable'); + expect(topology.intent.value.value.bindings.map((binding) => binding.actorId)).toEqual(['root-session', 'agent-a', 'agent-b']); + expect(topology.intent.value.value.activities.map((activity) => activity.actorId)).toEqual(['agent-a']); + expect(topology.intent.value.revision).toBeGreaterThan(0); + // Without a mounted state handle the provider says so instead of opening a store of its own. + const stateless = await agentTopologyProvider({ + host: { reason: 'not-provided', state: 'unavailable' }, + invocation: { kind: 'cli', props: { args: [], command: 'status' } }, + lineage: { reason: 'unsupported-surface', state: 'unavailable' }, + plugin: { reason: 'not-provided', state: 'unavailable' }, + session: { reason: 'not-provided', state: 'unavailable' }, + signal: new AbortController().signal, + workspace: { reason: 'not-provided', state: 'unavailable' }, + }); + expect(stateless).toEqual({ + agents: { reason: 'lineage unavailable (unsupported-surface)', state: 'unavailable' }, + intent: { reason: 'Intent state unavailable: this surface mounts no state handle.', state: 'unavailable' }, + }); + }); + it('renders state unavailability when an event module has no mounted handle', async () => { const rendered = await renderRoute({ default: BeforeTool }, { context: { diff --git a/packages/agent-bundle/fixtures/route-harness/src/cli/tooling/inspect.ts b/packages/agent-bundle/fixtures/route-harness/src/cli/tooling/inspect.ts index 7d509a2e1..832aea477 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/cli/tooling/inspect.ts +++ b/packages/agent-bundle/fixtures/route-harness/src/cli/tooling/inspect.ts @@ -12,6 +12,7 @@ export const resultSchema = z.object({ keys: z.array(z.string()), libraryTooling: z.unknown().optional(), processLifetime: z.object({ hits: z.number().int().min(1), instanceId: z.string(), pid: z.number().int() }).strict(), + requestView: z.unknown().optional(), }).strict(); export default async function inspect(_props: CliRouteProps) { @@ -20,5 +21,6 @@ export default async function inspect(_props: CliRouteProps) keys: Object.keys(providers).sort(), libraryTooling: providers['libraryTooling'], processLifetime: providers['processLifetime'], + requestView: providers['requestView'], }; } diff --git a/packages/agent-bundle/fixtures/route-harness/src/cli/tooling/report.tsx b/packages/agent-bundle/fixtures/route-harness/src/cli/tooling/report.tsx index 13b43e542..498c04565 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/cli/tooling/report.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/cli/tooling/report.tsx @@ -11,11 +11,16 @@ export const inputSchema = z.object({}).strict(); export const resultSchema = z.object({ keys: z.array(z.string()), libraryTooling: z.unknown().optional(), + requestView: z.unknown().optional(), }).strict(); export default async function ToolingReport(_props: CliRouteProps) { const { providers } = await agent(); - const value = { keys: Object.keys(providers).sort(), libraryTooling: providers['libraryTooling'] as JsonValue }; + const value = { + keys: Object.keys(providers).sort(), + libraryTooling: providers['libraryTooling'] as JsonValue, + requestView: providers['requestView'] as JsonValue, + }; return ( {`tooling: ${JSON.stringify(providers['libraryTooling'])}`} diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/tooling.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/tooling.tsx index 95b1d5a2c..4e587d171 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/tooling.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/tooling.tsx @@ -10,12 +10,15 @@ export const config = { export const inputSchema = z.object({ /** Makes the `library-tooling` provider throw, to prove the request fails closed. */ failProvider: z.boolean().optional(), + /** Makes the `request-view` provider read `notices.inbox()`, which records an exposure receipt. */ + inbox: z.boolean().optional(), }).strict(); export const resultSchema = z.object({ keys: z.array(z.string()), libraryTooling: z.unknown().optional(), processLifetime: z.object({ hits: z.number(), instanceId: z.string(), pid: z.number() }).optional(), + requestView: z.unknown().optional(), }).strict(); export default async function Tooling() { @@ -25,6 +28,7 @@ export default async function Tooling() { keys: Object.keys(providers).sort(), libraryTooling: providers['libraryTooling'] as JsonValue, ...(processLifetime === undefined ? {} : { processLifetime: { ...processLifetime } }), + requestView: providers['requestView'] as JsonValue, }; return ( diff --git a/packages/agent-bundle/fixtures/route-harness/src/providers/request-view.ts b/packages/agent-bundle/fixtures/route-harness/src/providers/request-view.ts new file mode 100644 index 000000000..88641a028 --- /dev/null +++ b/packages/agent-bundle/fixtures/route-harness/src/providers/request-view.ts @@ -0,0 +1,50 @@ +import { AgentRequestError, useAgent } from '@agent-bundle/runtime'; +import type { AgentProviderContext } from 'agent-bundle'; + +/** + * A conventional provider that reports what the request view hands it (#459): + * the identity axes and lineage as the route will read them, the read-only + * `state`/`notices` handles (their keys prove the narrowing; `state.read()` is + * a pure read), and the runtime error `useAgent()` raises because providers + * run outside the request's async context. `notices.inbox()` records an + * exposure receipt on the ledger, so it runs only when a tool input asks. + */ +export default async function requestView(context: AgentProviderContext) { + let handle: string; + try { + useAgent(); + handle = 'reachable'; + } catch (error) { + handle = error instanceof AgentRequestError ? error.code : 'unexpected'; + } + const inbox = context.invocation.kind === 'tool' + && typeof context.invocation.props.input === 'object' + && context.invocation.props.input !== null + && (context.invocation.props.input as { readonly inbox?: unknown }).inbox === true; + return { + handle, + host: context.host.state === 'available' ? context.host.value.name : context.host.reason, + lineage: context.lineage.state === 'available' + ? { + conversation: context.lineage.value.conversation, + depth: context.lineage.value.depth, + siblings: context.lineage.value.tree?.siblings.map((peer) => peer.conversation) ?? null, + } + : context.lineage.reason, + notices: context.notices === undefined + ? null + : { + keys: Object.keys(context.notices).sort(), + ...(inbox ? { inbox: (await context.notices.inbox()).map((notice) => notice.id) } : {}), + }, + session: context.session.state === 'available' ? context.session.value.sessionId : context.session.reason, + state: context.state === undefined + ? null + : { + keys: Object.keys(context.state).sort(), + lifetime: context.state.lifetime, + revision: (await context.state.read({ signal: context.signal })).revision, + }, + workspace: context.workspace.state === 'available' ? context.workspace.value.root : context.workspace.reason, + }; +} diff --git a/packages/agent-bundle/fixtures/route-harness/src/scripts/tooling-summary.tsx b/packages/agent-bundle/fixtures/route-harness/src/scripts/tooling-summary.tsx index d134b2e42..1bfd9baa0 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/scripts/tooling-summary.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/scripts/tooling-summary.tsx @@ -5,6 +5,7 @@ export const resultSchema = z.object({ arguments: z.number().int().nonnegative(), keys: z.array(z.string()), libraryTooling: z.unknown().optional(), + requestView: z.unknown().optional(), }).strict(); export default async function ToolingSummary({ argv, signal }: { @@ -17,6 +18,7 @@ export default async function ToolingSummary({ argv, signal }: { arguments: argv.length, keys: Object.keys(providers).sort(), libraryTooling: providers['libraryTooling'] as JsonValue, + requestView: providers['requestView'] as JsonValue, }; return ( diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index e7f14e613..2ac80d076 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -368,8 +368,8 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) '};', '', // Plain commands mount the same conventional providers as every other - // generated request scope (#313): once per request, in deterministic key - // order, fail-closed, before the typed Agent request context opens. + // generated request scope (#313, #459): once per request, in deterministic + // key order, fail-closed, as the typed Agent request's own resolver. 'const execute = async (command, input, context) => {', ' const route = routes[command.routeId];', " if (route === undefined || typeof route.module.default !== 'function') throw new TypeError('Generated CLI route must default-export an async function.');", @@ -379,12 +379,6 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) ...(options.state === undefined ? [] : [' const bindings = await runtimeState.requestBindings({ signal: context.signal });', ' try {']), - ...providerExecutionSource(providers, { - indent: plainIndent, - invocation: "{ kind: 'cli', props: { args: context.args, command: command.path.join(' ') } }", - plugin: 'pluginRoot.identity', - signal: 'context.signal', - }), `${plainIndent}const result = await runAgentRequest({`, ' capabilities: {', ' command: unavailable(),', @@ -397,7 +391,10 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) " lineage: unavailable('unsupported-surface'),", ...(options.state === undefined ? [] : [' noticeLedger: bindings.noticeLedger,']), ' plugin: pluginRoot.identity,', - ` providers: ${providerValuesExpression(providers)},`, + ...providersFieldSource(providers, { + indent: ' ', + invocation: "{ kind: 'cli', props: { args: context.args, command: command.path.join(' ') } }", + }), ' signal: context.signal,', ...(options.state === undefined ? [] : [' state: bindings.state,']), " terminal: available(context.terminal, 'native'),", @@ -590,7 +587,6 @@ export const generatedRenderedRouteWorkerSource = ( ? [] : [' const bindings = await runtimeState.requestBindings({ signal: controller.signal });']), ...(options.state === undefined ? [] : [' try {']), - ...providerExecutionSource(providers, { indent: ' ', invocation: 'message.invocation', plugin: 'pluginRoot.identity', signal: 'controller.signal' }), ' await runAgentRequest({', ' capabilities: {', ' command: unavailable(),', @@ -604,7 +600,7 @@ export const generatedRenderedRouteWorkerSource = ( ...(options.state === undefined ? [] : [' noticeLedger: bindings.noticeLedger,']), ' plugin: pluginRoot.identity,', " progress: { report: async (update) => { parentPort.postMessage({ id: message.id, type: 'progress', update }); } },", - ` providers: ${providerValuesExpression(providers)},`, + ...providersFieldSource(providers, { indent: ' ', invocation: 'message.invocation' }), ' signal: controller.signal,', ...(options.state === undefined ? [] : [' state: bindings.state,']), // The executable probed its terminal once and forwards the value; a worker @@ -857,45 +853,45 @@ const processHitSource = (indent: string): readonly string[] => [ const processLifetimeValueSource = 'processHit'; /** - * Per-request provider execution shared by every generated request scope - * (shared Flight worker, rendered CLI/script worker, plain routed CLI): once - * per request, sequentially in deterministic key order, fail-closed on a - * missing factory or a thrown/rejected factory, with the framework-owned - * `processLifetime` value seeded first. The emitted loop mirrors - * `executeProviders` in `../routes/provider-execution.ts`, which the - * in-process test harness runs; `entry-shell.test.ts` pins the two together. + * The `providers` field of every generated `runAgentRequest` init (shared + * Flight worker, rendered CLI/script worker, plain routed CLI): only the + * framework-owned process identity for a project without providers, otherwise + * the request's provider resolver (#459) — run by the runtime once per + * request, after the identity axes are frozen and the notice lease is open, + * before the route; sequentially in deterministic key order; fail-closed on a + * missing factory or a thrown/rejected factory; `processLifetime` seeded + * first. Each factory receives the runtime's read-only request view (`host`, + * `session`, `workspace`, `lineage`, `plugin`, `signal`, and the + * `read`/`inbox`-only `state`/`notices` handles) spread beside the + * surface-specific `invocation`. + * The emitted loop mirrors `executeProviders` in + * `../routes/provider-execution.ts`, which the in-process test harness runs; + * `entry-shell.test.ts` pins the two together. */ -const providerExecutionSource = ( +const providersFieldSource = ( providers: readonly CompiledProvider[], - expressions: { - readonly indent: string; - readonly invocation: string; - /** The observed plugin root the request scope publishes (#468); providers receive the same value. */ - readonly plugin: string; - readonly signal: string; - }, + expressions: { readonly indent: string; readonly invocation: string }, ): readonly string[] => { - if (providers.length === 0) return []; - const { indent, invocation, plugin, signal } = expressions; + const { indent, invocation } = expressions; + if (providers.length === 0) return [`${indent}providers: { processLifetime: ${processLifetimeValueSource} },`]; return [ - `${indent}const providerValues = { processLifetime: ${processLifetimeValueSource} };`, - `${indent}for (const provider of providers) {`, - `${indent} if (typeof provider.module.default !== 'function') {`, - `${indent} throw new TypeError(\`Context provider "\${provider.key}" (\${provider.source}) must default-export a factory.\`);`, - `${indent} }`, - `${indent} try {`, - `${indent} providerValues[provider.key] = await provider.module.default({ invocation: ${invocation}, plugin: ${plugin}, signal: ${signal} });`, - `${indent} } catch (error) {`, - `${indent} throw new Error(\`Context provider "\${provider.key}" (\${provider.source}) failed: \${error instanceof Error ? error.message : String(error)}\`, { cause: error });`, + `${indent}providers: async (request) => {`, + `${indent} const providerValues = { processLifetime: ${processLifetimeValueSource} };`, + `${indent} for (const provider of providers) {`, + `${indent} if (typeof provider.module.default !== 'function') {`, + `${indent} throw new TypeError(\`Context provider "\${provider.key}" (\${provider.source}) must default-export a factory.\`);`, + `${indent} }`, + `${indent} try {`, + `${indent} providerValues[provider.key] = await provider.module.default({ ...request, invocation: ${invocation} });`, + `${indent} } catch (error) {`, + `${indent} throw new Error(\`Context provider "\${provider.key}" (\${provider.source}) failed: \${error instanceof Error ? error.message : String(error)}\`, { cause: error });`, + `${indent} }`, `${indent} }`, - `${indent}}`, + `${indent} return providerValues;`, + `${indent}},`, ]; }; -/** The `providers` request-scope value: the executed map, or only the framework-owned process identity. */ -const providerValuesExpression = (providers: readonly CompiledProvider[]): string => - providers.length === 0 ? `{ processLifetime: ${processLifetimeValueSource} }` : 'providerValues'; - /** The long-lived react-server worker used by one generated MCP process. */ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWorkerOptions): string => { const routes = executableMcpRoutes(options.routes); @@ -951,17 +947,15 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo ...(options.state === undefined ? [] : [' const bindings = await runtimeState.requestBindings({ signal: controller.signal });', ' try {']), - ' const plugin = message.plugin ?? pluginRoot.identity;', - ...providerExecutionSource(providers, { indent: ' ', invocation: 'message.invocation', plugin: 'plugin', signal: 'controller.signal' }), ' const bytes = await runAgentRequest({', ' ...(message.actor === undefined ? {} : { actor: message.actor }),', ' ...(message.host === undefined ? {} : { host: message.host }),', ' invocation: { ...message.requestInvocation, artifactEpoch: ARTIFACT_EPOCH, kind: message.invocation.kind, operationId: route.id, surface: route.name },', " lineage: message.lineage ?? unavailable('not-provided'),", ...(options.state === undefined ? [] : [' noticeLedger: bindings.noticeLedger,']), - ' plugin,', + ' plugin: message.plugin ?? pluginRoot.identity,', ' progress: { report: async (update) => { parentPort.postMessage({ id: message.id, type: \'progress\', update }); } },', - ` providers: ${providerValuesExpression(providers)},`, + ...providersFieldSource(providers, { indent: ' ', invocation: 'message.invocation' }), ' ...(message.session === undefined ? {} : { session: message.session }),', ' signal: controller.signal,', ...(options.state === undefined ? [] : [' state: bindings.state,']), diff --git a/packages/agent-bundle/src/index.ts b/packages/agent-bundle/src/index.ts index 3e29cce32..5a5fd0592 100644 --- a/packages/agent-bundle/src/index.ts +++ b/packages/agent-bundle/src/index.ts @@ -48,8 +48,18 @@ export type { AgentLayoutRouteKind, AgentProviderContext, AgentProviderFactory, + AgentProviderLineage, + AgentProviderLineagePeer, + AgentProviderLineageResolution, + AgentProviderLineageSubagent, + AgentProviderLineageTree, + AgentProviderNotice, + AgentProviderNoticesHandle, + AgentProviderObserved, AgentProviderObservedPluginRoot, AgentProviderPluginRoot, + AgentProviderStateHandle, + AgentProviderStateSnapshot, AgentTerminal, AgentTerminalColor, AgentTerminalStream, diff --git a/packages/agent-bundle/src/routes/index.ts b/packages/agent-bundle/src/routes/index.ts index 4af02fa8f..25816aaf6 100644 --- a/packages/agent-bundle/src/routes/index.ts +++ b/packages/agent-bundle/src/routes/index.ts @@ -82,8 +82,18 @@ export type { AgentLayoutRouteKind, AgentProviderContext, AgentProviderFactory, + AgentProviderLineage, + AgentProviderLineagePeer, + AgentProviderLineageResolution, + AgentProviderLineageSubagent, + AgentProviderLineageTree, + AgentProviderNotice, + AgentProviderNoticesHandle, + AgentProviderObserved, AgentProviderObservedPluginRoot, AgentProviderPluginRoot, + AgentProviderStateHandle, + AgentProviderStateSnapshot, AppRouteConfig, CanonicalAgentEvent, CliRouteConfig, diff --git a/packages/agent-bundle/src/routes/provider-execution.ts b/packages/agent-bundle/src/routes/provider-execution.ts index a9d2cdd99..076caf0c0 100644 --- a/packages/agent-bundle/src/routes/provider-execution.ts +++ b/packages/agent-bundle/src/routes/provider-execution.ts @@ -63,21 +63,42 @@ export interface ExecutableProvider { readonly source: string; } +/** + * The read-only request view `runAgentRequest` hands a provider resolver + * (#459): the runtime's `AgentProviderRequest`, spelled structurally so this + * module — emitted into generated shells and imported by the harness — stays + * free of the optional runtime peer's declarations. Every member is spread + * onto the factory context verbatim, beside the surface-specific + * `invocation`; `signal` is the same request signal the scope opened with, + * and `plugin` the observed root the scope published (#468). + */ +export interface ProviderRequestView { + readonly host: unknown; + readonly lineage: unknown; + readonly notices?: unknown; + readonly plugin: unknown; + readonly session: unknown; + readonly signal: AbortSignal; + readonly state?: unknown; + readonly workspace: unknown; +} + export interface ExecuteProvidersOptions { /** The surface-specific provider invocation (`tool`, `event`, `cli`, `script`). */ readonly invocation: unknown; - /** The observed plugin root the request scope publishes (#468); handed to every factory unchanged. */ - readonly plugin: unknown; readonly processLifetime: ProviderProcessLifetime; /** Providers already in {@link orderedProviders} order. */ readonly providers: readonly ExecutableProvider[]; - readonly signal: AbortSignal; + /** The request view `runAgentRequest` resolved; the factory context is this plus `invocation`. */ + readonly request: ProviderRequestView; } /** * Executes conventional providers for one request exactly as a generated - * request scope does. The caller increments `processLifetime.hits` before the - * call, as every generated scope does before its provider loop. + * request scope does: as the request's provider resolver, after its identity + * axes are frozen and its notice lease is open, before the route runs. The + * caller increments `processLifetime.hits` before the call, as every generated + * scope does before its request opens. */ export const executeProviders = async ( options: ExecuteProvidersOptions, @@ -91,11 +112,10 @@ export const executeProviders = async ( throw new TypeError(providerFactoryMissingMessage(provider.key, provider.source)); } try { - values[provider.key] = await (factory as (context: { - readonly invocation: unknown; - readonly plugin: unknown; - readonly signal: AbortSignal; - }) => unknown)({ invocation: options.invocation, plugin: options.plugin, signal: options.signal }); + values[provider.key] = await (factory as (context: ProviderRequestView & { readonly invocation: unknown }) => unknown)({ + ...options.request, + invocation: options.invocation, + }); } catch (error) { throw new Error(providerFailedMessage(provider.key, provider.source, error), { cause: error }); } diff --git a/packages/agent-bundle/src/routes/public.ts b/packages/agent-bundle/src/routes/public.ts index 89747cb71..2ba897407 100644 --- a/packages/agent-bundle/src/routes/public.ts +++ b/packages/agent-bundle/src/routes/public.ts @@ -97,31 +97,140 @@ type AgentProviderInvocation = readonly props: { readonly input?: JsonValue; readonly view: string }; }; +/** + * An observed request axis as a provider receives it: the same shape as every + * `agent()` identity axis. Declared here so config-only consumers need no + * `@agent-bundle/runtime` import; structurally the runtime's `Observed`. + */ +export type AgentProviderObserved = + | { readonly source: 'native' | 'receipt' | 'derived'; readonly state: 'available'; readonly value: Value } + | { readonly reason: string; readonly state: 'unavailable' }; + /** * The plugin install root and durable-state anchor a generated scope resolved * (#468), as `(await agent()).plugin` observes it: `root` is the expanded * `AGENT_BUNDLE_PLUGIN_ROOT` (`source: 'native'`) or the shell's fallback * (`'derived'`), and `stateRoot` is `/state`, where the SQLite kernel, - * the notice ledger, and the lineage journal live. Declared here so - * config-only consumers need no runtime import; structurally identical to the - * runtime's `AgentPluginIdentity`. + * the notice ledger, and the lineage journal live. Structurally identical to + * the runtime's `AgentPluginIdentity`. */ export interface AgentProviderPluginRoot { readonly root: string; readonly stateRoot: string; } -/** The observed plugin root a provider receives; the same shape as every `agent()` identity axis. */ -export type AgentProviderObservedPluginRoot = - | { readonly source: 'native' | 'receipt' | 'derived'; readonly state: 'available'; readonly value: AgentProviderPluginRoot } - | { readonly reason: string; readonly state: 'unavailable' }; +/** The observed plugin root a provider receives. */ +export type AgentProviderObservedPluginRoot = AgentProviderObserved; + +/** Structurally the runtime's `AgentLineageSubagent`: the host's own id for a subagent and what it knows about the spawn. */ +export interface AgentProviderLineageSubagent { + readonly id: string; + readonly isParallelWorker?: boolean; + readonly toolCallId?: string; + readonly type?: string; +} + +/** Structurally the runtime's `AgentLineageResolution`: how the runtime arrived at a lineage's placement. */ +export type AgentProviderLineageResolution = 'native' | 'registry' | 'confirmed' | 'transcript' | 'inferred'; + +/** Structurally the runtime's `AgentLineagePeer` (#457): one other live conversation in the registry's tree. */ +export interface AgentProviderLineagePeer { + readonly conversation: string; + readonly depth: number; + readonly parent?: string; + readonly resolution: AgentProviderLineageResolution; + readonly startedAt: string; + readonly subagent?: AgentProviderLineageSubagent; +} + +/** Structurally the runtime's `AgentLineageTree` (#457): `children`, other live `roots`, and every live `siblings` under the same root. */ +export interface AgentProviderLineageTree { + readonly children: readonly AgentProviderLineagePeer[]; + readonly roots: readonly AgentProviderLineagePeer[]; + readonly siblings: readonly AgentProviderLineagePeer[]; +} + +/** + * Structurally the runtime's `AgentLineage`: the request's own chain + * (`conversation`, `parent`, `root`, `depth`) plus, when the warm runtime's + * registry placed it, the live `tree` around it. + */ +export interface AgentProviderLineage { + readonly conversation: string; + readonly depth: number; + readonly generation?: string; + readonly parent?: string; + readonly resolution: AgentProviderLineageResolution; + readonly root: string; + readonly subagent?: AgentProviderLineageSubagent; + readonly tree?: AgentProviderLineageTree; +} -/** Request-scoped inputs supplied to a conventional context provider factory. */ +/** The snapshot a provider's `state.read()` resolves; structurally the runtime's `AgentStateSnapshot`. */ +export interface AgentProviderStateSnapshot { + readonly revision: number; + readonly state: TState; +} + +/** + * The read-only view of the project's mounted state handle a provider receives + * (#459): the runtime's `AgentStateHandle` narrowed to `lifetime` and `read` + * by construction, so a provider can derive a view of shared state but never + * dispatch. Absent for stateless projects. + */ +export interface AgentProviderStateHandle { + readonly lifetime: 'request' | 'process' | 'workspace-durable' | 'external'; + read(options?: { readonly revision?: number; readonly signal?: AbortSignal }): Promise>; +} + +/** + * The fields of a notice a provider may rely on; at run time each element is + * the runtime's full `AgentNotice`, and every notice `inbox()` resolves is + * `pending`. + */ +export interface AgentProviderNotice { + readonly createdAt: string; + readonly dedupeKey?: string; + readonly id: string; + readonly priority: 'low' | 'normal' | 'high'; + readonly state: 'pending' | 'attempted' | 'expired' | 'unavailable' | 'withdrawn' | 'acknowledged'; +} + +/** + * The read-only view of the request's notice handle a provider receives + * (#459): the runtime's `AgentNoticesHandle` narrowed to `inbox` by + * construction — pending notices addressed to this request's principal, as + * the `mcp-inbox` route discloses them — never `publish` or `acknowledge`. + * Absent when the project mounts no notice ledger. + */ +export interface AgentProviderNoticesHandle { + inbox(): Promise; +} + +/** + * Request-scoped inputs supplied to a conventional context provider factory. + * Beyond the surface-specific `invocation` and the request `signal`, a + * factory observes the request's identity axes, plugin root, and lineage + * exactly as the route will read them from `await agent()`, plus read-only + * views of the mounted state and notice handles (#459). Providers run as the + * request's own resolver — after its axes are frozen and its notice lease is + * open, before the route — outside the request's async context: `agent()` + * throws `outside-invocation` there, and nothing on this context can dispatch + * state or publish a notice. + */ export interface AgentProviderContext { + readonly host: AgentProviderObserved<{ readonly name: string }>; readonly invocation: AgentProviderInvocation; + readonly lineage: AgentProviderObserved; + /** Present only for projects with a mounted notice ledger. */ + readonly notices?: AgentProviderNoticesHandle; /** The resolved plugin root, exactly what the route will read as `(await agent()).plugin`. */ readonly plugin: AgentProviderObservedPluginRoot; + readonly session: AgentProviderObserved<{ readonly sessionId: string }>; readonly signal: AbortSignal; + /** Present only for projects that declare `src/state.ts`. */ + readonly state?: AgentProviderStateHandle; + readonly workspace: AgentProviderObserved<{ readonly root: string }>; } /** Default export contract for one `src/providers/.{ts,tsx}` module. */ diff --git a/packages/agent-bundle/src/test/cli.ts b/packages/agent-bundle/src/test/cli.ts index 026b6adbb..fc1f7f5cb 100644 --- a/packages/agent-bundle/src/test/cli.ts +++ b/packages/agent-bundle/src/test/cli.ts @@ -253,14 +253,12 @@ export const invokeCli = async ( const root = process.cwd(); const plugin = harnessPluginRoot({ context, manifest, resolvePluginRoot: runtime.resolvePluginRoot }); // Same provider invocation the generated plain-command path builds (#366). - const providers = await mountProviders({ + const providers = mountProviders({ explicit: context.providers, invocation: { kind: 'cli', props: { args: execution.args, command: commandPath(command) } }, manifest, - plugin, processHit: claimProcessHit(processLifetime), provenance: { ...provenance, kind: 'cli', routeId: command.routeId, source: 'manifest', targets: [] }, - signal: execution.signal, }); const result = await runtime.runAgentRequest({ capabilities: { diff --git a/packages/agent-bundle/src/test/mcp.ts b/packages/agent-bundle/src/test/mcp.ts index 12b36b9e6..e2086b60a 100644 --- a/packages/agent-bundle/src/test/mcp.ts +++ b/packages/agent-bundle/src/test/mcp.ts @@ -470,21 +470,19 @@ export const openInMemoryMcpServer = async < const processHit = claimProcessHit(processLifetime); const bindings = await runtimeState?.requestBindings({ signal: request.signal }); try { - // Conventional providers run before the scope opens, over the same + // Conventional providers run as the scope's resolver, over the same // tool invocation the generated Flight worker hands them. const descriptor = manifest.routes[route.id]; // The server process's anchor, as the artifact's host scope forwards // it into the Flight worker; the context seam overrides it like every // other identity axis. const plugin = transport.plugin.state === 'available' ? transport.plugin : pluginRoot; - const providers = await mountProviders({ + const providers = mountProviders({ explicit: context.providers, invocation: request.invocation, manifest, - plugin, processHit, ...(descriptor === undefined ? {} : { provenance: routeProvenance(descriptor, manifest) }), - signal: request.signal, }); return streamOf(await dependencies.runAgentRequest({ // Mirror the Flight worker boundary while allowing the documented diff --git a/packages/agent-bundle/src/test/providers.ts b/packages/agent-bundle/src/test/providers.ts index 41ac982a9..d8a52aaec 100644 --- a/packages/agent-bundle/src/test/providers.ts +++ b/packages/agent-bundle/src/test/providers.ts @@ -1,6 +1,12 @@ import { join } from 'node:path'; -import type { AgentPluginIdentity, AgentProviderValues, Observed, resolvePluginRoot } from '@agent-bundle/runtime'; +import type { + AgentPluginIdentity, + AgentProviderResolver, + AgentProviderValues, + Observed, + resolvePluginRoot, +} from '@agent-bundle/runtime'; import { executeProviders, @@ -18,12 +24,16 @@ import type { RenderedRouteProvenance } from './types.ts'; * Conventional request context providers for harness request scopes. * * Every generated request scope discovers `src/providers/*` and executes them - * once per request before `runAgentRequest` (#313, #366). The harness does the - * same for every manifest-backed render, dispatch, and in-memory projection, - * through the shared execution helper the generated scopes mirror, so a test - * observes the provider map the artifact would mount. A test that passes - * `context.providers` opts out: the explicit map is used verbatim, exactly as - * the runtime's request contract reads it. + * once per request as the request's own provider resolver (#313, #366, #459): + * after `runAgentRequest` froze the identity axes and opened the notice lease, + * before the route runs, over the runtime's read-only request view. The + * harness does the same for every manifest-backed render, dispatch, and + * in-memory projection, through the shared execution helper the generated + * scopes mirror, so a test observes the provider map the artifact would mount + * and every provider observes the same `lineage`, identity, `state.read()`, + * and `notices.inbox()` the route will. A test that passes `context.providers` + * opts out: the explicit map is used verbatim, exactly as the runtime's + * request contract reads it. * * What the harness simulates per executable is the framework-owned process * identity (`processLifetime`), not module evaluation. Provider modules load @@ -45,15 +55,12 @@ export interface MountProvidersOptions { readonly invocation: unknown; /** Absent for a module rendered directly: no project, so nothing to discover. */ readonly manifest: AgentBundleTestManifest | undefined; - /** The observed plugin root the simulated scope publishes as `request.plugin` (#468). */ - readonly plugin: unknown; /** * This request's claimed hit on the simulated executable's process identity * (see {@link claimProcessHit}); mounted verbatim as `providers.processLifetime`. */ readonly processHit: ProviderProcessLifetimeValue; readonly provenance?: RenderedRouteProvenance; - readonly signal: AbortSignal; } const loadProvider = async ( @@ -116,24 +123,28 @@ export const harnessPluginRoot = (options: HarnessPluginRootOptions): Observed => { +export const mountProviders = (options: MountProvidersOptions): AgentProviderValues | AgentProviderResolver => { if (options.explicit !== undefined) return options.explicit; - if (options.manifest === undefined) { + const manifest = options.manifest; + if (manifest === undefined) { return { processLifetime: options.processHit }; } - const providers: ExecutableProvider[] = []; - for (const descriptor of options.manifest.providers ?? []) { - providers.push(await loadProvider(options.manifest, descriptor, options.provenance)); - } - return executeProviders({ - invocation: options.invocation, - plugin: options.plugin, - processLifetime: { ...options.processHit }, - providers, - signal: options.signal, - }); + return async (request) => { + const providers: ExecutableProvider[] = []; + for (const descriptor of manifest.providers ?? []) { + providers.push(await loadProvider(manifest, descriptor, options.provenance)); + } + return executeProviders({ + invocation: options.invocation, + processLifetime: { ...options.processHit }, + providers, + request, + }); + }; }; diff --git a/packages/agent-bundle/src/test/render.ts b/packages/agent-bundle/src/test/render.ts index 6e2abc5f8..fb5ed6ea9 100644 --- a/packages/agent-bundle/src/test/render.ts +++ b/packages/agent-bundle/src/test/render.ts @@ -1129,14 +1129,12 @@ export const prepareCliRenderHost = async ( requestInit: async (request) => { const root = process.cwd(); const plugin = harnessPluginRoot({ context, manifest: options.manifest, resolvePluginRoot: renderer.resolvePluginRoot }); - const providers = await mountProviders({ + const providers = mountProviders({ explicit: context.providers, invocation, manifest: options.manifest, - plugin, processHit: claimProcessHit(options.processLifetime), provenance: { ...options.provenance, routeId: command.routeId }, - signal: request.signal, }); return { capabilities: { @@ -1330,14 +1328,12 @@ export const prepareScriptRenderHost = async ( // The generated script's render worker hands its providers the // `script` invocation with the path-derived name, never the route id. const plugin = harnessPluginRoot({ context, manifest: options.manifest, resolvePluginRoot: renderer.resolvePluginRoot }); - const providers = await mountProviders({ + const providers = mountProviders({ explicit: context.providers, invocation, manifest: options.manifest, - plugin, processHit: claimProcessHit(options.processLifetime), provenance: options.provenance, - signal: request.signal, }); return { capabilities: { @@ -1460,14 +1456,12 @@ const prepareRender = async ( ...mounted.context, // The render invocation is exactly what the generated Flight worker // receives as `message.invocation`, so providers see the same shape. - providers: await mountProviders({ + providers: mountProviders({ explicit: context.providers, invocation: request.invocation, manifest: resolved.manifest, - plugin, processHit: claimProcessHit(processLifetime), provenance: resolved.provenance, - signal: request.signal, }), invocation: { ...requestInvocation(request.invocation, resolved.provenance.routeId, surface), diff --git a/packages/agent-bundle/tests/cli-routes-build.test.ts b/packages/agent-bundle/tests/cli-routes-build.test.ts index 7a59ed108..d79765619 100644 --- a/packages/agent-bundle/tests/cli-routes-build.test.ts +++ b/packages/agent-bundle/tests/cli-routes-build.test.ts @@ -21,6 +21,25 @@ const writeProjectFile = async (root: string, path: string, contents: string): P await writeFile(output, contents); }; +/** + * What the fixture's `library-tooling` provider observes of the request on + * every generated surface (#459): a routed-CLI executable mounts no host + * conversation, so `host`/`lineage` carry the typed `unsupported-surface` + * reason the route reads too; the process-lifetime `src/state.ts` mounts the + * `read`-only state handle and the `inbox`/`published`-only notice handle; `useAgent()` + * throws `outside-invocation` because the resolver runs outside the request. + */ +const providerView = { + handle: 'outside-invocation', + host: 'unsupported-surface', + lineage: 'unsupported-surface', + notices: ['inbox', 'published'], + plugin: 'available', + session: 'not-provided', + state: { keys: ['lifetime', 'read'], lifetime: 'process', revision: 0 }, + workspace: process.cwd(), +}; + /** * The routed-CLI packaging proof (#102 stage 2): `src/cli/**` routes feed the * existing package-build pipeline as one generated Rslib executable, and the @@ -68,16 +87,50 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 '}', '', ].join('\n')), + // Process-lifetime state, so every generated scope mounts a state handle + // and a notice ledger without touching the plugin root on disk. + writeProjectFile(root, 'src/state.ts', [ + "import { defineState } from '@agent-bundle/runtime/state';", + "import { z } from 'zod';", + 'export default defineState({', + ' events: { noted: z.object({ note: z.string() }).strict() },', + " id: 'cli-bin-fixture/notes',", + ' initial: { notes: [] },', + " lifetime: 'process',", + ' reduce: (state, event) => ({ notes: [...state.notes, event.payload.note] }),', + ' schema: z.object({ notes: z.array(z.string()) }).strict(),', + '});', + '', + ].join('\n')), // A conventional request context provider (#313): every generated request // scope — plain CLI, rendered CLI, projected MCP command, rendered script — - // mounts the same value. + // mounts the same value. Beside the invocation it reports the request view + // the scope resolved it over (#459): the identity axes as the route reads + // them, the read-only state and notice handles, and the runtime error + // `useAgent()` raises because providers run outside the request context. writeProjectFile(root, 'src/providers/library-tooling.ts', [ - 'export default async function libraryTooling({ invocation, signal }) {', + "import { AgentRequestError, useAgent } from '@agent-bundle/runtime';", + 'export default async function libraryTooling(context) {', + ' const { invocation, signal } = context;', " if (signal.aborted) throw new DOMException('aborted', 'AbortError');", + ' let handle;', + " try { useAgent(); handle = 'reachable'; } catch (error) { handle = error instanceof AgentRequestError ? error.code : 'unexpected'; }", + ' const view = {', + ' handle,', + " host: context.host.state === 'available' ? context.host.value.name : context.host.reason,", + " lineage: context.lineage.state === 'available' ? context.lineage.value.conversation : context.lineage.reason,", + ' notices: context.notices === undefined ? null : Object.keys(context.notices).sort(),', + ' plugin: context.plugin.state,', + " session: context.session.state === 'available' ? context.session.value.sessionId : context.session.reason,", + ' state: context.state === undefined', + ' ? null', + ' : { keys: Object.keys(context.state).sort(), lifetime: context.state.lifetime, revision: (await context.state.read()).revision },', + " workspace: context.workspace.state === 'available' ? context.workspace.value.root : context.workspace.reason,", + ' };', // Branching on the documented kind fails loudly if a surface ever posts // no invocation to its worker again (#319 review). " switch (invocation.kind) {", - " case 'cli': case 'script': case 'tool': return { kind: invocation.kind, tool: 'ffprobe 6.1' };", + " case 'cli': case 'script': case 'tool': return { kind: invocation.kind, tool: 'ffprobe 6.1', view };", " default: throw new Error(`unexpected invocation kind ${String(invocation.kind)}`);", ' }', '}', @@ -90,7 +143,7 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 'export const inputSchema = z.object({}).strict();', 'export const resultSchema = z.object({', ' hits: z.number().int().min(1),', - " libraryTooling: z.object({ kind: z.literal('cli'), tool: z.string() }).strict(),", + " libraryTooling: z.object({ kind: z.literal('cli'), tool: z.string(), view: z.unknown() }).strict(),", '}).strict();', 'export default async function tooling() {', ' const context = await agent();', @@ -121,12 +174,12 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 "import { z } from 'zod';", "export const config = { description: 'Render a library report.', positionals: ['root'] };", 'export const inputSchema = z.object({ root: z.string().min(1) }).strict();', - 'export const resultSchema = z.object({ books: z.number(), root: z.string(), tooling: z.string() }).strict();', + 'export const resultSchema = z.object({ books: z.number(), root: z.string(), tooling: z.string(), view: z.unknown() }).strict();', 'export default async function Report({ input, signal }) {', " if (signal.aborted) throw new DOMException('aborted', 'AbortError');", ' const context = await agent();', " await context.progress.report({ completed: 1, message: 'scanning', total: 2 });", - ' const result = { books: 2, root: input.root, tooling: `${context.providers.libraryTooling.kind}:${context.providers.libraryTooling.tool}` };', + ' const result = { books: 2, root: input.root, tooling: `${context.providers.libraryTooling.kind}:${context.providers.libraryTooling.tool}`, view: context.providers.libraryTooling.view };', ' return (', ' ', ' {`Found **2** books under ${input.root}.`}', @@ -149,11 +202,11 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 "import { z } from 'zod';", "export const config = { annotations: { readOnlyHint: true }, description: 'Looks up one value.' };", 'export const inputSchema = z.object({ message: z.string().default("ready") }).strict();', - "export const resultSchema = z.object({ invocation: z.literal('tool'), message: z.string(), operationId: z.string(), tooling: z.string() }).strict();", + "export const resultSchema = z.object({ invocation: z.literal('tool'), message: z.string(), operationId: z.string(), tooling: z.string(), view: z.unknown() }).strict();", 'export default async function Lookup({ input }) {', ' const context = await agent();', " await context.progress.report({ completed: 1, message: 'lookup', total: 1 });", - ' const result = { invocation: context.invocation.kind, message: input.message, operationId: context.invocation.operationId, tooling: `${context.providers.libraryTooling.kind}:${context.providers.libraryTooling.tool}` };', + ' const result = { invocation: context.invocation.kind, message: input.message, operationId: context.invocation.operationId, tooling: `${context.providers.libraryTooling.kind}:${context.providers.libraryTooling.tool}`, view: context.providers.libraryTooling.view };', ' return {`Lookup: ${input.message}`};', '}', '', @@ -186,7 +239,7 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 'export default async function Summarize({ argv, signal }) {', " if (signal.aborted) throw new DOMException('aborted', 'AbortError');", ' const context = await agent();', - ' const result = { arguments: argv.length, tooling: `${context.providers.libraryTooling.kind}:${context.providers.libraryTooling.tool}` };', + ' const result = { arguments: argv.length, tooling: `${context.providers.libraryTooling.kind}:${context.providers.libraryTooling.tool}`, view: context.providers.libraryTooling.view };', ' return (', ' ', ' {`Summarized ${String(argv.length)} arguments.`}', @@ -236,7 +289,7 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 // Plain .ts commands mount conventional providers once per request (#313), // with the framework-owned processLifetime value beside them. const tooling = await execFile(binPath, ['tooling']); - expect(JSON.parse(tooling.stdout)).toEqual({ hits: 1, libraryTooling: { kind: 'cli', tool: 'ffprobe 6.1' } }); + expect(JSON.parse(tooling.stdout)).toEqual({ hits: 1, libraryTooling: { kind: 'cli', tool: 'ffprobe 6.1', view: providerView } }); // Nested commands parse positionals/options and honor the result exit-code policy. const audit = await execFile(binPath, ['library', 'audit', 'a', 'b']); @@ -282,7 +335,7 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 // --json returns the canonical validated final value; the rendered command // observed the same conventional provider as the plain command (#313). const reportJson = await execFile(binPath, ['report', '/library', '--json']); - expect(JSON.parse(reportJson.stdout)).toEqual({ books: 2, root: '/library', tooling: 'cli:ffprobe 6.1' }); + expect(JSON.parse(reportJson.stdout)).toEqual({ books: 2, root: '/library', tooling: 'cli:ffprobe 6.1', view: providerView }); // --ndjson exposes the sequence-numbered render-event stream, including // the progress the component reported through the request context. const reportEvents = await execFile(binPath, ['report', '/library', '--ndjson']); @@ -306,6 +359,7 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 message: 'packed', operationId: 'tool:harness/lookup', tooling: 'tool:ffprobe 6.1', + view: providerView, }); const projectedNdjson = await execFile(binPath, [ 'harness', 'lookup', '--input', '{"message":"events"}', '--ndjson', @@ -347,7 +401,7 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 expect(scriptMarkdown.stdout).toBe('Summarized 2 arguments.\n'); // The rendered script's provider sees `invocation.kind === 'script'` (#313). const scriptJson = await execFile(process.execPath, [scriptPath, 'alpha', '--json']); - expect(JSON.parse(scriptJson.stdout)).toEqual({ arguments: 1, tooling: 'script:ffprobe 6.1' }); + expect(JSON.parse(scriptJson.stdout)).toEqual({ arguments: 1, tooling: 'script:ffprobe 6.1', view: providerView }); // #102 acceptance: one build ships custom, MCP-generated, plain, and rendered commands/scripts. const plainScriptPath = join(root, 'artifact', 'portable', 'scripts', 'checksum.mjs'); diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index a2f1e43d8..d2bbe3b49 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -421,7 +421,7 @@ it('generates the warm react-server Flight worker separately from the MCP dispat expect(source).toContain("lineage: message.lineage ?? unavailable('not-provided'),"); expect(source).toContain("terminal: message.terminal ?? unavailable('not-provided'),"); expect(createHash('sha256').update(source).digest('hex')).toBe( - '93cdfe64b98e0add920ed3f4daa3916620a3f750ec9dbcefc6be6419efab38e5', + 'd9800cd68df3c064363913d1042d1b661b9777ca1b8cfdc85dd15d1de50b7b40', ); expect(generate({ artifactEpoch: 'route-fixture@1.2.3', @@ -603,12 +603,15 @@ it('generates deterministic per-request provider execution in the shared Flight source.indexOf('/project/src/providers/zeta.ts'), ); expect(source).toContain('key: "alphaValue"'); - expect(source).toContain('await provider.module.default({ invocation: message.invocation, plugin: plugin, signal: controller.signal })'); - // The server's observed anchor rides each render message; the worker's own resolution backs it. - expect(source).toContain('const plugin = message.plugin ?? pluginRoot.identity;'); + // The request view (#459) is spread beside the surface invocation; the + // server's observed plugin anchor rides each render message into that view + // through the request init, with the worker's own resolution backing it. + expect(source).toContain('await provider.module.default({ ...request, invocation: message.invocation })'); + expect(source).toContain('plugin: message.plugin ?? pluginRoot.identity,'); expect(source).toContain('Context provider "'); expect(source).toContain('provider.source'); - expect(source).toContain('providers: providerValues'); + expect(source).toContain('providers: async (request) => {'); + expect(source).toContain('return providerValues;'); }); it('mounts deterministic per-request providers for plain routed CLI commands (#313)', () => { @@ -655,14 +658,19 @@ it('mounts deterministic per-request providers for plain routed CLI commands (#3 ); expect(withProviders).toContain('key: "alphaValue"'); expect(withProviders).toContain( - "await provider.module.default({ invocation: { kind: 'cli', props: { args: context.args, command: command.path.join(' ') } }, plugin: pluginRoot.identity, signal: context.signal })", + "await provider.module.default({ ...request, invocation: { kind: 'cli', props: { args: context.args, command: command.path.join(' ') } } })", ); expect(withProviders).toContain('must default-export a factory.'); expect(withProviders).toContain('failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error })'); - expect(withProviders).toContain('providers: providerValues,'); - // Providers run once per request, before the request scope opens. + // Providers run once per request as the request's own resolver (#459): the + // loop is the `providers` field of the `runAgentRequest` init, so the runtime + // runs it after the identity axes are frozen and the notice lease is open. + expect(withProviders).toContain('providers: async (request) => {'); + expect(withProviders.indexOf('const result = await runAgentRequest({')).toBeLessThan( + withProviders.indexOf('for (const provider of providers)'), + ); expect(withProviders.indexOf('for (const provider of providers)')).toBeLessThan( - withProviders.indexOf('const result = await runAgentRequest({'), + withProviders.indexOf('}, async () => route.module.default('), ); // The request's hit is claimed and snapshotted in one synchronous step // before any await, so concurrent requests cannot move each other's value. @@ -712,8 +720,8 @@ it('mounts deterministic per-request providers in rendered route workers', () => expect(source.indexOf('/project/src/providers/alpha-value.ts')).toBeLessThan( source.indexOf('/project/src/providers/zeta.ts'), ); - expect(source).toContain('await provider.module.default({ invocation: message.invocation, plugin: pluginRoot.identity, signal: controller.signal })'); - expect(source).toContain('providers: providerValues'); + expect(source).toContain('await provider.module.default({ ...request, invocation: message.invocation })'); + expect(source).toContain('providers: async (request) => {'); expect(source).toContain('processLifetime'); // The rendered-session bridge must post the invocation the worker's @@ -771,42 +779,52 @@ it('keeps the generated provider loop and the in-process execution helper identi 'provider.source': 'src/providers/alpha-value.ts', })).toBe(providerFailedMessage('alphaValue', 'src/providers/alpha-value.ts', new Error('boom'))); - // Behavior: processLifetime seeded first, deterministic order, fail-closed on both defects. + // Behavior: processLifetime seeded first, deterministic order, fail-closed on both defects, + // and the request view spread onto the factory context beside the surface invocation (#459). const lifetime = { hits: 3, instanceId: 'instance-1', pid: 42 }; + const signal = new AbortController().signal; + const plugin = { source: 'derived', state: 'available', value: { root: '/plugin', stateRoot: '/plugin/state' } } as const; + // The runtime omits `notices`/`state` when the request mounted none; here state is mounted, notices not. + const request = { + host: { source: 'native', state: 'available', value: { name: 'claude' } }, + lineage: { reason: 'not-provided', state: 'unavailable' }, + plugin, + session: { reason: 'not-provided', state: 'unavailable' }, + signal, + state: { lifetime: 'request', read: async () => ({ revision: 0, state: {} }) }, + workspace: { source: 'derived', state: 'available', value: { root: '/w' } }, + } as const; const calls: string[] = []; - const plugin = { source: 'derived', state: 'available', value: { root: '/plugin', stateRoot: '/plugin/state' } }; const values = await executeProviders({ invocation: { kind: 'cli', props: { args: [], command: 'report' } }, - plugin, processLifetime: lifetime, providers: [ { key: 'alphaValue', module: { default: (context: { invocation: unknown; plugin: unknown }) => { calls.push('alphaValue'); return [context.invocation, context.plugin]; } }, source: 'src/providers/alpha-value.ts' }, - { key: 'zeta', module: { default: async () => { calls.push('zeta'); return 'z'; } }, source: 'src/providers/zeta.ts' }, + { key: 'zeta', module: { default: async (context: Record) => { calls.push('zeta'); return Object.keys(context).sort(); } }, source: 'src/providers/zeta.ts' }, ], - signal: new AbortController().signal, + request, }); expect(Object.keys(values)).toEqual(['processLifetime', 'alphaValue', 'zeta']); - // Providers receive the invocation and the observed plugin root (#468) — the same value the request scope publishes. + // Providers receive the surface invocation beside the request view: the observed + // plugin root (#468) and every other axis the request scope publishes (#459). expect(values).toEqual({ alphaValue: [{ kind: 'cli', props: { args: [], command: 'report' } }, plugin], processLifetime: { hits: 3, instanceId: 'instance-1', pid: 42 }, - zeta: 'z', + zeta: ['host', 'invocation', 'lineage', 'plugin', 'session', 'signal', 'state', 'workspace'], }); - expect(source).toContain('await provider.module.default({ invocation: message.invocation, plugin: pluginRoot.identity, signal: controller.signal })'); + expect(source).toContain('await provider.module.default({ ...request, invocation: message.invocation })'); expect(calls).toEqual(['alphaValue', 'zeta']); await expect(executeProviders({ invocation: undefined, - plugin: undefined, processLifetime: lifetime, providers: [{ key: 'zeta', module: {}, source: 'src/providers/zeta.ts' }], - signal: new AbortController().signal, + request, })).rejects.toThrow('Context provider "zeta" (src/providers/zeta.ts) must default-export a factory.'); await expect(executeProviders({ invocation: undefined, - plugin: undefined, processLifetime: lifetime, providers: [{ key: 'zeta', module: { default: () => { throw new Error('boom'); } }, source: 'src/providers/zeta.ts' }], - signal: new AbortController().signal, + request, })).rejects.toThrow('Context provider "zeta" (src/providers/zeta.ts) failed: boom'); }); diff --git a/packages/agent-bundle/tests/projection/providers.test.ts b/packages/agent-bundle/tests/projection/providers.test.ts index 17dbb7b03..6a1f7bef0 100644 --- a/packages/agent-bundle/tests/projection/providers.test.ts +++ b/packages/agent-bundle/tests/projection/providers.test.ts @@ -1,6 +1,8 @@ import { setTimeout as sleep } from 'node:timers/promises'; import { describe, expect, it } from '@rstest/core'; +import { available } from '@agent-bundle/runtime'; +import { createAgentLineageRegistry } from '@agent-bundle/runtime/lineage'; import { createMemoryStateDriver, defineState, type AgentStateDriver } from '@agent-bundle/runtime/state'; import { z } from 'zod'; @@ -18,17 +20,47 @@ import { testManifest } from '../../src/test/registry.ts'; * `processLifetime`. A test that passes `context.providers` opts out and the * explicit map is used verbatim. */ +const keys = ['libraryTooling', 'processLifetime', 'requestView']; + +/** + * What the `request-view` fixture provider reports on a surface that mounts + * no host conversation (#459): the identity axes as the route reads them, the + * runtime error `useAgent()` raises inside a provider, and — where the harness + * mounts state — the `read`-only state handle and the `inbox`-only notice + * handle, nothing more. + */ +const requestView = (surface: { readonly host?: string; readonly mounted: boolean; readonly workspace?: string }) => ({ + handle: 'outside-invocation', + host: surface.host ?? 'unsupported-surface', + lineage: 'not-provided', + notices: surface.mounted ? { keys: ['inbox'] } : null, + session: 'not-provided', + state: surface.mounted ? { keys: ['lifetime', 'read'], lifetime: 'workspace-durable', revision: 0 } : null, + workspace: surface.workspace ?? process.cwd(), +}); +/** A route-unit render injects no identity unless the test does, so a provider sees the typed absence the route sees. */ +const routeUnitView = requestView({ host: 'not-provided', mounted: true, workspace: 'not-provided' }); + describe('conventional providers through the harness', () => { it('names the compiled providers in the manifest in the generated execution order', () => { const manifest = testManifest(); - expect(manifest.providers).toEqual([{ - id: 'provider:library-tooling', - key: 'libraryTooling', - name: 'library-tooling', - relativePath: 'src/providers/library-tooling.ts', - source: expect.stringMatching(/route-harness[\\/]src[\\/]providers[\\/]library-tooling\.ts$/u), - }]); + expect(manifest.providers).toEqual([ + { + id: 'provider:library-tooling', + key: 'libraryTooling', + name: 'library-tooling', + relativePath: 'src/providers/library-tooling.ts', + source: expect.stringMatching(/route-harness[\\/]src[\\/]providers[\\/]library-tooling\.ts$/u), + }, + { + id: 'provider:request-view', + key: 'requestView', + name: 'request-view', + relativePath: 'src/providers/request-view.ts', + source: expect.stringMatching(/route-harness[\\/]src[\\/]providers[\\/]request-view\.ts$/u), + }, + ]); }); it('mounts providers for a plain routed CLI command with the cli invocation', async () => { @@ -37,9 +69,11 @@ describe('conventional providers through the harness', () => { expect(run.exitCode).toBe(0); expect(run.stderr).toBe(''); expect(cliJson(run)).toEqual({ - keys: ['libraryTooling', 'processLifetime'], + keys, libraryTooling: { kind: 'cli', surface: 'tooling inspect', tool: 'ffprobe 6.1' }, processLifetime: { hits: 1, instanceId: expect.any(String), pid: process.pid }, + // The plain-command harness mounts no state, like a stateless executable. + requestView: requestView({ mounted: false }), }); }); @@ -49,8 +83,9 @@ describe('conventional providers through the harness', () => { expect(run.exitCode).toBe(0); expect(run.stderr).toBe(''); expect(cliJson(run)).toEqual({ - keys: ['libraryTooling', 'processLifetime'], + keys, libraryTooling: { kind: 'cli', surface: 'tooling report', tool: 'ffprobe 6.1' }, + requestView: requestView({ mounted: true }), }); }); @@ -59,9 +94,10 @@ describe('conventional providers through the harness', () => { expect(run.exitCode).toBe(0); expect(cliJson(run)).toEqual({ - keys: ['libraryTooling', 'processLifetime'], + keys, libraryTooling: { kind: 'tool', surface: 'tool:harness/tooling', tool: 'ffprobe 6.1' }, processLifetime: { hits: 1, instanceId: expect.any(String), pid: process.pid }, + requestView: requestView({ mounted: true }), }); }); @@ -70,9 +106,51 @@ describe('conventional providers through the harness', () => { expect(call.isError).toBe(false); expect(call.structuredContent).toEqual({ - keys: ['libraryTooling', 'processLifetime'], + keys, libraryTooling: { kind: 'tool', surface: 'tool:harness/tooling', tool: 'ffprobe 6.1' }, processLifetime: { hits: 1, instanceId: expect.any(String), pid: process.pid }, + // The server publishes the negotiated client as the host; a session + // opened without `state` mounts neither handle. `useAgent()` still throws + // although the in-process host scope wraps the render: the runtime runs + // the resolver outside every request context, as a worker boundary would. + requestView: requestView({ host: 'agent-bundle-in-memory-projection', mounted: false }), + }); + }); + + it('hands providers the lineage the call resolved to — tree included — and the read-only handles on the in-memory server (#459)', async () => { + const registry = createAgentLineageRegistry(); + const observe = (event: string, native: Record) => + registry.observe({ event, host: 'claude', idempotencyKey: `${event}:${JSON.stringify(native)}`, native, observedAt: '2026-09-03T00:00:00.000Z' }); + await observe('session/start', { hook_event_name: 'SessionStart', session_id: 'root' }); + await observe('tool/before', { hook_event_name: 'PreToolUse', session_id: 'root', tool_input: {}, tool_name: 'Agent', tool_use_id: 'spawn' }); + await observe('agent/start', { agent_id: 'child', agent_type: 'general-purpose', hook_event_name: 'SubagentStart', session_id: 'root' }); + await observe('tool/before', { hook_event_name: 'PreToolUse', session_id: 'root', tool_input: { inbox: true }, tool_name: 'mcp__plugin_harness_harness__tooling', tool_use_id: 'toolu_tooling' }); + const definition = defineState({ + events: { noted: z.object({ note: z.string() }).strict() }, + id: 'providers/request-view', + initial: { notes: [] as string[] }, + lifetime: 'process', + reduce: (state, event) => ({ notes: [...state.notes, event.payload.note] }), + schema: z.object({ notes: z.array(z.string()) }).strict(), + }); + await using session = await openInMemoryMcpServer({ + lineage: registry, + lineageHost: 'claude', + state: { definition, driver: createMemoryStateDriver({ lifetime: 'process' }) }, + }); + const result = await session.client.callTool({ _meta: { 'claudecode/toolUseId': 'toolu_tooling' }, arguments: { inbox: true }, name: 'tooling' }); + + expect(result.isError, JSON.stringify(result.content)).not.toBe(true); + expect((result.structuredContent as { requestView: unknown }).requestView).toEqual({ + handle: 'outside-invocation', + host: 'agent-bundle-in-memory-projection', + // The same registry answer the route reads from `request.lineage`, live tree included (#457). + lineage: { conversation: 'root', depth: 0, siblings: ['child'] }, + // `inbox()` is the real request-scoped read: no pending notices are addressed to this principal. + notices: { inbox: [], keys: ['inbox'] }, + session: 'not-provided', + state: { keys: ['lifetime', 'read'], lifetime: 'process', revision: 0 }, + workspace: process.cwd(), }); }); @@ -84,9 +162,39 @@ describe('conventional providers through the harness', () => { const rendered = await renderRoute('tool:harness/tooling'); expect(rendered.result).toEqual({ - keys: ['libraryTooling', 'processLifetime'], + keys, libraryTooling: { kind: 'tool', surface: 'tool:harness/tooling', tool: 'ffprobe 6.1' }, processLifetime: { hits: 1, instanceId: expect.any(String), pid: process.pid }, + requestView: routeUnitView, + }); + }); + + it('hands providers the injected identity axes and lineage a route-unit render mounts (#459)', async () => { + const rendered = await renderRoute('tool:harness/tooling', { + context: { + host: available({ name: 'route-unit-host' }, 'native'), + lineage: available({ + conversation: 'child', + depth: 1, + parent: 'root', + resolution: 'registry', + root: 'root', + subagent: { id: 'child' }, + tree: { children: [], roots: [], siblings: [{ conversation: 'root', depth: 0, resolution: 'native', startedAt: '2026-09-03T00:00:00.000Z' }] }, + }, 'derived'), + session: available({ sessionId: 'root' }, 'native'), + workspace: available({ root: '/tmp/route-unit' }, 'derived'), + }, + }); + + expect((rendered.result as { requestView: unknown }).requestView).toEqual({ + handle: 'outside-invocation', + host: 'route-unit-host', + lineage: { conversation: 'child', depth: 1, siblings: ['root'] }, + notices: { keys: ['inbox'] }, + session: 'root', + state: { keys: ['lifetime', 'read'], lifetime: 'workspace-durable', revision: 0 }, + workspace: '/tmp/route-unit', }); }); @@ -96,8 +204,9 @@ describe('conventional providers through the harness', () => { // The generated script passes `name: 'tooling-summary'`, never the route id. expect(rendered.result).toEqual({ arguments: 2, - keys: ['libraryTooling', 'processLifetime'], + keys, libraryTooling: { kind: 'script', surface: 'tooling-summary', tool: 'ffprobe 6.1' }, + requestView: routeUnitView, }); }); @@ -106,8 +215,9 @@ describe('conventional providers through the harness', () => { // The generated executable passes `command.path.join(' ')`, never the route id. expect(rendered.result).toEqual({ - keys: ['libraryTooling', 'processLifetime'], + keys, libraryTooling: { kind: 'cli', surface: 'tooling report', tool: 'ffprobe 6.1' }, + requestView: routeUnitView, }); }); diff --git a/packages/agent-bundle/tests/projection/script-dispatch.test.ts b/packages/agent-bundle/tests/projection/script-dispatch.test.ts index cfa320c34..04a5f8bd2 100644 --- a/packages/agent-bundle/tests/projection/script-dispatch.test.ts +++ b/packages/agent-bundle/tests/projection/script-dispatch.test.ts @@ -237,8 +237,9 @@ describe('rendered scripts at the script dispatch level', () => { // The generated script passes `name: 'tooling-summary'`, never the route id. expect(scriptJson(first)).toEqual({ arguments: 2, - keys: ['libraryTooling', 'processLifetime'], + keys: ['libraryTooling', 'processLifetime', 'requestView'], libraryTooling: { kind: 'script', surface: 'tooling-summary', tool: 'ffprobe 6.1' }, + requestView: expect.objectContaining({ handle: 'outside-invocation', lineage: 'not-provided' }), }); expect((scriptJson(second) as Summary).arguments).toBe(1); diff --git a/packages/agent-bundle/tests/provider-typegen.test.ts b/packages/agent-bundle/tests/provider-typegen.test.ts index cbb4264c3..706f70280 100644 --- a/packages/agent-bundle/tests/provider-typegen.test.ts +++ b/packages/agent-bundle/tests/provider-typegen.test.ts @@ -60,11 +60,20 @@ it('types (await agent()).providers. from the generated provider declaratio '});', '', ].join('\n')), + // The factory reads the request view a provider receives (#459): lineage + // with its tree, and the read-only state / notices handles. writeProjectFile(root, 'src/providers/library.ts', [ "import type { AgentProviderContext } from 'agent-bundle';", - 'export interface LibraryContext { readonly stages: readonly string[]; readonly surface: string; }', - 'export default async function library({ invocation }: AgentProviderContext): Promise {', - " return { stages: ['discover'], surface: invocation.kind };", + 'export interface LibraryContext { readonly revision: number | undefined; readonly siblings: number | undefined; readonly stages: readonly string[]; readonly surface: string; }', + 'export default async function library({ invocation, lineage, notices, state }: AgentProviderContext): Promise {', + ' const snapshot = state === undefined ? undefined : await state.read();', + " const pending = notices === undefined ? [] : (await notices.inbox()).map((notice) => notice.id);", + ' return {', + ' revision: snapshot?.revision,', + " siblings: lineage.state === 'available' ? lineage.value.tree?.siblings.length : undefined,", + " stages: ['discover', ...pending],", + ' surface: invocation.kind,', + ' };', '}', '', ].join('\n')), @@ -123,9 +132,11 @@ it('types (await agent()).providers. from the generated provider declaratio "import { renderRoute } from 'agent-bundle/test';", "import type { LibraryContext } from './src/providers/library.js';", '', - "const library: LibraryContext = { stages: ['discover'], surface: 'tool' };", + "const library: LibraryContext = { revision: undefined, siblings: undefined, stages: ['discover'], surface: 'tool' };", 'export const complete = async (): Promise => {', " await runAgentRequest({ invocation: { kind: 'tool' }, providers: { buildNumber: 7, library } }, async () => undefined);", + // A resolver function is typed against the same declared keys and reads the runtime's request view. + " await runAgentRequest({ invocation: { kind: 'tool' }, providers: async (request) => ({ buildNumber: request.lineage.state === 'available' ? 1 : 0, library }) }, async () => undefined);", " await renderRoute('tool:curator/status', { context: { providers: { buildNumber: 7, library } } });", " await renderRoute('tool:curator/status');", " await renderRoute('tool:curator/status', { input: {} });", @@ -140,10 +151,17 @@ it('types (await agent()).providers. from the generated provider declaratio writeProjectFile(root, 'missing-fixture.ts', [ "import { renderRoute } from 'agent-bundle/test';", "import type { LibraryContext } from './src/providers/library.js';", - "const library: LibraryContext = { stages: ['discover'], surface: 'tool' };", + "const library: LibraryContext = { revision: undefined, siblings: undefined, stages: ['discover'], surface: 'tool' };", "export const partial = renderRoute('tool:curator/status', { context: { providers: { library } } });", '', ].join('\n')), + writeProjectFile(root, 'missing-resolver.ts', [ + "import { runAgentRequest } from '@agent-bundle/runtime';", + "import type { LibraryContext } from './src/providers/library.js';", + "const library: LibraryContext = { revision: undefined, siblings: undefined, stages: ['discover'], surface: 'tool' };", + "export const partial = runAgentRequest({ invocation: { kind: 'tool' }, providers: async () => ({ library }) }, async () => undefined);", + '', + ].join('\n')), ]); const result = await inspect({ root }); @@ -165,4 +183,7 @@ it('types (await agent()).providers. from the generated provider declaratio const missingFixture = typecheck(root, 'missing-fixture.ts'); expect(missingFixture).toHaveLength(1); expect(missingFixture[0]).toContain("Property '\"buildNumber\"' is missing"); + const missingResolver = typecheck(root, 'missing-resolver.ts'); + expect(missingResolver).toHaveLength(1); + expect(missingResolver[0]).toContain("Property '\"buildNumber\"' is missing"); }); diff --git a/packages/agent-bundle/tests/test-harness-manifest.test.ts b/packages/agent-bundle/tests/test-harness-manifest.test.ts index 1c785b2b7..1ec1efaa0 100644 --- a/packages/agent-bundle/tests/test-harness-manifest.test.ts +++ b/packages/agent-bundle/tests/test-harness-manifest.test.ts @@ -99,13 +99,22 @@ describe('the compiled test manifest', () => { 'tool:harness/wait', ]); expect(manifest.diagnostics).toEqual([]); - expect(manifest.providers).toEqual([{ - id: 'provider:library-tooling', - key: 'libraryTooling', - name: 'library-tooling', - relativePath: 'src/providers/library-tooling.ts', - source: resolve(fixtureRoot, 'src/providers/library-tooling.ts'), - }]); + expect(manifest.providers).toEqual([ + { + id: 'provider:library-tooling', + key: 'libraryTooling', + name: 'library-tooling', + relativePath: 'src/providers/library-tooling.ts', + source: resolve(fixtureRoot, 'src/providers/library-tooling.ts'), + }, + { + id: 'provider:request-view', + key: 'requestView', + name: 'request-view', + relativePath: 'src/providers/request-view.ts', + source: resolve(fixtureRoot, 'src/providers/request-view.ts'), + }, + ]); // Layouts are never routes; the manifest carries them separately, ordered by id. expect(manifest.layouts).toEqual([ { @@ -472,6 +481,7 @@ describe('the generated route registry', () => { expect(providerLoaders).toContain('"provider:library-tooling": () => import('); expect(providerLoaders).toContain('/src/providers/library-tooling.ts'); + expect(providerLoaders).toContain('"provider:request-view": () => import('); // A project without providers emits no loader table at all. expect(routeTestSetupSource({ ...manifest, providers: undefined })).not.toContain('providerLoaders'); }); diff --git a/packages/rsc-runtime/src/agent-request.ts b/packages/rsc-runtime/src/agent-request.ts index 12337ae46..9a93c623b 100644 --- a/packages/rsc-runtime/src/agent-request.ts +++ b/packages/rsc-runtime/src/agent-request.ts @@ -6,7 +6,7 @@ import type { AgentNoticeRequestLease, AgentNoticesHandle, } from './notices/contract.js'; -import type { AgentStateHandle } from './state/contract.js'; +import type { AgentStateHandle, AgentStateReadOptions } from './state/contract.js'; // Bumped to 3 when `lineage` joined the handle shape, to 4 when `terminal` // did, and to 5 when `plugin` did: a realm that already holds an older store @@ -447,18 +447,60 @@ export interface AgentRequestContext { readonly notices: AgentNoticesHandle | undefined; } +/** The read-only view of a mounted state handle a provider resolver receives (#459): `read`, never `dispatch`. */ +export type AgentProviderStateHandle = Pick, 'lifetime' | 'read'>; + +/** The read-only view of the request's notice handle a provider resolver receives (#459): `inbox`, never `publish` or `acknowledge`. */ +export type AgentProviderNoticesHandle = Pick; + /** - * The `providers` member of {@link AgentRequestInit}. It is optional only while - * {@link AgentProviderValues} has no required keys. Once a project's generated - * `.agent-bundle/routes.d.ts` augmentation declares its conventional providers, - * every direct `runAgentRequest` caller — custom hosts and route-unit fixtures - * alike — must supply the full record, so a handler typed against those keys - * never observes an unchecked `undefined`. Generated request scopes always run - * the providers before the handler and are unaffected. + * What a provider resolver may read of the request it runs for (#459): the + * observed identity axes and plugin root, the conversation lineage (own chain + * and, when the registry placed the request, its live `tree`), the request + * signal, and read-only views of the mounted state and notice handles. The + * write paths — `state.dispatch`, `notices.publish`/`acknowledge`, `progress` + * — stay route-only, and the resolver runs outside the request's async + * context, so `agent()` inside a provider factory throws `outside-invocation` + * rather than handing it the full handle. Every field is exactly what the + * route will observe on `await agent()`. + */ +export interface AgentProviderRequest { + readonly host: Observed; + readonly lineage: Observed; + /** Present only when the request opened a notice lease (`noticeLedger` supplied). */ + readonly notices?: AgentProviderNoticesHandle; + readonly plugin: Observed; + readonly session: Observed; + readonly signal: AbortSignal; + /** Present only when the request mounted a state handle (`state` supplied). */ + readonly state?: AgentProviderStateHandle; + readonly workspace: Observed; +} + +/** + * The function form of {@link AgentRequestInit.providers}: resolved once per + * request by `runAgentRequest`, after the identity axes are frozen and the + * notice lease is open (so `notices.inbox()` is real) and before the + * operation runs, over the read-only {@link AgentProviderRequest}. The record + * it returns is frozen and mounted as `(await agent()).providers`; a rejection + * fails the request closed exactly as a rejected operation does. + */ +export type AgentProviderResolver = (request: AgentProviderRequest) => AgentProviderValues | Promise; + +/** + * The `providers` member of {@link AgentRequestInit}: the resolved record, or + * an {@link AgentProviderResolver} the request runs itself. It is optional + * only while {@link AgentProviderValues} has no required keys. Once a + * project's generated `.agent-bundle/routes.d.ts` augmentation declares its + * conventional providers, every direct `runAgentRequest` caller — custom hosts + * and route-unit fixtures alike — must supply the full record (or a resolver + * returning it), so a handler typed against those keys never observes an + * unchecked `undefined`. Generated request scopes always run the providers + * before the handler and are unaffected. */ export type AgentRequestProvidersInit = Record extends AgentProviderValues - ? { readonly providers?: AgentProviderValues } - : { readonly providers: AgentProviderValues }; + ? { readonly providers?: AgentProviderValues | AgentProviderResolver } + : { readonly providers: AgentProviderValues | AgentProviderResolver }; export interface AgentRequestInitBase { readonly actor?: Observed; @@ -729,29 +771,81 @@ export const runAgentRequest = async ( principal: Object.freeze({ actor, host, lineage, session, workspace }), signal, }); - const values: FrozenValues = Object.freeze({ - actor, - capabilities: snapshotCapabilities(init.capabilities ?? emptyCapabilities()), - host, - invocation, - lineage, - notices: noticeLease?.handle, - plugin, - progress: init.progress ?? silentProgress, - providers: Object.freeze({ ...(init.providers ?? {}) }), - services: Object.freeze({ ...(init.services ?? {}) }), - session, - signal, - state: init.state, - terminal, - workspace, - }); - const lease = new Lease(values); - try { - return await getStore().storage.run(lease, operation); + // A resolver runs here, after the notice lease opened and before the + // operation's async context exists — and outside any enclosing request's + // context (an in-process host scope around the render, as the test harness + // has), so `agent()` throws `outside-invocation` for it everywhere, exactly + // as in a generated worker. It reads the same frozen axes and the real + // handles, narrowed to their read paths. + const providers = typeof init.providers === 'function' + ? await resolveProvidersDetached(init.providers, providerRequest({ + host, + lineage, + notices: noticeLease?.handle, + plugin, + session, + signal, + state: init.state, + workspace, + })) + : init.providers; + const values: FrozenValues = Object.freeze({ + actor, + capabilities: snapshotCapabilities(init.capabilities ?? emptyCapabilities()), + host, + invocation, + lineage, + notices: noticeLease?.handle, + plugin, + progress: init.progress ?? silentProgress, + providers: Object.freeze({ ...(providers ?? {}) }), + services: Object.freeze({ ...(init.services ?? {}) }), + session, + signal, + state: init.state, + terminal, + workspace, + }); + const lease = new Lease(values); + try { + return await getStore().storage.run(lease, operation); + } finally { + lease.closed = true; + } } finally { - lease.closed = true; noticeLease?.close(); } }; + +/** Runs the resolver with no request lease in its async context, whatever context the caller was in. */ +const resolveProvidersDetached = ( + resolver: AgentProviderResolver, + request: AgentProviderRequest, +): Promise => getStore().storage.exit(async () => resolver(request)); + +/** The frozen request parts a provider view is built from: the axes as snapshotted, the handles as supplied. */ +interface ProviderRequestParts { + readonly host: Observed; + readonly lineage: Observed; + readonly notices: AgentNoticesHandle | undefined; + readonly plugin: Observed; + readonly session: Observed; + readonly signal: AbortSignal; + readonly state: AgentStateHandle | undefined; + readonly workspace: Observed; +} + +/** The read-only request view handed to a provider resolver; handles are narrowed by construction, not by type alone. */ +const providerRequest = ({ host, lineage, notices, plugin, session, signal, state, workspace }: ProviderRequestParts): AgentProviderRequest => Object.freeze({ + host, + lineage, + ...(notices === undefined ? {} : { notices: Object.freeze({ inbox: () => notices.inbox() }) }), + plugin, + session, + signal, + ...(state === undefined + ? {} + : { state: Object.freeze({ lifetime: state.lifetime, read: (options?: AgentStateReadOptions) => state.read(options) }) }), + workspace, +}); diff --git a/packages/rsc-runtime/src/plugin.ts b/packages/rsc-runtime/src/plugin.ts index 5bb550c9d..bdef9e50c 100644 --- a/packages/rsc-runtime/src/plugin.ts +++ b/packages/rsc-runtime/src/plugin.ts @@ -30,6 +30,10 @@ export type { AgentProgressUpdate, AgentProjectRootAuthority, AgentRenderInvocation, + AgentProviderNoticesHandle, + AgentProviderRequest, + AgentProviderResolver, + AgentProviderStateHandle, AgentProviderValues, Register, RegisteredMcpRouteId, diff --git a/packages/rsc-runtime/tests/agent-request.test.ts b/packages/rsc-runtime/tests/agent-request.test.ts index bab1227f5..dba6fd60d 100644 --- a/packages/rsc-runtime/tests/agent-request.test.ts +++ b/packages/rsc-runtime/tests/agent-request.test.ts @@ -328,6 +328,106 @@ describe('agent request store', () => { } }); + it('resolves a providers function over a read-only request view, after the notice lease opens and outside any request context (#459)', async () => { + const events: string[] = []; + const stateHandle = { + changes: async () => { throw new Error('unreachable'); }, + dispatch: async () => { events.push('dispatch'); throw new Error('unreachable'); }, + lifetime: 'workspace-durable' as const, + read: async (options?: { readonly revision?: number }) => ({ revision: options?.revision ?? 7, state: { notes: [] } }), + }; + const ledger = { + openRequest: async (request: { readonly invocation: { readonly id: string } }) => { + events.push(`open:${request.invocation.id}`); + return { + close: () => { events.push('close'); }, + handle: { + acknowledge: async () => { throw new Error('unreachable'); }, + inbox: async () => { events.push('inbox'); return []; }, + publish: async () => { throw new Error('unreachable'); }, + read: async () => [], + }, + }; + }, + }; + let view: Record | undefined; + let inside: unknown; + const result = await runAgentRequest({ + ...init('event', 'evt-1'), + host: available({ name: 'claude' }, 'native'), + lineage: available({ conversation: 'root', depth: 0, resolution: 'native', root: 'root', tree: { children: [], roots: [], siblings: [] } }, 'native'), + noticeLedger: ledger as never, + providers: async (request) => { + events.push('providers'); + view = request as unknown as Record; + try { + useAgent(); + inside = 'reachable'; + } catch (error) { + inside = error instanceof AgentRequestError ? error.code : error; + } + const snapshot = await request.state!.read({ revision: 3 }); + const pending = await request.notices!.inbox(); + return { topology: { pending: pending.length, revision: snapshot.revision, siblings: request.lineage.state === 'available' ? request.lineage.value.tree?.siblings.length : undefined } }; + }, + state: stateHandle as never, + }, async () => { + events.push('operation'); + return (await agent()).providers; + }); + + // Order: lease open → providers → operation → lease close; the resolver saw the real inbox. + expect(events).toEqual(['open:evt-1', 'providers', 'inbox', 'operation', 'close']); + expect(result).toEqual({ topology: { pending: 0, revision: 3, siblings: 0 } }); + expect(Object.isFrozen(result)).toBe(true); + // The view is frozen, carries exactly the read-only members, and the handles are narrowed by construction. + expect(Object.isFrozen(view)).toBe(true); + expect(Object.keys(view!).sort()).toEqual(['host', 'lineage', 'notices', 'plugin', 'session', 'signal', 'state', 'workspace']); + expect(Object.keys(view!['state'] as object).sort()).toEqual(['lifetime', 'read']); + expect(Object.keys(view!['notices'] as object)).toEqual(['inbox']); + expect(view!['host']).toEqual(available({ name: 'claude' }, 'native')); + expect(view!['plugin']).toEqual(unavailable()); + expect(view!['session']).toEqual(unavailable()); + // Providers never observe a request handle, so no write path is reachable through `agent()`. + expect(inside).toBe('outside-invocation'); + }); + + it('keeps a providers function outside an enclosing request context, and fails the request closed when it rejects', async () => { + let inside: unknown; + await runAgentRequest(init('tool', 'outer'), async () => { + const outer = await agent(); + expect(outer.invocation.id).toBe('outer'); + await runAgentRequest({ + ...init('tool', 'inner'), + providers: () => { + try { + inside = useAgent().invocation.id; + } catch (error) { + inside = error instanceof AgentRequestError ? error.code : error; + } + return {}; + }, + }, async () => undefined); + // The outer context is intact once the inner request settles. + expect((await agent()).invocation.id).toBe('outer'); + }); + expect(inside).toBe('outside-invocation'); + + let closed = false; + let ran = false; + await expect(runAgentRequest({ + ...init('tool'), + noticeLedger: { + openRequest: async () => ({ close: () => { closed = true; }, handle: {} }), + } as never, + providers: async () => { throw new Error('ffprobe is not installed'); }, + }, async () => { ran = true; })).rejects.toThrow('ffprobe is not installed'); + expect(ran).toBe(false); + expect(closed).toBe(true); + // A plain record is still mounted as before. + expect(await runAgentRequest({ ...init('tool'), providers: { library: 'x' } }, async () => (await agent()).providers)).toEqual({ library: 'x' }); + }); + it('re-exports the request store from the plugin entry', () => { expect(pluginUseAgent).toBe(useAgent); expect(pluginAgent).toBe(agent); diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index db25542b7..461567c36 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -175,6 +175,38 @@ as the second argument of `main` (see [Package entries](./package-entries.mdx#th In tests, the `tty` knob of `invokeCli` and `runScript` shapes a deterministic synthetic value, and `context.terminal` injects any other one through the same seam as every identity axis. +### Request context providers + +A conventional `src/providers/.ts` module default-exports a factory whose value is +mounted at `(await agent()).providers.` on every generated surface. Beyond the +surface-specific `invocation` and the request `signal`, the factory receives the request as the +route will read it — `host`, `session`, `workspace`, `lineage` (tree included), and `plugin` — +plus read-only views of the mounted handles: `state` (`read` only) when the project declares +`src/state.ts`, `notices` (`inbox` only) when the scope mounts the notice ledger. A provider can +therefore derive a view of shared state — a topology, a peers list — but cannot dispatch a state +event or publish a notice; those stay in routes. + +```ts +// src/providers/agent-topology.ts +import type { AgentProviderContext } from 'agent-bundle'; + +export default async function agentTopology({ lineage, state }: AgentProviderContext) { + const snapshot = state === undefined ? undefined : await state.read(); + return { + peers: lineage.state === 'available' ? lineage.value.tree?.siblings ?? [] : [], + revision: snapshot?.revision, + }; +} +``` + +Providers run once per request as the request's own resolver: after `runAgentRequest` has +frozen the identity axes and opened the notice lease, before the route, in deterministic key +order, outside the request's async context (`agent()` inside a factory throws +`outside-invocation`). A factory that throws fails the request closed; expected degradation +should return an honest unavailable-shaped value instead. The `agent-bundle/test` harness hands +its providers the same view, so a route-unit test that injects `context.lineage` or mounts state +sees them reflected in `providers.`; an explicit `context.providers` map is mounted verbatim. + ## Streaming and progress A route streams by rendering React `Suspense`: the shell goes out first with the fallback in diff --git a/website/docs/en/guide/start/project-structure.mdx b/website/docs/en/guide/start/project-structure.mdx index d7e04f5cc..e39ad813a 100644 --- a/website/docs/en/guide/start/project-structure.mdx +++ b/website/docs/en/guide/start/project-structure.mdx @@ -54,7 +54,7 @@ my-plugin/ | `src/layout.{ts,tsx}` | Shared document layout: default-exports one component receiving `{ children, route, signal }` that renders `Agent.Result` around every rendered route — generated MCP tools, resources, and prompts, rendered routed-CLI commands, projected MCP commands, and rendered scripts. Event routes and browser Apps are never wrapped. | Rename to `_layout.tsx`. | | `src/mcp//layout.{ts,tsx}` | Per-server layout nested inside the root layout for that generated server's routes. | Rename to `_layout.tsx`, or set `routes.servers.` to a non-generated mode. | | `src/state.ts` | Project state: default-exports `defineState`. Generated MCP, routed-CLI, and rendered-script request scopes mount it. | `state: false`, or rename to `_state.ts`. | -| `src/providers/.{ts,tsx}` | A request-context provider mounted at `providers.` on the request handle. | Prefix the file with `_`. | +| `src/providers/.{ts,tsx}` | A request-context provider mounted at `providers.` on the request handle; its factory receives the request's identity, lineage, and read-only state/notice handles. | Prefix the file with `_`. | | `assets/` | Static resources copied byte-for-byte into every target artifact's `assets/` directory. | Declare a top-level `assets` list instead. | Route and package entry conventions match `.ts` and `.tsx` files exactly; the state convention diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index 9a7577428..9f4ae9c35 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -158,6 +158,34 @@ interface AgentTerminal { [包入口](./package-entries.mdx#可执行封套))。在测试中,`invokeCli` 与 `runScript` 的 `tty` 开关会塑造 一个确定性的合成值,而 `context.terminal` 则可以像注入任何身份轴一样注入其他取值。 +### 请求上下文 provider + +约定式的 `src/providers/.ts` 模块默认导出一个工厂函数,其返回值在每个生成表面上挂载到 +`(await agent()).providers.`。除了表面特定的 `invocation` 和请求 `signal` 之外,工厂 +还会收到路由将要读到的请求本身——`host`、`session`、`workspace`、`lineage`(含 `tree`)与 `plugin`—— +以及已挂载句柄的只读视图:项目声明了 `src/state.ts` 时的 `state`(仅 `read`),作用域挂载了通知账本时的 +`notices`(仅 `inbox`)。因此 provider 可以派生共享状态的一个视图——拓扑、对等节点列表——但不能派发 +状态事件或发布通知;这些仍然只属于路由。 + +```ts +// src/providers/agent-topology.ts +import type { AgentProviderContext } from 'agent-bundle'; + +export default async function agentTopology({ lineage, state }: AgentProviderContext) { + const snapshot = state === undefined ? undefined : await state.read(); + return { + peers: lineage.state === 'available' ? lineage.value.tree?.siblings ?? [] : [], + revision: snapshot?.revision, + }; +} +``` + +provider 每个请求作为该请求自己的解析器运行一次:在 `runAgentRequest` 冻结身份轴、打开通知租约之后, +在路由之前,按确定的键顺序,并且在请求的异步上下文之外(工厂里调用 `agent()` 会抛出 +`outside-invocation`)。抛出异常的工厂会使请求失败关闭;预期中的降级应返回一个诚实的 unavailable +形状的值。`agent-bundle/test` 测试装置把同样的视图交给它的 provider,因此注入了 `context.lineage` +或挂载了状态的路由单元测试会在 `providers.` 中看到它们;显式的 `context.providers` 映射会被原样挂载。 + ## 流式输出与进度 路由通过渲染 React `Suspense` 实现流式输出:外壳(shell)先带着回退内容发出,之后每个解析完成的 diff --git a/website/docs/zh/guide/start/project-structure.mdx b/website/docs/zh/guide/start/project-structure.mdx index 61bd5d5a8..43867f81a 100644 --- a/website/docs/zh/guide/start/project-structure.mdx +++ b/website/docs/zh/guide/start/project-structure.mdx @@ -53,7 +53,7 @@ my-plugin/ | `src/layout.{ts,tsx}` | 共享文档布局:默认导出一个接收 `{ children, route, signal }` 的组件,在每个渲染式路由——生成式 MCP 工具、资源与提示、渲染式路由 CLI 命令、投影的 MCP 命令与渲染式脚本——外层渲染 `Agent.Result`。事件路由与浏览器 App 永不被包裹。 | 重命名为 `_layout.tsx`。 | | `src/mcp//layout.{ts,tsx}` | 按服务器的布局,嵌套在根布局之内,包裹该生成式服务器的路由。 | 重命名为 `_layout.tsx`,或把 `routes.servers.` 设为非生成模式。 | | `src/state.ts` | 项目状态:默认导出 `defineState`。生成的 MCP、路由式 CLI 与渲染式脚本的请求作用域都会挂载它。 | `state: false`,或改名为 `_state.ts`。 | -| `src/providers/.{ts,tsx}` | 一个请求上下文 provider,挂载在请求句柄的 `providers.` 上。 | 给文件名加 `_` 前缀。 | +| `src/providers/.{ts,tsx}` | 一个请求上下文 provider,挂载在请求句柄的 `providers.` 上;其工厂会收到请求的身份、lineage 以及只读的 state/notices 句柄。 | 给文件名加 `_` 前缀。 | | `assets/` | 静态资源,按字节复制到每个 target 产物的 `assets/` 目录。 | 改为声明顶层 `assets` 列表。 | 路由与包入口约定精确匹配 `.ts` 与 `.tsx` 文件;state 约定则专指 `src/state.ts`。被发现的条目在