Skip to content

feat(providers): hand context providers the request identity, lineage tree, and read-only state/notices handles (#459) - #552

Merged
ScriptedAlchemy merged 5 commits into
mainfrom
feat/459-provider-request-context
Sep 4, 2026
Merged

feat(providers): hand context providers the request identity, lineage tree, and read-only state/notices handles (#459)#552
ScriptedAlchemy merged 5 commits into
mainfrom
feat/459-provider-request-context

Conversation

@ScriptedAlchemy

@ScriptedAlchemy ScriptedAlchemy commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Fixes #459.

Why

A conventional src/providers/<name>.ts factory received only { invocation, plugin, signal }, because every generated scope ran its provider loop before runAgentRequest opened the request. A provider therefore could not read the request's identity, its lineage (with #457's tree), or the mounted state/notices handles — which is exactly what examples/worktree-proximity's agent-topology provider existed to expose, so it shipped as a permanent unavailable stub and routes read the tree and the intent state themselves.

Design

Ordering — the request opens first, then providers, then the route. runAgentRequest now accepts providers as either the values record or an AgentProviderResolver (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), so agent()/useAgent() inside a factory still throw outside-invocation exactly 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 for event invocations 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 keeps provider-execution a plain sequential loop over one context object — no deferred promises, no phase flags — and the entry-shell scopes emit the same loop as the providers: field of their runAgentRequest init.

API shape. AgentProviderContext (agent-bundle, packages/agent-bundle/src/routes/public.ts) gains:

interface AgentProviderContext {
  invocation: AgentProviderInvocation;                       // unchanged
  signal: AbortSignal;                                       // unchanged
  plugin: AgentProviderObserved<AgentProviderPluginRoot>;    // #468, unchanged shape
  host: AgentProviderObserved<{ name }>;                     // new — the same Observed values the
  session: AgentProviderObserved<{ sessionId }>;             //   route reads on `await agent()`,
  workspace: AgentProviderObserved<{ root }>;                //   provenance included
  lineage: AgentProviderObserved<AgentProviderLineage>;      // new — own chain + live `tree` (#457)
  state?: { lifetime; read(options?) };                      // new — read only
  notices?: { inbox(); published() };                        // new — reads only (#460)
}

dispatch, publish, acknowledge, and the admission-bound notices.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 only read / inbox+published, so a cast or dynamic property finds nothing else; packages/rsc-runtime/src/agent-request.ts providerRequest). No actor derivation (#391); lineage is the identity surface (#444); every axis is the request's own Observed value, source/reason intact.

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's AgentProviderPluginRoot and the AgentTerminal precedent — and pinned to the runtime's types by packages/agent-bundle/tests/provider-context-mirrors.test.ts: identity and lineage are exact copies, AgentProviderNotice covers every AgentNotice key (content opaque, 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; AgentRequestProvidersInit admits 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 emit providers: async (request) => { … provider.module.default({ ...request, invocation }) … }; the pre-request loop and its plugin/signal expressions are gone (the view carries both). executeProviders (routes/provider-execution.ts) takes the same request view and is what the agent-bundle/test harness passes as the resolver (test/providers.ts mountProviders now returns values or a resolver; render.ts, cli.ts, mcp.ts pass it through).

examples/worktree-proximity

src/providers/agent-topology.ts stops being a stub: it returns { agents, intent, notices } — the tree from context.lineage (agentTreeOf), one context.state.read() parsed against IntentStateSchema, and the #460 published-notice counts from context.notices.published(), each with its own availability and reason. src/mcp/coordinator/tools/status.tsx now reads (await agent()).providers.agentTopology and performs no read of its own; it no longer imports withIntent, withNotices, AGENT_NOTICE_STATES, or agentTree. Deleted: the stub's AgentTopologyProviderValue ({ reason, state: 'unavailable' }) and its explanatory comment, the agentTree() helper in event-support.ts (agentTreeOf stays, typed over either the runtime's or the provider's observed lineage), and status.tsx's emptyCounts/publishedNotices. Event routes keep withIntent/withNotices because 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-invocation inside 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, plus keyof state === 'lifetime' | 'read', keyof notices === 'inbox' | 'published', no dispatch/publish/acknowledge/read.
  • packages/agent-bundle/tests/provider-typegen.test.ts — a real project's provider destructures { invocation, lineage, notices, plugin, state } and reads published(); a resolver is typed against the declared keys; a factory calling state.dispatch / notices.publish / notices.acknowledge fails to compile with the three expected messages.
  • packages/agent-bundle/tests/entry-shell.test.ts — the emitted providers: async (request) => { field on all three scopes, { ...request, invocation } spread, executeProviders receives the view (plugin included) and hands factories ['host', 'invocation', 'lineage', 'plugin', 'session', 'signal', 'state', 'workspace'].
  • packages/agent-bundle/tests/projection/providers.test.ts — the new request-view fixture provider (fixtures/route-harness/src/providers/request-view.ts) reports handle: '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 real inbox()), route-unit renders (with injected identity), and rendered scripts.
  • examples/worktree-proximity/tests/route-unit/routes.test.ts — coordinator status renders now pass no providers, 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 and status (a tool call correlated through the root's hook window) reports the registry-fed tree from providers.agentTopology, across a server restart and after agent/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/runtime patch).

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-proximity check (6 unit + 17 route-unit), examples/host-test typecheck + 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 onto main at 415583176 (#550).

Self-review

Reviewer: the author (this lane runs without subagents, per its brief), reading the full diff vs origin/main at 415583176 after the rebase. Findings and disposition:

  1. Provider failure now happens after notice admission. Previously a throwing factory aborted before runAgentRequest opened, 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, and docs/entry-conventions.md says so; providers are documented to return an unavailable-shaped value rather than throw.
  2. entry-shell.ts providersFieldSource doc listed read/inbox only. Fixed (plugin, published named).
  3. A stub ledger in render-route.test.ts lacked inbox/published, so the fixture provider's eager published() threw. Fixed by completing the stub to the AgentNoticesHandle contract; real ledgers always carry both.
  4. The proximity provider reads state and published() 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 for status performing no read of its own (the issue's acceptance); noted in the example README.
  5. Structural mirrors in agent-bundle can drift from the runtime. Mitigated: provider-context-mirrors.test.ts pins every mirror at compile time (exact for identity/lineage, key-complete for notices, assignable for the handles), so pnpm typecheck fails on drift.
  6. No AGENT_REQUEST_STORE_VERSION bump is needed (the handle shape is unchanged; providers is still a frozen record), and existing factories destructuring { invocation, plugin, signal } compile unchanged — verified by the route-harness library-tooling provider and generated-route-server.test.ts.

…provider request view; pin the agent-bundle mirrors to the runtime types
… the typegen write-denial check, and refresh the proximity README
@changeset-bot

changeset-bot Bot commented Sep 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 47aed36

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@agent-bundle/runtime Patch
agent-bundle Patch

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

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-04T19:22:24.745533Z 2b04bee PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@pkg-pr-new

pkg-pr-new Bot commented Sep 4, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle@552
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/create-agent-bundle@552
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@552

commit: 47aed36

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

@ScriptedAlchemy
ScriptedAlchemy enabled auto-merge (squash) September 4, 2026 19:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pass request identity, lineage, and read-only state/notices handles to context providers

1 participant