feat(routes): pass request identity, lineage, and read-only state/notices to context providers (#459) - #556
feat(routes): pass request identity, lineage, and read-only state/notices to context providers (#459)#556ScriptedAlchemy wants to merge 4 commits into
Conversation
…ices to context providers (#459) Widen `AgentProviderContext` with `host`, `session`, `workspace`, `lineage` (live tree included), `plugin`, and read-only `state` (`lifetime`, `read`) / `notices` (`inbox`) views, exactly as the route observes them on `await agent()`. Providers now run as the request's own resolver: `runAgentRequest` accepts `providers` as an `AgentProviderResolver` `(request: AgentProviderRequest) => values` beside the plain record and runs it after the identity axes are frozen and the notice lease is open, before the operation, outside the request's async context (`agent()`/`useAgent()` inside a factory throw `outside-invocation`). Every generated request scope (Flight worker, rendered CLI/script worker, plain routed CLI) and the `agent-bundle/test` harness emit/mount that resolver instead of executing providers ahead of the request. worktree-proximity: `agent-topology` returns an available snapshot built from `context.lineage` (tree) and `context.state.read()`; the coordinator `status` tool reads `providers.agentTopology`.
🦋 Changeset detectedLatest commit: 9dfb7ee 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: 71060133e9
ℹ️ 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".
| 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 |
There was a problem hiding this comment.
Update the remaining provider-ordering reference
This new ordering contradicts docs/framework-mode.md:150-152, which still tells users that providers run “before the request scope opens.” That guidance is now operationally wrong: custom hosts following it cannot supply the newly promised frozen identity axes or mounted state/notice handles. Update that reference to describe resolution after the notice lease opens and before the route runs.
AGENTS.md reference: AGENTS.md:L78-L81
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in abe3f11: docs/framework-mode.md now states that providers resolve after runAgentRequest freezes the identity axes and opens the notice lease, before the route, and lists the fields the factory context carries; it points at entry-conventions.md for the full contract. A search for any other "before the request scope" wording across docs/, website/docs/{en,zh}, and the example READMEs found no remaining instance.
|
Closing as a duplicate of #552, which was opened first, carries the broader provider view (plugin, notices.published()) and is armed for auto-merge. The docs/framework-mode.md ordering fix from this PR will be carried in a follow-up. |
…fix framework-mode.md ordering (from #556)
… MCP negotiation test; provider-view cross-process coverage (integration) (#554) * fix(scripts): choose the TypeScript transform flag Node actually supports (Node 26 drops --experimental-transform-types) runScript spawned every plain .ts script under node --experimental-transform-types. Node 26 removed the flag (nodejs/node#61803) and rejects it as a bad option (exit code 9), so the Verify (Node 26) leg failed on every main push. typeScriptTransformFlags (core/runtime.ts) decides from process.allowedNodeEnvironmentFlags: the transform flag where the binary accepts it (Node 22, 24), nothing on Node 26, which strips types unflagged. Unit-tested against the flag sets of each release line. * chore: name #554 in the changeset * fix(scripts): name --strip-types on Node 26 so an inherited NODE_OPTIONS=--no-strip-types cannot switch TypeScript loading off Codex review on #554: with no command-line flag the child inherited the environment's --no-strip-types and failed on every typed .ts source. The helper now picks the first flag the binary accepts, strongest first: --experimental-transform-types (22, 24), then --strip-types (26). Covered by a script-dispatch test that sets the version-appropriate switch in NODE_OPTIONS and expects the source run to succeed regardless. * test(routes): prove the provider request view across built surfaces; fix framework-mode.md ordering (from #556) * fix(install): apply the operator .env layer before plugin modules evaluate and below manifest env defaults (#469) Two findings from the #538 self-review. Precedence: a host merges the stdio server's manifest `env` block into the child environment, so the shell could not tell a manifest default from a host export and reserved both — manifest env beat the file, contrary to the documented `manifest < .env < .env.local < process.env`. The emitted stdio entry now embeds the server's normalized `env` block as build-time literals and `applyOperatorEnv` takes it as `manifestEnv`: a present variable is reserved only when its value differs from the embedded default, so a passed-through default yields to the file while a host or operator export is kept. An operator export equal to the default is indistinguishable from the pass-through and yields too; a default carrying a path token never equals its expanded value and is always kept. Host manifests are unchanged. Import timing: the layer was a statement after the consumer imports, and ESM evaluates static imports first, so module-level `process.env` reads in hook handlers and CLI route/provider modules never saw the file. A dynamic `import()` after the statement does not help either — Rspack inlines a single-chunk bundle into one scope and places the dynamic target ahead of the static imports. The layer is now a generated virtual module (`agent-bundle/launch-env-layer`) that every stdio entry, hook wrapper, and artifact CLI bin imports first, with the server module, handler, routes, providers, and state definition as static imports after it; the build marks generated modules side-effectful so a consumer `"sideEffects": false` cannot drop the bare import. The MCP shell's `loadEntry` becomes a static import for the same reason, so the console guard now covers the factory call and the running server rather than the module's top-level evaluation. Tests build each shell through the real pipeline and run it under node with a `process.env` read at module top level: manifest-only key takes the file, host-exported key keeps the host value, absent key takes the file, `AGENT_BUNDLE_ENV_FILE=none` restores the previous behaviour. * chore: drop the tracked .superpowers scratch notes and ignore the folder * fix(mcp): install the stdout guard in the stdio entry's first import so module-scope writes never reach the protocol stream (#469) The env-precedence follow-up made the generated stdio entry import the server module statically so the operator .env layer lands by import order — but that put the module's top level ahead of the console guard that `runGeneratedStdioMcpEntry` installs in the shell body. A `console.log` or `process.stdout.write` at module scope in a consumer's server or tool module reached stdout, which carries JSON-RPC framing, contradicting the documented guarantee that redirection precedes the consumer module's evaluation. The stdio shell now imports a generated prelude (`agent-bundle/stdio-prelude`) as its first import: it calls `redirectConsoleToStderr` from `agent-bundle/mcp-entry`, then applies the operator .env layer with the server's manifest env defaults. Hook wrappers and the artifact CLI bin keep the env-only layer (`agent-bundle/launch-env-layer`) — they legitimately write stdout. The guard has one implementation: `redirectConsoleToStderr` returns the guard already installed (recognised by `process.stdout.write` still being its redirect) instead of stacking a second, which would capture the redirect as the original and restore stdout to stderr; the lifecycle adopts the prelude's guard and restores raw stdout from it before serving. Tests: a built stdio entry whose server module writes `console.log('hello')` and `process.stdout.write('raw\n')` at module scope, driven by a real stdio client through initialize, tools/list, and tools/call, asserts both land on stderr (fails on the previous code: stderr held only the factory-time line); the entry-shell unit tests pin the prelude as the stdio entry's first import and the env-only layer for hook wrappers and the CLI bin; the mcp-entry unit test pins guard adoption and re-install after restore. * fix(mcp): adopt the installed stdout guard regardless of write identity so a consumer wrapper cannot stack a second guard (#469) Adoption by identity (`process.stdout.write === redirectedWrite`) broke the moment a consumer module wrapped `process.stdout.write` at module scope: the lifecycle's `redirectConsoleToStderr()` saw a foreign function, installed a second guard with the wrapper recorded as the original, and restoring for the protocol stream handed stdout to the wrapper — which still forwarded to the first redirect, so every JSON-RPC frame left on stderr and the client hung in initialize. The rule is now: while a guard is installed, `redirectConsoleToStderr()` returns it whatever `process.stdout.write` has become; `restoreProtocolStdout()` restores the real original the guard owns, writes one stderr line if a module replaced the write in the meantime (the replacement is discarded — stdout is the protocol channel and wrapping it is unsupported), and clears the installed guard so a later call installs anew. Tests: the mcp-entry unit test wraps the redirect, adopts the same guard, restores to the real stdout, and installs fresh afterwards (fails on a677371 at the adoption step); the packed stdio test's server module now also wraps `process.stdout.write` at module scope and the real client still completes initialize, tools/list, and tools/call with the wrapper's output and the warning on stderr (hangs to timeout on a677371). * fix(mcp): make restoreProtocolStdout once-only so a stale or repeated restore cannot clobber a fresh guard (#469) Two holders of the same guard could restore twice: after the first restore and a fresh install, the stale restore overwrote the fresh redirect with the old original while `installedGuard` still named the fresh guard, so adoption returned a guard that was no longer installed. A plain double restore also emitted the foreign-wrapper warning twice. The guard now records that it has restored and returns immediately on later calls.
Fixes #459
Why
A conventional
src/providers/<name>factory received only{ invocation, plugin, signal }, and every generated surface executed the provider loop beforerunAgentRequestopened the request — so a provider could not see the request's identity or lineage, and had no handle to the state and notices the request was about to mount. The worktree-proximityagent-topologyprovider had to stay an honest stub (#544), and any provider that wanted shared state had to open a store of its own.Design
Providers run as the request's own resolver.
runAgentRequest(@agent-bundle/runtime) now acceptsproviderseither as the resolved record or as anAgentProviderResolver(request: AgentProviderRequest) => values | Promise<values>. The runtime runs the resolver after the identity axes are snapshotted and frozen and after the notice lease opens (sonotices.inbox()is real), before the operation, and — viaAsyncLocalStorage.exit— outside any request context, soagent()/useAgent()inside a factory throwoutside-invocationeven when an in-process host scope wraps the render (as the test harness does). The frozenprovidersmap is mounted as before; a rejected resolver fails the request closed exactly like a rejected operation.This was chosen over "open the request with a lazily-filled frozen record" because: (a) the request's
providersis documented and typed as a frozen, fully resolved record — a lazily-filled one would either break the freeze or reintroduce the unchecked-undefinedwindow theRegistertypegen exists to close; (b) a resolver keeps the generated loop and the harness'sexecuteProvidersone simple function that awaits real handles — a provider that readsinbox()eagerly can't deadlock on a lease that hasn't opened; (c) read-only narrowing is done by construction (providerRequestbuilds{ inbox }/{ lifetime, read }wrappers), not by type alone, sodispatch/publish/acknowledgeare genuinely absent at run time.AgentProviderRequest's handles arePick<AgentStateHandle, 'lifetime' | 'read'>andPick<AgentNoticesHandle, 'inbox'>.agent-bundle:AgentProviderContextgainshost,session,workspace,lineage(eachAgentProviderObserved<…>, tree included),state?: AgentProviderStateHandle,notices?: AgentProviderNoticesHandle, beside the existinginvocation,plugin,signal. Spelled structurally inroutes/public.ts(no runtime import for config-only consumers), matching the runtime's shapes field for field. New exports:AgentProviderObserved,AgentProviderLineage,AgentProviderLineageTree,AgentProviderLineagePeer,AgentProviderLineageSubagent,AgentProviderLineageResolution,AgentProviderStateHandle,AgentProviderStateSnapshot,AgentProviderNotice,AgentProviderNoticesHandle.build/entry-shell.ts:providerExecutionSource+providerValuesExpressionare replaced byprovidersFieldSource, which emitsproviders: async (request) => { …loop… }as the field of therunAgentRequestinit on all three generated scopes (shared Flight worker behind MCP tools + event routes, rendered CLI/script worker, plain routed CLI). Each factory gets{ ...request, invocation }. Projects without providers still emitproviders: { processLifetime }.routes/provider-execution.ts:executeProviderstakesrequest: ProviderRequestViewand spreads it onto the factory context;entry-shell.test.tscontinues to pin the emitted loop and this helper together.test/providers.ts:mountProvidersreturns the explicit map (verbatim — the route-unit fixture seam is unchanged), the bare process identity for a direct module render, or a resolver the harness's ownrunAgentRequestruns.cli.ts,mcp.ts,render.tsupdated accordingly. Typegen is untouched (Register-declared keys apply to the resolver's return type too).worktree-proximity example:
agent-topologyreturns{ agents, intent }fromagentTreeOf(context.lineage)andcontext.state.read()(each half with its own availability);status.tsxreadsproviders.agentTopologyand performs no second read. README updated where it said providers received no lineage.agentTree()(the route-side read) is deleted;agentTreeOfaccepts the provider's structural lineage too.Tests
packages/rsc-runtime/tests/agent-request.test.ts: resolver receives exactly{ host, lineage, notices, plugin, session, signal, state, workspace }, runs after the lease opens and outside any enclosing request context (useAgent()→outside-invocation), narrowed handles carry onlyinbox/lifetime+read, a rejection fails the request.packages/agent-bundle/tests/entry-shell.test.ts: all three generated surfaces emitproviders: async (request) => {with{ ...request, invocation }; the loop sits inside therunAgentRequestinit (after it opens, before the route);executeProvidersspreads the view; Flight-worker determinism hash re-pinned.packages/agent-bundle/tests/cli-routes-build.test.ts(built artifact, cross-process): the fixture gains a process-lifetimesrc/state.ts, and its provider reports the request view on every generated surface — plain CLI, rendered CLI, projected MCP command, rendered script — assertinghost/lineage=unsupported-surface(the same typed reason the route reads),state={ keys: ['lifetime','read'], lifetime: 'process', revision: 0 },notices=['inbox'],pluginavailable, anduseAgent()→outside-invocation.packages/agent-bundle/tests/projection/providers.test.ts+ newfixtures/route-harness/src/providers/request-view.ts: harness surfaces (CLI dispatch, in-memory MCP, route-unit render, script) all hand the view; a registry-backed in-memory MCP call shows the lineage the call resolved to with its livetree.siblings, a realnotices.inbox(), and read-only state; a route-unit render with injectedhost/lineage/session/workspaceshows them reflected in the provider.provider-typegen.test.ts: a resolverproviders: async (request) => …is typed against the declared keys (missing key is a compile error) and readslineage.value.tree,state.read(),notices.inbox()throughAgentProviderContext.test-harness-manifest.test.ts,script-dispatch.test.ts: manifest/loader tables include the new fixture provider.examples/worktree-proximity/tests/route-unit/routes.test.ts: the realagent-topologyfactory over the request view (tree + intent read), and the honest unavailable shape without a state handle; thestatustests run it with the lineage they inject.worktree-proximity-journeys.test.ts(cross-process): passes unchanged.Docs
docs/entry-conventions.md: the provider row and the "Request context providers" contract (context interface, read-only rationale, resolver ordering, custom-hostprovidersresolver).website/docs/{en,zh}/guide/authoring/mcp.mdx: new "Request context providers" section;website/docs/{en,zh}/guide/start/project-structure.mdx: provider row.examples/worktree-proximity/README.md..changeset/459-provider-request-context.md(agent-bundlepatch,@agent-bundle/runtimepatch).Gates run locally:
pnpm build,pnpm typecheck,pnpm lint,pnpm test:unit(3343 passed),pnpm test:route-unit,pnpm test:projection(171 passed),pnpm docs:site:build(language parity OK),pnpm --filter @agent-bundle-example/worktree-proximity check, and the integration filescli-routes-build,provider-typegen,worktree-proximity-journeys.Self-review