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
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 conventional `src/providers/<name>` factories the request they run for: `AgentProviderContext` (`agent-bundle`) gains `host`, `session`, `workspace`, and `lineage` beside `plugin` — the same observed axes the route reads on `await agent()`, provenance and the lineage's live `tree` included — plus read-only `state` (`lifetime`, `read()`) and `notices` (`inbox()`, `published()`) views of the mounted handles; `dispatch`, `publish`, and `acknowledge` stay route-only, and `agent()`/`useAgent()` inside a factory throw `outside-invocation`. New exported types `AgentProviderObserved`, `AgentProviderHostIdentity`, `AgentProviderSessionIdentity`, `AgentProviderWorkspaceIdentity`, `AgentProviderLineage`, `AgentProviderLineageTree`, `AgentProviderLineagePeer`, `AgentProviderLineageSubagent`, `AgentProviderLineageResolution`, `AgentProviderStateHandle`, `AgentProviderStateSnapshot`, `AgentProviderNoticesHandle`, `AgentProviderNotice`, `AgentProviderNoticeState`, `AgentProviderNoticeRecipient`, `AgentProviderNoticePublisher`, `AgentProviderNoticeAttempt`, `AgentProviderNoticeWithholding`; `AgentProviderObservedPluginRoot` is now `AgentProviderObserved<AgentProviderPluginRoot>`. 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. `@agent-bundle/runtime`: `runAgentRequest` accepts `providers` as an `AgentProviderResolver` `(request: AgentProviderRequest) => values` beside the plain record; new exported types `AgentProviderRequest`, `AgentProviderResolver`, `AgentProviderStateHandle`, `AgentProviderNoticesHandle`. Existing factories that destructure `{ invocation, plugin, signal }` are unchanged. (#459)
54 changes: 45 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, plugin, lineage, state?, notices? }` — the request's observed identity (plugin root included) and lineage plus read-only views of the mounted state (`read`) and notice (`inbox`, `published`) 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,58 @@ 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 }>;
plugin: Observed<{ root; stateRoot }>; // the resolved plugin root (#468)
lineage: Observed<AgentLineage>; // own chain plus the live `tree` (#457)
state?: { lifetime; read(options?) }; // the mounted state handle, `read` only
notices?: { inbox(); published() }; // the request's notice handle, reads only
}
```

`host`, `session`, `workspace`, `plugin`, and `lineage` are the same observed
values the route will read, provenance and 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) — `inbox()` is
what is pending for this request's principal, `published()` what became of
the notices it published (#460) — 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
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
35 changes: 21 additions & 14 deletions examples/worktree-proximity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,18 @@ 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`
turns that into the coordinator's report; `liveConversations()` turns it
into the liveness the domain uses.
`resolution` for its placement. `agentTreeOf()` in `src/event-support.ts`
turns that into the coordinator's report (the `agent-topology` provider
calls it over the `lineage` the framework hands it); `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
`context.lineage` resolved (own chain plus the live tree), a read of the
mounted intent state through `context.state.read()`, and the counts of the
notices this caller published through `context.notices.published()`; each
part 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 +80,15 @@ 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`, `plugin`, and `lineage` — with the
live tree ([#457](https://github.com/scriptedalchemy/agent-bundle/issues/457))
— plus read-only `state` (`read()`) and `notices` (`inbox()`, `published()`)
handles ([#459](https://github.com/scriptedalchemy/agent-bundle/issues/459)),
so the coordinator `status` tool reads `providers.agentTopology` and performs
no read of its own. Event routes still use the mounted `(await agent()).state`
and `.notices` handles through `withIntent`/`withNotices`, because they
dispatch and publish.

`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 Expand Up @@ -165,8 +172,8 @@ message to one peer, not to the tree.
current invocation, and `inbox()` only what is pending for the current
recipient. The coordinator status reports, beside the agent tree, bindings,
and intents, what became of the notices *the calling agent* published — `pending`,
`attempted`, `acknowledged`, and the other ledger states, counted from
`(await agent()).notices.published()`
`attempted`, `acknowledged`, and the other ledger states, counted by the
`agent-topology` provider from the request's own `notices.published()`
([#460](https://github.com/scriptedalchemy/agent-bundle/issues/460)). That
view is scoped by the publisher identity the ledger recorded at publish, the
agent's lineage conversation, so a status call correlated to agent B's
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
50 changes: 29 additions & 21 deletions examples/worktree-proximity/src/mcp/coordinator/tools/status.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { Agent, type AgentNoticeState, type JsonValue } from '@agent-bundle/runtime';
import { AGENT_NOTICE_STATES } from '@agent-bundle/runtime/notices';
import { Agent, agent, type JsonValue } from '@agent-bundle/runtime';
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 type { AgentTopologyProviderValue } from '../../../providers/agent-topology.js';
import { ActivitySchema, BindingSchema } from '../../../state.js';

export const config = {
Expand Down Expand Up @@ -100,27 +98,37 @@ export const resultSchema = z
.strict();

type StatusResult = z.output<typeof resultSchema>;
type PublishedNotices = z.output<typeof PublishedNoticesSchema>;

const emptyCounts = (): Record<AgentNoticeState, number> =>
Object.fromEntries(AGENT_NOTICE_STATES.map((state) => [state, 0])) as Record<AgentNoticeState, number>;

const publishedNotices = async (): Promise<PublishedNotices> => {
const result = await withNotices(async (notices) => notices.published());
if (result.state === 'unavailable') {
return { ...emptyCounts(), reason: result.reason, state: 'unavailable', total: 0 };
}
const counts = emptyCounts();
for (const notice of result.value) counts[notice.state] += 1;
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, a read of the intent state, and the counts of the notices this caller
* published, 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' },
notices: {
acknowledged: 0,
attempted: 0,
expired: 0,
pending: 0,
reason: 'Published notices unavailable: the agent-topology provider is not mounted.',
state: 'unavailable',
total: 0,
unavailable: 0,
withdrawn: 0,
},
};

export default async function Status({
input,
}: ToolRouteProps<typeof inputSchema>) {
const agents = await agentTree();
const intentResult = await withIntent(async (store) => store.read());
const notices = await publishedNotices();
// Everything this tool reports was read once, by the provider, from the
// request the runtime opened for this call: no second read, no guess.
const { agents, intent: intentResult, notices } = topologyOf((await agent()).providers);
let result: StatusResult;
if (intentResult.state === 'unavailable') {
result = {
Expand All @@ -135,7 +143,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
Loading