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" } }