diff --git a/.changeset/459-provider-request-context.md b/.changeset/459-provider-request-context.md new file mode 100644 index 000000000..de0015370 --- /dev/null +++ b/.changeset/459-provider-request-context.md @@ -0,0 +1,6 @@ +--- +"@agent-bundle/runtime": patch +"agent-bundle": patch +--- + +Hand conventional `src/providers/` factories the request they run for: `AgentProviderContext` (`agent-bundle`) gains `host`, `session`, `workspace`, and `lineage` beside `plugin` — the same observed axes the route reads on `await agent()`, provenance and the lineage's live `tree` included — plus read-only `state` (`lifetime`, `read()`) and `notices` (`inbox()`, `published()`) views of the mounted handles; `dispatch`, `publish`, and `acknowledge` stay route-only, and `agent()`/`useAgent()` inside a factory throw `outside-invocation`. New exported types `AgentProviderObserved`, `AgentProviderHostIdentity`, `AgentProviderSessionIdentity`, `AgentProviderWorkspaceIdentity`, `AgentProviderLineage`, `AgentProviderLineageTree`, `AgentProviderLineagePeer`, `AgentProviderLineageSubagent`, `AgentProviderLineageResolution`, `AgentProviderStateHandle`, `AgentProviderStateSnapshot`, `AgentProviderNoticesHandle`, `AgentProviderNotice`, `AgentProviderNoticeState`, `AgentProviderNoticeRecipient`, `AgentProviderNoticePublisher`, `AgentProviderNoticeAttempt`, `AgentProviderNoticeWithholding`; `AgentProviderObservedPluginRoot` is now `AgentProviderObserved`. 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. `@agent-bundle/runtime`: `runAgentRequest` accepts `providers` as an `AgentProviderResolver` `(request: AgentProviderRequest) => values` beside the plain record; new exported types `AgentProviderRequest`, `AgentProviderResolver`, `AgentProviderStateHandle`, `AgentProviderNoticesHandle`. Existing factories that destructure `{ invocation, plugin, signal }` are unchanged. (#459) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 1d7d725a7..6c5f7a100 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, plugin, lineage, state?, notices? }` — the request's observed identity (plugin root included) and lineage plus read-only views of the mounted state (`read`) and notice (`inbox`, `published`) 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,58 @@ 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 }>; + plugin: Observed<{ root; stateRoot }>; // the resolved plugin root (#468) + lineage: Observed; // own chain plus the live `tree` (#457) + state?: { lifetime; read(options?) }; // the mounted state handle, `read` only + notices?: { inbox(); published() }; // the request's notice handle, reads only +} +``` + +`host`, `session`, `workspace`, `plugin`, and `lineage` are the same observed +values the route will read, provenance and 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) — `inbox()` is +what is pending for this request's principal, `published()` what became of +the notices it published (#460) — 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/examples/worktree-proximity/README.md b/examples/worktree-proximity/README.md index 4d3123b3f..3fbf64cbf 100644 --- a/examples/worktree-proximity/README.md +++ b/examples/worktree-proximity/README.md @@ -50,13 +50,18 @@ 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` - turns that into the coordinator's report; `liveConversations()` turns it - into the liveness the domain uses. + `resolution` for its placement. `agentTreeOf()` in `src/event-support.ts` + turns that into the coordinator's report (the `agent-topology` provider + calls it over the `lineage` the framework hands it); `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 + `context.lineage` resolved (own chain plus the live tree), a read of the + mounted intent state through `context.state.read()`, and the counts of the + notices this caller published through `context.notices.published()`; each + part 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 +80,15 @@ 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`, `plugin`, and `lineage` — with the +live tree ([#457](https://github.com/scriptedalchemy/agent-bundle/issues/457)) +— plus read-only `state` (`read()`) and `notices` (`inbox()`, `published()`) +handles ([#459](https://github.com/scriptedalchemy/agent-bundle/issues/459)), +so the coordinator `status` tool reads `providers.agentTopology` and performs +no read of its own. Event routes still use the mounted `(await agent()).state` +and `.notices` handles through `withIntent`/`withNotices`, because they +dispatch and publish. `worktree()` in `src/api.ts` is the issue-mandated custom Promise API over the provider value. `useWorktree()` is the hook-shaped variant for Server @@ -165,8 +172,8 @@ message to one peer, not to the tree. current invocation, and `inbox()` only what is pending for the current recipient. The coordinator status reports, beside the agent tree, bindings, and intents, what became of the notices *the calling agent* published — `pending`, -`attempted`, `acknowledged`, and the other ledger states, counted from -`(await agent()).notices.published()` +`attempted`, `acknowledged`, and the other ledger states, counted by the +`agent-topology` provider from the request's own `notices.published()` ([#460](https://github.com/scriptedalchemy/agent-bundle/issues/460)). That view is scoped by the publisher identity the ledger recorded at publish, the agent's lineage conversation, so a status call correlated to agent B's 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..d83fa62c5 100644 --- a/examples/worktree-proximity/src/mcp/coordinator/tools/status.tsx +++ b/examples/worktree-proximity/src/mcp/coordinator/tools/status.tsx @@ -1,11 +1,9 @@ -import { Agent, type AgentNoticeState, type JsonValue } from '@agent-bundle/runtime'; -import { AGENT_NOTICE_STATES } from '@agent-bundle/runtime/notices'; +import { Agent, agent, type JsonValue } from '@agent-bundle/runtime'; 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 type { AgentTopologyProviderValue } from '../../../providers/agent-topology.js'; import { ActivitySchema, BindingSchema } from '../../../state.js'; export const config = { @@ -100,27 +98,37 @@ export const resultSchema = z .strict(); type StatusResult = z.output; -type PublishedNotices = z.output; -const emptyCounts = (): Record => - Object.fromEntries(AGENT_NOTICE_STATES.map((state) => [state, 0])) as Record; - -const publishedNotices = async (): Promise => { - const result = await withNotices(async (notices) => notices.published()); - if (result.state === 'unavailable') { - return { ...emptyCounts(), reason: result.reason, state: 'unavailable', total: 0 }; - } - const counts = emptyCounts(); - for (const notice of result.value) counts[notice.state] += 1; - 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, a read of the intent state, and the counts of the notices this caller + * published, 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' }, + notices: { + acknowledged: 0, + attempted: 0, + expired: 0, + pending: 0, + reason: 'Published notices unavailable: the agent-topology provider is not mounted.', + state: 'unavailable', + total: 0, + unavailable: 0, + withdrawn: 0, + }, + }; export default async function Status({ input, }: ToolRouteProps) { - const agents = await agentTree(); - const intentResult = await withIntent(async (store) => store.read()); - const notices = await publishedNotices(); + // Everything this tool reports was read once, by the provider, from the + // request the runtime opened for this call: no second read, no guess. + const { agents, intent: intentResult, notices } = topologyOf((await agent()).providers); let result: StatusResult; if (intentResult.state === 'unavailable') { result = { @@ -135,7 +143,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..70e74c281 100644 --- a/examples/worktree-proximity/src/providers/agent-topology.ts +++ b/examples/worktree-proximity/src/providers/agent-topology.ts @@ -1,22 +1,88 @@ -export interface AgentTopologyProviderValue { - readonly reason: string; - readonly state: 'unavailable'; -} +import { AGENT_NOTICE_STATES, type AgentNoticeState } from '@agent-bundle/runtime/notices'; +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'; + +/** + * What became of the notices the calling agent published, counted by ledger + * state (agent-bundle#460). Scoped by the publisher identity the ledger + * recorded at publish — the caller's lineage conversation — so it is this + * agent's own notices, never the whole ledger and never another agent's. + */ +export type PublishedNoticeCounts = Readonly> & { + readonly reason?: string; + readonly state: 'available' | 'unavailable'; + readonly total: number; +}; /** - * 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) — a read of + * the mounted intent state (worktree bindings, activities, refusals), and the + * counts of the notices this request's principal published. Each part + * 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 { +export interface AgentTopologyProviderValue { + readonly agents: AgentTreeView; + readonly intent: CapabilityResult<{ readonly revision: number; readonly value: IntentState }>; + readonly notices: PublishedNoticeCounts; +} + +const emptyCounts = (): Record => + Object.fromEntries(AGENT_NOTICE_STATES.map((state) => [state, 0])) as Record; + +const readIntent = async ( + context: AgentProviderContext, +): Promise => { + if (context.state === undefined) { + return { 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 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 { + reason: `Intent state unavailable: ${error instanceof Error ? error.message : String(error)}`, + state: 'unavailable', + }; + } +}; + +const readPublishedNotices = async (context: AgentProviderContext): Promise => { + if (context.notices === undefined) { + return { ...emptyCounts(), reason: 'Published notices unavailable: this surface mounts no notice ledger.', state: 'unavailable', total: 0 }; + } + try { + const published = await context.notices.published(); + const counts = emptyCounts(); + for (const notice of published) counts[notice.state] += 1; + return { ...counts, state: 'available', total: published.length }; + } catch (error) { + return { + ...emptyCounts(), + reason: `Published notices unavailable: ${error instanceof Error ? error.message : String(error)}`, + state: 'unavailable', + total: 0, + }; + } +}; + +export default async function agentTopologyProvider( + context: AgentProviderContext, +): Promise { + const [intent, notices] = await Promise.all([readIntent(context), readPublishedNotices(context)]); 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', + agents: agentTreeOf(context.lineage), + intent, + notices, }; } diff --git a/examples/worktree-proximity/tests/route-unit/routes.test.ts b/examples/worktree-proximity/tests/route-unit/routes.test.ts index ee489e6a2..902fbb54c 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(); @@ -45,14 +45,35 @@ 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. +// makes every declared provider key required on an explicit map, so the event +// fixtures carry `agentTopology` too, as an honest unavailable snapshot: event +// routes never read it. The coordinator `status` tests pass no `providers` at +// all, so the harness runs the project's real providers as the request's +// resolver — over the mounted state handle, the notice lease, and the injected +// `lineage` — exactly as a generated scope would (#459). +const noTopology: AgentTopologyProviderValue = { + agents: { reason: 'fixture: not resolved', state: 'unavailable' }, + intent: { reason: 'fixture: not read', state: 'unavailable' }, + notices: { + acknowledged: 0, + attempted: 0, + expired: 0, + pending: 0, + reason: 'fixture: not read', + state: 'unavailable', + total: 0, + unavailable: 0, + withdrawn: 0, + }, +}; const providers = (root: string) => ({ - agentTopology: agentTopologyProvider(), + agentTopology: noTopology, gitWorktree: provider(root), }); +/** The identity axes a direct factory call receives when nothing observed them. */ +const unobserved = { reason: 'not-provided', state: 'unavailable' } as const; + // 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. @@ -495,10 +516,7 @@ describe('worktree proximity journeys', () => { await recordIntent('agent-b', worktrees.b, 'src/shared.ts', 'intent:b'); const rendered = await renderRoute('tool:coordinator/status', { - context: { - ...mounted.context(), - providers: providers(worktrees.root), - }, + context: mounted.context(), input: {}, }); @@ -528,7 +546,6 @@ describe('worktree proximity journeys', () => { context: { ...mounted.context(), lineage: childLineageWithTree('agent-a', ['agent-b']), - providers: providers(worktrees.a), }, input: {}, }); @@ -625,12 +642,13 @@ 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. + // No explicit `providers`: the real `agent-topology` factory runs as the + // request's resolver and counts `notices.published()` for this principal. const statusFor = (conversation: string) => renderRoute('tool:coordinator/status', { context: { ...mounted.context(), host: available({ name: 'claude-code' }, 'native'), lineage: childLineage(conversation), - providers: providers(worktrees.root), session: available({ sessionId: 'mcp-session' }, 'native'), workspace: available({ root: worktrees.root }, 'derived'), }, @@ -674,6 +692,83 @@ 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, a read of the intent state, and published-notice counts (#459)', async () => { + await bindActors(); + await recordIntent('agent-a', worktrees.a, 'src/shared.ts', 'intent:a'); + // agent-b's intent publishes the proximity notice addressed to agent-a. + await recordIntent('agent-b', worktrees.b, 'src/shared.ts', 'intent:b'); + // The factory over the read-only view a generated scope hands it: the + // request's lineage, the mounted state handle (its `read` only), and the + // notice handle the request opened for agent-b's principal. + const topology = await runAgentRequest({ + ...mounted.context(), + host: available({ name: 'claude' }, 'native'), + invocation: { id: 'invocation:topology', kind: 'tool', startedAt: '2026-09-01T20:05:00.000Z' }, + lineage: childLineageWithTree('agent-b', ['agent-a']), + providers: providers(worktrees.b), + session: available({ sessionId: 'root-session' }, 'native'), + workspace: available({ root: worktrees.b }, 'native'), + }, async () => { + const request = await agent(); + const notices = request.notices!; + const state = request.state!; + return agentTopologyProvider({ + host: request.host, + invocation: { kind: 'tool', props: { input: {}, operationId: 'tool:coordinator/status' } }, + lineage: request.lineage, + notices: { inbox: () => notices.inbox(), published: () => notices.published() }, + plugin: request.plugin, + session: request.session, + signal: request.signal, + state: { lifetime: state.lifetime, read: (options) => state.read(options) }, + workspace: request.workspace, + }); + }); + 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', 'agent-b']); + expect(topology.intent.value.revision).toBeGreaterThan(0); + // agent-b published one notice (to agent-a), still pending: the provider counts exactly that. + expect(topology.notices).toEqual({ + acknowledged: 0, + attempted: 0, + expired: 0, + pending: 1, + state: 'available', + total: 1, + unavailable: 0, + withdrawn: 0, + }); + // Without a mounted state handle or notice ledger the provider says so + // instead of opening a store of its own. + const stateless = await agentTopologyProvider({ + host: unobserved, + invocation: { kind: 'cli', props: { args: [], command: 'status' } }, + lineage: { reason: 'unsupported-surface', state: 'unavailable' }, + plugin: unobserved, + session: unobserved, + signal: new AbortController().signal, + workspace: unobserved, + }); + expect(stateless).toEqual({ + agents: { reason: 'lineage unavailable (unsupported-surface)', state: 'unavailable' }, + intent: { reason: 'Intent state unavailable: this surface mounts no state handle.', state: 'unavailable' }, + notices: { + acknowledged: 0, + attempted: 0, + expired: 0, + pending: 0, + reason: 'Published notices unavailable: this surface mounts no notice ledger.', + state: 'unavailable', + total: 0, + unavailable: 0, + withdrawn: 0, + }, + }); + }); + 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..d325df176 --- /dev/null +++ b/packages/agent-bundle/fixtures/route-harness/src/providers/request-view.ts @@ -0,0 +1,53 @@ +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 (plugin root included) and lineage as the route will read + * them, the read-only `state`/`notices` handles (their keys prove the + * narrowing; `state.read()` and `notices.published()` are pure reads), 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(), + published: (await context.notices.published()).map((notice) => notice.id), + ...(inbox ? { inbox: (await context.notices.inbox()).map((notice) => notice.id) } : {}), + }, + plugin: context.plugin.state === 'available' ? context.plugin.value.stateRoot : context.plugin.reason, + 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..977e2b576 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`, `plugin`, `lineage`, `signal`, the `read`-only + * `state` handle, and the `inbox`/`published`-only `notices` handle) 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); @@ -952,7 +948,6 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo ? [] : [' 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 }),', @@ -961,7 +956,7 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo ...(options.state === undefined ? [] : [' noticeLedger: bindings.noticeLedger,']), ' plugin,', ' 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..bd692f5f6 100644 --- a/packages/agent-bundle/src/index.ts +++ b/packages/agent-bundle/src/index.ts @@ -48,8 +48,26 @@ export type { AgentLayoutRouteKind, AgentProviderContext, AgentProviderFactory, + AgentProviderHostIdentity, + AgentProviderLineage, + AgentProviderLineagePeer, + AgentProviderLineageResolution, + AgentProviderLineageSubagent, + AgentProviderLineageTree, + AgentProviderNotice, + AgentProviderNoticeAttempt, + AgentProviderNoticePublisher, + AgentProviderNoticeRecipient, + AgentProviderNoticesHandle, + AgentProviderNoticeState, + AgentProviderNoticeWithholding, + AgentProviderObserved, AgentProviderObservedPluginRoot, AgentProviderPluginRoot, + AgentProviderSessionIdentity, + AgentProviderStateHandle, + AgentProviderStateSnapshot, + AgentProviderWorkspaceIdentity, AgentTerminal, AgentTerminalColor, AgentTerminalStream, diff --git a/packages/agent-bundle/src/routes/index.ts b/packages/agent-bundle/src/routes/index.ts index 4af02fa8f..884c7c804 100644 --- a/packages/agent-bundle/src/routes/index.ts +++ b/packages/agent-bundle/src/routes/index.ts @@ -82,8 +82,26 @@ export type { AgentLayoutRouteKind, AgentProviderContext, AgentProviderFactory, + AgentProviderHostIdentity, + AgentProviderLineage, + AgentProviderLineagePeer, + AgentProviderLineageResolution, + AgentProviderLineageSubagent, + AgentProviderLineageTree, + AgentProviderNotice, + AgentProviderNoticeAttempt, + AgentProviderNoticePublisher, + AgentProviderNoticeRecipient, + AgentProviderNoticesHandle, + AgentProviderNoticeState, + AgentProviderNoticeWithholding, + AgentProviderObserved, AgentProviderObservedPluginRoot, AgentProviderPluginRoot, + AgentProviderSessionIdentity, + AgentProviderStateHandle, + AgentProviderStateSnapshot, + AgentProviderWorkspaceIdentity, 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..9ede21379 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. + */ +export interface ProviderRequestView { + readonly host: unknown; + readonly lineage: unknown; + readonly notices?: unknown; + /** The observed plugin root the request scope publishes (#468); handed to every factory unchanged. */ + 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..f943c5ba0 100644 --- a/packages/agent-bundle/src/routes/public.ts +++ b/packages/agent-bundle/src/routes/public.ts @@ -97,14 +97,38 @@ 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, provenance included. 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' }; + +/** Structurally the runtime's `AgentHostIdentity`: the host the request came through. */ +export interface AgentProviderHostIdentity { + readonly name: string; +} + +/** Structurally the runtime's `AgentSessionIdentity`: the host session the request belongs to. */ +export interface AgentProviderSessionIdentity { + readonly sessionId: string; +} + +/** Structurally the runtime's `AgentWorkspaceIdentity`: the workspace root the request runs in. */ +export interface AgentProviderWorkspaceIdentity { + readonly root: string; +} + /** * 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; @@ -112,16 +136,177 @@ export interface AgentProviderPluginRoot { } /** 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' }; +export type AgentProviderObservedPluginRoot = AgentProviderObserved; + +/** Structurally the runtime's `AgentLineageSubagent`. */ +export interface AgentProviderLineageSubagent { + readonly id: string; + readonly isParallelWorker?: boolean; + readonly toolCallId?: string; + readonly type?: string; +} + +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). */ +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 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; +} + +/** 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 and for surfaces that mount none. + */ +export interface AgentProviderStateHandle { + readonly lifetime: 'request' | 'process' | 'workspace-durable' | 'external'; + read(options?: { readonly revision?: number; readonly signal?: AbortSignal }): Promise>; +} + +export type AgentProviderNoticeState = 'pending' | 'attempted' | 'expired' | 'unavailable' | 'withdrawn' | 'acknowledged'; + +/** + * Structurally the runtime's `AgentRecipient`: the conjunction of identity + * axes a notice is addressed to. `conversation` and `root` are lineage ids. + */ +export interface AgentProviderNoticeRecipient { + readonly actor?: { readonly id: string }; + readonly conversation?: string; + readonly host?: AgentProviderHostIdentity; + readonly root?: string; + readonly session?: AgentProviderSessionIdentity; + readonly workspace?: AgentProviderWorkspaceIdentity; +} + +/** Structurally the runtime's `AgentNoticePublisher`: the identity `publish()` recorded (#460). */ +export interface AgentProviderNoticePublisher { + readonly actor?: { readonly id: string }; + readonly conversation?: string; + readonly host?: AgentProviderHostIdentity; + readonly session?: AgentProviderSessionIdentity; + readonly workspace?: AgentProviderWorkspaceIdentity; +} + +/** Structurally the runtime's `AgentNoticeAttemptReceipt`. */ +export interface AgentProviderNoticeAttempt { + readonly attemptedAt: string; + readonly channel: 'next-event'; + readonly invocationId: string; +} -/** Request-scoped inputs supplied to a conventional context provider factory. */ +/** Structurally the runtime's `AgentNoticeWithholding`: a route's refusal to disclose the notice. */ +export interface AgentProviderNoticeWithholding { + readonly count: number; + readonly firstAt: string; + readonly lastAt: string; + readonly reason: 'route-unavailable' | 'sensitivity-exceeds-route'; +} + +/** + * One notice as a provider reads it from `inbox()` or `published()`: every + * field of the runtime's `AgentNotice`, spelled structurally. `content` is the + * persisted Agent Document snapshot (the runtime's `AgentDocumentSnapshot`); + * it is `unknown` here because the Agent Document types ship with the runtime, + * so a provider that needs the authored text narrows it with the runtime's + * types — a route reads the same notice through `(await agent()).notices`. + */ +export interface AgentProviderNotice { + readonly acknowledgement?: { readonly acknowledgedAt: string; readonly invocationId: string }; + readonly attempts: readonly AgentProviderNoticeAttempt[]; + readonly availability?: { readonly channel: 'mcp-resource-updated'; readonly count: number; readonly firstAt: string; readonly lastAt: string }; + readonly availabilityReservation?: { readonly at: string; readonly key: string }; + readonly content: unknown; + readonly createdAt: string; + readonly dedupeKey?: string; + readonly expiredAt?: string; + readonly expiresAt?: string; + readonly exposure?: { readonly channel: 'mcp-inbox'; readonly count: number; readonly firstAt: string; readonly lastAt: string; readonly lastInvocationId: string }; + readonly id: string; + readonly nextAttemptAt?: string; + readonly priority: 'low' | 'normal' | 'high'; + readonly publisher?: AgentProviderNoticePublisher; + readonly recipient: AgentProviderNoticeRecipient; + readonly retryBudget?: number; + readonly sensitivity?: 'public' | 'internal' | 'secret'; + readonly state: AgentProviderNoticeState; + readonly unavailableAt?: string; + readonly unavailableReason?: 'delivery-authorization-unavailable'; + readonly withdrawnAt?: string; + readonly withheld?: Readonly>>; +} + +/** + * The read-only view of the request's notice handle a provider receives + * (#459): the runtime's `AgentNoticesHandle` narrowed by construction to + * `inbox` — pending notices addressed to this request's principal, as the + * `mcp-inbox` route discloses them — and `published` — what became of the + * notices this principal published, in every state (#460). Never `publish`, + * `acknowledge`, or the admission-bound `read`. Absent when the project mounts + * no notice ledger. + */ +export interface AgentProviderNoticesHandle { + inbox(): Promise; + published(): 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 (`host`, `session`, + * `workspace`, `plugin`) and `lineage` exactly as the route will read them + * from `await agent()` — the same `Observed` values, provenance included — plus + * read-only views of the mounted state and notice handles (#459). Providers + * run after the request's handles exist and 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 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 signal: AbortSignal; + /** Present only for projects that declare `src/state.ts`. */ + readonly state?: AgentProviderStateHandle; + readonly workspace: AgentProviderObserved; } /** 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..37b07c4e3 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,17 @@ 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 (`plugin` + * included), `state.read()`, and `notices.inbox()`/`published()` 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 +56,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 +124,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/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index a2f1e43d8..3682b7d11 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -603,12 +603,14 @@ 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('await provider.module.default({ ...request, invocation: message.invocation })'); + // The server's observed anchor rides each render message; the worker's own + // resolution backs it, and the request view hands the same value to providers. expect(source).toContain('const 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 +657,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 +719,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 +778,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; + // The runtime omits `notices`/`state` when the request mounted none; here state is mounted, notices not. + const plugin = { source: 'derived', state: 'available', value: { root: '/plugin', stateRoot: '/plugin/state' } } as const; + 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 invocation and, through the request view, the observed + // plugin root (#468) — the same value the request scope publishes. 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..57c732e82 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,49 @@ 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 + * plugin root is the harness's `.agent-bundle/state` anchor, #468), the + * runtime error `useAgent()` raises inside a provider, and — where the harness + * mounts state — the `read`-only state handle and the `inbox`/`published`-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', 'published'], published: [] } : null, + plugin: expect.stringMatching(/[\\/]\.agent-bundle[\\/]state$/u), + 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 +71,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 +85,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 +96,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 +108,53 @@ 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()` and `published()` are the real request-scoped reads: nothing + // is addressed to this principal and it published nothing. + notices: { inbox: [], keys: ['inbox', 'published'], published: [] }, + plugin: expect.stringMatching(/[\\/]\.agent-bundle[\\/]state$/u), + session: 'not-provided', + state: { keys: ['lifetime', 'read'], lifetime: 'process', revision: 0 }, + workspace: process.cwd(), }); }); @@ -84,9 +166,40 @@ 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', 'published'], published: [] }, + plugin: expect.stringMatching(/[\\/]\.agent-bundle[\\/]state$/u), + session: 'root', + state: { keys: ['lifetime', 'read'], lifetime: 'workspace-durable', revision: 0 }, + workspace: '/tmp/route-unit', }); }); @@ -96,8 +209,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 +220,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-context-mirrors.test.ts b/packages/agent-bundle/tests/provider-context-mirrors.test.ts new file mode 100644 index 000000000..b2e3019f3 --- /dev/null +++ b/packages/agent-bundle/tests/provider-context-mirrors.test.ts @@ -0,0 +1,78 @@ +import type { + AgentHostIdentity, + AgentLineage, + AgentNotice, + AgentNoticesHandle, + AgentPluginIdentity, + AgentProviderRequest, + AgentSessionIdentity, + AgentStateHandle, + AgentWorkspaceIdentity, + Observed, +} from '@agent-bundle/runtime'; +import { expect, it } from '@rstest/core'; + +import type { + AgentProviderContext, + AgentProviderHostIdentity, + AgentProviderLineage, + AgentProviderNotice, + AgentProviderNoticesHandle, + AgentProviderObserved, + AgentProviderPluginRoot, + AgentProviderSessionIdentity, + AgentProviderStateHandle, + AgentProviderWorkspaceIdentity, +} from '../src/index.ts'; + +/** + * `agent-bundle`'s root declarations must not import `@agent-bundle/runtime` + * (an optional peer a config-only consumer need not install), so the request + * view a provider receives is spelled structurally in `routes/public.ts` + * (#459). This test pins those mirrors to the runtime's own types: the + * identity axes and lineage are exact copies, the notice record covers every + * runtime field (with `content` opaque), and the read-only handles accept the + * runtime's narrowed handles — so a runtime change that drifts from the mirror + * fails to compile here instead of surprising a provider author. + */ + +type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Extends = A extends B ? true : false; +type Assert<_T extends true> = true; + +// Exact mirrors. +type _host = Assert>; +type _session = Assert>; +type _workspace = Assert>; +type _plugin = Assert>; +type _lineage = Assert>; +// `Observed` narrows `reason` to the runtime's reason union; the mirror admits any string. +type _observed = Assert, AgentProviderObserved>>; + +// The notice record: every runtime field is present, and every runtime notice is a provider notice. +type _noticeKeys = Assert>; +type _notice = Assert>; + +// The read-only handles: the runtime's narrowed handles satisfy the mirrors … +type _state = Assert, AgentProviderStateHandle>>; +type _notices = Assert, AgentProviderNoticesHandle>>; +// … and the runtime's whole request view, beside the surface invocation, is a provider context. +type _request = Assert, AgentProviderContext>>; + +// Type-level read-only: no write path is spelled on the provider context. +type StateKeys = keyof NonNullable; +type NoticeKeys = keyof NonNullable; +type _stateReadOnly = Assert>; +type _noticesReadOnly = Assert>; +type _noDispatch = Assert, false>>; +type _noPublish = Assert, false>>; + +it('pins the provider context mirrors to the runtime types (#459)', () => { + // The assertions above are compile-time; this keeps the file in the suite. + const pinned: [ + _host, _session, _workspace, _plugin, _lineage, _observed, + _noticeKeys, _notice, _state, _notices, _request, + _stateReadOnly, _noticesReadOnly, _noDispatch, _noPublish, + ] = [true, true, true, true, true, true, true, true, true, true, true, true, true, true, true]; + expect(pinned.every(Boolean)).toBe(true); +}); diff --git a/packages/agent-bundle/tests/provider-typegen.test.ts b/packages/agent-bundle/tests/provider-typegen.test.ts index cbb4264c3..060a5c9d5 100644 --- a/packages/agent-bundle/tests/provider-typegen.test.ts +++ b/packages/agent-bundle/tests/provider-typegen.test.ts @@ -60,11 +60,24 @@ it('types (await agent()).providers. from the generated provider declaratio '});', '', ].join('\n')), + // The factory reads the request view a provider receives (#459): the + // identity axes (plugin root included), 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 published: readonly string[]; readonly revision: number | undefined; readonly siblings: number | undefined; readonly stages: readonly string[]; readonly stateRoot: string | undefined; readonly surface: string; }', + 'export default async function library({ invocation, lineage, notices, plugin, state }: AgentProviderContext): Promise {', + ' const snapshot = state === undefined ? undefined : await state.read();', + " const pending = notices === undefined ? [] : (await notices.inbox()).map((notice) => notice.id);", + " const published = notices === undefined ? [] : (await notices.published()).map((notice) => `${notice.id}:${notice.state}`);", + ' return {', + ' published,', + ' revision: snapshot?.revision,', + " siblings: lineage.state === 'available' ? lineage.value.tree?.siblings.length : undefined,", + " stages: ['discover', ...pending],", + " stateRoot: plugin.state === 'available' ? plugin.value.stateRoot : undefined,", + ' surface: invocation.kind,', + ' };', '}', '', ].join('\n')), @@ -123,9 +136,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 = { published: [], revision: undefined, siblings: undefined, stages: ['discover'], stateRoot: undefined, 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 +155,29 @@ 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 = { published: [], revision: undefined, siblings: undefined, stages: ['discover'], stateRoot: undefined, surface: 'tool' };", "export const partial = renderRoute('tool:curator/status', { context: { providers: { library } } });", '', ].join('\n')), + // The write paths are not on the provider context: a factory that reaches + // for `state.dispatch`, `notices.publish`, or `notices.acknowledge` does + // not compile (#459). + writeProjectFile(root, 'writing-provider.ts', [ + "import type { AgentProviderContext } from 'agent-bundle';", + 'export default async function writing({ notices, state }: AgentProviderContext): Promise {', + " await state?.dispatch('noted', {}, { idempotencyKey: 'k' });", + " await notices?.publish({ content: { root: { kind: 'text', text: '' }, status: 'success', version: 1 }, priority: 'low', recipient: {} }, { idempotencyKey: 'k' });", + " await notices?.acknowledge('n-1');", + '}', + '', + ].join('\n')), + writeProjectFile(root, 'missing-resolver.ts', [ + "import { runAgentRequest } from '@agent-bundle/runtime';", + "import type { LibraryContext } from './src/providers/library.js';", + "const library: LibraryContext = { published: [], revision: undefined, siblings: undefined, stages: ['discover'], stateRoot: undefined, surface: 'tool' };", + "export const partial = runAgentRequest({ invocation: { kind: 'tool' }, providers: async () => ({ library }) }, async () => undefined);", + '', + ].join('\n')), ]); const result = await inspect({ root }); @@ -165,4 +199,12 @@ 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"); + const writing = typecheck(root, 'writing-provider.ts'); + expect(writing).toHaveLength(3); + expect(writing[0]).toContain("Property 'dispatch' does not exist on type 'AgentProviderStateHandle'"); + expect(writing[1]).toContain("Property 'publish' does not exist on type 'AgentProviderNoticesHandle'"); + expect(writing[2]).toContain("Property 'acknowledge' does not exist on type 'AgentProviderNoticesHandle'"); }); diff --git a/packages/agent-bundle/tests/route-unit/render-route.test.ts b/packages/agent-bundle/tests/route-unit/render-route.test.ts index fe21ac485..cc27bc0c3 100644 --- a/packages/agent-bundle/tests/route-unit/render-route.test.ts +++ b/packages/agent-bundle/tests/route-unit/render-route.test.ts @@ -256,7 +256,10 @@ describe('renderRoute through the real renderer', () => { openRequest: async () => Object.freeze({ close: () => undefined, handle: Object.freeze({ + // The `request-view` fixture provider reads `published()` on every request (#459). + inbox: async () => [], publish: async () => { throw new Error('unused in journal route'); }, + published: async () => [], read: async () => ({ notices: [] }), }), }), 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..7cf242521 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,67 @@ 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 `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. + * The read-only view of the request's notice handle a provider resolver + * receives (#459): `inbox` (what is pending for this request's principal) and + * `published` (what became of the notices this principal published, #460) — + * never `publish`, `acknowledge`, or the admission-bound `read`. + */ +export type AgentProviderNoticesHandle = Pick; + +/** + * What a provider resolver may read of the request it runs for (#459): the + * observed identity axes (`host`, `session`, `workspace`, `plugin`), 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()`, provenance included. + */ +export interface AgentProviderRequest { + readonly host: Observed; + readonly lineage: Observed; + /** Present only when the request opened a notice lease (`noticeLedger` supplied). */ + readonly notices?: AgentProviderNoticesHandle; + /** The plugin install root and durable-state anchor the scope resolved (#468). */ + 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 +778,82 @@ 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 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)); + +interface ProviderRequestSources { + 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. The handles are + * narrowed by construction, not by type alone: each is a fresh frozen object + * holding only the read methods, so a factory that casts or spells a property + * dynamically still finds no `dispatch`, `publish`, or `acknowledge` on it. + */ +const providerRequest = ({ notices, state, ...axes }: ProviderRequestSources): AgentProviderRequest => Object.freeze({ + ...axes, + ...(notices === undefined + ? {} + : { notices: Object.freeze({ inbox: () => notices.inbox(), published: () => notices.published() }) }), + ...(state === undefined + ? {} + : { state: Object.freeze({ lifetime: state.lifetime, read: (options?: AgentStateReadOptions) => state.read(options) }) }), +}); 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..aa72900cb 100644 --- a/packages/rsc-runtime/tests/agent-request.test.ts +++ b/packages/rsc-runtime/tests/agent-request.test.ts @@ -328,6 +328,119 @@ 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'); }, + published: async () => { events.push('published'); return [{ id: 'n-1', state: 'attempted' }]; }, + 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, + plugin: available({ root: '/plugin', stateRoot: '/plugin/state' }, 'native'), + 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(); + const published = await request.notices!.published(); + return { + topology: { + pending: pending.length, + published: published.map((notice) => notice.state), + revision: snapshot.revision, + siblings: request.lineage.state === 'available' ? request.lineage.value.tree?.siblings.length : undefined, + stateRoot: request.plugin.state === 'available' ? request.plugin.value.stateRoot : request.plugin.reason, + }, + }; + }, + 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 and published view. + expect(events).toEqual(['open:evt-1', 'providers', 'inbox', 'published', 'operation', 'close']); + expect(result).toEqual({ topology: { pending: 0, published: ['attempted'], revision: 3, siblings: 0, stateRoot: '/plugin/state' } }); + 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).sort()).toEqual(['inbox', 'published']); + expect(Object.isFrozen(view!['state'])).toBe(true); + expect(Object.isFrozen(view!['notices'])).toBe(true); + expect(view!['host']).toEqual(available({ name: 'claude' }, 'native')); + expect(view!['plugin']).toEqual(available({ root: '/plugin', stateRoot: '/plugin/state' }, 'native')); + 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..64c837938 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -175,6 +175,39 @@ 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`, `plugin`, and `lineage` (tree included), the +same `Observed` values with the same provenance — plus read-only views of the mounted handles: +`state` (`read` only) when the project declares `src/state.ts`, `notices` (`inbox` and +`published` only) when the scope mounts the notice ledger. A provider can therefore derive a view +of shared state — a topology, a peers list, what became of the notices this agent published — 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..50ebe075c 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -158,6 +158,35 @@ interface AgentTerminal { [包入口](./package-entries.mdx#可执行封套))。在测试中,`invokeCli` 与 `runScript` 的 `tty` 开关会塑造 一个确定性的合成值,而 `context.terminal` 则可以像注入任何身份轴一样注入其他取值。 +### 请求上下文 provider + +约定式的 `src/providers/.ts` 模块默认导出一个工厂函数,其返回值在每个生成表面上挂载到 +`(await agent()).providers.`。除了表面特定的 `invocation` 和请求 `signal` 之外,工厂 +还会收到路由将要读到的请求本身——`host`、`session`、`workspace`、`plugin` 与 `lineage`(含 `tree`), +即同样的 `Observed` 值、同样的来源——以及已挂载句柄的只读视图:项目声明了 `src/state.ts` 时的 +`state`(仅 `read`),作用域挂载了通知账本时的 `notices`(仅 `inbox` 与 `published`)。因此 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`。被发现的条目在