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

Add the optional Agent state kernel contract (#98 v1) behind the new
`./state` subpath: `defineState({ schema, initial, events, reduce })` with
the explicit lifetime taxonomy (`request` | `process` | `workspace-durable`
| `external`), typed `AgentStateError` codes, monotonic revisions,
exact-revision reads, idempotency-key replay/conflict, compare-and-swap,
explicit versioned migrations, and polling change cursors. Ships the
volatile in-memory driver (request/process lifetimes; never durable), the
request-bound handle that fills the reserved `state` slot on
`AgentRequestContext`, and the driver conformance suite every driver —
including external ones — must pass. Stateless projects import none of it.
6 changes: 4 additions & 2 deletions docs/framework-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,10 @@ hydrate, or hold server component state. What the MCP projection uses is an **MC
result DSL**: `render` returns ordinary React elements, and
`lowerMcpResult` walks that tree synchronously — function components are
simply called — to produce the `CallToolResult` the MCP SDK sends. The
package owns no transport, persistence, or application state; those remain
explicit dependencies of `execute` implementations.
package owns no transport, and operations receive no implicit storage:
persistent application state exists only through the opt-in state kernel
subpath (`@agent-bundle/runtime/state`, issue #98), which stateless projects
never import.

Operation modules are `.tsx` for exactly one reason: the `render` callback
returns JSX. Everything else in an operation — schemas, argv parsing, MCP
Expand Down
53 changes: 51 additions & 2 deletions packages/rsc-runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,5 +140,54 @@ and hands it to the server through `import apps from 'agent-bundle/mcp-apps'`.
`createRscMcpServer` registers tools only, so serving that resource remains an
explicit `registerResource` call on the server it returns.

This layer intentionally does not own transport persistence or application
state. Those remain explicit dependencies of operation implementations.
This layer intentionally does not own transport persistence, and operations
never receive implicit storage: application state is an opt-in kernel behind
its own subpath, described next.

## State (optional)

`@agent-bundle/runtime/state` is the event-sourced Agent state kernel
([#98](https://github.com/ScriptedAlchemy/agent-bundle/issues/98)). Stateless
projects never import the subpath and ship none of it. Stateful applications
declare typed events and a pure reducer once, and every mutation flows through
`dispatch` with a caller-owned idempotency key:

```ts
import { defineState } from '@agent-bundle/runtime/state';
import { z } from 'zod';

export const editTimeline = defineState({
id: 'my-plugin/edit-timeline',
lifetime: 'workspace-durable',
schema: z.object({ edits: z.array(EditSchema) }).strict(),
initial: { edits: [] },
events: { editRecorded: EditSchema },
reduce: (state, event) => ({ edits: [...state.edits, event.payload] }),
});
```

Every state declares one explicit lifetime — `request` (discarded with the
invocation), `process` (a warm runtime's heap; lost on restart by definition),
`workspace-durable` (survives restarts for one workspace), or `external` (an
application-provided authority). Nothing infers durability from the presence
of an MCP process: hosts restart, multiply, or omit it.

The kernel owns monotonic revisions, exact-revision snapshot reads,
idempotency-key replay (a committed key returns its committed result; the same
key with a different payload is a typed `idempotency-conflict`),
compare-and-swap via `expectedRevision`, deterministic resets, explicit
versioned migrations, and polling change cursors — subscriptions across
short-lived processes are polling, and the kernel promises nothing stronger.
Corruption fails closed with typed errors, and error messages never embed
state or payload contents.

Host wiring opens a store from a driver and installs a request-bound handle on
the reserved context slot, so routes read
`const { state } = await agent()` and call
`state.dispatch(event, payload, { idempotencyKey })`. The in-memory driver
(`createMemoryStateDriver`) serves the two volatile lifetimes and doubles as
the test stand-in; it is never durable. The workspace-durable driver ships on
`node:sqlite` behind `@agent-bundle/runtime/state/sqlite`. Any driver —
Comment thread
ScriptedAlchemy marked this conversation as resolved.
including external ones — must pass the exported conformance suite
(`stateDriverConformanceCases`); a disconnected adapter is not a completed
integration.
4 changes: 4 additions & 0 deletions packages/rsc-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@
"./plugin": {
"types": "./dist/plugin.d.ts",
"import": "./dist/plugin.js"
},
"./state": {
"types": "./dist/state/index.d.ts",
"import": "./dist/state.js"
}
},
"scripts": {
Expand Down
1 change: 1 addition & 0 deletions packages/rsc-runtime/rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export default defineConfig({
entry: {
index: './src/index.ts',
plugin: './src/plugin.ts',
state: './src/state/index.ts',
},
tsconfigPath: './tsconfig.build.json',
},
Expand Down
14 changes: 10 additions & 4 deletions packages/rsc-runtime/src/agent-request.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AsyncLocalStorage } from 'node:async_hooks';

import type { JsonValue } from './lower-mcp.js';
import type { AgentStateHandle } from './state/contract.js';

export const AGENT_REQUEST_STORE_VERSION = 1;

Expand Down Expand Up @@ -130,8 +131,11 @@ export interface AgentRequestContext {
readonly signal: AbortSignal;
readonly services: AgentServiceRegistry;
readonly providers: AgentProviderValues;
/** Reserved for the durable state kernel (#98). Wave 1 leaves this undefined. */
readonly state: undefined;
/**
* State kernel handle (#98) installed by the host wiring via
* `runAgentRequest({ state })`; undefined for stateless projects.
*/
readonly state: AgentStateHandle | undefined;
/** Reserved for recipient-aware notices (#99). Wave 1 leaves this undefined. */
readonly notices: undefined;
}
Expand All @@ -146,6 +150,8 @@ export interface AgentRequestInit {
readonly services?: AgentServiceRegistry;
readonly session?: Observed<AgentSessionIdentity>;
readonly signal?: AbortSignal;
/** Request-bound state handle from `createAgentStateHandle` (subpath `./state`). */
readonly state?: AgentStateHandle;
readonly workspace?: Observed<AgentWorkspaceIdentity>;
}

Expand Down Expand Up @@ -240,7 +246,7 @@ interface FrozenValues {
readonly services: AgentServiceRegistry;
readonly session: Observed<AgentSessionIdentity>;
readonly signal: AbortSignal;
readonly state: undefined;
readonly state: AgentStateHandle | undefined;
readonly workspace: Observed<AgentWorkspaceIdentity>;
}

Expand Down Expand Up @@ -353,7 +359,7 @@ export const runAgentRequest = async <T>(
services: Object.freeze({ ...(init.services ?? {}) }),
session: snapshotObserved(init.session ?? unavailable<AgentSessionIdentity>()),
signal: init.signal ?? new AbortController().signal,
state: undefined,
state: init.state,
workspace: snapshotObserved(init.workspace ?? unavailable<AgentWorkspaceIdentity>()),
});
const lease: Lease = {
Expand Down
3 changes: 3 additions & 0 deletions packages/rsc-runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,8 @@ export type { NativePostToolUseOutput } from './lower-hook.js';
export { lowerMcpResult } from './lower-mcp.js';
export type { JsonObject, JsonValue } from './lower-mcp.js';
export { createRscRequestContext } from './request-context.js';
// Type-only: the state kernel itself ships behind the './state' subpath so
// stateless artifacts include none of it (#98).
export type { AgentStateHandle, AgentStateLifetime } from './state/contract.js';
export type { RscRequestContext } from './request-context.js';
export * from './plugin.js';
Loading
Loading