Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@ added behind the same interface. See [`docs/workspace.md`](docs/workspace.md).

## A minimal sketch

The first application needs only the root package. Host adapters and the
advanced transport protocol are separate concerns, available from
`@ambionframework/ambion/host` and `@ambionframework/ambion/protocol` when an
embedding environment needs them.

```ts
import { defineAgent, defineHuman, startSession, visitSession } from '@ambionframework/ambion';

Expand Down
22 changes: 19 additions & 3 deletions docs/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -676,14 +676,30 @@ system prompt and the context, and sends the two strings with the model
id and the hand the activation holds. The seat side resolves the definition
by name through the runtime's catalog, builds the Pi `Agent`, and reaches
the room through three calls: `view`, `commit` and `lease`. The room
reaches a seat through one, `wake`, which carries the line a running
activation is steered with when a message caused it. Every request and
response survives a round trip through `JSON.stringify` unchanged
reaches a seat through two. `wake` carries the line a running activation
is steered with when a message caused it. `cut` names an activation whose
lease the room ended, so the seat side stops it now, wherever the seat
runs. Every request and response survives a round trip through
`JSON.stringify` unchanged
([`wire.ts`](../packages/ambion/src/wire.ts)), so a seat and a room can
live in two processes. The room answers the three calls from the fold: a
lease is a row on the log, and the seat side releases it when the
activation ends.

**A cut is the room's word, and the record is written before it.** The
room ends every lease the seat holds as `revoked`, and then it cuts. A
seat that never hears the cut is refused whatever it writes, because its
lease ended. A seat that hears it aborts the activation and moves on to
the wake that queued behind it. A model call that ignores the abort
finishes on its own, past a seat that has moved on. A renewal the room
refuses cuts the activation the same way, and a renewal that never
reached the room leaves the lease to expire where it stands: the seat
cuts the activation at that expiry, when the room expires the lease.

**A cut reaches a seat the run never woke.** A run that resumes over a
live lease holds no wire to the seat that took it. The room opens one to
say the cut, so a seat that still runs the activation stops.

**A host owns a `Runtime`.** It holds the clock, the session opener, the
model call, the catalog, the rooms that are running, the workspace names
that are taken, and the policy for wakes and retries: how long a lease
Expand Down
17 changes: 12 additions & 5 deletions packages/ambion/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ whether it has anything to add. The assistant selects specialists from the
room's reserve and consolidates multi-agent work when needed, without gaining
general-purpose authority over the application.

`defineAgent` makes an agent, `defineHuman` names a person, `defineTool` gives
agents hands, and `defineWorkspace` names the identity and data boundary those
hands reach into. `startSession` brings up the room, `visitSession` puts
somebody in it, `readSession` reads it without starting anything, and
`stopSession` takes it down.
The root import is deliberately the small application surface. `defineAgent`
makes an agent, `defineHuman` names a person, `defineTool` gives agents hands,
and `defineWorkspace` names the identity and data boundary those hands reach
into. `startSession` brings up the room, `visitSession` puts somebody in it,
`readSession` reads it without starting anything, and `stopSession` takes it
down.

```ts
import {
Expand Down Expand Up @@ -55,6 +56,12 @@ await session.quiet();
await stopSession(session);
```

Hosts that replace time, persistence, model sessions, transport, or workspace
storage import those integration points from `@ambionframework/ambion/host`.
Transport authors can import the advanced, JSON-safe seat protocol from
`@ambionframework/ambion/protocol`. Neither subpath is needed to define and run
an application with the defaults.

The design contract is [`docs/agent.md`](https://github.com/ambionframework/ambion/blob/main/docs/agent.md),
with presence — who is in a session, and what the agents do about it — in
[`docs/presence.md`](https://github.com/ambionframework/ambion/blob/main/docs/presence.md),
Expand Down
8 changes: 8 additions & 0 deletions packages/ambion/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs"
},
"./host": {
"types": "./dist/host.d.mts",
"import": "./dist/host.mjs"
},
"./protocol": {
"types": "./dist/protocol.d.mts",
"import": "./dist/protocol.mjs"
},
"./package.json": "./package.json"
},
"main": "./dist/index.mjs",
Expand Down
28 changes: 28 additions & 0 deletions packages/ambion/src/host.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/** Host integration points. Application code normally needs only the root package. */
export type {
ExecutionEnv,
SessionMetadata,
SessionRepo,
SessionStorage,
} from '@earendil-works/pi-agent-core';
export {
InMemorySessionRepo,
InMemorySessionStorage,
JsonlSessionRepo,
} from '@earendil-works/pi-agent-core';
export type {
CreateRuntimeOptions,
RunningRoom,
Runtime,
SessionRepoLike,
Transport,
} from './host/runtime.ts';
export { createRuntime, defaultRuntime, sessionsOver, systemClock } from './host/runtime.ts';
export type {
MemoryBackendFile,
MemoryBackendOptions,
MemoryWorkspaceBackend,
SeedWriter,
} from './tools/just-bash.ts';
export { directoryBackend, memoryBackend } from './tools/just-bash.ts';
export type { Clock, ModelResolver, SessionOpener, WorkspaceBackend } from './types.ts';
79 changes: 4 additions & 75 deletions packages/ambion/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,13 @@
/**
* The Ambion runtime: five primitives, and a dependency for every other concern.
* Ambion's application surface: define the participants and tools, give an
* agent a workspace, and start or visit a session.
*
* `defineAgent` makes an agent, `defineHuman` names a person, `defineTool`
* gives agents hands, `defineWorkspace` names the identity and data boundary
* an agent's tools reach into, `seated` chooses what wakes a seat — with
* `passive` and `attentive` for the two points worth naming — and
* `startSession` brings up a named room the agents work in and people visit.
* A person's question opens an exchange, the room works, and quiescence
* closes it — the exchange every other feature reads. `stopSession` takes
* the room down, `readSession` reads a name without starting anything,
* `visitSession` puts a person in a running room, and `destroyWorkspace`
* retires a workspace for good. The room's assistant composes the room at
* the open of an exchange, from the agents held in reserve, and writes the
* one message a person reads when the exchange closes. The design contracts
* live in docs/agent.md, docs/exchange.md, docs/presence.md,
* docs/assistant.md, docs/workspace.md and docs/roster.md.
* Host adapters live at `@ambionframework/ambion/host`; the seat protocol
* lives at `@ambionframework/ambion/protocol`.
*/

export type {
ExecutionEnv,
SessionMetadata,
SessionRepo,
SessionStorage,
} from '@earendil-works/pi-agent-core';
// Storage is Pi's, re-exported — Ambion adds no storage abstraction of its own.
// `ExecutionEnv` is what a workspace backend's `connect` returns, and Pi's too.
export {
InMemorySessionRepo,
InMemorySessionStorage,
JsonlSessionRepo,
} from '@earendil-works/pi-agent-core';
export type { DefineAgentOptions, DefineHumanOptions, DefineToolOptions } from './define.ts';
export { attentive, defineAgent, defineHuman, defineTool, passive, seated } from './define.ts';
export type {
CreateRuntimeOptions,
RunningRoom,
Runtime,
SessionRepoLike,
Transport,
} from './host/runtime.ts';
export { createRuntime, defaultRuntime, sessionsOver, systemClock } from './host/runtime.ts';
export type { SeatContext } from './seat/seat.ts';
export { inProcessTransport, SeatActor } from './seat/seat.ts';
export type {
ReadSessionOptions,
ResumeSessionOptions,
Expand All @@ -51,16 +17,6 @@ export type {
Visit,
} from './session.ts';
export { readSession, resumeSession, 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 {
Expand All @@ -69,13 +25,11 @@ export type {
AgentSeatInfo,
AmbionTool,
Attention,
Clock,
ClosedExchange,
Exchange,
HumanDefinition,
HumanSeatInfo,
Message,
ModelResolver,
Participant,
PresenceChange,
PresenceMessage,
Expand All @@ -85,35 +39,10 @@ export type {
SeatStatus,
Seq,
SessionEvent,
SessionOpener,
SpokenMessage,
SummaryMessage,
ToolContext,
Workspace,
WorkspaceBackend,
WorkspaceHandle,
} from './types.ts';
export { isPresence, isSpoken, isSummary } from './types.ts';
export type {
ActivationView,
CloseRow,
Commit,
CommitResponse,
CompositionRow,
EndReason,
Hand,
Intent,
Lease,
LeaseResponse,
LeaseRow,
SeatPort,
SeatRoom,
SeatRow,
Stale,
ViewResponse,
Wake,
} from './wire.ts';
export { assertWire, roundTrip } from './wire.ts';

/** Kept in step with package.json by a test. */
export const PACKAGE_NAME = '@ambionframework/ambion';
20 changes: 20 additions & 0 deletions packages/ambion/src/protocol.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Advanced seat protocol for transport implementors. These JSON-safe values
* are the complete contract crossing a room/seat boundary.
*/
export type { SeatContext } from './seat/seat.ts';
export { inProcessTransport, SeatActor } from './seat/seat.ts';
export type {
ActivationView,
Commit,
CommitResponse,
Lease,
LeaseResponse,
LeaseRow,
SeatPort,
SeatRoom,
Stale,
ViewResponse,
Wake,
} from './wire.ts';
export { assertWire, roundTrip } from './wire.ts';
68 changes: 53 additions & 15 deletions packages/ambion/src/seat/seat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ interface Current {
activation: Activation;
/** The activation ran to its end, and its release is in flight. It takes no steer. */
over: boolean;
/** Resolves when the room ended the lease: the actor moves on, whatever the run still does. */
cut: () => void;
cutOff: Promise<void>;
}

/**
Expand Down Expand Up @@ -156,22 +159,44 @@ export class SeatActor implements SeatPort {
if (!this.queued.includes(id)) this.queued.push(id);
}

/**
* The room ended this activation's lease. The activation is aborted, and
* the actor moves on at once: a run that ignores the abort is left to
* finish on its own, and every call it still makes is answered stale.
*/
async cut(activation: string): Promise<void> {
if (this.current?.id === activation) this.cutCurrent();
}

/** Cut the activation in flight, whatever its id. The room hears how it ended. */
abort(): void {
this.current?.activation.abort();
this.cutCurrent();
}

private cutCurrent(): void {
const current = this.current;
if (current === undefined) return;
current.activation.abort();
current.cut();
}

private async take(id: string): Promise<void> {
// Held before the claim, so a steer that lands while the claim is in
// flight reaches the activation and not the floor.
const activation = new Activation(id, this.context.seat, this.host(id));
const current: Current = { id, activation, over: false };
let cut = () => {};
const cutOff = new Promise<void>((resolve) => {
cut = resolve;
});
const current: Current = { id, activation, over: false, cut, cutOff };
this.current = current;
const claimed = await this.claim(id);
if (claimed !== undefined) {
const stopRenewing = this.renewUntil(activation, claimed.expiry);
const stopRenewing = this.renewUntil(current, claimed.expiry);
try {
await activation.run();
// The cut ends the wait, and never the run: a run that ignores the
// abort finishes on its own, past a seat that took its next wake.
await Promise.race([activation.run(), cutOff]);
} finally {
stopRenewing();
// Over, and holding the seat through the release: a wake that lands
Expand Down Expand Up @@ -225,31 +250,44 @@ export class SeatActor implements SeatPort {
}
}

/** One renewal: the new expiry, or nothing when the room refused it or it never reached the room. */
private async renew(activation: Activation): Promise<number | undefined> {
/**
* One renewal: the new expiry, `stale` when the room refused it, or
* `lost` when it never reached the room.
*/
private async renew(activation: Activation): Promise<number | 'stale' | 'lost'> {
try {
const renewed = await this.room.lease({ activation: activation.id, phase: 'running' });
return 'stale' in renewed ? undefined : renewed.ok.expiry;
return 'stale' in renewed ? 'stale' : renewed.ok.expiry;
} catch {
return undefined;
return 'lost';
}
}

/**
* Renew at half the expiry, for as long as the activation runs and the
* room renews it. The cancel stops the loop for good: a renewal in flight
* when the activation ends arms nothing when it comes back.
* room renews it. A refused renewal cuts the activation now: its lease
* ended, so nothing it writes lands. A renewal that never reached the
* room leaves the lease to expire where it stands, and the actor cuts
* the activation at that expiry, when the room expires the lease. The
* cancel stops the loop for good: a renewal in flight when the
* activation ends arms nothing when it comes back.
*/
private renewUntil(activation: Activation, firstExpiry: number): () => void {
private renewUntil(current: Current, firstExpiry: number): () => void {
const clock = this.context.clock;
let stopped = false;
let cancel = () => {};
const cut = () => {
if (this.current === current) this.cutCurrent();
};
const schedule = (expiry: number) => {
cancel = clock.alarm(clock.now() + (expiry - clock.now()) / 2, () => void again());
cancel = clock.alarm(clock.now() + (expiry - clock.now()) / 2, () => void again(expiry));
};
const again = async () => {
const renewed = await this.renew(activation);
if (!stopped && renewed !== undefined) schedule(renewed);
const again = async (held: number) => {
const renewed = await this.renew(current.activation);
if (stopped) return;
if (renewed === 'stale') cut();
else if (renewed === 'lost') cancel = clock.alarm(held, cut);
else schedule(renewed);
};
schedule(firstExpiry);
return () => {
Expand Down
Loading