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
22 changes: 22 additions & 0 deletions .changeset/generated-request-scope-state-mounting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"@agent-bundle/runtime": minor
"agent-bundle": minor
---

Mount the #98 state kernel and #99 notice ledger into generated request
scopes (#233). `@agent-bundle/runtime/mount` exports
`createGeneratedRuntimeState`, which owns the project state store and the
notice ledger over one driver and returns typed-failing handles when that
driver cannot open. `createWarmFlightHost` accepts optional `runtimeState`
ownership so the warm host closes the generated owner with the process.
Conventional `src/state.ts` default-exports `defineState({ ... })` with
statically extracted literal `id` and `lifetime` (`AB4818`–`AB4820`);
`state: false` opts out. Generated MCP flight workers, routed CLI bins, and
rendered workers and scripts mount `state` and `noticeLedger` into every
request scope — memory driver for `request`/`process` lifetimes,
`node:sqlite` at the `AGENT_BUNDLE_PLUGIN_ROOT`-anchored `state/` root for
`workspace-durable`, and a cwd `.agent-bundle/state` fallback for package
bins. Event invocations run notice admission once in the render scope with
invocation identity forwarded from the host process. Stateless projects
emit none of this. The test harness auto-mounts declared state at
route-unit level, and `openInMemoryMcpServer` accepts a state owner.
5 changes: 4 additions & 1 deletion docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ simply not been built yet is a validation **warning** that only
| `AB4749` | error (build) | A payload directory overlaps the artifact `--output` root. |
| `AB4750` | info | A payload is older than the newest project source file and may be stale; rerun the project's own build if so. |

## Route graph (`AB4800`–`AB4817`)
## Route graph and state convention (`AB4800`–`AB4820`)

The route-graph compiler discovers conventional route modules
(`src/mcp/<server>/{tools,resources,prompts,apps}/*`, `src/events/*/*`,
Expand Down Expand Up @@ -230,6 +230,9 @@ schema constants), unions, nested objects, transforms, coercions — raises
| `AB4815` | error | A CLI route does not satisfy the routed command contract: missing named `inputSchema`/`resultSchema` exports, a default export that is not an async function, or malformed `config.description`/`aliases`/`exitCode` fields. |
| `AB4816` | retired | The stage-2 rendered-command gate. Rendered command routes render through the dispatcher since #102 stage 3; the code is never reused. |
| `AB4817` | error | An event route requires the shared runtime for a target, but no generated MCP entry hosts that runtime and the route does not allow standalone fallback. |
| `AB4818` | error | `src/state.ts` is present but does not default-export one direct `defineState({ ... })` call, or `state` config is not the supported `false` opt-out. |
| `AB4819` | error | The state definition's `id` or `lifetime` is missing, non-literal, empty, duplicated, or outside the state lifetime vocabulary. |
| `AB4820` | error | A generated project selects `external` state lifetime; v1 generated mounting supports only `request`, `process`, and `workspace-durable` because external drivers require embedder wiring. |

## Development package build (`AB7103`)

Expand Down
25 changes: 23 additions & 2 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,29 @@ entries carry `provenance.kind: 'conventional'` in the normalized model.
| `src/scripts/<name>.ts` | Plain script compiled to `scripts/<name>.mjs` in every selected target artifact — the same pipeline explicit `scripts` entries use, with ordinary Node stdout/stderr semantics. A `scripts` entry that references the file claims it. Nested modules are hard errors (`AB4808`). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry |
| `src/scripts/<name>.tsx` | Rendered script: the async default component receives `{ argv, signal }` and renders through the Agent renderer with the CLI output contract (`--json`, `--ndjson`, TTY progress, piped Markdown). Compiles to `scripts/<name>.mjs` plus a `scripts/<name>-flight.mjs` react-server worker. The extension is the explicit, visible contract — plain `.ts` scripts are never wrapped in React behavior, and explicit `scripts` config entries stay plain regardless of extension. | Rename to `.ts`, prefix a path segment with `_`, or claim the file with an explicit `scripts` entry |
| `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). 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 `_` |

Conventions match `.ts` and `.tsx` files exactly.
| `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` |

Route and package entry conventions match `.ts` and `.tsx` files exactly;
the state convention is specifically `src/state.ts`.

### Generated state mounting

The compiler parses `src/state.ts` without executing it and requires one
`export default defineState({ ... })` call whose `id` and `lifetime` are
string literals. Generated mounting currently supports `request`, `process`,
and `workspace-durable`; `external` remains embedder-owned driver wiring.
Volatile lifetimes use the memory driver. Request lifetime opens and releases
fresh project and notice stores per invocation; process lifetime shares them
for the generated worker or executable process.

Workspace-durable generated MCP workers store under
`$AGENT_BUNDLE_PLUGIN_ROOT/state`. If that host-provided anchor is absent,
the worker derives the artifact root from the parent of its own `mcp/`
directory. Routed CLI bins and rendered scripts use
`$AGENT_BUNDLE_PLUGIN_ROOT/state` when present and otherwise
`$PWD/.agent-bundle/state`. Notice authorization is deliberately permissive
in generated mounting v1 (`authorized`); recipient/principal matching remains
enforced by the ledger, while application authorization policy is deferred.

### Migration nudges

Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import { Agent, agent } from '@agent-bundle/runtime';
import type { AgentEventRouteProps } from 'agent-bundle';

export default async function AfterTool({ canonical, native }: AgentEventRouteProps) {
export default async function AfterTool({ canonical }: AgentEventRouteProps) {
const context = await agent();
const deliveries = await context.notices?.read() ?? [];
const notices = deliveries.map(({ notice }) => ({
id: notice.id,
message: notice.content.root.kind === 'text' ? notice.content.root.text : '',
}));
return (
<Agent.Result value={{
event: canonical.event,
invocationKind: context.invocation.kind,
tool: typeof native['tool_name'] === 'string' ? native['tool_name'] : 'unknown',
}}
>
<Agent.Result>
<Agent.Markdown>{`Observed ${canonical.event} from ${canonical.provenance.host}.`}</Agent.Markdown>
{notices.map((notice) => (
<Agent.Context key={notice.id}>{`notice ${notice.id}: ${notice.message}`}</Agent.Context>
))}
</Agent.Result>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Agent, agent } from '@agent-bundle/runtime';
import { z } from 'zod';

export const config = {
description: 'Records and reads durable route-harness journal entries.',
title: 'Journal',
};

export const inputSchema = z.object({ note: z.string().optional() }).strict();

export const resultSchema = z.object({
entries: z.array(z.object({ note: z.string() }).strict()),
revision: z.number().int().nonnegative(),
}).strict();

interface JournalState {
readonly entries: readonly { readonly note: string }[];
}

export default async function Journal({ input }: { readonly input: z.infer<typeof inputSchema> }) {
const context = await agent();
if (context.state === undefined) throw new TypeError('Journal state is unavailable.');
if (input.note !== undefined) {
await context.state.dispatch('recorded', { note: input.note }, {
idempotencyKey: `journal:${input.note}`,
});
}
const snapshot = await context.state.read();
const state = snapshot.state as JournalState;
const result = { entries: state.entries, revision: snapshot.revision };
return (
<Agent.Result value={result}>
<Agent.Markdown>{[
`# Journal revision ${String(snapshot.revision)}`,
'',
...state.entries.map((entry) => `- ${entry.note}`),
].join('\n')}</Agent.Markdown>
</Agent.Result>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Agent, agent } from '@agent-bundle/runtime';
import { z } from 'zod';

export const config = {
description: 'Publishes a durable notice for a later session event.',
title: 'Publish notice',
};

export const inputSchema = z.object({
message: z.string(),
recipientSession: z.string(),
}).strict();

export const resultSchema = z.object({
noticeId: z.string(),
state: z.literal('pending'),
}).strict();

export default async function PublishNotice({ input }: { readonly input: z.infer<typeof inputSchema> }) {
const context = await agent();
if (context.notices === undefined) throw new TypeError('Notice publishing is unavailable.');
const published = await context.notices.publish({
content: {
root: { kind: 'text', text: input.message },
status: 'success',
version: 1,
},
priority: 'normal',
recipient: {
session: { sessionId: input.recipientSession },
},
}, {
idempotencyKey: `notice:${input.recipientSession}:${input.message}`,
});
const result = { noticeId: published.notice.id, state: published.notice.state };
return (
<Agent.Result value={result}>
<Agent.Text>{`notice ${result.noticeId}: ${result.state}`}</Agent.Text>
</Agent.Result>
);
}
23 changes: 23 additions & 0 deletions packages/agent-bundle/fixtures/route-harness/src/state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { defineState } from '@agent-bundle/runtime/state';
import { z } from 'zod';

const journalEntrySchema = z.object({
note: z.string(),
}).strict();

export default defineState({
events: {
recorded: journalEntrySchema,
},
id: 'route-harness/journal',
initial: {
entries: [],
},
lifetime: 'workspace-durable',
reduce: (state, event) => ({
entries: [...state.entries, event.payload],
}),
schema: z.object({
entries: z.array(journalEntrySchema),
}).strict(),
});
8 changes: 7 additions & 1 deletion packages/agent-bundle/src/build/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,12 @@ export const build = async (options: BuildOptions): Promise<BuildResult> => {
compiledEntries.push(
...(await compileEntries(
options.model.scripts.filter((script) => script.targets.includes(target.name)),
{ cwd: options.projectRoot, outDir: target.root, ...tools },
{
cwd: options.projectRoot,
outDir: target.root,
...(options.model.state === undefined ? {} : { state: options.model.state }),
...tools,
},
)),
);
compiledHooks.push(...(await compileHooks(target.hookEntries, {
Expand All @@ -378,6 +383,7 @@ export const build = async (options: BuildOptions): Promise<BuildResult> => {
.map((entry) => entry.hook),
outDir: target.root,
plugin: { name: options.model.metadata.name, version: options.model.metadata.version },
...(options.model.state === undefined ? {} : { state: options.model.state }),
target: target.name,
...tools,
})));
Expand Down
20 changes: 18 additions & 2 deletions packages/agent-bundle/src/build/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ import {
eventProjectRuntimeSpecifier,
type TargetHookEntry,
} from '../adapters/hook-contract.ts';
import type { AgentBundleToolsConfig, NormalizedHook, NormalizedMcpServer, NormalizedScript } from '../core/types.ts';
import type {
AgentBundleToolsConfig,
NormalizedHook,
NormalizedMcpServer,
NormalizedScript,
NormalizedStateDefinition,
} from '../core/types.ts';
import { mcpEntryAliasPattern } from '../config/normalize.ts';
import { stableJson } from '../core/digest.ts';
import { emitPlanEntries, resolveArtifactDestination } from './emit.ts';
Expand Down Expand Up @@ -148,7 +154,12 @@ export const planCompiledEntries = (

export const compileEntries = async (
entries: readonly NormalizedScript[],
options: { readonly cwd: string; readonly outDir: string; readonly tools?: AgentBundleToolsConfig },
options: {
readonly cwd: string;
readonly outDir: string;
readonly state?: NormalizedStateDefinition;
readonly tools?: AgentBundleToolsConfig;
},
): Promise<readonly CompiledEntry[]> => {
const compiled = planCompiledEntries(entries, options);
const bundled = compiled.filter((entry) => entry.mode === 'bundle');
Expand All @@ -174,6 +185,7 @@ export const compileEntries = async (
virtualSource: generatedRenderedScriptEntrySource({
name,
routeId: rendered.routeId,
...(options.state === undefined ? {} : { state: options.state }),
workerFile: rendered.workerFile,
}),
}),
Expand All @@ -192,6 +204,7 @@ export const compileEntries = async (
provenance: { kind: 'conventional', relativePath: `scripts/${name}` },
source,
}],
...(options.state === undefined ? {} : { state: options.state }),
}),
}),
];
Expand Down Expand Up @@ -296,6 +309,7 @@ export const compileMcpEntries = async (
readonly eventHooks: readonly NormalizedHook[];
readonly outDir: string;
readonly plugin: { readonly name: string; readonly version: string };
readonly state?: NormalizedStateDefinition;
readonly target: string;
readonly tools?: AgentBundleToolsConfig;
},
Expand Down Expand Up @@ -331,6 +345,7 @@ export const compileMcpEntries = async (
plugin: options.plugin,
routes: server.generatedRoutes,
serverName: server.name,
...(options.state === undefined ? {} : { state: options.state }),
target: options.target,
workerFile: `${entry.name}-flight.mjs`,
});
Expand All @@ -344,6 +359,7 @@ export const compileMcpEntries = async (
eventRoutes: entry.id === eventHostId ? options.eventHooks : [],
routes: server.generatedRoutes,
serverName: server.name,
...(options.state === undefined ? {} : { state: options.state }),
});
});
// Factory-exporting entries (default export) are wrapped in the framework
Expand Down
Loading
Loading