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

Add fail-closed size, time, and retention budgets to the optional Agent state
kernel. `defineState` now resolves configurable runtime policy defaults, and
the memory and SQLite drivers enforce identical typed `budget-exceeded`
semantics without changing reads or replay of committed history.
7 changes: 7 additions & 0 deletions .changeset/state-lifetime-visibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"agent-bundle": minor
---

Expose declared state lifetime, driver, budgets, and provenance through
`agent-bundle inspect --state`, and add read-only durable SQLite store
inventory to `agent-bundle doctor`.
15 changes: 13 additions & 2 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ gate a build, a validation, or a dev rebuild.
| `AB5000` | General CLI and adapter failures. |
| `AB700x` | Host installation: bundle identity, host availability, scope, command failure, and collision checks. |
| `AB7xxx` | Project preparation and development rebuilds. |
| `AB7300`–`AB7315` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, and runtime endpoint health. |
| `AB7300`–`AB7316` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health, and durable-state inventory. |
| `AB8xxx` | Development server configuration. |
| `AB9xxx` | Eval selection, harnesses, and persisted runs. |

Expand Down Expand Up @@ -151,7 +151,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, state, and provider conventions (`AB4800`–`AB4820`, `AB4940`–`AB4942`)
## Route graph, state, and provider conventions (`AB4800`–`AB4821`, `AB4940`–`AB4942`)

The route-graph compiler discovers conventional route modules
(`src/mcp/<server>/{tools,resources,prompts,apps}/*`, `src/events/*/*`,
Expand Down Expand Up @@ -264,10 +264,21 @@ schema constants), unions, nested objects, transforms, coercions — raises
| `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. |
| `AB4821` | error | A project state definition uses the reserved notice-ledger id `@agent-bundle/runtime/agent-notice-ledger/v1`; generated runtimes own that id for the co-mounted notice store. |
| `AB4940` | error | A conventional provider module has no default export or its default export is not a function. Default-export a factory receiving `{ invocation, signal }`. |
| `AB4941` | error | Two provider filenames derive the same camel-cased provider key. Rename one file so every provider key is unique. |
| `AB4942` | error | A provider filename derives the reserved `processLifetime` key. Rename the file so its camel-cased key does not collide with the framework-owned provider. |

## Read-only Doctor durable-state inventory (`AB7316`)

`agent-bundle doctor` inventories workspace-durable SQLite stores by directory
entry and filesystem metadata only. It never opens a database or creates
SQLite lock or shared-memory files.

| Code | Severity | Trigger |
| --- | --- | --- |
| `AB7316` | warning | An installed bundle's `state/` directory or one of its `*.sqlite`, `-wal`, or `-shm` files cannot be read with filesystem metadata operations. Repair permissions and rerun Doctor; Doctor never repairs state. |

## Development package build (`AB7103`)

`agent-bundle dev` rebuilds the framework-owned package build (`dist/` bin
Expand Down
27 changes: 27 additions & 0 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,33 @@ directory. Routed CLI bins and rendered scripts use
in generated mounting v1 (`authorized`); recipient/principal matching remains
enforced by the ledger, while application authorization policy is deferred.

#### State mutation budgets

`defineState({ ... })` accepts an optional `budgets` runtime policy. Omitted
fields resolve to these fail-closed defaults:

- `maxEventBytes: 262_144` — UTF-8 bytes in the canonical JSON of each
schema-validated event payload.
- `maxStateBytes: 1_048_576` — UTF-8 bytes in the canonical JSON of the
initial state and each event, reset, or migration result.
- `maxRevisions: 100_000` — total journal revisions admitted for
caller-initiated events and resets.
- `maxCommitMs: 5_000` — wall-clock milliseconds from mutation validation
start until the commit is ready to append.

Each override must be an integer of at least 1. A definition whose initial
state exceeds its state cap is rejected as `invalid-definition`; a mutation
that exceeds any cap fails typed `budget-exceeded` and commits nothing.
Raise the corresponding field in `budgets` to admit a larger or slower
mutation or retain more revisions.

Budgets are runtime policy, not persisted state metadata. The same storage
may be reopened with different caps. Lowering a cap never breaks reads,
change cursors, or exact-revision replay of already-committed history.
Kernel-generated migration commits still enforce `maxStateBytes`, but are
exempt from `maxRevisions` and `maxCommitMs` so a full journal cannot brick
an otherwise valid migration.

### Request context providers (power tier)

Each direct child of `src/providers/` derives its key by camel-casing the file
Expand Down
82 changes: 81 additions & 1 deletion packages/agent-bundle/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { join, resolve } from 'node:path';

import {
AGENT_STATE_DEFAULT_BUDGETS,
type AgentStateBudgets,
} from '@agent-bundle/runtime/state';

import { capabilityIsSupported } from './adapters/capability-state.ts';
import { createDefaultRegistry, TargetRegistry } from './adapters/registry.ts';
import type { TargetArtifactEntry, TargetHookEntry } from './adapters/types.ts';
Expand Down Expand Up @@ -253,10 +258,35 @@ export interface InspectionPlan {
}

export interface InspectOptions extends ProjectOptions {
readonly focus?: 'bundler' | 'hooks' | 'routes' | 'skills';
readonly focus?: 'bundler' | 'hooks' | 'routes' | 'skills' | 'state';
readonly target?: string;
}

export type StateInspectionDriver = 'memory' | 'sqlite';

export type StateInspection =
| {
readonly declared: false;
}
| {
readonly budgets:
| {
readonly resolved: AgentStateBudgets;
readonly source: 'declared' | 'defaults';
}
| {
readonly source: 'dynamic';
};
readonly declared: true;
readonly driver: StateInspectionDriver;
readonly durableLocation?: string;
readonly id: string;
readonly lifetime: NonNullable<NormalizedPlugin['state']>['lifetime'];
readonly notices: readonly string[];
readonly provenance: NonNullable<NormalizedPlugin['state']>['provenance'];
readonly source: string;
};

export interface ReadyInspectResult {
readonly diagnostics: readonly Diagnostic[];
readonly model: NormalizedPlugin;
Expand All @@ -267,6 +297,7 @@ export interface ReadyInspectResult {
readonly hooks?: NormalizedPlugin['hooks'];
readonly routes?: RouteGraphInspection;
readonly skills?: NormalizedPlugin['skills'];
readonly state?: StateInspection;
readonly skillTreeLayouts?: readonly {
readonly layout?: NormalizedPlugin['skills'][number]['skillTreeLayout'];
readonly skillId: string;
Expand Down Expand Up @@ -491,6 +522,54 @@ const skippedComponentsFor = (
: 'unsupported-capability') satisfies InspectionSkipReason,
})));

const durableStateLocation =
'$AGENT_BUNDLE_PLUGIN_ROOT/state (falls back to the artifact root or ./.agent-bundle/state for CLI bins)';

const noticeLedgerInspection =
'Generated runtimes co-mount the notice ledger store at the same lifetime under reserved id @agent-bundle/runtime/agent-notice-ledger/v1.';

const stateDriver = (
lifetime: NonNullable<NormalizedPlugin['state']>['lifetime'],
): StateInspectionDriver => {
switch (lifetime) {
case 'request':
case 'process':
return 'memory';
case 'workspace-durable':
return 'sqlite';
default: {
const unreachable: never = lifetime;
throw new TypeError(`Unknown normalized state lifetime ${String(unreachable)}.`);
}
}
};

const inspectState = (model: NormalizedPlugin): StateInspection => {
const definition = model.state;
if (definition === undefined) return Object.freeze({ declared: false });
const budgets: Extract<StateInspection, { readonly declared: true }>['budgets'] =
definition.budgets === 'dynamic'
? Object.freeze({ source: 'dynamic' })
: Object.freeze({
resolved: Object.freeze({
...AGENT_STATE_DEFAULT_BUDGETS,
...(definition.budgets?.declared ?? {}),
}),
source: definition.budgets === undefined ? 'defaults' : 'declared',
});
return deepFreeze({
budgets,
declared: true,
driver: stateDriver(definition.lifetime),
...(definition.lifetime === 'workspace-durable' ? { durableLocation: durableStateLocation } : {}),
id: definition.id,
lifetime: definition.lifetime,
notices: [noticeLedgerInspection],
provenance: definition.provenance,
source: definition.source,
});
};

export const inspect = async (options: InspectOptions): Promise<InspectResult> => {
const prepared = await prepareProject(options, 'inspect');
if (
Expand Down Expand Up @@ -582,6 +661,7 @@ export const inspect = async (options: InspectOptions): Promise<InspectResult> =
}))),
}
: {}),
...(options.focus === 'state' ? { state: inspectState(model) } : {}),
});
return Object.freeze({
diagnostics: prepared.diagnostics,
Expand Down
41 changes: 39 additions & 2 deletions packages/agent-bundle/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type {
InstallScope,
} from './install/install.ts';
import type {
DoctorDurableStateReport,
DoctorHost,
DoctorReport,
runDoctor,
Expand Down Expand Up @@ -103,6 +104,7 @@ interface InspectCommandOptions {
readonly root: string;
readonly routes?: boolean;
readonly skills?: boolean;
readonly state?: boolean;
readonly target?: string;
}

Expand Down Expand Up @@ -258,6 +260,13 @@ const writeHumanInstall = (output: Output, result: InstallResult): void => {
);
};

const formatByteSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
const kibibytes = bytes / 1024;
if (kibibytes < 1024) return `${kibibytes.toFixed(1).replace(/\.0$/u, '')} KiB`;
return `${(kibibytes / 1024).toFixed(1).replace(/\.0$/u, '')} MiB`;
};

const writeHumanDoctor = (output: Output, result: DoctorReport): void => {
for (const host of result.hosts) {
const detail = host.probe.version ?? host.probe.evidence;
Expand All @@ -272,6 +281,18 @@ const writeHumanDoctor = (output: Output, result: DoctorReport): void => {
: ` ${host.bundle.name}${host.bundle.version === undefined ? '' : `@${host.bundle.version}`}`;
output.write(` bundle:${identity} ${host.bundle.state}\n`);
}
const reports = [
...host.inventory.findings.map((finding) => finding.durableState),
host.bundle?.durableState,
].filter((report): report is DoctorDurableStateReport => report !== undefined);
const uniqueReports = [...new Map(reports.map((report) => [report.directory, report])).values()];
if (uniqueReports.length > 0) {
const stores = uniqueReports.reduce((total, report) => total + report.summary.stores, 0);
const bytes = uniqueReports.reduce((total, report) => total + report.summary.bytes, 0);
output.write(
` durable state: ${stores} ${stores === 1 ? 'store' : 'stores'}, ${formatByteSize(bytes)}\n`,
);
}
}
output.write(
`runtime endpoints: ${result.endpoints.status}; ${result.endpoints.summary.live} live, ` +
Expand Down Expand Up @@ -306,13 +327,21 @@ const writeHumanInspect = (output: Output, result: Awaited<ReturnType<typeof ins
output.write(`${JSON.stringify(result.selected.routes, null, 2)}\n`);
return;
}
if (result.selected?.state !== undefined) {
output.write(`${JSON.stringify(result.selected.state, null, 2)}\n`);
return;
}
output.write(`Inspected ${result.model.metadata.name}: ${result.plans.map((plan) => plan.target).join(', ')}\n`);
// Release identity is derived from package.json (issue #94); a project
// without a package version gets a clearly labeled development fallback.
if (result.projectContext.packageName !== undefined) {
output.write(`Package: ${result.projectContext.packageName}\n`);
}
output.write(`Version: ${projectVersionLabel(result.projectContext)}\n`);
if (result.model.state !== undefined) {
const driver = result.model.state.lifetime === 'workspace-durable' ? 'sqlite' : 'memory';
output.write(`state: ${result.model.state.id} (${result.model.state.lifetime}, ${driver} driver)\n`);
}
};

const emptyEvalSummary = Object.freeze({ cases: 0, fail: 0, inconclusive: 0, pass: 0, trials: 0 });
Expand Down Expand Up @@ -553,9 +582,16 @@ export const runCli = async (
.option('--bundler', 'Include the synthesized bundler configuration focus')
.option('--hooks', 'Include the hook focus')
.option('--routes', 'Include the compiled route-graph focus')
.option('--skills', 'Include the skill focus');
.option('--skills', 'Include the skill focus')
.option('--state', 'Include the state lifetime focus');
inspectCommand.action(async (options: InspectCommandOptions) => {
const focuses = [options.bundler, options.hooks, options.routes, options.skills].filter((focus) => focus === true);
const focuses = [
options.bundler,
options.hooks,
options.routes,
options.skills,
options.state,
].filter((focus) => focus === true);
if (focuses.length > 1) {
throw new TypeError('Choose at most one inspect focus.');
}
Expand All @@ -566,6 +602,7 @@ export const runCli = async (
...(options.hooks === true ? { focus: 'hooks' as const } : {}),
...(options.routes === true ? { focus: 'routes' as const } : {}),
...(options.skills === true ? { focus: 'skills' as const } : {}),
...(options.state === true ? { focus: 'state' as const } : {}),
...(options.target === undefined ? {} : { target: options.target }),
});
if (options.json === true) writeMachine(stdout, result);
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-bundle/src/config/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export interface DiscoveredProject {
skills: SkillDocument[];
/** Conventional src/state.ts declaration and its parse-only diagnostics. */
state?: {
readonly definition?: Pick<NormalizedStateDefinition, 'id' | 'lifetime'>;
readonly definition?: Pick<NormalizedStateDefinition, 'budgets' | 'id' | 'lifetime'>;
readonly diagnostics: readonly Diagnostic[];
readonly source: string;
};
Expand Down
Loading
Loading