Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/use-agent-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-bundle/runtime": patch

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 Mark the new public export as a minor change

When Changesets produces the release, this declares only a patch even though the commit adds and documents a new public useAgent() export. That ships an additive API without the expected minor-version signal for consumers and release tooling; the existing request-store API introduction is also classified as minor in .changeset/request-store-agent.md, so this entry should use minor as well.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Not a defect: patch is the repository's pre-1.0 convention, adopted on main in #403. AGENTS.md ("Pre-1.0 semver: minor = breaking, patch = everything else (features included). No major before 1.0.") and .changeset/README.md ("Semver before 1.0: minor = breaking change …; patch = everything else") both classify an additive export such as useAgent() as patch. The older request-store-agent.md entry predates that convention; the changeset here was deliberately switched from minor to patch in d2cfd47 to follow it.

---

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)
4 changes: 3 additions & 1 deletion docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion docs/framework-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +75 to +76

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 Update the runtime README for the shipped API

The package README still states that useAgent() “arrive[s] later” at packages/rsc-runtime/README.md:66, directly contradicting this newly documented and exported API. Package users consulting the published README may therefore conclude that useAgent() is unavailable; update that section as part of this release.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in bf7c0ab: the package README now documents the shipped useAgent() (same handle, same lease rules) and the provider-populated providers slot instead of saying they arrive later; only state and notices remain reserved.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Acknowledged — the runtime README still says useAgent() arrives later. This PR is still open, so the fix belongs on feat/95-use-agent (the #95 useAgent lane) rather than a follow-up on main: update packages/rsc-runtime/README.md alongside the docs/framework-mode.md change before merge. Tracked by the late-review sweep; not fixed on main.

Async components should still prefer `await agent()`.

A **context provider** contributes one request-scoped value without touching
the compiler. Each `src/providers/<name>.{ts,tsx}` module default-exports a
Expand Down
29 changes: 28 additions & 1 deletion packages/agent-bundle/tests/route-unit/render-route.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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' },
Expand Down
12 changes: 9 additions & 3 deletions packages/rsc-runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions packages/rsc-runtime/src/agent-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,24 @@ export const agent = async (): Promise<AgentRequestContext> => {
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 <T>(
init: AgentRequestInit,
operation: () => T | Promise<T>,
Expand Down
1 change: 1 addition & 0 deletions packages/rsc-runtime/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export {
available,
runAgentRequest,
unavailable,
useAgent,
} from './agent-request.js';
export type {
AgentActorIdentity,
Expand Down
33 changes: 33 additions & 0 deletions packages/rsc-runtime/tests/agent-request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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<typeof useAgent> | 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);
Expand Down
Loading