From 7093a2870a4f424c3b96363e6961cc3fd3125019 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 18:04:00 +0000 Subject: [PATCH] Add the Runtime value, lay out the core's layers, and add the harness A host owns a Runtime: the clock, the session opener, the model call, the catalog, the rooms that run and the workspace names that are taken. startSession, readSession and defineWorkspace take one and default to defaultRuntime. Every Date call in src routes through the runtime's clock, and the view a seat reads carries the time it was built at. session.ts keeps its shape: only its globals move into the runtime it is handed. The core gains its layers, and Biome holds them with one noRestrictedImports override per layer. The vocabulary is types, define and render. host/ holds what a host owns. tools/ holds what an agent's tools reach into. session.ts composes them. runtime.ts moves to host/, the three workspace files move to tools/, and the host contracts (Clock, SessionOpener, ModelResolver) and BUILTIN_TOOL_NAMES move to types.ts. Backlog items 1 and 5 close. The harness gains a fake clock, the two storages (memory, and JSONL over a temporary directory), the two workspace backends, the shared invariants, and four scenarios that matrix.test.ts runs on both storages. runtime.test.ts proves two runtimes share nothing and that a room on JSONL reads back through a second runtime. This is PR 1 of the stack SPLIT_PLAN.md cuts from #48. It carries 90d5871 from that branch, moved into the layout of 8f0c1bc. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SJjjR3S6iwqEbDXsRDJgiq --- CLAUDE.md | 5 +- biome.jsonc | 208 ++++++++++++- docs/agent.md | 27 +- docs/presence.md | 5 +- docs/toolchain.md | 36 ++- docs/workspace.md | 27 +- packages/ambion/src/activation.ts | 10 +- packages/ambion/src/assistant.ts | 7 +- packages/ambion/src/define.ts | 2 +- packages/ambion/src/host/runtime.ts | 144 +++++++++ packages/ambion/src/index.ts | 32 +- packages/ambion/src/record.ts | 32 +- packages/ambion/src/render.ts | 4 +- packages/ambion/src/seat.ts | 2 +- packages/ambion/src/session.ts | 119 ++++--- packages/ambion/src/{ => tools}/bash-env.ts | 0 packages/ambion/src/{ => tools}/just-bash.ts | 2 +- packages/ambion/src/{ => tools}/workspace.ts | 25 +- packages/ambion/src/types.ts | 28 +- packages/ambion/test/live/support.ts | 34 +- packages/ambion/test/matrix.test.ts | 25 ++ packages/ambion/test/runtime.test.ts | 76 +++++ packages/ambion/test/support/clock.ts | 49 +++ packages/ambion/test/support/invariants.ts | 47 +++ packages/ambion/test/support/scenarios.ts | 307 +++++++++++++++++++ packages/ambion/test/support/storage.ts | 96 ++++++ packages/ambion/test/workspace.test.ts | 4 +- planning/backlog.md | 45 +-- planning/next.md | 16 +- turbo.jsonc | 2 + 30 files changed, 1196 insertions(+), 220 deletions(-) create mode 100644 packages/ambion/src/host/runtime.ts rename packages/ambion/src/{ => tools}/bash-env.ts (100%) rename packages/ambion/src/{ => tools}/just-bash.ts (99%) rename packages/ambion/src/{ => tools}/workspace.ts (92%) create mode 100644 packages/ambion/test/matrix.test.ts create mode 100644 packages/ambion/test/runtime.test.ts create mode 100644 packages/ambion/test/support/clock.ts create mode 100644 packages/ambion/test/support/invariants.ts create mode 100644 packages/ambion/test/support/scenarios.ts create mode 100644 packages/ambion/test/support/storage.ts diff --git a/CLAUDE.md b/CLAUDE.md index e1be408..785409c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ pnpm workspace, Node >= 22.19, ESM only, TypeScript. | Path | What | | ------------------- | -------------------------------------------------------------------------------------- | -| `packages/ambion` | The runtime. One file per concern; `session.ts` is the room that composes them | +| `packages/ambion` | The runtime. One file per concern, in layers Biome holds; `session.ts` composes them | | `packages/cli` | The `ambion` binary | | `docs/agent.md` | Design contract for the core — read before changing the runtime | | `docs/exchange.md` | Design contract for the exchange, the room's unit of work — read with `agent.md` | @@ -72,6 +72,9 @@ Run `pnpm format` and `pnpm check` before every push. CI runs the same gate. everything a participant reads — prompts, roster, record, the ask at the end of a turn — and stays pure and stateless so it does not become one. What the room says to a developer stays with the mechanism that says it. +- The core is laid out in layers (`docs/toolchain.md` §1), and an import + points down only. Biome refuses the rest; a new file goes in the layer + that may reach what it needs, and never above `session.ts`. - No `any`, no non-null assertions, no unused imports or variables. - `packages/ambion/src` must not write to stdout. Hosts pass a logger in. - Cognitive complexity: max 10 in source, 15 in tests. diff --git a/biome.jsonc b/biome.jsonc index c148746..7c572ff 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -60,9 +60,213 @@ }, { // The library must not write to stdout on its owner's behalf; hosts - // pass a logger in instead. + // pass a logger in instead. It also runs on any host: nothing in it + // reaches a platform's own modules. "includes": ["packages/ambion/src/**"], - "linter": { "rules": { "suspicious": { "noConsole": "error" } } } + "linter": { + "rules": { + "suspicious": { "noConsole": "error" }, + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": ["cloudflare:*", "node:sqlite"], + "message": "The core runs on any host. A platform's own modules belong to its package." + } + ] + } + } + } + } + } + }, + // The core is laid out in layers, and an import points down only: + // + // types, wire, define, render the vocabulary: shapes, and what a participant reads + // host/ what a host owns: the runtime value, a SQLite storage + // log/ the log: one serial queue over a Pi session + // room/ every fact and every decision, pure over the log + // tools/ what an agent's tools reach into + // seat/ the seat side of the wire: one activation at a time + // session.ts the room, which composes them all + // + // `docs/toolchain.md` §1 names the layers; each override below refuses + // the imports its layer may not make. + { + "includes": [ + "packages/ambion/src/types.ts", + "packages/ambion/src/wire.ts", + "packages/ambion/src/define.ts", + "packages/ambion/src/render.ts" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": [ + "./host/*", + "./log/*", + "./room/*", + "./tools/*", + "./seat/*", + "./session.ts", + "./index.ts" + ], + "message": "The vocabulary imports nothing that does anything." + } + ] + } + } + } + } + } + }, + { + "includes": ["packages/ambion/src/host/**"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": [ + "../log/*", + "../room/*", + "../tools/*", + "../seat/*", + "../session.ts", + "../index.ts" + ], + "message": "A host owns values; it reads no log, folds nothing, and runs no seat." + } + ] + } + } + } + } + } + }, + { + "includes": ["packages/ambion/src/log/**"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": [ + "../host/*", + "../room/*", + "../tools/*", + "../seat/*", + "../session.ts", + "../index.ts" + ], + "message": "The log knows entries and a queue, and nothing that reads them." + } + ] + } + } + } + } + } + }, + { + "includes": ["packages/ambion/src/room/**"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": [ + "../host/*", + "../tools/*", + "../seat/*", + "../session.ts", + "../index.ts" + ], + "message": "The room's facts and decisions are pure over the log: no host, no tool, no seat." + } + ] + } + } + } + } + } + }, + { + "includes": ["packages/ambion/src/tools/**"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": ["../log/*", "../room/*", "../seat/*", "../session.ts", "../index.ts"], + "message": "A workspace is what a tool reaches into; it knows no room and no seat." + } + ] + } + } + } + } + } + }, + { + "includes": ["packages/ambion/src/seat/**"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": ["../log/*", "../room/*", "../session.ts", "../index.ts"], + "message": "The seat side reaches its room through the wire alone: no log, no fold, no session." + } + ] + } + } + } + } + } + }, + { + // A host package reaches the core through its published surface. + "includes": ["packages/cloudflare/src/**", "packages/cli/src/**", "examples/**/src/**"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": ["@ambionframework/ambion/*", "**/packages/ambion/src/*"], + "message": "Reach the core through `@ambionframework/ambion`, never a file inside it." + } + ] + } + } + } + } + } }, { // Biome charges a nested function for the nesting it sits in, so diff --git a/docs/agent.md b/docs/agent.md index 3000ea0..b10a0eb 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -517,7 +517,9 @@ sooner. `seats()`, `subscribe()` — and `Session` extends it, so code that only reads takes the narrower type and cannot start anything by accident. -One file per concern, and `session.ts` is the room that composes them: the +One file per concern, in layers an import points down through, and +`session.ts` is the room that composes them ([`toolchain.md`](toolchain.md) +§1 names the layers, and Biome holds them): the record in [`record.ts`](../packages/ambion/src/record.ts), who is here in [`presence.ts`](../packages/ambion/src/presence.ts), a seat and what wakes it in [`seat.ts`](../packages/ambion/src/seat.ts), one activation in @@ -525,8 +527,9 @@ in [`seat.ts`](../packages/ambion/src/seat.ts), one activation in [`exchange.ts`](../packages/ambion/src/exchange.ts), what the assistant writes in [`assistant.ts`](../packages/ambion/src/assistant.ts), what an agent's tools reach into in -[`workspace.ts`](../packages/ambion/src/workspace.ts), and what any of them -reads in [`render.ts`](../packages/ambion/src/render.ts). +[`workspace.ts`](../packages/ambion/src/tools/workspace.ts), what a host +owns in [`runtime.ts`](../packages/ambion/src/host/runtime.ts), and what +any of them reads in [`render.ts`](../packages/ambion/src/render.ts). **A seat is seated for the run. An activation lasts seconds.** What an activation has heard, what landed while it worked, and whether it left a mark @@ -534,13 +537,23 @@ belong to the activation and end with it. Rule 5's `readThrough` is an activation's fact. Storage is Pi's. The record lives in a Pi session — each message a custom -entry, replayed in `seq` order on reopen — obtained from Pi's own -`SessionRepo`, which `startSession` and `readSession` accept and default to -an in-process `InMemorySessionRepo`. A name that outlives the process is a -durable `SessionRepo` implementation; the API stays the same. +entry, replayed in `seq` order on reopen — opened through a `SessionOpener` +on the room's `Runtime`. `sessionsOver(repo)` makes an opener from Pi's own +`SessionRepo`; `startSession` and `readSession` still accept a `repo` as +the shorthand for one. The default runtime opens sessions in an in-memory +`InMemorySessionRepo`. A name that outlives the process is a durable +`SessionRepo` implementation; the API stays the same. [`index.ts`](../packages/ambion/src/index.ts) re-exports Pi's storage surface, and Ambion adds no storage layer of its own. +**A host owns a `Runtime`.** It holds the clock, the session opener, the +model call, the rooms that are running and the workspace names that are +taken ([`runtime.ts`](../packages/ambion/src/host/runtime.ts)). `startSession`, +`readSession` and `defineWorkspace` take one as an option and default to +`defaultRuntime`, one value per process. Two runtimes in one process share +nothing: one name runs in both, and neither reads the other. "One run per +name" above holds per runtime. + --- ## 6. What proves it diff --git a/docs/presence.md b/docs/presence.md index 103cb23..ec06097 100644 --- a/docs/presence.md +++ b/docs/presence.md @@ -457,7 +457,10 @@ Four things beyond the record, each pulling its weight: arrival matter" answerable at all. - **The time**, absolute at the top and relative on each line, is what a persistent ambient room needs and a bare transcript never gives: without - it an agent cannot tell a three-day gap from a three-minute one. + it an agent cannot tell a three-day gap from a three-minute one. The room + reads it off its runtime's `Clock` (`agent.md` §5): the system clock by + default, and a clock a test moves by hand in the scripted suite. Every + `at` on the record is stamped from the same clock. - **The gap** is derived from the record, because the arrivals are on it; the runtime stores no separate timer. diff --git a/docs/toolchain.md b/docs/toolchain.md index 5efa78b..3fd1945 100644 --- a/docs/toolchain.md +++ b/docs/toolchain.md @@ -59,6 +59,28 @@ if the workspace protocol does not resolve. `@ambionframework/cli` is the `ambion` binary; it currently reports its version and nothing else. +### The core's layers + +`packages/ambion/src` is laid out in layers, and an import points down +only. Biome refuses every other import (`noRestrictedImports`, one +override per layer in `biome.jsonc`), so the layout is a fact the gate +holds, and a reviewer reads a file knowing what it cannot reach. + +| Layer | What it holds | May import | +| --------------------------- | ---------------------------------------------------------------- | -------------------------- | +| `types`, `define`, `render` | The vocabulary: the public shapes, and what a participant reads | Nothing that does anything | +| `host/` | What a host owns: the runtime value, a clock, an opener | The vocabulary | +| `tools/` | What an agent's tools reach into: the workspace and its backends | The vocabulary, `host/` | +| `session.ts` | The room, which composes them all | Everything | + +The files beside `session.ts` at the root of `src` (the activation, the +assistant, the exchange, presence, the record, the seat) are in no layer +yet, and no override constrains them. + +Two rules hold across packages: the core imports no platform module +(`node:sqlite`, `cloudflare:*`), and every other package reaches the core +through `@ambionframework/ambion`, its published surface. + --- ## 2. Toolchain choices @@ -148,14 +170,15 @@ Notable settings and what they buy: ``` build dependsOn: ^build outputs: dist/** check:types dependsOn: build, ^build (needs upstream .d.mts) -test dependsOn: build, ^build +test dependsOn: build, ^build inputs: src, test, vitest configs, tsconfig, package.json dev persistent, never cached ``` `check:types` and `test` wait on upstream builds because the CLI type-checks against the runtime's _emitted_ declarations. That is the same resolution a published consumer gets, so a broken `exports` map fails here, -before release. +before release. `test` names its inputs, so a change outside them, a +document or a demo report, reads the cached result. --- @@ -288,6 +311,15 @@ roster, every summary covering the range before it, no `error` event, and every activation ended. Every test ends with one line of what it spent, read off the seats' downstream sessions. +**One harness, two tiers.** The invariants live in +[`test/support/invariants.ts`](../packages/ambion/test/support/invariants.ts), +and the live support re-exports them. The scripted tier runs the same +scenarios on every storage (`matrix.test.ts`): Pi's in-memory repository, +and Pi's JSONL repository over a temporary directory. It runs them on a +clock it moves by hand (`test/support/clock.ts`), so a test never waits +on real time. The live tier runs the room on a real model and holds it to +the same invariants. + `pnpm test:live` runs the tier. Two configurations keep the tiers apart: `vitest.config.ts` excludes `test/live` from `pnpm test`, and `vitest.live.config.ts` includes nothing else. In the live configuration diff --git a/docs/workspace.md b/docs/workspace.md index dc13664..a2403ae 100644 --- a/docs/workspace.md +++ b/docs/workspace.md @@ -3,9 +3,9 @@ This document is the design contract for the workspace: the identity and data boundary an agent connects to when it is defined. The workspace is shipped. The handle, the resolver and the built-in tools live in -[`workspace.ts`](../packages/ambion/src/workspace.ts), the adapter around a -just-bash instance in [`bash-env.ts`](../packages/ambion/src/bash-env.ts), -the two backends in [`just-bash.ts`](../packages/ambion/src/just-bash.ts), +[`workspace.ts`](../packages/ambion/src/tools/workspace.ts), the adapter around a +just-bash instance in [`bash-env.ts`](../packages/ambion/src/tools/bash-env.ts), +the two backends in [`just-bash.ts`](../packages/ambion/src/tools/just-bash.ts), and the public shapes in [`types.ts`](../packages/ambion/src/types.ts). Read [`agent.md`](agent.md) first: a workspace attaches to the agent that document specifies, and changes none of its eight rules. @@ -92,15 +92,18 @@ does for an agent happens later, inside `connect` (§7). **The handle carries its backend and its destroyed mark as fields the public type does not show.** `types.ts` brands `AgentDefinition` with a symbol key, `AGENT_BRAND`. A `WorkspaceHandle` takes the same shape, with -its `WorkspaceBackend` (§7) and a destroyed flag behind the brand. No table -keyed by name holds either of them. The one thing the runtime remembers -across calls is which names are taken, the way `session.ts` keeps its -`running` map of session names. - -**A second `defineWorkspace` call for a name already defined in this -process is refused**, the same way `startSession` refuses a name already -running (`agent.md` §5). One name has one handle for the life of the -process, until `destroyWorkspace` (below) frees it. +its `WorkspaceBackend` (§7), its `Runtime` and a destroyed flag behind the +brand. No table keyed by name holds either of them. The one thing the +runtime remembers across calls is which names are taken: the `taken` set on +the `Runtime` value (`agent.md` §5), beside its `running` map of session +names. + +**A second `defineWorkspace` call for a name already defined in the same +runtime is refused**, the same way `startSession` refuses a name already +running (`agent.md` §5). One name has one handle in one runtime, until +`destroyWorkspace` (below) frees it. `defineWorkspace` takes `runtime` as +an option and defaults to `defaultRuntime`, so two hosts in one process +define the same name in their own runtimes. **An optional `backend` field takes a `WorkspaceBackend`**, the way a session's `repo` option takes a `SessionRepo` (`agent.md` §5). §7 specifies diff --git a/packages/ambion/src/activation.ts b/packages/ambion/src/activation.ts index c927660..68fd472 100644 --- a/packages/ambion/src/activation.ts +++ b/packages/ambion/src/activation.ts @@ -43,6 +43,8 @@ export interface ActivationRoom { /** Keep what the model did, in the seat's own downstream session. */ persist(agent: Agent): Promise; emit(event: SessionEvent): void; + /** The room's clock: Pi stamps every message it is handed. */ + now(): number; } /** One activation, from the moment the room wakes a seat until it stops. */ @@ -82,7 +84,7 @@ export class Activation { */ steer(message: Message, line: string): void { this.pending.push(message.seq); - this.agent?.steer(userMessage(`[new] ${line}`)); + this.agent?.steer(userMessage(`[new] ${line}`, this.room.now())); } /** Pi's abort ends the run but not its queues; this stops the rebuild too. */ @@ -111,7 +113,7 @@ export class Activation { const { agent, context } = this.room.open(this); this.agent = agent; agent.subscribe((event) => this.note(event)); - await agent.prompt(userMessage(context)); + await agent.prompt(userMessage(context, this.room.now())); await this.room.persist(agent); const failure = failureOf(agent); if (failure) return this.broke(failure); @@ -157,8 +159,8 @@ export class Activation { } } -function userMessage(text: string): UserMessage { - return { role: 'user', content: text, timestamp: Date.now() }; +function userMessage(text: string, timestamp: number): UserMessage { + return { role: 'user', content: text, timestamp }; } function failureOf(agent: Agent): Error | undefined { diff --git a/packages/ambion/src/assistant.ts b/packages/ambion/src/assistant.ts index 01216c3..991a292 100644 --- a/packages/ambion/src/assistant.ts +++ b/packages/ambion/src/assistant.ts @@ -112,6 +112,8 @@ function draftOver( export interface SummaryRoom { /** Whether the room is closing: a draft that finishes after it commits nothing. */ stopped(): boolean; + /** The room's clock, as an ISO stamp for the record. */ + now(): string; /** The last seq the record holds. */ lastSeq(): Seq; /** Rule 5: the same lock a say commits under. */ @@ -156,7 +158,7 @@ export function summariseTool(assistant: string, draft: Draft, room: SummaryRoom { name: assistant, readThrough: draft.through }, { kind: 'summary', - at: new Date().toISOString(), + at: room.now(), from: assistant, to: person, text, @@ -216,6 +218,7 @@ export interface Composing { /** What the seat tool needs of the room: the reserve, the roster, and the record. */ export interface ComposeRoom { stopped(): boolean; + now(): string; /** The reserve as it stands: who may be seated, by name and identity. */ reserve(): { name: string; identity: string }[]; /** Move one name from the reserve to the roster. The roster changes before the message lands. */ @@ -266,7 +269,7 @@ export function seatTool(assistant: string, composing: Composing, room: ComposeR room.seat(name); const message = room.commit({ kind: 'seated', - at: new Date().toISOString(), + at: room.now(), from: name, identity: entry.identity, by: assistant, diff --git a/packages/ambion/src/define.ts b/packages/ambion/src/define.ts index ecb4169..036318d 100644 --- a/packages/ambion/src/define.ts +++ b/packages/ambion/src/define.ts @@ -14,6 +14,7 @@ import { type AgentDefinition, type AmbionTool, type Attention, + BUILTIN_TOOL_NAMES, HUMAN_BRAND, type HumanDefinition, isAgent, @@ -24,7 +25,6 @@ import { type ToolContext, type WorkspaceHandle, } from './types.ts'; -import { BUILTIN_TOOL_NAMES } from './workspace.ts'; export interface DefineAgentOptions { /** Identifies the agent inside a session and on the record. */ diff --git a/packages/ambion/src/host/runtime.ts b/packages/ambion/src/host/runtime.ts new file mode 100644 index 0000000..cde1d5a --- /dev/null +++ b/packages/ambion/src/host/runtime.ts @@ -0,0 +1,144 @@ +/** + * The runtime: what a host owns and every room in it shares. + * + * A room needs a clock, a place to open Pi sessions, a model call, and a + * register of what is running. Until now each of those was a module-level + * value, so two hosts in one process shared them whether they wanted to or + * not. A `Runtime` holds them as one value: `startSession`, `readSession` + * and `defineWorkspace` take one, and `defaultRuntime` is the value they + * take when a host passes none. + * + * The clock is an interface so a test can move time by hand, and so a host + * on a platform with its own alarms maps `alarm` to them. The opener is an + * interface so a host supplies whatever Pi's repository needs to create a + * session, which an in-memory repository needs nothing for and a JSONL + * repository needs a working directory for. + */ +import type { + Session as PiSession, + SessionCreateOptions, + SessionMetadata, + StreamFn, +} from '@earendil-works/pi-agent-core'; +import { InMemorySessionRepo } from '@earendil-works/pi-agent-core'; +import type { Api, Model } from '@earendil-works/pi-ai'; +import { builtinModels } from '@earendil-works/pi-ai/providers/all'; +import type { AgentDefinition, Clock, ModelResolver, SessionOpener } from '../types.ts'; + +/** A room the runtime holds while it runs. `session.ts` implements it. */ +export interface RunningRoom { + readonly name: string; +} + +export interface Runtime { + /** One run per name: the rooms running in this runtime. */ + readonly running: Map; + /** One workspace handle per name. */ + readonly taken: Set; + /** Every agent definition a room in this runtime was started with, by name. */ + readonly catalog: Map; + readonly clock: Clock; + readonly sessions: SessionOpener; + /** The model call every seat in this runtime makes, unless a room overrides it. */ + readonly stream: StreamFn; + readonly model: ModelResolver; +} + +export interface CreateRuntimeOptions { + clock?: Clock; + /** Where the rooms' Pi sessions open. `repo` is the shorthand for `sessionsOver(repo)`. */ + sessions?: SessionOpener; + repo?: SessionRepoLike; + /** + * The model call. A scripted stream makes every room deterministic; the + * model then resolves to a stub, because a custom stream never reads it. + */ + stream?: StreamFn; +} + +/** What `sessionsOver` needs of a Pi repository: list, open, create. */ +export interface SessionRepoLike< + TMetadata extends SessionMetadata, + TCreate extends SessionCreateOptions, +> { + list(): Promise; + open(metadata: TMetadata): Promise>; + create(options: TCreate): Promise>; +} + +/** + * Open an id into its Pi session in `repo`, creating it on the first open. + * `create` carries what the repository's `create` needs beyond the id: a + * JSONL repository needs a `cwd`, an in-memory one needs nothing. + */ +export function sessionsOver< + TMetadata extends SessionMetadata, + TCreate extends SessionCreateOptions, +>( + repo: SessionRepoLike, + create?: Omit, +): SessionOpener { + return { + async open(id, parentId) { + const known = (await repo.list()).find((metadata) => metadata.id === id); + if (known) return repo.open(known); + const options = { ...(create ?? {}), id } as TCreate; + if (parentId !== undefined) options.parentSessionId = parentId; + return repo.create(options); + }, + }; +} + +/** The system clock, and one timer that never holds the process open. */ +export function systemClock(): Clock { + return { + now: () => Date.now(), + alarm(at, fire) { + const timer = setTimeout(fire, Math.max(0, at - Date.now())); + timer.unref?.(); + return () => clearTimeout(timer); + }, + }; +} + +/** Pi's model registry, built once on first use. It loads every provider SDK. */ +let builtinRegistry: ReturnType | undefined; +const registry = () => (builtinRegistry ??= builtinModels()); + +/** The default model call: Pi's builtin registry, keyed from the provider's env var. */ +const registryStream: StreamFn = (model, context, streamOptions) => { + const envKey = process.env[`${model.provider.toUpperCase().replace(/-/g, '_')}_API_KEY`]; + const resolved = + streamOptions?.apiKey || !envKey ? streamOptions : { ...streamOptions, apiKey: envKey }; + return registry().streamSimple(model, context, resolved); +}; + +/** `provider/model-id` through Pi's catalog. */ +const registryModel: ModelResolver = (id, agent) => { + const slash = id.indexOf('/'); + if (slash > 0) { + const model = registry().getModel(id.slice(0, slash), id.slice(slash + 1)); + if (model) return model; + } + throw new Error(`Unknown model '${id}' for agent '${agent}': expected 'provider/model-id'.`); +}; + +/** A custom stream never reads the model; a stub keeps Pi's loop satisfied. */ +export const stubModel: ModelResolver = (id) => + ({ id, name: id, api: 'scripted', provider: 'scripted' }) as unknown as Model; + +export function createRuntime(options: CreateRuntimeOptions = {}): Runtime { + const sessions = options.sessions ?? sessionsOver(options.repo ?? new InMemorySessionRepo()); + return { + running: new Map(), + taken: new Set(), + catalog: new Map(), + clock: options.clock ?? systemClock(), + sessions, + stream: options.stream ?? registryStream, + model: options.stream ? stubModel : registryModel, + }; +} + +/** What a host gets when it passes no runtime: one process-wide value. */ +export const defaultRuntime: Runtime = createRuntime(); diff --git a/packages/ambion/src/index.ts b/packages/ambion/src/index.ts index f02cb06..7e3a4c4 100644 --- a/packages/ambion/src/index.ts +++ b/packages/ambion/src/index.ts @@ -35,15 +35,12 @@ export { attentive, defineAgent, defineHuman, defineTool, passive, seated } from // The room's own exchange: what a question opened, and what quiescence closed. export type { ClosedExchange, Exchange } from './exchange.ts'; export type { - MemoryBackendFile, - MemoryBackendOptions, - MemoryWorkspaceBackend, - SeedWriter, -} from './just-bash.ts'; -// A workspace over a real directory, or the in-memory default with seeding -// and read-back. Neither import is needed for the in-memory default's own -// use inside `defineWorkspace` — only a host that wants to seed or read it. -export { directoryBackend, memoryBackend } from './just-bash.ts'; + CreateRuntimeOptions, + RunningRoom, + Runtime, + SessionRepoLike, +} from './host/runtime.ts'; +export { createRuntime, defaultRuntime, sessionsOver, systemClock } from './host/runtime.ts'; export type { ReadSessionOptions, Session, @@ -52,15 +49,29 @@ export type { Visit, } from './session.ts'; export { readSession, startSession, stopSession, visitSession } from './session.ts'; +export type { + MemoryBackendFile, + MemoryBackendOptions, + MemoryWorkspaceBackend, + SeedWriter, +} from './tools/just-bash.ts'; +// A workspace over a real directory, or the in-memory default with seeding +// and read-back. Neither import is needed for the in-memory default's own +// use inside `defineWorkspace` — only a host that wants to seed or read it. +export { directoryBackend, memoryBackend } from './tools/just-bash.ts'; +export type { DefineWorkspaceOptions } from './tools/workspace.ts'; +export { defineWorkspace, destroyWorkspace } from './tools/workspace.ts'; export type { AgentDefinition, AgentSeat, AgentSeatInfo, AmbionTool, Attention, + Clock, HumanDefinition, HumanSeatInfo, Message, + ModelResolver, Participant, PresenceChange, PresenceMessage, @@ -70,6 +81,7 @@ export type { SeatStatus, Seq, SessionEvent, + SessionOpener, SpokenMessage, SummaryMessage, ToolContext, @@ -78,8 +90,6 @@ export type { WorkspaceHandle, } from './types.ts'; export { isPresence, isSpoken, isSummary } from './types.ts'; -export type { DefineWorkspaceOptions } from './workspace.ts'; -export { defineWorkspace, destroyWorkspace } from './workspace.ts'; /** Kept in step with package.json by a test. */ export const PACKAGE_NAME = '@ambionframework/ambion'; diff --git a/packages/ambion/src/record.ts b/packages/ambion/src/record.ts index 3ea1ad8..d2bdb8e 100644 --- a/packages/ambion/src/record.ts +++ b/packages/ambion/src/record.ts @@ -6,7 +6,7 @@ * seqs one at a time, and persists on a chain that keeps commit order. What a * seat reads is a rendering of this (`render.ts`), never this itself. */ -import type { Agent, Session as PiSession, SessionRepo } from '@earendil-works/pi-agent-core'; +import type { Agent, Session as PiSession } from '@earendil-works/pi-agent-core'; import type { Message, Seq } from './types.ts'; /** The record lives as custom entries of this type in a Pi session. */ @@ -24,11 +24,8 @@ export class RecordStore { /** The first write that failed since the last report. See `drained`. */ private failure: Error | undefined; - constructor( - private readonly repo: SessionRepo, - private readonly name: string, - ) { - this.ready = this.open(); + constructor(open: Promise) { + this.ready = this.replay(open); // A host can hold a session and read nothing from it for hours, so // nothing may await `ready` for a long time. Mark the rejection handled // here: a repo that cannot open must surface at the call that needs the @@ -36,8 +33,8 @@ export class RecordStore { void this.ready.catch(() => {}); } - private async open(): Promise { - const piSession = await openOrCreate(this.repo, this.name); + private async replay(open: Promise): Promise { + const piSession = await open; const found = await piSession.findEntries(); // findEntries does not promise append order; seq does. found.sort((a, b) => a.seq - b.seq); @@ -90,21 +87,14 @@ export class RecordStore { } } -/** Open an id into its Pi session, creating it on first open. */ -export async function openOrCreate( - repo: SessionRepo, - id: string, - parentSessionId?: string, -): Promise { - const known = (await repo.list()).find((metadata) => metadata.id === id); - if (known) return repo.open(known); - return repo.create(parentSessionId ? { id, parentSessionId } : { id }); -} - /** Every turn a model took, in the downstream session that owns it. */ -export async function persistTurns(open: Promise, agent: Agent): Promise { +export async function persistTurns( + open: Promise, + agent: Agent, + at: string, +): Promise { const piSeat = await open; - await piSeat.appendCustomEntry('ambion/activation', { at: new Date().toISOString() }); + await piSeat.appendCustomEntry('ambion/activation', { at }); for (const message of agent.state.messages) { // Provider messages may carry undefined-valued fields, which Pi's // durability check rejects; a JSON round-trip drops them. diff --git a/packages/ambion/src/render.ts b/packages/ambion/src/render.ts index 16a18fb..dabd939 100644 --- a/packages/ambion/src/render.ts +++ b/packages/ambion/src/render.ts @@ -224,6 +224,8 @@ export function refusal(opening: string, missed: Message[], advice: string): str */ export interface RoomView { readonly name: string; + /** The room's clock, in milliseconds since the epoch, when the view was built. */ + readonly now: number; /** What the room is for, or nothing when it was started without one. */ readonly goal: string | undefined; readonly seats: SeatInfo[]; @@ -329,7 +331,7 @@ function duties(seat: SeatSpeaking, room: RoomView): string[] { } export function renderTurnContext(seat: SeatSpeaking, room: RoomView): string { - const now = Date.now(); + const now = room.now; const people = room.people; return [ renderClock(now), diff --git a/packages/ambion/src/seat.ts b/packages/ambion/src/seat.ts index 8c46654..2683d74 100644 --- a/packages/ambion/src/seat.ts +++ b/packages/ambion/src/seat.ts @@ -17,9 +17,9 @@ import type { Session as PiSession, } from '@earendil-works/pi-agent-core'; import type { Activation } from './activation.ts'; +import { toolContext } from './tools/workspace.ts'; import type { AgentDefinition, Attention, Message } from './types.ts'; import { isAmbionTool, isSpoken } from './types.ts'; -import { toolContext } from './workspace.ts'; export interface SeatRuntime { def: AgentDefinition; diff --git a/packages/ambion/src/session.ts b/packages/ambion/src/session.ts index 398f06b..0086a5b 100644 --- a/packages/ambion/src/session.ts +++ b/packages/ambion/src/session.ts @@ -23,9 +23,7 @@ import type { SessionRepo, StreamFn, } from '@earendil-works/pi-agent-core'; -import { Agent, InMemorySessionRepo } from '@earendil-works/pi-agent-core'; -import type { Api, Model } from '@earendil-works/pi-ai'; -import { builtinModels } from '@earendil-works/pi-ai/providers/all'; +import { Agent } from '@earendil-works/pi-agent-core'; import { Type } from 'typebox'; import { Activation } from './activation.ts'; import { @@ -38,8 +36,9 @@ import { } from './assistant.ts'; import { seated } from './define.ts'; import { type ClosedExchange, type Exchange, Exchanges } from './exchange.ts'; +import { defaultRuntime, type Runtime, sessionsOver, stubModel } from './host/runtime.ts'; import { Attendance, type VisitRuntime } from './presence.ts'; -import { openOrCreate, persistTurns, RecordStore } from './record.ts'; +import { persistTurns, RecordStore } from './record.ts'; import { type Closing, type ComposingView, @@ -52,6 +51,7 @@ import { type SeatSpeaking, } from './render.ts'; import { delivered, isActive, type SeatRuntime, toPiTool, wakes } from './seat.ts'; +import { builtinTools } from './tools/workspace.ts'; import { type AgentDefinition, type AgentSeat, @@ -62,20 +62,16 @@ import { isSeatedAgent, isSpoken, type Message, + type ModelResolver, type Participant, type PresenceMessage, type SeatInfo, type Seq, type SessionEvent, + type SessionOpener, type SpokenMessage, type SummaryMessage, } from './types.ts'; -import { builtinTools } from './workspace.ts'; - -const defaultRepo = new InMemorySessionRepo(); - -/** One run per name: a second live room over one record would diverge from it. */ -const running = new Map(); /** An agent held in reserve: the definition, and the attention it takes when seated. */ interface Reserved { @@ -83,18 +79,6 @@ interface Reserved { attention: Attention; } -/** Pi's model registry, built once on first use. */ -let builtinRegistry: ReturnType | undefined; -const registry = () => (builtinRegistry ??= builtinModels()); - -/** The default model call: Pi's builtin registry, keyed from the provider's env var. */ -const registryStream: StreamFn = (model, context, streamOptions) => { - const envKey = process.env[`${model.provider.toUpperCase().replace(/-/g, '_')}_API_KEY`]; - const resolved = - streamOptions?.apiKey || !envKey ? streamOptions : { ...streamOptions, apiKey: envKey }; - return registry().streamSimple(model, context, resolved); -}; - export interface StartSessionOptions { /** The session's name: the record belongs to it, across every run. */ name: string; @@ -120,15 +104,18 @@ export interface StartSessionOptions { /** * Override the model call — Pi's own extension surface, and the only one * here: a scripted stream makes the room deterministic, a custom stream - * brings custom providers. + * brings custom providers. Defaults to the runtime's. */ streamFn?: StreamFn; - /** Pi's own session repository. Defaults to a process-wide `InMemorySessionRepo`. */ + /** Pi's own session repository. Defaults to the runtime's opener. */ repo?: SessionRepo; + /** The runtime this room runs in. Defaults to `defaultRuntime`. */ + runtime?: Runtime; } export interface ReadSessionOptions { repo?: SessionRepo; + runtime?: Runtime; } /** Reading a room takes no run: the pull side, and nothing that starts anything. */ @@ -178,13 +165,14 @@ export interface Visit { /** Sets up the context where the agents work. */ export function startSession(options: StartSessionOptions): Session { - if (running.has(options.name)) { + const runtime = options.runtime ?? defaultRuntime; + if (runtime.running.has(options.name)) { throw new Error( `Session '${options.name}' is already running: stop it before starting it again.`, ); } - const session = new SessionImpl(options); - running.set(options.name, session); + const session = new SessionImpl(options, runtime); + runtime.running.set(options.name, session); return session; } @@ -206,7 +194,10 @@ export function visitSession(session: Session, human: HumanDefinition): Promise< /** Reads a name and starts nothing. A running name reads through its live room. */ export function readSession(name: string, options: ReadSessionOptions = {}): SessionView { - return running.get(name) ?? new ReadOnlySession(name, options.repo ?? defaultRepo); + const runtime = options.runtime ?? defaultRuntime; + const live = runtime.running.get(name); + if (live instanceof SessionImpl) return live; + return new ReadOnlySession(name, options.repo ? sessionsOver(options.repo) : runtime.sessions); } class ReadOnlySession implements SessionView { @@ -215,9 +206,9 @@ class ReadOnlySession implements SessionView { constructor( readonly name: string, - repo: SessionRepo, + sessions: SessionOpener, ) { - this.store = new RecordStore(repo, name); + this.store = new RecordStore(sessions.open(name)); this.here = new Attendance(() => this.store.entries); } @@ -247,7 +238,8 @@ class ReadOnlySession implements SessionView { class SessionImpl implements Session { readonly name: string; private readonly goal?: string; - private readonly repo: SessionRepo; + private readonly runtime: Runtime; + private readonly sessions: SessionOpener; private readonly store: RecordStore; private readonly agents = new Map(); /** The reserve: agents the room may seat later, held with the attention they will take. */ @@ -259,7 +251,7 @@ class SessionImpl implements Session { private readonly settledWaiters: (() => void)[] = []; private readonly quietWaiters: (() => void)[] = []; private readonly streamFn: StreamFn; - private readonly customStream: boolean; + private readonly model: ModelResolver; private stopped = false; /** * Whether a seat has worked since the room last settled. A failed draft @@ -271,18 +263,29 @@ class SessionImpl implements Session { /** The room's exchanges: what a question opened, and what quiescence closes. */ private readonly exchanges = new Exchanges(); - constructor(options: StartSessionOptions) { + constructor(options: StartSessionOptions, runtime: Runtime) { this.name = options.name; this.goal = options.goal?.trim() || undefined; - this.repo = options.repo ?? defaultRepo; - this.store = new RecordStore(this.repo, this.name); + this.runtime = runtime; + this.sessions = options.repo ? sessionsOver(options.repo) : runtime.sessions; + this.store = new RecordStore(this.sessions.open(this.name)); for (const seat of options.agents ?? []) this.place(seat); // Seated at the narrow end: nothing said in the room wakes the assistant; // the open and the close of an exchange do, and it is here for the whole run. this.assistant = new Assistant(this.place(seated(assertAssistant(options.assistant), 'none'))); for (const seat of options.available ?? []) this.hold(seat); - this.customStream = options.streamFn !== undefined; - this.streamFn = options.streamFn ?? registryStream; + this.streamFn = options.streamFn ?? runtime.stream; + this.model = options.streamFn ? stubModel : runtime.model; + // The seat side resolves a definition by name, so every one this room + // was composed with is on the runtime's catalog. + for (const { def } of [...this.agents.values(), ...this.reserve.values()]) { + runtime.catalog.set(def.name, def); + } + } + + /** The room's clock, as an ISO stamp for the record. */ + private now(): string { + return new Date(this.runtime.clock.now()).toISOString(); } private get record(): Message[] { @@ -378,7 +381,7 @@ class SessionImpl implements Session { private seatSession(seat: SeatRuntime): Promise { seat.piSeat ??= (async () => { await this.store.ready; - return openOrCreate(this.repo, `${this.name}:${seat.def.name}`, this.name); + return this.sessions.open(`${this.name}:${seat.def.name}`, this.name); })(); return seat.piSeat; } @@ -493,9 +496,7 @@ class SessionImpl implements Session { } private async commitPresence(change: Omit): Promise { - await this.publish( - this.store.append({ ...change, at: new Date().toISOString() }), - ); + await this.publish(this.store.append({ ...change, at: this.now() })); } /** @@ -590,7 +591,7 @@ class SessionImpl implements Session { } finally { // The name comes free whatever the repo did. A failed write must // not leave a room that can never be started again. - if (running.get(this.name) === this) running.delete(this.name); + if (this.runtime.running.get(this.name) === this) this.runtime.running.delete(this.name); // A stopped room never goes quiet on its own, so nobody waits on it. for (const resolve of this.quietWaiters.splice(0)) resolve(); } @@ -600,7 +601,7 @@ class SessionImpl implements Session { private commitUnrouted(change: Omit): void { this.emit({ type: 'message', - message: this.store.append({ ...change, at: new Date().toISOString() }), + message: this.store.append({ ...change, at: this.now() }), }); } @@ -617,7 +618,7 @@ class SessionImpl implements Session { await this.publish( this.store.append({ kind: 'said', - at: new Date().toISOString(), + at: this.now(), from, ...(to === undefined ? {} : { to }), text: input.text, @@ -687,8 +688,9 @@ class SessionImpl implements Session { private activate(seat: SeatRuntime): void { const activation = new Activation(seat.def.name, this.store.lastSeq, { open: (running) => this.open(seat, running), - persist: (agent) => persistTurns(this.seatSession(seat), agent), + persist: (agent) => persistTurns(this.seatSession(seat), agent, this.now()), emit: (event) => this.emit(event), + now: () => this.runtime.clock.now(), }); // The seat holding it is what makes the room busy: there is no count to // keep in step, and so none to drift. @@ -773,6 +775,7 @@ class SessionImpl implements Session { return { name: this.name, goal: this.goal, + now: this.runtime.clock.now(), seats: this.seats(), people: this.peopleViews(), record: this.record, @@ -792,7 +795,7 @@ class SessionImpl implements Session { streamFn: this.streamFn, initialState: { systemPrompt: renderSystemPrompt(speaking, view), - model: this.resolveModel(seat.def), + model: this.model(seat.def.model, seat.def.name), thinkingLevel: 'off', tools: this.handsFor(seat, activation), messages: [], @@ -832,6 +835,7 @@ class SessionImpl implements Session { private seatHand(seat: SeatRuntime, activation: Activation, composing: Composing): AgentTool { return seatTool(seat.def.name, composing, { stopped: () => this.stopped, + now: () => this.now(), reserve: () => this.reserved(), seat: (name) => this.admit(name), commit: (draft) => this.store.append(draft), @@ -846,6 +850,7 @@ class SessionImpl implements Session { private summarise(seat: SeatRuntime, activation: Activation, closing: Draft): AgentTool { return summariseTool(seat.def.name, closing, { stopped: () => this.stopped, + now: () => this.now(), lastSeq: () => this.store.lastSeq, claim: (author, draft) => this.claim(author, draft), publish: (message) => this.publish(message), @@ -886,7 +891,7 @@ class SessionImpl implements Session { // seat read it, and the append is the commit half of rule 5's one tick. const message = this.store.append({ kind: 'said', - at: new Date().toISOString(), + at: this.now(), from: seat.def.name, ...(to === undefined ? {} : { to }), text, @@ -1040,24 +1045,4 @@ class SessionImpl implements Session { } return views; } - - private resolveModel(def: AgentDefinition): Model { - if (this.customStream) { - // A custom streamFn never reads the model; a stub keeps Pi's loop satisfied. - return { - id: def.model, - name: def.model, - api: 'scripted', - provider: 'scripted', - } as unknown as Model; - } - const slash = def.model.indexOf('/'); - if (slash > 0) { - const model = registry().getModel(def.model.slice(0, slash), def.model.slice(slash + 1)); - if (model) return model; - } - throw new Error( - `Unknown model '${def.model}' for agent '${def.name}': expected 'provider/model-id'.`, - ); - } } diff --git a/packages/ambion/src/bash-env.ts b/packages/ambion/src/tools/bash-env.ts similarity index 100% rename from packages/ambion/src/bash-env.ts rename to packages/ambion/src/tools/bash-env.ts diff --git a/packages/ambion/src/just-bash.ts b/packages/ambion/src/tools/just-bash.ts similarity index 99% rename from packages/ambion/src/just-bash.ts rename to packages/ambion/src/tools/just-bash.ts index 3181bdf..78a1bc3 100644 --- a/packages/ambion/src/just-bash.ts +++ b/packages/ambion/src/tools/just-bash.ts @@ -29,8 +29,8 @@ import { mkdir, readdir, rm } from 'node:fs/promises'; import { join, posix } from 'node:path'; import { Bash, type IFileSystem, InMemoryFs, ReadWriteFs } from 'just-bash'; +import type { AgentDefinition, WorkspaceBackend } from '../types.ts'; import { BashEnv } from './bash-env.ts'; -import type { AgentDefinition, WorkspaceBackend } from './types.ts'; /** Build one agent's environment over the workspace's filesystem. */ async function connectOver(fs: IFileSystem, agent: AgentDefinition): Promise { diff --git a/packages/ambion/src/workspace.ts b/packages/ambion/src/tools/workspace.ts similarity index 92% rename from packages/ambion/src/workspace.ts rename to packages/ambion/src/tools/workspace.ts index 92fbd1f..06f572f 100644 --- a/packages/ambion/src/workspace.ts +++ b/packages/ambion/src/tools/workspace.ts @@ -32,7 +32,7 @@ import { createReadTool, createWriteTool, } from '@earendil-works/pi-agent-core'; -import { memoryBackend } from './just-bash.ts'; +import { defaultRuntime, type Runtime } from '../host/runtime.ts'; import { type AgentDefinition, isWorkspace, @@ -41,17 +41,14 @@ import { type Workspace, type WorkspaceBackend, type WorkspaceHandle, -} from './types.ts'; - -/** The names a workspace binds to every connected agent. `defineAgent` keeps them free. */ -export const BUILTIN_TOOL_NAMES: ReadonlySet = new Set(['read', 'write', 'edit', 'bash']); - -/** One handle per name: two handles over one backend would each destroy it. */ -const taken = new Set(); +} from '../types.ts'; +import { memoryBackend } from './just-bash.ts'; -/** What the public handle does not show: its backend, and whether it is gone. */ +/** What the public handle does not show: its backend, its runtime, and whether it is gone. */ interface WorkspaceState extends WorkspaceHandle { readonly backend: WorkspaceBackend; + /** The runtime whose `taken` set holds the name. One handle per name in it. */ + readonly runtime: Runtime; destroyed: boolean; } @@ -64,6 +61,8 @@ export interface DefineWorkspaceOptions { * real directory. */ backend?: WorkspaceBackend; + /** The runtime that holds the name. Defaults to `defaultRuntime`. */ + runtime?: Runtime; } /** @@ -73,7 +72,8 @@ export interface DefineWorkspaceOptions { */ export function defineWorkspace(options: DefineWorkspaceOptions): WorkspaceHandle { assertWorkspaceName(options.name); - if (taken.has(options.name)) { + const runtime = options.runtime ?? defaultRuntime; + if (runtime.taken.has(options.name)) { throw new Error( `Workspace '${options.name}' is already defined: destroy it before defining it again.`, ); @@ -82,9 +82,10 @@ export function defineWorkspace(options: DefineWorkspaceOptions): WorkspaceHandl [WORKSPACE_BRAND]: true, name: options.name, backend: options.backend ?? memoryBackend(), + runtime, destroyed: false, }; - taken.add(options.name); + runtime.taken.add(options.name); return state; } @@ -105,7 +106,7 @@ export async function destroyWorkspace(workspace: WorkspaceHandle): Promise void): () => void; +} + +/** Opens one Pi session by id, and creates it on the first open. */ +export interface SessionOpener { + open(id: string, parentId?: string): Promise; +} + +/** Resolves an agent's `provider/model-id` to the model Pi's loop runs. */ +export type ModelResolver = (id: string, agent: string) => Model; + +/** The names a workspace binds to every connected agent. `defineAgent` keeps them free. */ +export const BUILTIN_TOOL_NAMES: ReadonlySet = new Set(['read', 'write', 'edit', 'bash']); + /** What a participant said. */ export interface SpokenMessage { kind: 'said'; diff --git a/packages/ambion/test/live/support.ts b/packages/ambion/test/live/support.ts index c60ee8d..244fb01 100644 --- a/packages/ambion/test/live/support.ts +++ b/packages/ambion/test/live/support.ts @@ -11,14 +11,13 @@ */ import type { SessionRepo } from '@earendil-works/pi-agent-core'; import type { Usage } from '@earendil-works/pi-ai'; -import { describe, expect } from 'vitest'; +import { describe } from 'vitest'; import { type DefineAgentOptions, defineAgent, defineHuman, InMemorySessionRepo, isSpoken, - isSummary, type Message, type Session, type SessionEvent, @@ -112,36 +111,7 @@ export const saidByAgents = (messages: Message[], people: string[]) => export const activationsOf = (events: SessionEvent[], name: string) => events.filter((e) => e.type === 'activation_start' && e.agent === name).length; -export const errorsIn = (events: SessionEvent[]) => - events.flatMap((e) => (e.type === 'error' ? [`${e.agent}: ${e.error.message}`] : [])); - -/** - * What holds whatever the model said. A live run can go many ways; the record - * it leaves has one shape. - */ -export async function invariants(session: Session, events: SessionEvent[]): Promise { - const messages = await session.messages(); - expect(messages.map((m) => m.seq)).toEqual(messages.map((_, i) => i + 1)); - // One message, one event, in record order — from the first message this run saw. - const emitted = events.flatMap((e) => (e.type === 'message' ? [e.message.seq] : [])); - const since = emitted[0] ?? Number.POSITIVE_INFINITY; - expect(emitted).toEqual(messages.filter((m) => m.seq >= since).map((m) => m.seq)); - const names = new Set(session.seats().map((seat) => seat.name)); - for (const message of messages) { - expect(names).toContain(message.from); - if ('by' in message && message.by !== undefined) expect(names).toContain(message.by); - } - for (const summary of messages.filter(isSummary)) { - expect(summary.covers.through).toBe(summary.seq - 1); - expect(summary.covers.from).toBeLessThanOrEqual(summary.covers.through); - } - expect(errorsIn(events)).toEqual([]); - expect(count(events, 'activation_start')).toBe(count(events, 'activation_end')); - expect(count(events, 'exchange_opened')).toBe(count(events, 'exchange_closed')); -} - -const count = (events: SessionEvent[], type: SessionEvent['type']) => - events.filter((e) => e.type === type).length; +export { errorsIn, invariants } from '../support/invariants.ts'; export interface Spent { activations: number; diff --git a/packages/ambion/test/matrix.test.ts b/packages/ambion/test/matrix.test.ts new file mode 100644 index 0000000..82dc965 --- /dev/null +++ b/packages/ambion/test/matrix.test.ts @@ -0,0 +1,25 @@ +/** + * Every scenario, on every storage, on a clock the test holds. `memory` is + * where the scenarios prove the room; `jsonl` proves the same room writes + * through to disk and reads back. + */ +import { describe, it } from 'vitest'; +import { createRuntime } from '../src/index.ts'; +import { fakeClock } from './support/clock.ts'; +import { roomName } from './support/room.ts'; +import { scenarios } from './support/scenarios.ts'; +import { storages } from './support/storage.ts'; + +describe.each(storages)('the scenarios on $name', (storage) => { + for (const scenario of scenarios) { + it(scenario.name, async () => { + const opened = await storage.open(); + try { + const runtime = createRuntime({ sessions: opened.sessions, clock: fakeClock() }); + await scenario.run({ runtime, name: roomName(`matrix-${storage.name}`) }); + } finally { + await opened.dispose(); + } + }); + } +}); diff --git a/packages/ambion/test/runtime.test.ts b/packages/ambion/test/runtime.test.ts new file mode 100644 index 0000000..71a5e49 --- /dev/null +++ b/packages/ambion/test/runtime.test.ts @@ -0,0 +1,76 @@ +/** + * The runtime is what a host owns. Two runtimes in one process are two + * hosts: they share nothing, and a room on a durable storage is read by a + * second runtime over the same storage. + */ +import { readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + createRuntime, + defineWorkspace, + destroyWorkspace, + isSpoken, + readSession, + startSession, + stopSession, + visitSession, +} from '../src/index.ts'; +import { fakeClock } from './support/clock.ts'; +import { andrei, assistant, roomName } from './support/room.ts'; +import { quiet, scripted } from './support/scripted.ts'; +import { jsonl, jsonlSessions, memory } from './support/storage.ts'; + +describe('createRuntime', () => { + it('keeps two runtimes apart: one name runs in both, and neither reads the other', async () => { + const name = roomName('runtime'); + const [one, two] = await Promise.all([memory.open(), memory.open()]); + const first = createRuntime({ sessions: one.sessions, clock: fakeClock() }); + const second = createRuntime({ sessions: two.sessions, clock: fakeClock() }); + const a = startSession({ name, runtime: first, assistant, streamFn: scripted(() => quiet()) }); + const b = startSession({ name, runtime: second, assistant, streamFn: scripted(() => quiet()) }); + await (await visitSession(a, andrei)).deliver({ text: 'in the first' }); + await (await visitSession(b, andrei)).deliver({ text: 'in the second' }); + await Promise.all([a.settled(), b.settled()]); + + expect((await a.messages()).filter(isSpoken).map((m) => m.text)).toEqual(['in the first']); + expect((await b.messages()).filter(isSpoken).map((m) => m.text)).toEqual(['in the second']); + expect(readSession(name, { runtime: first })).toBe(a); + expect(readSession(name, { runtime: second })).toBe(b); + // a workspace name is taken per runtime, the way a room name is + const here = defineWorkspace({ name: 'shared-drive', runtime: first }); + const there = defineWorkspace({ name: 'shared-drive', runtime: second }); + expect(() => defineWorkspace({ name: 'shared-drive', runtime: first })).toThrow(/already/); + await Promise.all([destroyWorkspace(here), destroyWorkspace(there)]); + await Promise.all([stopSession(a), stopSession(b)]); + }); + + it('writes a room on JSONL through to disk, where a second runtime reads it', async () => { + const opened = await jsonl.open(); + const dir = opened.dir ?? ''; + try { + const name = roomName('jsonl'); + const writer = createRuntime({ sessions: opened.sessions, clock: fakeClock() }); + const session = startSession({ + name, + runtime: writer, + assistant, + streamFn: scripted(() => quiet()), + }); + const visit = await visitSession(session, andrei); + await visit.deliver({ text: 'kept on disk' }); + await session.settled(); + await stopSession(session); + + const files = await readdir(join(dir, 'sessions'), { recursive: true }); + expect(files.some((file) => String(file).endsWith('.jsonl'))).toBe(true); + + const reader = createRuntime({ sessions: jsonlSessions(dir), clock: fakeClock() }); + const view = readSession(name, { runtime: reader }); + expect((await view.messages()).map((m) => m.kind)).toEqual(['arrived', 'said', 'left']); + expect((await view.messages()).filter(isSpoken).map((m) => m.text)).toEqual(['kept on disk']); + } finally { + await opened.dispose(); + } + }); +}); diff --git a/packages/ambion/test/support/clock.ts b/packages/ambion/test/support/clock.ts new file mode 100644 index 0000000..0f70c4c --- /dev/null +++ b/packages/ambion/test/support/clock.ts @@ -0,0 +1,49 @@ +import type { Clock } from '../../src/index.ts'; + +/** A clock a test moves by hand. Alarms fire inside `advance`, in order. */ +export interface FakeClock extends Clock { + /** Move the clock forward, firing every alarm due on the way, in time order. */ + advance(ms: number): Promise; +} + +interface Pending { + at: number; + fire: () => void; +} + +/** Let the promise chains an alarm started run to their end. */ +const settle = async (): Promise => { + for (let i = 0; i < 20; i += 1) await new Promise((resolve) => setImmediate(resolve)); +}; + +export function fakeClock(start = Date.parse('2026-01-01T09:00:00.000Z')): FakeClock { + let now = start; + const pending = new Set(); + const next = (): Pending | undefined => + [...pending].sort((a, b) => a.at - b.at).find((alarm) => alarm.at <= now); + return { + now: () => now, + alarm(at, fire) { + const alarm: Pending = { at, fire }; + pending.add(alarm); + return () => pending.delete(alarm); + }, + async advance(ms) { + const target = now + ms; + while (true) { + const due = [...pending] + .filter((alarm) => alarm.at <= target) + .sort((a, b) => a.at - b.at)[0]; + if (!due) break; + now = Math.max(now, due.at); + pending.delete(due); + due.fire(); + await settle(); + // An alarm the firing scheduled for now or earlier fires in this same pass. + if (next() === undefined && now >= target) break; + } + now = target; + await settle(); + }, + }; +} diff --git a/packages/ambion/test/support/invariants.ts b/packages/ambion/test/support/invariants.ts new file mode 100644 index 0000000..5d8fe59 --- /dev/null +++ b/packages/ambion/test/support/invariants.ts @@ -0,0 +1,47 @@ +/** + * What holds whatever a run did. A room can go many ways; the record it + * leaves has one shape, and every scenario ends by checking it. + */ +import { expect } from 'vitest'; +import { isSummary, type SessionEvent, type SessionView } from '../../src/index.ts'; + +export interface InvariantOptions { + /** How many `error` events the run may hold. A live model may refuse one call. */ + allowErrors?: number; +} + +export const errorsIn = (events: SessionEvent[]) => + events.flatMap((e) => (e.type === 'error' ? [`${e.agent}: ${e.error.message}`] : [])); + +const count = (events: SessionEvent[], type: SessionEvent['type']) => + events.filter((e) => e.type === type).length; + +export async function invariants( + session: SessionView, + events: SessionEvent[], + options: InvariantOptions = {}, +): Promise { + const messages = await session.messages(); + // Seqs are contiguous from 1. + expect(messages.map((m) => m.seq)).toEqual(messages.map((_, i) => i + 1)); + // One message, one event, in record order — from the first message this run saw. + const emitted = events.flatMap((e) => (e.type === 'message' ? [e.message.seq] : [])); + const since = emitted[0] ?? Number.POSITIVE_INFINITY; + expect(emitted).toEqual(messages.filter((m) => m.seq >= since).map((m) => m.seq)); + // Every author is a name the room seated, admitted, or was composed with. + const names = new Set(session.seats().map((seat) => seat.name)); + for (const message of messages) { + if (message.kind === 'arrived' || message.kind === 'seated') names.add(message.from); + } + for (const message of messages) { + expect(names).toContain(message.from); + if ('by' in message && message.by !== undefined) expect(names).toContain(message.by); + } + for (const summary of messages.filter(isSummary)) { + expect(summary.covers.through).toBe(summary.seq - 1); + expect(summary.covers.from).toBeLessThanOrEqual(summary.covers.through); + } + expect(errorsIn(events).length).toBeLessThanOrEqual(options.allowErrors ?? 0); + expect(count(events, 'activation_start')).toBe(count(events, 'activation_end')); + expect(count(events, 'exchange_opened')).toBe(count(events, 'exchange_closed')); +} diff --git a/packages/ambion/test/support/scenarios.ts b/packages/ambion/test/support/scenarios.ts new file mode 100644 index 0000000..0a88466 --- /dev/null +++ b/packages/ambion/test/support/scenarios.ts @@ -0,0 +1,307 @@ +/** + * The scenarios every storage and every backend runs. Each one starts a + * room in the runtime it is given, drives it on a scripted stream, checks + * the invariants, and stops it. + */ +import type { Context } from '@earendil-works/pi-ai'; +import { expect } from 'vitest'; +import { + defineAgent, + defineHuman, + defineWorkspace, + destroyWorkspace, + isSpoken, + isSummary, + type Runtime, + type Session, + startSession, + stopSession, + visitSession, +} from '../../src/index.ts'; +import { invariants } from './invariants.ts'; +import { collect, deferred } from './room.ts'; +import { + byAgent, + callTool, + contextText, + quiet, + type Script, + scripted, + seat, + speak, + summarise, + toolNames, +} from './scripted.ts'; +import { backends } from './storage.ts'; + +export interface ScenarioContext { + readonly runtime: Runtime; + /** A room name no other scenario in the process has used. */ + readonly name: string; +} + +export interface Scenario { + readonly name: string; + run(ctx: ScenarioContext): Promise; +} + +const assistant = defineAgent({ + name: 'assistant', + identity: 'Composes the room, and writes the one message a person reads.', + instructions: 'Seat who the question needs. Answer what was asked, once.', + model: 'scripted/assistant', +}); + +const priya = defineHuman({ + name: 'priya', + identity: 'Project manager.', + preferences: 'Lead with the decision.', +}); +const sam = defineHuman({ name: 'sam', identity: 'Site foreman.' }); + +const agent = ( + name: string, + identity: string, + extra: Partial[0]> = {}, +) => + defineAgent({ + name, + identity, + instructions: `You are ${name}.`, + model: `scripted/${name}`, + ...extra, + }); + +const product = agent('product', 'The product.'); +const colleague = agent('colleague', 'The second product.'); +const surveyor = agent('surveyor', 'Quantity surveyor. Holds the tonnage.'); + +const holding = (context: Context, tool: string) => toolNames(context).includes(tool); + +/** An assistant that seats every name given at an open, and writes once at a close. */ +function composes(names: string[], summary: string): Script { + return (context) => { + if (holding(context, 'seat')) { + const next = names.shift(); + return next ? seat(next) : quiet(); + } + return holding(context, 'summarise') ? summarise(summary) : quiet(); + }; +} + +/** Every tool result the model has been shown so far, oldest first. */ +function toolResults(context: Context): string[] { + return context.messages.flatMap((message) => + message.role === 'toolResult' + ? [message.content.map((c) => (c.type === 'text' ? c.text : '')).join('')] + : [], + ); +} + +/** Two answers to every question, then silence until the next. */ +const twoAnswersEach: Script = (_context, _name, call) => + call % 3 === 0 ? quiet() : speak(`answer ${call}`); + +/** + * A seat that answers the last question on the record once. A refused say + * speaks again; a delivered one ends the pass; a record that already holds + * the answer stays quiet. + */ +const answersOnce: Script = (context, name) => { + const text = contextText(context); + const question = [...text.matchAll(/^\[(?:priya|sam)\] (.+?)(?: {2}\(.*\))?$/gm)].at(-1)?.[1]; + if (question === undefined) return quiet(); + const answer = `${name} on ${question}`; + if (text.includes(`[${name}] ${answer}`) || toolResults(context).includes('delivered')) { + return quiet(); + } + return speak(answer); +}; + +async function finish(session: Session, events: ReturnType): Promise { + await invariants(session, events); + await stopSession(session); +} + +export const oneExchange: Scenario = { + name: 'one exchange closes into one message', + async run({ runtime, name }) { + const session = startSession({ + name, + runtime, + assistant, + agents: [product], + streamFn: scripted( + byAgent({ product: twoAnswersEach, assistant: composes([], 'The one message.') }), + ), + }); + const events = collect(session); + const visit = await visitSession(session, priya); + await visit.deliver({ text: 'Can I tell the client Thursday?' }); + await session.quiet(); + const record = await session.messages(); + expect(record.filter(isSpoken).map((m) => m.from)).toEqual(['priya', 'product', 'product']); + const summary = record.find(isSummary); + expect(summary).toMatchObject({ to: 'priya', text: 'The one message.' }); + await finish(session, events); + }, +}; + +export const twoPeopleTwoExchanges: Scenario = { + name: 'two people open two exchanges, and each is written for', + async run({ runtime, name }) { + const session = startSession({ + name, + runtime, + assistant, + agents: [product, colleague], + streamFn: scripted( + byAgent({ + product: answersOnce, + colleague: answersOnce, + assistant: (context) => { + const person = /(\w+)'s exchange is over/.exec(contextText(context))?.[1] ?? ''; + if (!holding(context, 'summarise') || toolResults(context).includes('delivered')) { + return quiet(); + } + return summarise(`for ${person}`); + }, + }), + ), + }); + const events = collect(session); + const hers = await visitSession(session, priya); + const his = await visitSession(session, sam); + await hers.deliver({ text: 'First?' }); + await session.quiet(); + await his.deliver({ text: 'Second?' }); + await session.quiet(); + await hers.leave(); + const summaries = (await session.messages()).filter(isSummary); + expect(summaries.map((m) => [m.to, m.text])).toEqual([ + ['priya', 'for priya'], + ['sam', 'for sam'], + ]); + expect(session.seats().find((s) => s.name === 'priya')).toMatchObject({ presence: 'absent' }); + await finish(session, events); + }, +}; + +export const seatFromReserve: Scenario = { + name: 'the assistant seats from the reserve, and the newcomer answers', + async run({ runtime, name }) { + const session = startSession({ + name, + runtime, + assistant, + agents: [product], + available: [surveyor], + streamFn: scripted( + byAgent({ + assistant: composes(['surveyor'], 'Steel: 11.7 tonnes.'), + product: (_context, _name, call) => + call <= 3 ? speak('The pour is Saturday.') : quiet(), + surveyor: (_context, _name, call) => + call === 1 ? speak('11.7 tonnes on site.') : quiet(), + }), + ), + }); + const events = collect(session); + const visit = await visitSession(session, priya); + await visit.deliver({ text: 'Is there enough steel for the pour?' }); + await session.quiet(); + const record = await session.messages(); + expect(record.find((m) => m.kind === 'seated')).toMatchObject({ + from: 'surveyor', + by: 'assistant', + }); + expect(record.filter(isSpoken).map((m) => m.from)).toContain('surveyor'); + expect(record.find(isSummary)).toBeDefined(); + expect(session.seats().map((s) => s.name)).toContain('surveyor'); + await finish(session, events); + }, +}; + +export const twoWorkspaces: Scenario = { + name: 'two workspaces on two backends, and one destroyed mid-activation', + async run({ runtime, name }) { + const [memoryBackend, directoryBackend] = await Promise.all(backends.map((b) => b.open())); + if (!memoryBackend || !directoryBackend) throw new Error('two backends are expected'); + const memoryDrive = defineWorkspace({ + name: `${name}-memory`, + backend: memoryBackend.backend, + runtime, + }); + const directoryDrive = defineWorkspace({ + name: `${name}-directory`, + backend: directoryBackend.backend, + runtime, + }); + const alpha = agent('alpha', 'Works in memory.', { workspace: memoryDrive }); + const beta = agent('beta', 'Works on disk.', { workspace: directoryDrive }); + const gamma = agent('gamma', 'Has no workspace.'); + const destroyed = deferred(); + const alphaResults: string[] = []; + const betaResults: string[] = []; + const session = startSession({ + name, + runtime, + assistant, + agents: [alpha, beta, gamma], + streamFn: scripted( + byAgent({ + alpha: async (context, _name, call) => { + alphaResults.push(...toolResults(context).slice(alphaResults.length)); + if (call === 1) + return callTool('write', { path: '/home/alpha/note.txt', content: 'one' }); + if (call === 2) { + await destroyed.promise; + return callTool('read', { path: '/home/alpha/note.txt' }); + } + return call === 3 ? speak('alpha done') : quiet(); + }, + beta: (context, _name, call) => { + betaResults.push(...toolResults(context).slice(betaResults.length)); + if (call === 1) return callTool('bash', { command: 'echo two > /home/beta/note.txt' }); + if (call === 2) return callTool('read', { path: '/home/beta/note.txt' }); + return call === 3 ? speak('beta done') : quiet(); + }, + gamma: (context) => { + expect(toolNames(context)).toEqual(['say']); + return quiet(); + }, + }), + ), + }); + const events = collect(session); + const visit = await visitSession(session, priya); + await visit.deliver({ text: 'go' }); + // alpha has written; destroy its workspace while its activation runs + await new Promise((resolve) => { + const off = session.subscribe((event) => { + if (event.type !== 'tool_execution_end' || event.agent !== 'alpha') return; + off(); + resolve(); + }); + }); + await destroyWorkspace(memoryDrive); + destroyed.resolve(); + await session.quiet(); + + expect(alphaResults.some((r) => r.includes('destroyed'))).toBe(true); + expect(betaResults.some((r) => r.includes('two'))).toBe(true); + const said = (await session.messages()).filter(isSpoken).map((m) => m.text); + expect(said).toContain('alpha done'); + expect(said).toContain('beta done'); + await finish(session, events); + await destroyWorkspace(directoryDrive); + await Promise.all([memoryBackend.dispose(), directoryBackend.dispose()]); + }, +}; + +export const scenarios: readonly Scenario[] = [ + oneExchange, + twoPeopleTwoExchanges, + seatFromReserve, + twoWorkspaces, +]; diff --git a/packages/ambion/test/support/storage.ts b/packages/ambion/test/support/storage.ts new file mode 100644 index 0000000..bc720c8 --- /dev/null +++ b/packages/ambion/test/support/storage.ts @@ -0,0 +1,96 @@ +/** + * The storages and the workspace backends every scenario runs on. + * + * `memory` is Pi's in-memory repository; `jsonl` is Pi's JSONL repository + * over a temporary directory, through Pi's own Node filesystem. A room on + * JSONL writes through to disk, so a second runtime over the same directory + * reads what the first wrote. + */ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { NodeExecutionEnv } from '@earendil-works/pi-agent-core/node'; +import { + directoryBackend, + InMemorySessionRepo, + JsonlSessionRepo, + memoryBackend, + type SessionOpener, + sessionsOver, + type WorkspaceBackend, +} from '../../src/index.ts'; + +export interface OpenedStorage { + readonly sessions: SessionOpener; + /** The directory a JSONL storage writes under; absent for memory. */ + readonly dir?: string; + dispose(): Promise; +} + +export interface Storage { + readonly name: 'memory' | 'jsonl'; + open(): Promise; +} + +export const memory: Storage = { + name: 'memory', + async open() { + return { sessions: sessionsOver(new InMemorySessionRepo()), dispose: async () => {} }; + }, +}; + +/** + * A JSONL opener over one directory. Pi's JSONL repository takes an id of + * letters, digits, `.`, `_` and `-`, and a seat's audit session is named + * `:`; the colon is written as `--` on disk, and only there. + */ +export function jsonlSessions(dir: string): SessionOpener { + const fs = new NodeExecutionEnv({ cwd: dir }); + const repo = new JsonlSessionRepo({ fs, sessionsRoot: join(dir, 'sessions') }); + const base = sessionsOver(repo, { cwd: dir }); + const onDisk = (id: string) => id.replaceAll(':', '--'); + return { + open: (id, parentId) => + base.open(onDisk(id), parentId === undefined ? undefined : onDisk(parentId)), + }; +} + +export const jsonl: Storage = { + name: 'jsonl', + async open() { + const dir = await mkdtemp(join(tmpdir(), 'ambion-jsonl-')); + return { + sessions: jsonlSessions(dir), + dir, + dispose: () => rm(dir, { recursive: true, force: true }), + }; + }, +}; + +export const storages: readonly Storage[] = [memory, jsonl]; + +// -- workspace backends ------------------------------------------------------ + +export interface Backend { + readonly name: 'memory' | 'directory'; + open(): Promise<{ backend: WorkspaceBackend; dispose(): Promise }>; +} + +export const backends: readonly Backend[] = [ + { + name: 'memory', + async open() { + return { backend: memoryBackend(), dispose: async () => {} }; + }, + }, + { + name: 'directory', + async open() { + const dir = await mkdtemp(join(tmpdir(), 'ambion-drive-')); + return { + backend: directoryBackend(dir), + dispose: () => rm(dir, { recursive: true, force: true }), + }; + }, + }, +]; diff --git a/packages/ambion/test/workspace.test.ts b/packages/ambion/test/workspace.test.ts index 450d14a..bb5bd24 100644 --- a/packages/ambion/test/workspace.test.ts +++ b/packages/ambion/test/workspace.test.ts @@ -7,7 +7,6 @@ import { fauxAssistantMessage, fauxToolCall } from '@earendil-works/pi-ai'; import { Bash, InMemoryFs } from 'just-bash'; import { Type } from 'typebox'; import { describe, expect, it } from 'vitest'; -import { BashEnv, DEFAULT_TIMEOUT_SECONDS } from '../src/bash-env.ts'; import { type AgentDefinition, defineAgent, @@ -22,7 +21,8 @@ import { type ToolContext, type WorkspaceBackend, } from '../src/index.ts'; -import { MEMORY_LIMIT_BYTES, memoryBackend } from '../src/just-bash.ts'; +import { BashEnv, DEFAULT_TIMEOUT_SECONDS } from '../src/tools/bash-env.ts'; +import { MEMORY_LIMIT_BYTES, memoryBackend } from '../src/tools/just-bash.ts'; import { assistant, enter, roomName as name } from './support/room.ts'; import { byAgent, diff --git a/planning/backlog.md b/planning/backlog.md index 2b6e546..ab994f5 100644 --- a/planning/backlog.md +++ b/planning/backlog.md @@ -13,23 +13,14 @@ the open questions about a design; this file holds the work. ## Runtime module boundaries -### 1. The room is a process global +### 1. The room is a process global — closed -**What.** `session.ts` holds a module-level `running` map, a shared -`defaultRepo`, a lazily built model registry, and a `registryStream` that -reads `process.env` for API keys. `workspace.ts` holds a global `taken` set. - -**Why.** Two hosts in one process cannot each run a room with the same -name. Tests keep unique-name counters to stay apart. A room resumed after -a restart shares one in-memory repo with every other room in the process. -For hermetic execution and session resumption, the host must own these. - -**Where.** `packages/ambion/src/session.ts` lines 75 to 95, -`packages/ambion/src/workspace.ts` line 50. - -**Fix.** A `Runtime` value that holds the registry, the repo and the -environment source. `startSession`, `readSession` and `defineWorkspace` -take it as an option. The current globals become the default instance. +`host/runtime.ts` holds the clock, the session opener, the model call, +the catalog, the rooms that run and the workspace names that are taken. +`startSession`, `readSession` and `defineWorkspace` take a `Runtime` and +default to `defaultRuntime`, the one process-wide value. The default model +call alone reads `process.env`; a runtime with its own `stream` reads +nothing. `test/runtime.test.ts` proves two runtimes never see each other. ### 2. Nothing bounds the record, and the room rescans it per message @@ -88,20 +79,16 @@ protobufjs and the Anthropic SDK before a host defines anything. The four ignored build-script warnings on every `pnpm install` come from this tree, and `docs/toolchain.md` §3 says nothing in the tree needs one. -**Where.** `packages/ambion/src/session.ts` line 28 and `registry()`. +**Where.** `packages/ambion/src/host/runtime.ts`, `registry()`. **Fix.** Make `registry()` a dynamic import, or move default provider resolution to the host. `streamFn` is already the extension surface. -### 5. `defineAgent` imports the shell runtime - -**What.** `define.ts` imports `BUILTIN_TOOL_NAMES` from `workspace.ts`, -which imports `memoryBackend` from `just-bash.ts`. A value module depends -on just-bash for a set of four strings. - -**Where.** `packages/ambion/src/define.ts` line 27. +### 5. `defineAgent` imports the shell runtime — closed -**Fix.** Move the constant to `types.ts`. +`BUILTIN_TOOL_NAMES` lives in `types.ts`, and the vocabulary imports +nothing that does anything: Biome refuses it +([`docs/toolchain.md`](../docs/toolchain.md) §1). ### 6. Two copies of typebox @@ -118,10 +105,10 @@ latent break on the next typebox release that changes a type. ### 7. Test affordances leak into the runtime -**What.** `resolveModel` returns `{ api: 'scripted' } as unknown as +**What.** `stubModel` returns `{ api: 'scripted' } as unknown as Model` when a host passes a custom `streamFn`. -**Where.** `packages/ambion/src/session.ts`, `resolveModel`. +**Where.** `packages/ambion/src/host/runtime.ts`, `stubModel`. **Fix.** Build a real `Model` value with Pi's own shape. @@ -416,7 +403,7 @@ the `bash` tool's own description promises a Unix shell. - Whether `connect` should seed `/dev` into a `ReadWriteFs` the way just-bash seeds it into an `InMemoryFs`, so the two backends at least agree. -**Where.** `connectOver` in [`just-bash.ts`](../packages/ambion/src/just-bash.ts). +**Where.** `connectOver` in [`just-bash.ts`](../packages/ambion/src/tools/just-bash.ts). ### 23. A backend on a real machine @@ -445,7 +432,7 @@ abort signal because `useradd` and a process spawn are real waits. **Where.** `WorkspaceBackend` in [`types.ts`](../packages/ambion/src/types.ts); `directoryBackend` in -[`just-bash.ts`](../packages/ambion/src/just-bash.ts) is the shape to copy. +[`just-bash.ts`](../packages/ambion/src/tools/just-bash.ts) is the shape to copy. ### 24. Whether Agent or AgentHarness is Ambion's foundation diff --git a/planning/next.md b/planning/next.md index da4c8a4..fc70fb7 100644 --- a/planning/next.md +++ b/planning/next.md @@ -14,17 +14,13 @@ diff. commit path lives in `record.ts`. The reserve lives in its own module. `session.ts` holds compose and route and stays under 600 lines. -## 2. A `Runtime` value in place of the process globals (backlog 1) +## 2. A `Runtime` value in place of the process globals (backlog 1) — done -**Why now.** This is the change that decides whether a host can run rooms -hermetically and resume them. Everything a long-horizon deployment needs -starts here. - -**Done when.** `startSession`, `readSession` and `defineWorkspace` accept -a runtime that holds the registry, the repo and the environment source. -The module-level `running`, `taken`, `defaultRepo` and `builtinRegistry` -are fields of the default instance. Two hosts in one process run rooms -with the same name and never see each other. +`startSession`, `readSession` and `defineWorkspace` take a runtime that +holds the clock, the session opener, the model call and the catalog. The +module-level `running`, `taken`, `defaultRepo` and `builtinRegistry` are +fields of `defaultRuntime`. Two hosts in one process run rooms with the +same name and never see each other. ## 3. Bound the record, index the presence (backlog 2) diff --git a/turbo.jsonc b/turbo.jsonc index b3f48db..bef4b0a 100644 --- a/turbo.jsonc +++ b/turbo.jsonc @@ -27,6 +27,8 @@ }, "test": { "dependsOn": ["build", "^build"], + "inputs": ["src/**", "test/**", "vitest*.ts", "tsconfig.json", "package.json"], + "outputs": [], "outputLogs": "new-only" } }