feat(providers): hand context providers the request identity, lineage tree, and read-only state/notices handles (#459) - #552
Conversation
… tree, and read-only state/notices handles (#459)
…provider request view; pin the agent-bundle mirrors to the runtime types
… the typegen write-denial check, and refresh the proximity README
🦋 Changeset detectedLatest commit: 47aed36 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
commit: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2b04bee2c3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| 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({ |
There was a problem hiding this comment.
Mount manifest state for plain-CLI provider resolvers
When invokeCli runs a plain .ts command for a manifest that declares src/state.ts, this resolver is passed to runAgentRequest without either state or noticeLedger, so its providers observe both handles as undefined. The generated executable instead opens runtimeState.requestBindings() and supplies both handles before provider resolution (src/build/entry-shell.ts:379-399), meaning a plain-command test can pass with provider behavior different from the built CLI; the new assertion in projection/providers.test.ts:77-78 currently codifies that mismatch.
Useful? React with 👍 / 👎.
Fixes #459.
Why
A conventional
src/providers/<name>.tsfactory received only{ invocation, plugin, signal }, because every generated scope ran its provider loop beforerunAgentRequestopened the request. A provider therefore could not read the request's identity, itslineage(with #457'stree), or the mountedstate/noticeshandles — which is exactly whatexamples/worktree-proximity'sagent-topologyprovider existed to expose, so it shipped as a permanentunavailablestub and routes read the tree and the intent state themselves.Design
Ordering — the request opens first, then providers, then the route.
runAgentRequestnow acceptsprovidersas either the values record or anAgentProviderResolver(request: AgentProviderRequest) => values. When given a resolver it runs it after the identity axes are frozen and the notice lease is open, and before the operation — outside the request's async context (AsyncLocalStorage#exit), soagent()/useAgent()inside a factory still throwoutside-invocationexactly as they did when providers ran before the request. The resolved record is frozen and mounted as(await agent()).providers; a rejecting resolver fails the request closed and still closes the notice lease.Why this ordering rather than a lazy handle that resolves once mounted: the notices handle is created by
noticeLedger.openRequest(), which foreventinvocations performs the admission (a state transition keyed by the invocation id and principal). A provider-only lease opened before the request would double-admit or need the invocation pre-computed; a lazy handle that waits for the request to open deadlocks any factory that awaits it in its own body. Handing providers the request's own handles after they exist keepsprovider-executiona plain sequential loop over one context object — no deferred promises, no phase flags — and theentry-shellscopes emit the same loop as theproviders:field of theirrunAgentRequestinit.API shape.
AgentProviderContext(agent-bundle,packages/agent-bundle/src/routes/public.ts) gains:dispatch,publish,acknowledge, and the admission-boundnotices.read()are not on the context — type-level (the mirrors spell only the read members) and at run time (the runtime builds fresh frozen objects holding onlyread/inbox+published, so a cast or dynamic property finds nothing else;packages/rsc-runtime/src/agent-request.tsproviderRequest). No actor derivation (#391);lineageis the identity surface (#444); every axis is the request's ownObservedvalue,source/reasonintact.agent-bundle's root declarations cannot import@agent-bundle/runtime(an optional peer a config-only consumer need not install), so the view is spelled structurally there — following #532'sAgentProviderPluginRootand theAgentTerminalprecedent — and pinned to the runtime's types bypackages/agent-bundle/tests/provider-context-mirrors.test.ts: identity and lineage are exact copies,AgentProviderNoticecovers everyAgentNoticekey (contentopaque, since the Agent Document types ship with the runtime), and the runtime's narrowed handles are assignable to the mirrors.Runtime (
@agent-bundle/runtime).AgentProviderRequest(host,session,workspace,plugin,lineage,signal,state?: Pick<AgentStateHandle, 'lifetime' | 'read'>,notices?: Pick<AgentNoticesHandle, 'inbox' | 'published'>),AgentProviderResolver,AgentProviderStateHandle,AgentProviderNoticesHandle;AgentRequestProvidersInitadmits the resolver beside the record. The request store version is unchanged (no new handle getters).Generated scopes (
packages/agent-bundle/src/build/entry-shell.ts,providersFieldSource): the Flight worker, the rendered CLI/script worker, and the plain routed CLI all emitproviders: async (request) => { … provider.module.default({ ...request, invocation }) … }; the pre-request loop and itsplugin/signalexpressions are gone (the view carries both).executeProviders(routes/provider-execution.ts) takes the samerequestview and is what theagent-bundle/testharness passes as the resolver (test/providers.tsmountProvidersnow returns values or a resolver;render.ts,cli.ts,mcp.tspass it through).examples/worktree-proximitysrc/providers/agent-topology.tsstops being a stub: it returns{ agents, intent, notices }— the tree fromcontext.lineage(agentTreeOf), onecontext.state.read()parsed againstIntentStateSchema, and the #460 published-notice counts fromcontext.notices.published(), each with its own availability and reason.src/mcp/coordinator/tools/status.tsxnow reads(await agent()).providers.agentTopologyand performs no read of its own; it no longer importswithIntent,withNotices,AGENT_NOTICE_STATES, oragentTree. Deleted: the stub'sAgentTopologyProviderValue({ reason, state: 'unavailable' }) and its explanatory comment, theagentTree()helper inevent-support.ts(agentTreeOfstays, typed over either the runtime's or the provider's observed lineage), andstatus.tsx'semptyCounts/publishedNotices. Event routes keepwithIntent/withNoticesbecause they dispatch and publish. README updated.Tests
packages/rsc-runtime/tests/agent-request.test.ts— resolver order (open → providers → inbox → published → operation → close), the view's exact keys (host, lineage, notices, plugin, session, signal, state, workspace), frozen narrowed handles (state: lifetime, read;notices: inbox, published),useAgent()→outside-invocationinside the resolver (also under an enclosing request), rejection fails closed and closes the lease, plain record unchanged.packages/agent-bundle/tests/provider-context-mirrors.test.ts(new) — compile-time pins of every mirror to the runtime type, pluskeyof state === 'lifetime' | 'read',keyof notices === 'inbox' | 'published', nodispatch/publish/acknowledge/read.packages/agent-bundle/tests/provider-typegen.test.ts— a real project's provider destructures{ invocation, lineage, notices, plugin, state }and readspublished(); a resolver is typed against the declared keys; a factory callingstate.dispatch/notices.publish/notices.acknowledgefails to compile with the three expected messages.packages/agent-bundle/tests/entry-shell.test.ts— the emittedproviders: async (request) => {field on all three scopes,{ ...request, invocation }spread,executeProvidersreceives the view (pluginincluded) and hands factories['host', 'invocation', 'lineage', 'plugin', 'session', 'signal', 'state', 'workspace'].packages/agent-bundle/tests/projection/providers.test.ts— the newrequest-viewfixture provider (fixtures/route-harness/src/providers/request-view.ts) reportshandle: 'outside-invocation', the plugin root,state: { keys: ['lifetime','read'] },notices: { keys: ['inbox','published'], published: [] }on the plain CLI, rendered CLI, projected MCP command, in-memory MCP server (with a registry-resolved lineage tree and a realinbox()), route-unit renders (with injected identity), and rendered scripts.examples/worktree-proximity/tests/route-unit/routes.test.ts— coordinatorstatusrenders now pass noproviders, so the harness runs the real providers as the request's resolver; new test assembles the provider value inside a request (tree, intent bindings/activities,notices: { pending: 1, total: 1 }for the publisher) and the stateless/ledgerless path. 17 route-unit tests.packages/agent-bundle/tests/worktree-proximity-journeys.test.ts— unchanged and passing: the real artifact's Flight worker runs the provider as the resolver andstatus(a tool call correlated through the root's hook window) reports the registry-fed tree fromproviders.agentTopology, across a server restart and afteragent/stop.Docs
docs/entry-conventions.md(provider contract),website/docs/{en,zh}/guide/authoring/mcp.mdx("Request context providers" section beside the terminal capability),website/docs/{en,zh}/guide/start/project-structure.mdx(providers row). Changeset.changeset/459-provider-request-context.md(agent-bundle+@agent-bundle/runtimepatch).Verification
pnpm build,pnpm typecheck,pnpm lint,pnpm test:unit(3332; seven unrelated files timed out once under a concurrent run and pass alone),pnpm test:projection(167),pnpm test:route-unit(83),worktree-proximity-journeys.test.ts(integration pool),examples/worktree-proximitycheck(6 unit + 17 route-unit),examples/host-testtypecheck + route-unit (7),pnpm docs:site:build.Coordination
Built on #544 (
request.lineage.tree), #532 (request.plugin— the view carries the same observed value the scope publishes), #539/#541 (recipient.conversation,notices.published()), and #545 (canonical.payload). Rebased ontomainat415583176(#550).Self-review
Reviewer: the author (this lane runs without subagents, per its brief), reading the full diff vs
origin/mainat415583176after the rebase. Findings and disposition:runAgentRequestopened, so a pending notice stayed pending; now the lease has admitted (and receipted) deliveries for this invocation before the factory runs, so a factory that throws consumes that attempt exactly as a route that throws after admission does. Accepted: this is the cost of the chosen ordering (the alternative — a provider-only lease or a lazy handle — double-admits or deadlocks, see Design), the behavior matches a failing route, anddocs/entry-conventions.mdsays so; providers are documented to return an unavailable-shaped value rather than throw.entry-shell.tsprovidersFieldSourcedoc listedread/inboxonly. Fixed (plugin,publishednamed).render-route.test.tslackedinbox/published, so the fixture provider's eagerpublished()threw. Fixed by completing the stub to theAgentNoticesHandlecontract; real ledgers always carry both.stateandpublished()on every request, including the six event routes that only need the tree. Accepted: one extra ledger read per hook in a reference example, in exchange forstatusperforming no read of its own (the issue's acceptance); noted in the example README.agent-bundlecan drift from the runtime. Mitigated:provider-context-mirrors.test.tspins every mirror at compile time (exact for identity/lineage, key-complete for notices, assignable for the handles), sopnpm typecheckfails on drift.AGENT_REQUEST_STORE_VERSIONbump is needed (the handle shape is unchanged;providersis still a frozen record), and existing factories destructuring{ invocation, plugin, signal }compile unchanged — verified by the route-harnesslibrary-toolingprovider andgenerated-route-server.test.ts.