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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/host.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ this page with each change to a file of `src/host/`.
| The host | `src/host/host.ts` | `openLab`, the `Lab` interface, and the delivery of a message |
| The schema guide | `src/host/schemas.ts` | `writeSchemas`: the file `schemas.md` of the data directory |
| The step log | `src/host/activations.ts` | The append stream of `activations.jsonl` |
| The config log | `src/host/config.ts` | `writeConfig`: the lines of `configs.jsonl` |
| The rooms | `src/host/rooms.ts` | `openRooms`: runtime, canvas, workspace, and room views |
| The names | `src/host/names.ts` | `ROOM_NAME` and `MAX_GOAL` |
| The seats | `src/domain/room.ts` | `seats`, and `buildRoom`, the seeded room |
Expand Down Expand Up @@ -119,6 +120,16 @@ At each start the host writes `schemas.md` in the same directory. The file
lists the entries, gives the `CREATE` statements of `rooms.db`, and states
the format of `activations.jsonl`, so a coding agent can explore the data.

**The host records the config of the seats in `configs.jsonl`.** At each
start `writeConfig` copies the live definitions that the host seats: the
model, the thinking level, the instructions, the policies, and each tool
with its parameters. It adds the versions of the packages, the runtime
limits, the workstation host, and whether the model has a login. The file
never holds a credential. The host appends one line, with the time and the
SHA-256 of the snapshot, only when the snapshot differs from the last line.
Each line is a change of config. The line in force for an activation is the
last line whose `at` is before the first step of the activation.

**`send` needs a person who is in the room.** It needs a nonempty key and
text. The same key and text return the first exchange and add no message.
The host seats an unseated specialist at `named` when `to` names it, and
Expand Down
199 changes: 199 additions & 0 deletions src/host/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { createHash } from 'node:crypto';
import { appendFile, readFile } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
type AgentDefinition,
DEFAULT_RESPOND_POLICY,
DEFAULT_SUMMARY_POLICY,
type Executor,
type JsonValue,
type PersonDefinition,
} from '@ambionframework/ambion';
import { packageDirectory } from '../domain/package-root.ts';

/** The name of the config log, in the data directory. */
const CONFIG_LOG = 'configs.jsonl';

/** The package scope of the Ambion packages. */
const SCOPE = '@ambionframework/';

/** A JSON object. */
type JsonObject = { readonly [key: string]: JsonValue };

/** What the host knows about a start, besides the definitions. */
export interface ConfigInput {
/** The definitions that the host seats. */
specialists: readonly AgentDefinition[];
/** The people, with their definitions. */
people: readonly PersonDefinition[];
/** The limits that the runtime runs with. */
limits: object;
/** The host name of the workstation. Without it, the workspace runs on this machine. */
workstation?: string;
/** True when the model has a key or a sign-in. The file records the state and no credential. */
login: boolean;
}

/** One snapshot of the config: the content of a line, without `at` and `hash`. */
export type ConfigSnapshot = JsonObject;

/**
* A plain JSON copy of a value. A function, a symbol, and an undefined value
* leave no field. A number that JSON cannot hold, and a bigint, become text.
* A reference back to a value that the copy is inside becomes `"[cycle]"`.
*/
export function plain(value: unknown, parents: readonly object[] = []): JsonValue | undefined {
switch (typeof value) {
case 'string':
case 'boolean':
return value;
case 'number':
return Number.isFinite(value) ? value : String(value);
case 'bigint':
return String(value);
case 'object':
return value === null ? null : plainObject(value, parents);
default:
return undefined;
}
}

function plainObject(value: object, parents: readonly object[]): JsonValue {
if (parents.includes(value)) return '[cycle]';
const inside = [...parents, value];
if (value instanceof Date) return value.toISOString();
if (Array.isArray(value)) return value.map((item) => plain(item, inside) ?? null);
return plainRecord(value, inside);
}

/** The own fields of an object that hold a plain value. */
function plainRecord(value: object, parents: readonly object[]): JsonObject {
const fields: [string, JsonValue][] = [];
for (const [key, field] of Object.entries(value)) {
const copy = plain(field, parents);
if (copy !== undefined) fields.push([key, copy]);
}
return Object.fromEntries(fields);
}

/** The executor of a definition, with the effective value of each default. */
function executorConfig(executor: Executor): JsonObject {
const { tools, reminders, respondPolicy, summaryPolicy, ...rest } = executor;
return {
...plainRecord(rest, [executor]),
respondPolicy: respondPolicy ?? DEFAULT_RESPOND_POLICY,
respondPolicySource: respondPolicy === undefined ? 'default' : 'definition',
summaryPolicy: summaryPolicy ?? DEFAULT_SUMMARY_POLICY,
summaryPolicySource: summaryPolicy === undefined ? 'default' : 'definition',
activationTokenLimit: executor.activationTokenLimit ?? null,
estimateTokens: executor.estimateTokens ?? 'length',
reminderCount: reminders?.length ?? 0,
tools: tools.map((tool) => plainRecord(tool, [tool])),
};
}

/** One specialist, as the host seats it. */
function specialistConfig(definition: AgentDefinition): JsonObject {
return {
name: definition.name,
identity: definition.identity,
trace: plain(definition.trace) ?? null,
executor: executorConfig(definition.executor),
};
}

/** The installed version of a package, or undefined when the host cannot find it. */
async function installedVersion(name: string): Promise<string | undefined> {
try {
let directory = dirname(fileURLToPath(import.meta.resolve(name)));
for (;;) {
const found = await readFile(join(directory, 'package.json'), 'utf8').catch(() => '');
const manifest = found ? (JSON.parse(found) as { name?: string; version?: string }) : {};
if (manifest.name === name) return manifest.version;
const parent = dirname(directory);
if (parent === directory) return undefined;
directory = parent;
}
} catch {
return undefined;
}
}

/** The version of Workbench, and the installed version of each Ambion dependency. */
async function versions(): Promise<JsonObject> {
const manifest = JSON.parse(await readFile(packageDirectory('package.json'), 'utf8')) as {
version: string;
dependencies?: Record<string, string>;
};
const names = Object.keys(manifest.dependencies ?? {}).filter((name) => name.startsWith(SCOPE));
const installed = await Promise.all(
names.map(async (name) => [name, (await installedVersion(name)) ?? 'unknown'] as const),
);
return { workbench: manifest.version, ambion: Object.fromEntries(installed) };
}

/**
* The snapshot of the config for one start. It copies the live definitions
* that the host seats, so the snapshot cannot differ from what the seats run.
*/
export async function configSnapshot(input: ConfigInput): Promise<ConfigSnapshot> {
return {
versions: await versions(),
model: { login: input.login ? 'present' : 'missing' },
workstation: input.workstation === undefined ? null : { host: input.workstation },
limits: { setBy: 'ambion defaults', values: plain(input.limits) ?? null },
people: input.people.map((person) => plainRecord(person, [person])),
specialists: input.specialists.map(specialistConfig),
};
}

/** The JSON text of a value with the keys of each object in order, so equal values give equal text. */
function canonical(value: JsonValue): string {
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
if (value === null || typeof value !== 'object') return JSON.stringify(value);
const fields = Object.entries(value as JsonObject).sort(([a], [b]) => (a < b ? -1 : 1));
return `{${fields.map(([key, field]) => `${JSON.stringify(key)}:${canonical(field)}`).join(',')}}`;
}

/** The SHA-256 of the canonical JSON of a snapshot, in hex. */
export function configHash(snapshot: ConfigSnapshot): string {
return createHash('sha256').update(canonical(snapshot)).digest('hex');
}

/** The hash on the last line of the log text. An empty text and a bad last line give undefined. */
function lastHash(text: string): string | undefined {
const line = text
.split('\n')
.filter((candidate) => candidate.trim() !== '')
.at(-1);
try {
const hash = line === undefined ? undefined : (JSON.parse(line) as { hash?: unknown }).hash;
return typeof hash === 'string' ? hash : undefined;
} catch {
return undefined;
}
}

/**
* Append a line to `configs.jsonl` in `directory` when the snapshot differs
* from the last line. Returns true when it appended a line.
*/
export async function writeConfig(
directory: string,
input: ConfigInput,
now: Date = new Date(),
): Promise<boolean> {
const path = resolve(directory, CONFIG_LOG);
const snapshot = await configSnapshot(input);
const hash = configHash(snapshot);
const text = await readFile(path, 'utf8').catch(() => '');
if (lastHash(text) === hash) return false;
// A last line without a line break would join the new line, so end it first.
const separator = text === '' || text.endsWith('\n') ? '' : '\n';
await appendFile(
path,
`${separator}${JSON.stringify({ at: now.toISOString(), hash, ...snapshot })}\n`,
);
return true;
}
10 changes: 10 additions & 0 deletions src/host/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import type { Room } from '@ambionframework/ambion';
import type { WidgetAct, WidgetActResult } from '@ambionframework/canvas';
import type { PiExecutionOptions } from '@ambionframework/pi';
import { type Person, people } from '../domain/definitions.ts';
import { missingLogin } from '../domain/model.ts';
import { buildRoom } from '../domain/room.ts';
import type { ActivationSteps } from '../view/steps.ts';
import { type ActionWidget, frameActions } from './actions.ts';
import { writeConfig } from './config.ts';
import {
type Attachment,
attachFile,
Expand Down Expand Up @@ -189,6 +191,14 @@ export async function openLab(options: OpenOptions): Promise<Lab> {
workstation,
});
if (fresh) await seedRooms(rooms);
// The config goes first, so that `schemas.md` lists `configs.jsonl` at the first start.
await writeConfig(options.directory, {
specialists: rooms.definitions,
people,
limits: rooms.limits,
workstation: workstation?.host,
login: missingLogin(options.env ?? process.env) === undefined,
});
// The tables of `rooms.db` exist now.
await writeSchemas(database, options.directory);
} catch (error) {
Expand Down
6 changes: 5 additions & 1 deletion src/host/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
readRoom,
type TraceStep,
} from '@ambionframework/ambion';
import type { Execution } from '@ambionframework/ambion/hosting';
import { type Execution, hostingOf } from '@ambionframework/ambion/hosting';
import {
type CanvasClose,
type CanvasError,
Expand Down Expand Up @@ -289,6 +289,10 @@ export async function openRooms(
}
}
return {
/** The definitions that the host seats, as the runtime runs them. */
definitions: roomTeam.specialists,
/** The limits that the runtime runs with. */
limits: hostingOf(runtime).limits,
/** The specialists that a room can seat. */
team: roomTeam.specialists.map(({ name, identity }) => ({ name, identity })),
/** Whether a room is a breakout room. */
Expand Down
49 changes: 48 additions & 1 deletion src/host/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const ENTRIES: Record<string, string> = {
'rooms.db-shm': 'SQLite. The shared memory of `rooms.db`.',
'activations.jsonl':
'JSON lines. One line for each step of each activation. The format is below.',
'configs.jsonl':
'JSON lines. One line for each change of the config of the seats. The format is below.',
'git.db': 'SQLite. The git repositories of the workspace, such as the templates and the notes.',
workspace: 'Folder. The files of the workspace: the shared folders and the home of each seat.',
[SCHEMAS]: 'Markdown. This file. The host writes it again at each start.',
Expand Down Expand Up @@ -74,6 +76,50 @@ jq -c 'select(.room=="build" and .step.activation=="message:4:engineer:1")' acti
\`\`\`
`;

const CONFIGS = `## configs.jsonl

**One line is one snapshot of the config that the seats run with.** The host
copies the snapshot from the live definitions at each start. It appends a
line only when the snapshot differs from the last line, so each line is a
change of config. A line has these fields:

- \`at\`: the ISO time of the start that wrote the line.
- \`hash\`: the SHA-256, in hex, of the canonical JSON of the line without
\`at\` and \`hash\`.
- \`versions\`: the version of Workbench, and the installed version of each
\`@ambionframework\` package.
- \`model\`: \`login\` is \`present\` or \`missing\`. The line never holds a key,
a token, or an environment value.
- \`workstation\`: \`null\` for the local backend, or \`{ "host": string }\`.
- \`limits\`: the limits of the runtime. The host sets none, so the values
are the Ambion defaults. A function is not a value, so the retry backoff
is absent. \`Infinity\` shows as the text \`"Infinity"\`.
- \`people\`: the definition of each person.
- \`specialists\`: for each seat, \`name\`, \`identity\`, \`trace\`, and
\`executor\`. The executor holds \`kind\`, \`model\`, \`thinking\`,
\`instructions\`, \`guidance\`, \`respondPolicy\`, \`summaryPolicy\`,
\`activationTokenLimit\` (\`null\` for no limit), \`estimateTokens\`,
\`reminderCount\`, and \`tools\`. A tool holds its \`name\`,
\`label\`, \`description\`, \`parameters\` (JSON schema), and
\`compose\` (\`false\`, or the output schema). Any other plain field of
a tool or an executor is also in the line. The source of a
function is not in the file. \`respondPolicySource\` and
\`summaryPolicySource\` say whether the text is the Ambion \`default\` or
comes from the \`definition\`.

**The line in force for an activation is the last line whose \`at\` is before
the first step of the activation.** The \`at\` of a step is in
\`activations.jsonl\`. The \`session\` step of an activation records the
model and the tools that the activation ran with, as they ran.

Example:

\`\`\`sh
# The model and the thinking level of each seat, at each change of config.
jq -c '{at, hash, models: [.specialists[] | {name, model: .executor.model, thinking: .executor.thinking}]}' configs.jsonl
\`\`\`
`;

/** The entries of the data directory, one line each. */
async function listing(directory: string): Promise<string> {
const found = await readdir(directory, { withFileTypes: true });
Expand Down Expand Up @@ -106,7 +152,8 @@ ${await listing(directory)}
${statements(database)}
\`\`\`

${ACTIVATIONS}`;
${ACTIVATIONS}
${CONFIGS}`;
}

/** Write \`schemas.md\` in the data directory. An old file is overwritten. */
Expand Down
Loading
Loading