Skip to content
Closed
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
6 changes: 6 additions & 0 deletions .changeset/459-provider-request-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@agent-bundle/runtime": patch
"agent-bundle": patch
---

Hand `src/providers/<name>` factories the request they run for: `AgentProviderContext` gains `host`, `session`, `workspace`, and `lineage` (live `tree` included) exactly as the route observes them on `await agent()`, plus read-only `state` (`lifetime`, `read()`) and `notices` (`inbox()`) views of the mounted handles; `dispatch`, `publish`, and `acknowledge` stay route-only, and `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 now run providers as the request's own resolver — after `runAgentRequest` freezes the identity axes and opens the notice lease, before the route — and `runAgentRequest` accepts `providers` as an `AgentProviderResolver` `(request: AgentProviderRequest) => values` beside the plain record. New exports: `AgentProviderObserved`, `AgentProviderLineage`, `AgentProviderLineageTree`, `AgentProviderLineagePeer`, `AgentProviderLineageSubagent`, `AgentProviderLineageResolution`, `AgentProviderStateHandle`, `AgentProviderStateSnapshot`, `AgentProviderNotice`, `AgentProviderNoticesHandle` from `agent-bundle`; `AgentProviderRequest`, `AgentProviderResolver`, `AgentProviderStateHandle`, `AgentProviderNoticesHandle` from `@agent-bundle/runtime`. Factories that destructure `{ invocation, signal }` are unchanged. (#556)
52 changes: 43 additions & 9 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ entries carry `provenance.kind: 'conventional'` in the normalized model.
| `src/cli/**/*.{ts,tsx}` | Routed CLI commands compiled into one collision-checked command graph and one generated package executable named after `plugin.name` (superseding the `src/cli.ts` bin convention for the project), plus the same executable as `bin/<plugin-name>.mjs` in every selected host artifact whose target publishes the `cli` capability (all built-in targets). Nesting is identity: `src/cli/library/audit.ts` runs as `<bin> library audit`. Plain `.ts` commands execute directly and print one canonical JSON line; `.tsx` commands render through the dispatcher with the four output modes. | `bin: false`, `routes.cli: 'conventional'`, or prefix a path segment with `_` |
| `src/events/<family>/<event>.{ts,tsx}`, `src/events/stop.{ts,tsx}` | Semantic event route: the path is the canonical event family (`src/events/tool/after.tsx` is `tool/after`; `stop` is the one top-level family) and must be one of the admitted `canonicalAgentEvents`. The optional static `config` (`AgentEventRouteConfig`: `targets`, `tools`, `runtime: 'shared' \| 'standalone'`, `fallback`, `delivery`, `timeoutMs`) restricts hosts and selects the execution mode; the async default Server Component receives `AgentEventRouteProps<E>` (`{ canonical, native, signal }`) and returns `Agent.*` output that the selected host adapter encodes into its native hook envelope. `canonical.payload` is the family's cross-host reading of the envelope (#466) — the fields at least two hosts report (`toolName`, `toolInput`, `toolResponse`, `sessionId`, `transcriptPath`, `cwd`, `prompt`, `agentId`/`agentType`, `reentry`, …), each as `{ value, nativeKey }` naming the host key it came from and absent when the host did not send it; `E` narrows it to the route's family. The per-family field table is `agentEventPayloadFields` and the per-host key table `agentEventPayloadNativeKeys` (`routes/events.ts`), mirrored under `hooks.eventRoutes.<event>.payload` in each pinned capability table so the generated events reference documents the mapping per host. Application code never branches on host JSON or emits native hook documents; per-host support is a capability state (`supported`/`degraded`/`unavailable`/`prohibited`) surfaced by `inspect` and enforced at build time (`AB4817`, `AB4823`–`AB4825`). | Restrict `config.targets`, or prefix a path segment with `_` |
| `src/state.ts` | Project state definition: default-exports `defineState({ ... })`; generated MCP, routed-CLI, and rendered-script request scopes mount `(await agent()).state` and `.notices`. | `state: false`, or rename the file to `_state.ts` |
| `src/providers/<name>.{ts,tsx}` | Request context provider: default-exports a factory receiving `{ invocation, plugin, signal }`; its value is mounted at `(await agent()).providers.<camelCaseName>` for generated MCP and event routes, projected MCP commands, plain and rendered routed CLI commands, and rendered scripts. | Prefix the file with `_` |
| `src/providers/<name>.{ts,tsx}` | Request context provider: default-exports a factory receiving `{ invocation, signal, host, session, workspace, lineage, plugin, state?, notices? }` — the request's observed identity, lineage, and plugin root plus read-only views of the mounted state (`read`) and notice (`inbox`) handles; its value is mounted at `(await agent()).providers.<camelCaseName>` for generated MCP and event routes, projected MCP commands, plain and rendered routed CLI commands, and rendered scripts. | Prefix the file with `_` |
| `src/layout.{ts,tsx}` | Shared document layout: default-exports one component receiving `{ children, route, signal }` that renders `Agent.Result` around every rendered route — generated MCP tools, resources, and prompts, rendered routed CLI commands, projected MCP commands, and rendered scripts. Event routes are never wrapped. | Rename to `_layout.tsx` |
| `src/mcp/<server>/layout.{ts,tsx}` | Per-server layout nested inside the root layout for that generated server's routes. | Rename to `_layout.tsx`, or set `routes.servers.<server>` to a non-generated mode |

Expand Down Expand Up @@ -261,22 +261,56 @@ an otherwise valid migration.
Each direct child of `src/providers/` derives its key by camel-casing the file
stem: for example, `src/providers/project-auth.ts` mounts at
`(await agent()).providers.projectAuth`. Every module default-exports a factory
with the contract `(context: { invocation, plugin, signal }) => value |
Promise<value>`, where `invocation` is the current route invocation, `plugin`
is the observed plugin root the request will publish as
`(await agent()).plugin` (#468), and `signal` is its request abort signal.
with the contract `(context: AgentProviderContext) => value | Promise<value>`:

```ts
interface AgentProviderContext {
invocation: AgentProviderInvocation; // the surface-specific route invocation
signal: AbortSignal; // the request abort signal
host: Observed<{ name }>; // exactly what the route reads on `await agent()`
session: Observed<{ sessionId }>;
workspace: Observed<{ root }>;
lineage: Observed<AgentLineage>; // own chain plus the live `tree` (#457)
plugin: Observed<{ root, stateRoot }>; // the plugin root the request publishes (#468)
state?: { lifetime; read(options?) }; // the mounted state handle, `read` only
notices?: { inbox() }; // the request's notice handle, `inbox` only
}
```

`host`, `session`, `workspace`, `lineage`, and `plugin` are the same observed
values the route will read, unavailable reasons included. `state` is present
for projects that declare `src/state.ts` and `notices` for projects whose scope
mounts the notice ledger; both are the real request handles narrowed by
construction to their read paths (#459), so a provider can expose a derived
view of shared state — a topology, a summary, a peers list — but never dispatch
a state event or publish, acknowledge, or withdraw a notice: those stay
route-only. Providers also run outside the request's async context, so
`agent()` and `useAgent()` inside a factory throw `outside-invocation` rather
than handing it the full handle. The types ship from `agent-bundle`
(`AgentProviderContext`, `AgentProviderStateHandle`,
`AgentProviderNoticesHandle`, `AgentProviderLineage`, …) without a runtime
import; at run time the handles are the runtime's own.

Every generated request scope — the shared Flight worker behind generated MCP
and event routes, the react-server worker behind rendered routed CLI commands
and rendered scripts, and the routed-CLI executable itself for plain `.ts`
commands — executes providers once per request, sequentially in deterministic
key order, before entering `runAgentRequest`. The returned values join the
request's provider map. A thrown or rejected factory fails the request closed;
commands — executes providers once per request as the request's own provider
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
Comment on lines +298 to +300

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

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 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.

and notices mounted before providers, rather than a lazy handle that resolves
later — is what keeps the generated loop and the harness's `executeProviders`
one simple function: a provider awaits real handles, and a factory that reads
`inbox()` eagerly cannot deadlock on a lease that has not opened yet. The
returned values join the request's provider map. A thrown or rejected factory
fails the request closed, exactly as a route that throws after admission does;
expected degradation should return an honest unavailable-shaped value instead
of throwing. `invocation.kind` stays surface-specific (`tool`, `event`, `cli`,
`script`), so a provider can branch on the entry surface deliberately.
`processLifetime` is reserved for the framework-owned process identity and hit
counter, so provider filenames must not derive that key.
counter, so provider filenames must not derive that key. A custom host calling
`runAgentRequest` directly may pass `providers` as the resolved record or as
the same resolver function `(request: AgentProviderRequest) => values`.

The `agent-bundle/test` harness mounts the same providers, in the same order
and with the same fail-closed semantics, for every manifest-backed helper
Expand Down
10 changes: 7 additions & 3 deletions docs/framework-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,13 @@ export default async function library({ invocation }: AgentProviderContext): Pro
}
```

Providers run once per request in deterministic key order before the request
scope opens; a thrown factory fails that request closed, so return an honest
unavailable-shaped value for expected degradation. The compiler validates the
Providers run once per request in deterministic key order as the request's own
resolver: after `runAgentRequest` freezes the identity axes and opens the
notice lease, before the route runs, so the factory context carries `host`,
`session`, `workspace`, `lineage`, and `plugin` as the route will read them plus
the read-only `state` (`read`) and `notices` (`inbox`) handles (#459; see
`entry-conventions.md`). A thrown factory fails that request closed, so return
an honest unavailable-shaped value for expected degradation. The compiler validates the
default export (`AB4940`), unique keys (`AB4941`), and the reserved
framework-owned `processLifetime` key (`AB4942`).

Expand Down
24 changes: 14 additions & 10 deletions examples/worktree-proximity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,16 @@ The application has four planes:
`resolution`) and, through `lineage.value.tree`, who else is alive:
`siblings` (every other live conversation under the same root, the root
included), `children`, and other live `roots`, each with the registry's own
`resolution` for its placement. `agentTree()` in `src/event-support.ts`
`resolution` for its placement. `agentTreeOf()` in `src/event-support.ts`
turns that into the coordinator's report; `liveConversations()` turns it
into the liveness the domain uses.
- **Providers** — `git-worktree` derives repository, branch, commit, common
Git directory, and linked-worktree identity without throwing for expected
degradation. `agent-topology` reports that its snapshot is unavailable
because providers receive no request lineage.
degradation. `agent-topology` assembles the coordinator's snapshot once per
request from the request view every provider receives: the agent tree
resolved on `context.lineage` (own chain plus the live tree) and a read of
the mounted intent state through `context.state.read()`; each half carries
its own availability, and the provider can only read.
- **Events** — canonical shared-runtime routes bind actors to worktrees,
record or clear intent, detect conflicts, render current-actor context,
release stopped actors, and publish or admit notices.
Expand All @@ -75,13 +78,14 @@ lineage journal over that same driver. The application never opens a second
store from Git identity data; `gitWorktree.commonDir` remains identity
evidence only.

The issue sketch places the agent tree at `providers.agentTopology`. The tree
is on the request (`request.lineage.tree`,
[#457](https://github.com/scriptedalchemy/agent-bundle/issues/457)), but a
provider factory receives only `{ invocation, signal }` — not the request's
`lineage` ([#459](https://github.com/scriptedalchemy/agent-bundle/issues/459)) —
so this provider reports an honest unavailable result and routes read the
tree from `(await agent()).lineage` instead.
`providers.agentTopology` is that snapshot: a provider factory receives the
request's `host`, `session`, `workspace`, and `lineage` — with the live tree
([#457](https://github.com/scriptedalchemy/agent-bundle/issues/457)) — plus
read-only `state` (`read()`) and `notices` (`inbox()`) handles
([#459](https://github.com/scriptedalchemy/agent-bundle/issues/459)), so the
coordinator `status` tool reads `providers.agentTopology` and performs no
second read of its own. Event routes still use the mounted
`(await agent()).state` handle, because they dispatch.

`worktree()` in `src/api.ts` is the issue-mandated custom Promise API over the
provider value. `useWorktree()` is the hook-shaped variant for Server
Expand Down
21 changes: 14 additions & 7 deletions examples/worktree-proximity/src/event-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,21 @@ export type AgentTreeView =
| { readonly reason: string; readonly state: 'unavailable' };

/**
* The whole-tree view the coordinator reports, read from `request.lineage`
* and nothing else. A lineage with no `tree` (a payload that proved only its
* own chain, or a standalone hook) is reported as unavailable rather than as
* an empty tree.
* An observed lineage as a route reads it (`Observed<AgentLineage>`) or as a
* provider receives it (`AgentProviderContext['lineage']`, the same shape
* spelled without a runtime import); both are assignable here.
*/
export const agentTreeOf = (lineage: Observed<AgentLineage>): AgentTreeView => {
type ObservedLineage =
| { readonly state: 'available'; readonly value: AgentLineage }
| { readonly reason: string; readonly state: 'unavailable' };

/**
* The whole-tree view the coordinator reports, read from the request's
* lineage and nothing else. A lineage with no `tree` (a payload that proved
* only its own chain, or a standalone hook) is reported as unavailable rather
* than as an empty tree.
*/
export const agentTreeOf = (lineage: ObservedLineage): AgentTreeView => {
if (lineage.state !== 'available') {
return { reason: `lineage unavailable (${lineage.reason})`, state: 'unavailable' };
}
Expand All @@ -90,8 +99,6 @@ export const agentTreeOf = (lineage: Observed<AgentLineage>): AgentTreeView => {
};
};

export const agentTree = async (): Promise<AgentTreeView> => agentTreeOf(await requestLineage());

/**
* The subagent a request speaks for, when the runtime's `request.lineage`
* places it below the root. The runtime resolves the same shape on every
Expand Down
23 changes: 17 additions & 6 deletions examples/worktree-proximity/src/mcp/coordinator/tools/status.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { Agent, type AgentNoticeState, type JsonValue } from '@agent-bundle/runtime';
import { Agent, agent, type AgentNoticeState, type JsonValue } from '@agent-bundle/runtime';
import { AGENT_NOTICE_STATES } from '@agent-bundle/runtime/notices';
import type { ToolConfig, ToolRouteProps } from 'agent-bundle';
import React from 'react';
import { z } from 'zod';

import { withIntent, withNotices } from '../../../coordination.js';
import { agentTree } from '../../../event-support.js';
import { withNotices } from '../../../coordination.js';
import type { AgentTopologyProviderValue } from '../../../providers/agent-topology.js';
import { ActivitySchema, BindingSchema } from '../../../state.js';

export const config = {
Expand Down Expand Up @@ -115,11 +115,22 @@ const publishedNotices = async (): Promise<PublishedNotices> => {
return { ...counts, state: 'available', total: result.value.length };
};

/**
* The topology snapshot the `agent-topology` provider assembled for this
* request (agent-bundle#459): the agent tree the runtime resolved for the call
* and a read of the intent state, each with its own availability. A fixture
* that omits the provider is reported, never worked around with a second read.
*/
const topologyOf = (providers: { readonly agentTopology?: AgentTopologyProviderValue }): AgentTopologyProviderValue =>
providers.agentTopology ?? {
agents: { reason: 'agent-topology provider not mounted', state: 'unavailable' },
intent: { reason: 'Intent state unavailable: the agent-topology provider is not mounted.', state: 'unavailable' },
};

export default async function Status({
input,
}: ToolRouteProps<typeof inputSchema>) {
const agents = await agentTree();
const intentResult = await withIntent(async (store) => store.read());
const { agents, intent: intentResult } = topologyOf((await agent()).providers);
const notices = await publishedNotices();
let result: StatusResult;
if (intentResult.state === 'unavailable') {
Expand All @@ -135,7 +146,7 @@ export default async function Status({
state: 'unavailable',
};
} else {
const { revision, state: intent } = intentResult.value;
const { revision, value: intent } = intentResult.value;
const bindings = input.actorId === undefined
? intent.bindings
: intent.bindings.filter((binding) => binding.actorId === input.actorId);
Expand Down
Loading