diff --git a/docs/exchange.md b/docs/exchange.md index 241e530..0133cc0 100644 --- a/docs/exchange.md +++ b/docs/exchange.md @@ -137,6 +137,29 @@ mid-question: the record keeps what was said, and nobody is mid-question after a restart. A person whose question the room was working on asks again, and that question opens a new exchange. +**`Exchanges` owns the whole span.** It holds the open exchange, whether an +activation that counts as work began since the seats last settled, and the +two promises of §6. The room holds none of that. What is running is a fact +about the seats, so the room reads it off them and passes two booleans in +at every transition: whether a seat that speaks for itself is working, and +whether nothing at all is. Every transition is synchronous and takes no +model, so [`exchange.test.ts`](../packages/ambion/test/exchange.test.ts) +proves the lifecycle with no room around it. + +The transitions, in the order the room calls them: + +| Call | When | What it decides | +| --------------------------- | ----------------------------------------- | -------------------------------------------------------------------------- | +| `note(message, fromPerson)` | A message landed | Whether it opened an exchange | +| `stir()` | An activation that counts as work began | The next settle is the seats stopping | +| `settle(working, through)` | An activation ended, or a question routed | Nothing while working; else who waited, what closed, whether a seat worked | +| `quiesce(idle)` | After every settle | Whether the room went quiet, once | +| `drain()` | The run stops | Nobody waits on a room that never goes quiet | + +The assistant drafting stirs nothing, so a settle at a draft's end reports +that no seat worked. That is the one fact a failed draft waits on: the next +settle where a seat did ([`assistant.md`](assistant.md) §5). + A closed exchange is an owner and a range, so it is derivable from the record. Nothing derives it today; a host that wants a history of exchanges records the `exchange_closed` events as they arrive. @@ -250,6 +273,12 @@ The exchange is proved beside the assistant that first reads one, in All in-process, in vitest, on a scripted stream. +The lifecycle alone is proved in +[`exchange.test.ts`](../packages/ambion/test/exchange.test.ts), with no +room and no stream: what opens one, what a settle closes and reports, when +each promise resolves, and what the assistant's scheduler makes of a settle +where a seat worked and one where none did (§5). + The live run is [`demos/2026-08-31-one-exchange-one-message.html`](../demos/2026-08-31-one-exchange-one-message.html): four questions opened four exchanges, and each one closed into one message. diff --git a/packages/ambion/src/exchange.ts b/packages/ambion/src/exchange.ts index ed3f7ce..13adb71 100644 --- a/packages/ambion/src/exchange.ts +++ b/packages/ambion/src/exchange.ts @@ -22,6 +22,12 @@ * - **What lands while it is open steers it and changes nothing.** Not the * owner, not the range, not who the answer belongs to. * + * This module also holds the two ends the room reports, `settled` and + * `quiet`, because they are the two edges of this span. What is running is + * a fact about the seats, so the room reads it off them and passes it in. + * Every transition here is synchronous and takes no model, so the whole + * lifecycle is provable with no room around it. + * * The design contract is `docs/exchange.md`; `docs/assistant.md` says what an * assistant makes of one. */ @@ -43,13 +49,38 @@ export interface ClosedExchange extends Exchange { readonly through: Seq; } +/** The seats settled: what that closed, and whether a seat worked since they last settled. */ +export interface Settled { + /** The exchange the room was working on, or nothing when it worked on its own account. */ + readonly closed: ClosedExchange | undefined; + /** + * Whether an activation that counts as work began since the last settle. + * A second settle at one quiescence — a question that woke nobody, an + * aborted activation ending after the exchange closed — is not the seats + * stopping again, and the assistant reads the difference. + */ + readonly worked: boolean; +} + /** - * The open exchange, if there is one. Run state: an exchange belongs to a - * running room, and a restart begins with none — the record keeps what was - * said, and nobody is mid-question after a restart. + * The room's exchanges: the open one, and the two ends the room reports. + * + * Run state: an exchange belongs to a running room, and a restart begins with + * none — the record keeps what was said, and nobody is mid-question after a + * restart. + * + * Two facts arrive from the room at every transition, and this holds neither: + * whether a seat that speaks for itself is taking an activation (*working*), + * and whether nothing at all is (*idle*). The room draws that one distinction + * about its assistant, and the seats hold the activations, so there is no + * count here to keep in step. */ export class Exchanges { private open: Exchange | undefined; + /** Whether an activation that counts as work began since the seats last settled. */ + private stirred = false; + private readonly settledWaiters: (() => void)[] = []; + private readonly quietWaiters: (() => void)[] = []; /** What the room is working on, or nothing when nobody has asked. */ current(): Exchange | undefined { @@ -72,13 +103,52 @@ export class Exchanges { return this.open; } + /** An activation that counts as work began: the next settle is the seats stopping. */ + stir(): void { + this.stirred = true; + } + /** - * The room went quiet. Closes whatever was open and returns it with the - * range it held, or nothing when the room was working on its own account. + * Something stopped. While a seat is working nothing settles, and the + * result says so. Otherwise the seats have settled: whoever waited hears + * it first, then the open exchange closes with the range it reached, and + * the result says whether a seat worked since the last settle. */ - close(through: Seq): ClosedExchange | undefined { + settle(working: boolean, through: Seq): Settled | undefined { + if (working) return undefined; + for (const resolve of this.settledWaiters.splice(0)) resolve(); + const worked = this.stirred; + this.stirred = false; const open = this.open; this.open = undefined; - return open === undefined ? undefined : { ...open, through }; + return { closed: open === undefined ? undefined : { ...open, through }, worked }; + } + + /** + * Nothing at all is taking an activation, so the room is quiet: whoever + * waited hears it, and the result says the room should say so. An active + * room is not quiet, and the result says nothing. + */ + quiesce(idle: boolean): boolean { + if (!idle) return false; + for (const resolve of this.quietWaiters.splice(0)) resolve(); + return true; + } + + /** Resolves when no seat that speaks for itself is taking an activation. */ + settled(working: boolean): Promise { + if (!working) return Promise.resolve(); + return new Promise((resolve) => this.settledWaiters.push(resolve)); + } + + /** Resolves when nothing at all is taking an activation. */ + quiet(idle: boolean): Promise { + if (idle) return Promise.resolve(); + return new Promise((resolve) => this.quietWaiters.push(resolve)); + } + + /** A stopped room never goes quiet on its own, so nobody waits on it. */ + drain(): void { + for (const resolve of this.quietWaiters.splice(0)) resolve(); } } diff --git a/packages/ambion/src/session.ts b/packages/ambion/src/session.ts index 398f06b..ae9dabe 100644 --- a/packages/ambion/src/session.ts +++ b/packages/ambion/src/session.ts @@ -15,7 +15,9 @@ * - **Route.** Who hears a message, and who wakes for it. * - **Give an activation what only the room knows.** The model, the prompt, the * hands, and the room as it stands at that moment. - * - **Say when it has stopped.** An exchange closed, and nothing running. + * + * When the room has stopped is the exchange's own fact, and `exchange.ts` + * holds it: the room reads what is running off the seats and hands it over. */ import type { AgentTool, @@ -37,7 +39,7 @@ import { summariseTool, } from './assistant.ts'; import { seated } from './define.ts'; -import { type ClosedExchange, type Exchange, Exchanges } from './exchange.ts'; +import { type Exchange, Exchanges, type Settled } from './exchange.ts'; import { Attendance, type VisitRuntime } from './presence.ts'; import { openOrCreate, persistTurns, RecordStore } from './record.ts'; import { @@ -256,19 +258,10 @@ class SessionImpl implements Session { private readonly assistant: Assistant; private readonly here = new Attendance(() => this.record); private readonly listeners = new Set<(event: SessionEvent) => void>(); - private readonly settledWaiters: (() => void)[] = []; - private readonly quietWaiters: (() => void)[] = []; private readonly streamFn: StreamFn; private readonly customStream: boolean; private stopped = false; - /** - * Whether a seat has worked since the room last settled. A failed draft - * waits for the seats to stop again, and a second settle at one quiescence - * — an aborted activation ending after an unseat closed the exchange, a - * question that woke nobody — is not the seats stopping again. - */ - private stirred = false; - /** The room's exchanges: what a question opened, and what quiescence closes. */ + /** The room's exchanges: what a question opened, what quiescence closes, and both ends. */ private readonly exchanges = new Exchanges(); constructor(options: StartSessionOptions) { @@ -393,8 +386,7 @@ class SessionImpl implements Session { } settled(): Promise { - if (!this.working()) return Promise.resolve(); - return new Promise((resolve) => this.settledWaiters.push(resolve)); + return this.exchanges.settled(this.working()); } /** @@ -425,8 +417,7 @@ class SessionImpl implements Session { // The same condition the `quiet` event reports. A summary a race left // owed is not work in flight: it waits for the next quiet room, and the // room is quiet in the meantime. - if (this.idle()) return Promise.resolve(); - return new Promise((resolve) => this.quietWaiters.push(resolve)); + return this.exchanges.quiet(this.idle()); } abort(): void { @@ -514,10 +505,7 @@ class SessionImpl implements Session { // A question that wakes no seat has no seat to stop, so the exchange it // opened would never close. The same check an ending activation runs, // and the same last word: the room was quiet, and it says so. - if (this.exchanges.current() !== undefined && !this.working()) { - this.settle(); - if (this.idle()) this.markQuiet(); - } + if (this.exchanges.current() !== undefined) this.rest(false); } /** @@ -592,7 +580,7 @@ class SessionImpl implements Session { // not leave a room that can never be started again. if (running.get(this.name) === this) 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(); + this.exchanges.drain(); } } @@ -693,7 +681,7 @@ class SessionImpl implements Session { // The seat holding it is what makes the room busy: there is no count to // keep in step, and so none to drift. seat.activation = activation; - if (this.closingOf(seat) === undefined) this.stirred = true; + if (this.closingOf(seat) === undefined) this.exchanges.stir(); this.emit({ type: 'activation_start', agent: seat.def.name }); // The assistant's activations are one pass: a summary answers a room that // moved with a redraft inside its own tool, and a composition decides on @@ -708,28 +696,26 @@ class SessionImpl implements Session { private ended(seat: SeatRuntime, activation: Activation): void { seat.activation = undefined; const assistant = this.assistant.is(seat.def.name); - const drafted = assistant && this.assistant.composing() === undefined; if (assistant) { this.assistant.activationEnded({ wrote: activation.spoke, failed: activation.failed }); } this.emit({ type: 'activation_end', agent: seat.def.name, spoke: activation.spoke }); - // An exchange ends when the seats stop, and a composing assistant is one - // of them. The assistant writing about an exchange is not the room still - // working on it, so a draft's end closes none — which also keeps a failing - // assistant from retrying for ever. What a draft's end frees is the seat, - // for whoever was owed while it drafted. - if (drafted) this.draftNext(this.assistant.dueAfterDraft(...this.dueArgs())); - else if (!this.working()) this.settle(); - else if (assistant) this.draftNext(this.assistant.dueAfterDraft(...this.dueArgs())); - if (this.idle()) this.markQuiet(); + this.rest(assistant); } - /** The seats stopped: whoever waited hears it, and the exchange closes. */ - private settle(): void { - for (const resolve of this.settledWaiters.splice(0)) resolve(); - const worked = this.stirred; - this.stirred = false; - this.closeExchange(worked); + /** + * Something stopped, so the room may have too. The exchange decides: the + * seats settle when none that speaks for itself is working, and the room + * is quiet when nothing at all is. The assistant writing about an exchange + * is not the room still working on it, so a draft's end closes none — which + * also keeps a failing assistant from retrying for ever. What a draft's end + * frees is the seat, for whoever was owed while it drafted. + */ + private rest(freed: boolean): void { + const settled = this.exchanges.settle(this.working(), this.store.lastSeq); + if (settled) this.closed(settled); + else if (freed) this.draftNext(this.assistant.dueAfterDraft(...this.dueArgs())); + this.markQuiet(); } /** The range the assistant is closing, when this seat is the assistant and it is closing one. */ @@ -956,18 +942,9 @@ class SessionImpl implements Session { // -- the assistant ------------------------------------------------------------ /** - * The room went quiet, so the exchange it was working on is over. The host + * The seats settled, so the exchange they were working on is over. The host * hears that before anything is written about it: the assistant is the first - * reader of a closed exchange and not the only one. - */ - private closeExchange(worked: boolean): void { - const closing = this.exchanges.close(this.store.lastSeq); - if (closing) this.emit({ type: 'exchange_closed', exchange: closing }); - this.summariseClosed(closing, worked); - } - - /** - * What the assistant makes of a closed exchange: its owner is owed the one + * reader of a closed exchange and not the only one. Its owner is owed the one * message that stands for it, and the room activates the assistant for it. * Nothing else in the room wakes for a close — the assistant is seated `none`, * and the close is the one thing that reaches it. @@ -977,8 +954,11 @@ class SessionImpl implements Session { * waits for the next time the seats stop rather than retrying on itself — * and a settle that no seat worked before is not the seats stopping again. */ - private summariseClosed(closing: ClosedExchange | undefined, worked: boolean): void { - if (closing) this.assistant.owe(closing.owner, closing.from); + private closed({ closed, worked }: Settled): void { + if (closed) { + this.emit({ type: 'exchange_closed', exchange: closed }); + this.assistant.owe(closed.owner, closed.from); + } const due = worked ? this.assistant.dueAtQuiescence(...this.dueArgs()) : this.assistant.dueAfterDraft(...this.dueArgs()); @@ -1017,9 +997,8 @@ class SessionImpl implements Session { * has gone quiet. */ private markQuiet(): void { - if (this.stopped || !this.idle()) return; - this.emit({ type: 'quiet' }); - for (const resolve of this.quietWaiters.splice(0)) resolve(); + if (this.stopped) return; + if (this.exchanges.quiesce(this.idle())) this.emit({ type: 'quiet' }); } // -- what an agent reads ------------------------------------------------- diff --git a/packages/ambion/test/exchange.test.ts b/packages/ambion/test/exchange.test.ts new file mode 100644 index 0000000..c4babce --- /dev/null +++ b/packages/ambion/test/exchange.test.ts @@ -0,0 +1,168 @@ +/** + * The exchange lifecycle on its own: no room, no seats, no model. `Exchanges` + * holds the open exchange and the two ends the room reports, and every + * transition is synchronous, so each claim in docs/exchange.md §3 and §6 is + * one call and one assertion. The assistant's scheduler is proved beside it, + * because the one fact it reads at a settle is the one `Exchanges` reports. + */ +import { describe, expect, it } from 'vitest'; +import { Assistant } from '../src/assistant.ts'; +import { defineAgent } from '../src/define.ts'; +import { Exchanges } from '../src/exchange.ts'; +import type { Message } from '../src/types.ts'; + +const at = new Date().toISOString(); +const said = (seq: number, from: string, text = 'Thursday.'): Message => ({ + kind: 'said', + seq, + at, + from, + text, +}); +const question = (seq: number, from = 'priya'): Message => said(seq, from, 'Thursday?'); +const arrival = (seq: number, from = 'priya'): Message => ({ + kind: 'arrived', + seq, + at, + from, + identity: 'Site manager.', +}); + +/** Whether a promise has resolved by the next microtask. */ +async function resolved(promise: Promise): Promise { + let done = false; + void promise.then(() => { + done = true; + }); + await Promise.resolve(); + return done; +} + +describe('an exchange', () => { + it('opens on a question from a person, and closes with the range it reached', () => { + const exchanges = new Exchanges(); + expect(exchanges.note(question(3), true)).toMatchObject({ owner: 'priya', from: 3 }); + expect(exchanges.current()?.owner).toBe('priya'); + + // the seats work, so nothing settles + exchanges.stir(); + expect(exchanges.settle(true, 5)).toBeUndefined(); + expect(exchanges.current()?.owner).toBe('priya'); + + // the seats stop, and the range is the record as it stands + expect(exchanges.settle(false, 7)).toEqual({ + closed: { owner: 'priya', from: 3, at, through: 7 }, + worked: true, + }); + expect(exchanges.current()).toBeUndefined(); + }); + + it('opens for nobody but a person, and never twice at once', () => { + const exchanges = new Exchanges(); + expect(exchanges.note(arrival(1), true)).toBeUndefined(); + expect(exchanges.note(question(2, 'product'), false)).toBeUndefined(); + expect(exchanges.current()).toBeUndefined(); + + const opened = exchanges.note(question(3), true); + // a second question steers the open one and changes nothing + expect(exchanges.note(question(4, 'sam'), true)).toBeUndefined(); + expect(exchanges.current()).toBe(opened); + }); + + it('settles with nothing to close when the room worked on its own account', () => { + const exchanges = new Exchanges(); + exchanges.stir(); + expect(exchanges.settle(false, 2)).toEqual({ closed: undefined, worked: true }); + }); + + it('reports work once per settle, and a second settle at one quiescence reports none', () => { + const exchanges = new Exchanges(); + exchanges.stir(); + expect(exchanges.settle(false, 1)?.worked).toBe(true); + // a question that woke nobody, an aborted activation ending late + expect(exchanges.settle(false, 2)?.worked).toBe(false); + // the assistant drafting stirs nothing, so its end reports none either + expect(exchanges.settle(false, 3)?.worked).toBe(false); + exchanges.stir(); + expect(exchanges.settle(false, 4)?.worked).toBe(true); + }); +}); + +describe('the two ends', () => { + it('settles whoever waited before it closes, and at once when nothing works', async () => { + const exchanges = new Exchanges(); + expect(await resolved(exchanges.settled(false))).toBe(true); + + exchanges.note(question(1), true); + const waited = exchanges.settled(true); + expect(await resolved(waited)).toBe(false); + exchanges.settle(false, 2); + expect(await resolved(waited)).toBe(true); + }); + + it('is quiet only when nothing at all is active, and says so once per quiescence', async () => { + const exchanges = new Exchanges(); + expect(await resolved(exchanges.quiet(true))).toBe(true); + + const waited = exchanges.quiet(false); + expect(exchanges.quiesce(false)).toBe(false); + expect(await resolved(waited)).toBe(false); + expect(exchanges.quiesce(true)).toBe(true); + expect(await resolved(waited)).toBe(true); + }); + + it('drains whoever waited on quiet when the room stops', async () => { + const exchanges = new Exchanges(); + const waited = exchanges.quiet(false); + exchanges.drain(); + expect(await resolved(waited)).toBe(true); + }); +}); + +describe('what the assistant makes of a settle', () => { + const def = defineAgent({ + name: 'assistant', + identity: 'Writes the one message a person reads.', + instructions: 'stay quiet', + model: 'scripted/assistant', + }); + /** Two answers from a seat: enough that one message would serve. */ + const record: Message[] = [question(1), said(2, 'product'), said(3, 'planner')]; + const fromSeat = (name: string) => name !== 'priya' && name !== 'assistant'; + + it('drafts for the owner at the settle that closed the exchange', () => { + const assistant = new Assistant({ def, attention: 'none' }); + assistant.owe('priya', 1); + expect(assistant.dueAtQuiescence(record, 3, fromSeat)).toMatchObject({ + person: 'priya', + from: 1, + through: 3, + }); + }); + + /** A failed draft waits for the next settle where a seat worked, and for nothing less. */ + it('holds a failed draft until the seats stop again', () => { + const assistant = new Assistant({ def, attention: 'none' }); + assistant.owe('priya', 1); + assistant.dueAtQuiescence(record, 3, fromSeat); + assistant.activationEnded({ wrote: false, failed: true }); + + // the draft's own end, and a settle no seat worked before: not yet + expect(assistant.dueAfterDraft(record, 3, fromSeat)).toBeUndefined(); + // the seats worked and stopped: now + expect(assistant.dueAtQuiescence(record, 3, fromSeat)).toMatchObject({ person: 'priya' }); + }); + + it('owes nothing for a draft that stood down, and nothing for one that wrote', () => { + const assistant = new Assistant({ def, attention: 'none' }); + assistant.owe('priya', 1); + assistant.dueAtQuiescence(record, 3, fromSeat); + assistant.activationEnded({ wrote: false, failed: false }); + expect(assistant.dueAtQuiescence(record, 3, fromSeat)).toBeUndefined(); + + assistant.owe('priya', 1); + assistant.dueAtQuiescence(record, 3, fromSeat); + assistant.activationEnded({ wrote: true, failed: false }); + expect(assistant.dueAtQuiescence(record, 3, fromSeat)).toBeUndefined(); + }); +}); diff --git a/planning/backlog.md b/planning/backlog.md index 2b6e546..10b3017 100644 --- a/planning/backlog.md +++ b/planning/backlog.md @@ -51,15 +51,20 @@ without limit. `docs/agent.md` §8 says Ambion owns no context window, and on append. Long term: a window policy on `RoomView.record`, and a decision in the contract about which module owns it. -### 3. `session.ts` holds six jobs in 1063 lines - -**What.** `SessionImpl` has 61 methods. Its header lists compose, commit, -route, hands, and quiescence. The reserve, `seat` and `unseat` joined it in -the last change. The `say` tool sits inline at line 858, while the -assistant's `summarise` and `seat` tools live in `assistant.ts` behind -small room interfaces. The commit path (`claim`, `publish`, -`commitPresence`, `deliverFrom`) and the assistant scheduling -(`closeExchange` through `draftNext`) are two more concerns. +### 3. `session.ts` holds five jobs in 1042 lines + +**What.** `SessionImpl` has about 60 methods. Its header lists compose, +commit, route, and hands. The reserve, `seat` and `unseat` joined it in +the change that seats agents from a reserve. The `say` tool sits inline, +while the assistant's `summarise` and `seat` tools live in `assistant.ts` +behind small room interfaces. The commit path (`claim`, `publish`, +`commitPresence`, `deliverFrom`) and the assistant scheduling (`closed` +through `draftNext`) are two more concerns. + +Quiescence left the file on 2026-09-06: `Exchanges` owns the open +exchange, whether a seat worked since the last settle, and the `settled` +and `quiet` promises, and `exchange.test.ts` proves the lifecycle with no +room. The room holds one boolean of its own, `stopped`. **Why.** Every feature lands in one file. `dispatch` sits at the complexity cap by design, and the file around it has no cap.