diff --git a/.changeset/use-agent-sync.md b/.changeset/use-agent-sync.md new file mode 100644 index 000000000..00861f61a --- /dev/null +++ b/.changeset/use-agent-sync.md @@ -0,0 +1,5 @@ +--- +"@agent-bundle/runtime": patch +--- + +Add `useAgent()` to `@agent-bundle/runtime`, the synchronous convenience over `await agent()` for Server Components and server utilities that cannot await. It returns the identical request handle from the same realm-singleton store under the same lease rules — `outside-invocation` when no request is in the async context, `request-closed` on a handle captured from a completed request — and never suspends, because the handle is already resolved in the request's async context. (#402) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 867641713..6dab783d8 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -163,7 +163,9 @@ modules, so a test chooses exactly the values a component observes. Conventional route components receive only their surface props, such as `{ input, signal }`. They read transport-owned request context with -`await agent()` from `@agent-bundle/runtime`. The handle exposes the +`await agent()` from `@agent-bundle/runtime` — or, in a synchronous component +or utility, `useAgent()`, which returns the identical handle under the same +lease rules without suspending. The handle exposes the invocation plus `host`, `session`, `actor`, and `workspace` identity axes. Each identity axis is `Observed`: transports publish an `available` value and source when they know it, or `unavailable` with a typed reason when they do diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 88e68d942..f738594ec 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -71,7 +71,10 @@ returns the invocation plus `Observed` `host`, `session`, `actor`, and progress, the request signal, and the `state`, `notices`, and `providers` slots. The handle is request-scoped: it survives `await`, two concurrent requests never observe each other, and reading a captured handle after the -request closes throws a typed `AgentRequestError`. +request closes throws a typed `AgentRequestError`. A synchronous Server +Component or utility that cannot `await` calls `useAgent()` instead; it +returns the identical handle under the same lease rules and never suspends. +Async components should still prefer `await agent()`. A **context provider** contributes one request-scoped value without touching the compiler. Each `src/providers/.{ts,tsx}` module default-exports a 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 6247961ce..ca1ed8511 100644 --- a/packages/agent-bundle/tests/route-unit/render-route.test.ts +++ b/packages/agent-bundle/tests/route-unit/render-route.test.ts @@ -1,4 +1,4 @@ -import { Agent, agent } from '@agent-bundle/runtime'; +import { Agent, agent, useAgent } from '@agent-bundle/runtime'; import { describe, expect, it } from '@rstest/core'; import { createElement } from 'react'; @@ -295,6 +295,33 @@ describe('renderRoute through the real renderer', () => { expectDocument(unfixtured).toHaveValue({ frozen: true, keys: [], library: undefined }); }); + it('serves useAgent() synchronously inside a rendered Server Component', async () => { + // A synchronous component cannot await agent(); useAgent() hands it the + // same request handle from the same store, so identity axes, providers, + // and the invocation are observable without suspending. + const Synchronous = (): unknown => { + const context = useAgent(); + return createElement(Agent.Result, { + value: { + invocation: context.invocation.kind, + library: context.providers['library'] as never, + workspace: context.workspace.state === 'available' ? context.workspace.value.root : context.workspace.reason, + }, + }, createElement(Agent.Text, null, 'synchronous context observed')); + }; + + const rendered = await renderRoute({ default: Synchronous as never }, { + context: { providers: { library: { stages: ['discover'] } }, workspace }, + routeId: 'tool:harness/use-agent (module)', + }); + + expectDocument(rendered).toHaveStatus('success').toHaveValue({ + invocation: 'tool', + library: { stages: ['discover'] }, + workspace: '/tmp/harness-library', + }); + }); + it('renders a route module handed in directly, without the compiled manifest', async () => { const rendered = await renderRoute({ default: Echo }, { input: { message: 'module form' }, diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index 15f945d99..cae328569 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -71,9 +71,15 @@ actor, or workspace is a typed reason, never a fabricated string. The context handle throws after the request completes. Workspace identity is deliberately scalar: when a native envelope provides multiple `workspace_roots` and no `cwd`, the first root is the primary workspace exposed by `agent()`; later -roots remain available only in the native event payload. `state`, `notices`, -and `providers` are reserved extension slots; provider discovery and -`useAgent()` arrive later. +roots remain available only in the native event payload. Synchronous Server +Components and utilities that cannot `await` call `useAgent()` instead; it +returns the identical handle from the same store under the same lease rules: +a call with no request in its async context — before a request, or after +`runAgentRequest` has settled — throws `outside-invocation`, while a handle +captured inside the request throws `request-closed` once it completes. `providers` +carries the values contributed by conventional `src/providers/*` modules, which +the `agent-bundle` compiler discovers, executes in order, and types per project; +`state` and `notices` remain reserved extension slots. Structured MCP metadata and content are copied through a strict finite-JSON boundary before being returned, so later caller mutations do not alter a result. diff --git a/packages/rsc-runtime/src/agent-request.ts b/packages/rsc-runtime/src/agent-request.ts index ad56460eb..3ec26137a 100644 --- a/packages/rsc-runtime/src/agent-request.ts +++ b/packages/rsc-runtime/src/agent-request.ts @@ -388,6 +388,24 @@ export const agent = async (): Promise => { return lease.handle; }; +/** + * Synchronous convenience over {@link agent} for Server Components and + * ordinary server utilities that cannot `await`. It returns the identical + * request handle (`useAgent() === await agent()` within one invocation) from + * the same realm-singleton store, so every lease rule holds unchanged: a call + * with no request in its async context — including a call made after + * `runAgentRequest` has settled — throws `outside-invocation`, and a handle + * captured inside the request (or a continuation that retained its lease) + * throws `request-closed` once the request completes. No React dependency: + * the handle is already resolved in the request's async context, so nothing + * has to suspend. + */ +export const useAgent = (): AgentRequestContext => { + const lease = currentLease(); + open(lease); + return lease.handle; +}; + export const runAgentRequest = async ( init: AgentRequestInit, operation: () => T | Promise, diff --git a/packages/rsc-runtime/src/plugin.ts b/packages/rsc-runtime/src/plugin.ts index 6a8cb8e06..448177b43 100644 --- a/packages/rsc-runtime/src/plugin.ts +++ b/packages/rsc-runtime/src/plugin.ts @@ -5,6 +5,7 @@ export { available, runAgentRequest, unavailable, + useAgent, } from './agent-request.js'; export type { AgentActorIdentity, diff --git a/packages/rsc-runtime/tests/agent-request.test.ts b/packages/rsc-runtime/tests/agent-request.test.ts index 1d5de5898..3b27a0201 100644 --- a/packages/rsc-runtime/tests/agent-request.test.ts +++ b/packages/rsc-runtime/tests/agent-request.test.ts @@ -14,12 +14,14 @@ import { runAgentRequest, runRscCli, unavailable, + useAgent, } from '../src/index.js'; import { AGENT_REQUEST_STORE_VERSION as pluginStoreVersion, AgentRequestError as PluginAgentRequestError, agent as pluginAgent, runAgentRequest as pluginRunAgentRequest, + useAgent as pluginUseAgent, } from '../src/plugin.js'; const STORE_SYMBOL = Symbol.for('@agent-bundle/runtime/request-store'); @@ -279,7 +281,38 @@ describe('agent request store', () => { await expect(agent()).rejects.toMatchObject({ code: 'outside-invocation' }); }); + it('returns the identical handle synchronously through useAgent() under the same lease rules', async () => { + let captured: ReturnType | undefined; + await runAgentRequest(init('tool', 'sync'), async () => { + const synchronous = useAgent(); + captured = synchronous; + expect(synchronous).toBe(await agent()); + expect(synchronous.invocation.id).toBe('sync'); + await Promise.resolve(); + expect(useAgent()).toBe(synchronous); + }); + // After runAgentRequest() settles the caller's async context is restored, + // so a fresh useAgent() call is `outside-invocation` — the same code + // agent() rejects with. Only the handle captured inside the request (or a + // continuation that retained its closed lease) reports `request-closed`. + expect(() => useAgent()).toThrow(AgentRequestError); + try { + useAgent(); + throw new Error('expected useAgent() outside an invocation to throw'); + } catch (error) { + expect(error).toMatchObject({ code: 'outside-invocation' }); + } + expect(() => captured?.invocation).toThrow(AgentRequestError); + try { + void captured?.invocation; + throw new Error('expected the captured handle to be closed'); + } catch (error) { + expect(error).toMatchObject({ code: 'request-closed' }); + } + }); + it('re-exports the request store from the plugin entry', () => { + expect(pluginUseAgent).toBe(useAgent); expect(pluginAgent).toBe(agent); expect(pluginRunAgentRequest).toBe(runAgentRequest); expect(PluginAgentRequestError).toBe(AgentRequestError);