@vzlentin/harness 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +66 -2
  2. package/dist/answer.d.ts +29 -0
  3. package/dist/answer.js +176 -0
  4. package/dist/chat.d.ts +48 -0
  5. package/dist/chat.js +208 -0
  6. package/dist/children.d.ts +215 -0
  7. package/dist/children.js +419 -0
  8. package/dist/chrome.d.ts +18 -0
  9. package/dist/chrome.js +74 -0
  10. package/dist/cli.d.ts +2 -0
  11. package/dist/cli.js +251 -0
  12. package/dist/client.d.ts +49 -0
  13. package/dist/client.js +176 -0
  14. package/dist/context.d.ts +13 -0
  15. package/dist/context.js +115 -0
  16. package/dist/durable-entry.d.ts +13 -0
  17. package/dist/durable-entry.js +33 -0
  18. package/dist/entries.d.ts +9 -0
  19. package/dist/entries.js +50 -0
  20. package/dist/herdr.d.ts +38 -0
  21. package/dist/herdr.js +111 -0
  22. package/dist/host.d.ts +18 -0
  23. package/dist/host.js +827 -0
  24. package/dist/picker.d.ts +22 -0
  25. package/dist/picker.js +70 -0
  26. package/dist/projects.d.ts +23 -0
  27. package/dist/projects.js +96 -0
  28. package/dist/protocol.d.ts +323 -0
  29. package/dist/protocol.js +126 -0
  30. package/dist/setup.d.ts +13 -0
  31. package/dist/setup.js +133 -0
  32. package/dist/tasks.d.ts +31 -0
  33. package/dist/tasks.js +74 -0
  34. package/dist/theme.d.ts +3 -0
  35. package/dist/theme.js +19 -0
  36. package/dist/tool.d.ts +137 -0
  37. package/dist/tool.js +213 -0
  38. package/dist/trust.d.ts +5 -0
  39. package/dist/trust.js +15 -0
  40. package/dist/view-state.d.ts +19 -0
  41. package/dist/view-state.js +44 -0
  42. package/dist/viewer.d.ts +20 -0
  43. package/dist/viewer.js +307 -0
  44. package/package.json +57 -5
  45. package/pi/index.ts +168 -0
  46. package/pi/package.json +14 -0
  47. package/scripts/postinstall.js +13 -0
@@ -0,0 +1,215 @@
1
+ /**
2
+ * The children library: creates and drives child conversations on whatever pi-durable store its caller hands it. Its
3
+ * caller passes its pi-durable module, so a process never holds two copies.
4
+ */
5
+ import type { JsonValue } from "@earendil-works/chord";
6
+ import { type Context } from "@earendil-works/chord/context";
7
+ import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
8
+ import { type Models } from "@earendil-works/pi-ai/models";
9
+ import type * as Durable from "@earendil-works/pi-durable";
10
+ import type { Agent, Conversation, ConversationId, DocumentReader, EntryId, Extension, JsonObject, ModelRef, RegistrySnapshot, Submission, SubmissionId, TaskId, Tx, UsageState } from "@earendil-works/pi-durable";
11
+ /** The deepest child by default: a child of a child of a root. */
12
+ export declare const DEFAULT_DEPTH_LIMIT = 2;
13
+ /**
14
+ * The store children live in: a Harness, or a running task's runtime with the Harness's `submission`. `commit`
15
+ * callbacks return nothing, as a task's do, and read back through `tx` or `snapshot`.
16
+ */
17
+ export type Store = {
18
+ commit(change: (tx: Tx) => Promise<undefined>, context: Context): Promise<void>;
19
+ readonly snapshot: DocumentReader["snapshot"];
20
+ conversation(id: ConversationId, context: Context): Promise<Conversation | undefined>;
21
+ submission(id: SubmissionId, context: Context): Promise<Submission | undefined>;
22
+ };
23
+ /** Where children live: the store, the models they run on, and the tools that `Spec.tools` names. */
24
+ export type Place = {
25
+ readonly store: Store;
26
+ readonly models: Models;
27
+ /** The tools that `Spec.tools` names resolve among, by name; the last of a name wins. */
28
+ readonly registry: RegistrySnapshot;
29
+ };
30
+ /**
31
+ * Who owns a child, which decides what aborts reach it: a running task in `conversation`, whose abort aborts the child,
32
+ * or an anchor task of its own in `root`, the store's root conversation, so the child outlives every request. The
33
+ * owner's conversation gives the spec what it leaves out. A task owner with a `group` finds the children of every task
34
+ * of that group, such as the tasks of one workflow run across resumes; an abort still reaches only the children that
35
+ * its own task opened.
36
+ */
37
+ export type Owner = {
38
+ readonly kind: "task";
39
+ readonly taskId: TaskId;
40
+ readonly conversation: ConversationId;
41
+ readonly group?: string;
42
+ } | {
43
+ readonly kind: "detached";
44
+ readonly root: Conversation;
45
+ };
46
+ /** What a child runs with. JSON only, so a socket can carry it. A field left out comes from the owner's agent. */
47
+ export type Spec = {
48
+ /** The root of the tree, for a child of a caller outside this store: a Pi session, a caller name. */
49
+ readonly root?: string;
50
+ /** The conversation in this store that spawns the child. */
51
+ readonly parent?: string;
52
+ readonly provider?: string;
53
+ readonly model?: string;
54
+ readonly thinking?: string;
55
+ /** The caller's thinking level, used without `thinking`: the nearest level that the model has, not refused. */
56
+ readonly fallbackThinking?: string;
57
+ readonly cwd?: string;
58
+ /** The only tools the child gets, by name. */
59
+ readonly tools?: readonly string[];
60
+ readonly instructions?: string;
61
+ };
62
+ /** Where a child sits in its tree. */
63
+ export type Lineage = {
64
+ readonly key: string;
65
+ readonly root: string;
66
+ readonly parent?: string;
67
+ /** 1 for a child of a root. */
68
+ readonly depth: number;
69
+ /** The deepest depth of the tree. */
70
+ readonly limit: number;
71
+ };
72
+ export type ChildState = "busy" | "idle" | "error" | "closed";
73
+ /**
74
+ * What a turn returns: its text, or a named failure. `cut` stopped before its end, such as at its length limit, and
75
+ * keeps its text. `no-respond` is a schema ask with no accepted `respond` call. `unanswered` settled with no answer,
76
+ * after a provider error, an abort or a close; `cause` is the settlement's reason, or `queued` when the child stopped
77
+ * with the request still queued after a failed turn.
78
+ */
79
+ export type Answer = (Ok & {
80
+ readonly value?: JsonValue;
81
+ }) | Failure;
82
+ /** A turn that reached its end. */
83
+ export type Ok = {
84
+ readonly status: "ok";
85
+ readonly text: string;
86
+ readonly entry: EntryId;
87
+ };
88
+ /** A turn without an answer that counts. */
89
+ export type Failure = {
90
+ readonly status: "failed";
91
+ readonly reason: "cut";
92
+ readonly text: string;
93
+ readonly entry: EntryId;
94
+ readonly stopReason: string;
95
+ } | {
96
+ readonly status: "failed";
97
+ readonly reason: "no-respond";
98
+ readonly text: string;
99
+ readonly entry: EntryId;
100
+ } | {
101
+ readonly status: "failed";
102
+ readonly reason: "unanswered";
103
+ readonly cause: string;
104
+ readonly detail?: JsonValue | undefined;
105
+ };
106
+ /** Input to a child, at most once per request ID. */
107
+ export type Input = Parameters<Conversation["submit"]>[0] & {
108
+ readonly type: "input";
109
+ readonly requestId: string;
110
+ };
111
+ /** One child, driven by its operations. Failures are returned, not thrown, so each caller keeps its own policy. */
112
+ export type Child = {
113
+ readonly id: ConversationId;
114
+ readonly lineage: Lineage;
115
+ /** The child's conversation, for what the library does not cover: its agent, a watch, an abort of its turn. */
116
+ readonly conversation: Conversation;
117
+ /**
118
+ * Admits `input` and returns. A request ID admitted before is not admitted again, and a closed child refuses input.
119
+ * A new request ID becomes the last request before the submit, so a crash between the two still finds it.
120
+ * A send while a schema ask runs turns `respond` off, so that ask gives `no-respond` unless `respond` already saved
121
+ * its answer.
122
+ */
123
+ send(input: Input, context: Context): Promise<void>;
124
+ /**
125
+ * Sends `input` as one turn and gives its answer. A request ID answered before gives its saved answer with no new
126
+ * turn. A request ID that was not answered, or whose answer does not count, is sent again as `<requestId>#2`, `#3`
127
+ * and so on. With `schema`, the child must answer through its `respond` tool with a value that matches the schema,
128
+ * which becomes `value`; else the answer is `no-respond`. A child opened with `Spec.tools` has no `respond`, so its
129
+ * schema asks give `no-respond`. A closed child refuses input, and cancelling `context` stops only the wait.
130
+ */
131
+ ask(input: Input, context: Context, options: {
132
+ readonly schema: JsonObject;
133
+ }): Promise<(Ok & {
134
+ readonly value: JsonValue;
135
+ }) | Failure>;
136
+ ask(input: Input, context: Context, options?: {
137
+ readonly schema?: undefined;
138
+ }): Promise<Ok | Failure>;
139
+ ask(input: Input, context: Context, options?: {
140
+ readonly schema?: JsonObject;
141
+ }): Promise<Answer>;
142
+ /**
143
+ * Waits until `request` (default: the last one) settles, then gives its answer. Cancelling the wait through
144
+ * `context` stops only the wait; the child runs until `close()`.
145
+ */
146
+ settled(context: Context, request?: string): Promise<Answer>;
147
+ /** The newest request ID admitted, which `settled` waits for by default, or null before the first one. */
148
+ lastRequest(context: Context): Promise<string | null>;
149
+ state(context: Context): Promise<ChildState>;
150
+ /** The spend of the child and everything under it, for reports. */
151
+ usage(context: Context): Promise<UsageState>;
152
+ /**
153
+ * Withdraws the queued input of the child and its subtree, aborts them, background work included, and marks them
154
+ * closed. Gives the conversations it closed, parents first. With `below`, it does this only for the subtree under
155
+ * the child, and the child stays open. A turn of the child's conversation that a task outside the child's owner
156
+ * sent, such as a later task of its group, is aborted only through `conversation.abort`.
157
+ */
158
+ close(context: Context, options?: {
159
+ readonly below?: boolean;
160
+ }): Promise<ConversationId[]>;
161
+ };
162
+ export type Children = {
163
+ /** The extension with the library's anchor task; the caller installs it in its registry. */
164
+ readonly extension: Extension;
165
+ /**
166
+ * The extension with `respond` for schema asks; the caller installs it in its registry. Each child selects it, so
167
+ * the caller does not.
168
+ */
169
+ readonly answerExtension: Extension;
170
+ /**
171
+ * The child of `key`: created the first time `key` is seen, with its lineage, in one commit, and the same child after.
172
+ * `setup` runs in the creating commit. Before creating anything, it refuses a spec whose model neither it nor the
173
+ * owner's agent names, an unknown model or tool, and a parent at its depth limit. Keys are global in a store, so
174
+ * callers prefix them.
175
+ */
176
+ open(place: Place, owner: Owner, key: string, spec: Spec, context: Context, setup?: (tx: Tx, id: ConversationId) => Promise<void>): Promise<Child>;
177
+ /**
178
+ * The child of `owner` with a key or a conversation ID opened earlier, also after a restart. A child of another
179
+ * owner is refused.
180
+ */
181
+ find(place: Place, owner: Owner, which: {
182
+ readonly key: string;
183
+ } | {
184
+ readonly id: string;
185
+ }, context: Context): Promise<Child | undefined>;
186
+ /** Where the conversation `id` sits in its tree; nothing for a root, or another conversation the library did not open. */
187
+ lineage(store: Pick<Store, "snapshot">, id: string, context: Context): Promise<Lineage | undefined>;
188
+ /**
189
+ * The `send` and `state` of `Child` for any conversation: `read` gives its last request, and `write` records a new
190
+ * request ID in the commit that admits it, before the submit. `state` is never `closed`.
191
+ */
192
+ turn(place: Place, id: ConversationId, conversation: Conversation, read: (context: Context) => Promise<string | null | undefined>, write: (tx: Tx, requestId: string) => Promise<void>): Turn;
193
+ };
194
+ /** The input and the state of one conversation, as `Children.turn` gives them. */
195
+ export type Turn = {
196
+ send(input: Input, context: Context): Promise<void>;
197
+ state(context: Context): Promise<Exclude<ChildState, "closed">>;
198
+ };
199
+ export declare function createChildren(durable: typeof Durable): Children;
200
+ /** The model and the thinking level of a child. */
201
+ export type ResolvedModel = {
202
+ model: ModelRef;
203
+ thinkingLevel: ModelThinkingLevel;
204
+ };
205
+ /**
206
+ * The model and thinking level of `request`; a field left out comes from `fallback`. An explicit `thinking` that the
207
+ * model does not have is refused. Without it, the level is `fallbackThinking`, else the fallback's, clamped to the
208
+ * nearest level that the model has.
209
+ */
210
+ export declare function resolveModel(models: Models, fallback: Pick<Agent, "model" | "thinkingLevel"> | undefined, request: {
211
+ readonly provider?: string | undefined;
212
+ readonly model?: string | undefined;
213
+ readonly thinking?: string | undefined;
214
+ readonly fallbackThinking?: string | undefined;
215
+ }): ResolvedModel;
@@ -0,0 +1,419 @@
1
+ import { withCancel } from "@earendil-works/chord/context";
2
+ import { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai/models";
3
+ import { MODEL_THINKING_LEVELS } from "@earendil-works/pi-ai/providers/model-schema";
4
+ import { complete, createAnswers } from "./answer.js";
5
+ /** The deepest child by default: a child of a child of a root. */
6
+ export const DEFAULT_DEPTH_LIMIT = 2;
7
+ export function createChildren(durable) {
8
+ const ChildDoc = durable.defineDoc({
9
+ kind: "pi-subagent.child",
10
+ version: 1,
11
+ scope: "conversation",
12
+ history: "latest",
13
+ fork: "initial",
14
+ initial: () => ({
15
+ key: "",
16
+ root: "",
17
+ parent: null,
18
+ depth: 0,
19
+ limit: 0,
20
+ owner: { kind: "detached" },
21
+ children: [],
22
+ lastRequest: null,
23
+ closed: false,
24
+ }),
25
+ });
26
+ /** The conversation of each key; 0 before it exists. */
27
+ const Keys = durable.defineDocFamily({
28
+ kind: "pi-subagent.key",
29
+ version: 1,
30
+ scope: "session",
31
+ family: true,
32
+ initial: () => ({ conversation: 0 }),
33
+ });
34
+ /**
35
+ * Owns a detached child's conversation. It is a background task of the root conversation that finishes at once, so
36
+ * the child outlives any one request and only an abort with `background: true` reaches it.
37
+ */
38
+ const Anchor = durable.defineTask({
39
+ name: "subagent.anchor",
40
+ version: 1,
41
+ initial: () => ({ phase: "done" }),
42
+ phases: {
43
+ done: (_anchor, runtime, taskContext) => runtime.commit(() => ({ status: "terminal", outcome: { status: "completed", result: null } }), taskContext),
44
+ },
45
+ abort: (_anchor, runtime, taskContext) => runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), taskContext),
46
+ });
47
+ const answers = createAnswers(durable);
48
+ const extension = durable.defineExtension({ name: "pi-subagent-children", tasks: [Anchor] });
49
+ const ownerState = (owner) => owner.kind === "detached"
50
+ ? { kind: "detached" }
51
+ : owner.group === undefined
52
+ ? { kind: "task", taskId: owner.taskId }
53
+ : { kind: "task", taskId: owner.taskId, group: owner.group };
54
+ const sameOwner = (a, b) => {
55
+ if (a.kind === "detached" || b.kind === "detached")
56
+ return a.kind === b.kind;
57
+ return a.group !== undefined || b.group !== undefined ? a.group === b.group : a.taskId === b.taskId;
58
+ };
59
+ const lineageOf = (doc) => {
60
+ const found = { key: doc.key, root: doc.root, depth: doc.depth, limit: doc.limit };
61
+ return doc.parent === null ? found : { ...found, parent: doc.parent };
62
+ };
63
+ async function childDoc(store, id, context) {
64
+ const doc = await store.snapshot(ChildDoc, id, context);
65
+ return doc === undefined || doc.key === "" ? undefined : doc;
66
+ }
67
+ async function lineage(store, id, context) {
68
+ const conversation = conversationIdOf(id);
69
+ const doc = conversation === undefined ? undefined : await childDoc(store, conversation, context);
70
+ return doc === undefined ? undefined : lineageOf(doc);
71
+ }
72
+ /** Reads through a commit, which returns nothing on a task's runtime. */
73
+ async function read(place, reader, context) {
74
+ let result;
75
+ await place.store.commit(async (tx) => {
76
+ result = { value: await reader(tx) };
77
+ return undefined;
78
+ }, context);
79
+ if (result === undefined)
80
+ throw new Error("The commit did not run its change");
81
+ return result.value;
82
+ }
83
+ /** `id` and every child under it, parents first. */
84
+ async function subtree(place, id, context) {
85
+ const ids = [id];
86
+ // The loop also visits the IDs that it appends.
87
+ for (const member of ids) {
88
+ const doc = await childDoc(place.store, member, context);
89
+ for (const child of doc?.children ?? [])
90
+ ids.push(child);
91
+ }
92
+ return ids;
93
+ }
94
+ /** The answer of the settled input `request` of `id`. */
95
+ async function answerIn(tx, id, request) {
96
+ const settled = await tx.submissionByRequest(id, request);
97
+ if (settled?.status === "unanswered") {
98
+ return { status: "failed", reason: "unanswered", cause: settled.reason, detail: settled.detail };
99
+ }
100
+ if (settled?.status !== "done")
101
+ return { status: "failed", reason: "unanswered", cause: "queued" };
102
+ const entry = settled.answer;
103
+ if (entry === undefined)
104
+ return { status: "failed", reason: "unanswered", cause: "missing answer" };
105
+ const message = (await tx.entry(durable.AssistantEntry, entry))?.model?.[0];
106
+ if (message?.role !== "assistant")
107
+ return { status: "failed", reason: "unanswered", cause: "missing answer" };
108
+ const text = message.content
109
+ .flatMap((part) => (part.type === "text" ? [part.text] : []))
110
+ .join("\n")
111
+ .trim();
112
+ if (!complete(message.stopReason)) {
113
+ return { status: "failed", reason: "cut", text, entry, stopReason: message.stopReason };
114
+ }
115
+ return { status: "ok", text, entry };
116
+ }
117
+ const turn = (place, id, conversation, readLast, write) => ({
118
+ async send(input, context) {
119
+ await place.store.commit(async (tx) => {
120
+ if ((await tx.submissionByRequest(id, input.requestId)) !== undefined)
121
+ return undefined;
122
+ await write(tx, input.requestId);
123
+ return undefined;
124
+ }, context);
125
+ await conversation.submit(input, context);
126
+ },
127
+ async state(context) {
128
+ const live = await place.store.snapshot(durable.LiveDoc, id, context);
129
+ if (live?.run !== undefined)
130
+ return "busy";
131
+ const requestId = await readLast(context);
132
+ const settled = requestId == null ? undefined : await read(place, (tx) => tx.submissionByRequest(id, requestId), context);
133
+ // An aborted turn leaves the conversation idle.
134
+ return settled?.status === "unanswered" && settled.reason !== "aborted" ? "error" : "idle";
135
+ },
136
+ });
137
+ function makeChild(place, id, childLineage, conversation) {
138
+ const lastRequest = async (context) => (await place.store.snapshot(ChildDoc, id, context))?.lastRequest;
139
+ const turns = turn(place, id, conversation, lastRequest, async (tx, requestId) => {
140
+ const doc = await tx.doc(ChildDoc, id);
141
+ if (doc.closed)
142
+ throw new Error(`The child ${id} is closed`);
143
+ await answers.prepare(tx, id, requestId);
144
+ doc.lastRequest = requestId;
145
+ });
146
+ /** `answer` of the turn `attempt`, with the `respond` answer of a schema ask, or `no-respond` without one. */
147
+ const withRespond = async (answer, attempt, schema, context) => {
148
+ if (schema === undefined || answer.status !== "ok")
149
+ return answer;
150
+ const value = await read(place, (tx) => answers.valueIn(tx, id, attempt), context);
151
+ if (value === undefined) {
152
+ return { status: "failed", reason: "no-respond", text: answer.text, entry: answer.entry };
153
+ }
154
+ return { ...answer, value };
155
+ };
156
+ const settled = async (context, request) => {
157
+ const requestId = request ?? (await lastRequest(context));
158
+ if (requestId == null)
159
+ return { status: "failed", reason: "unanswered", cause: "no request" };
160
+ const record = await read(place, (tx) => tx.submissionByRequest(id, requestId), context);
161
+ if (record === undefined)
162
+ return { status: "failed", reason: "unanswered", cause: "no request" };
163
+ const submission = await place.store.submission(record.id, context);
164
+ // A request still queued when the child stops after a failed turn never settles, so idle ends the wait too.
165
+ const wait = withCancel(context);
166
+ try {
167
+ await Promise.race([
168
+ ...(submission === undefined ? [] : [submission.wait(wait.context)]),
169
+ conversation.waitForIdle(wait.context),
170
+ ]);
171
+ }
172
+ finally {
173
+ wait.cancel();
174
+ }
175
+ context.abortSignal?.throwIfAborted();
176
+ return read(place, (tx) => answerIn(tx, id, requestId), context);
177
+ };
178
+ async function ask(input, context, options) {
179
+ const schema = options?.schema;
180
+ let attempt = input.requestId;
181
+ let saved;
182
+ await place.store.commit(async (tx) => {
183
+ let record = await tx.submissionByRequest(id, attempt);
184
+ for (let n = 2; record !== undefined; n++) {
185
+ if (record.status === "done") {
186
+ const answer = await answerIn(tx, id, attempt);
187
+ const value = schema === undefined ? undefined : await answers.valueIn(tx, id, attempt);
188
+ if (answer.status === "ok" && (schema === undefined || value !== undefined)) {
189
+ saved = value === undefined ? answer : { ...answer, value };
190
+ return undefined;
191
+ }
192
+ }
193
+ else if (record.status !== "unanswered")
194
+ break;
195
+ attempt = `${input.requestId}#${n}`;
196
+ record = await tx.submissionByRequest(id, attempt);
197
+ }
198
+ const doc = await tx.doc(ChildDoc, id);
199
+ if (doc.closed)
200
+ throw new Error(`The child ${id} is closed`);
201
+ // A request that is still pending keeps its ask.
202
+ if (record !== undefined)
203
+ return undefined;
204
+ await answers.prepare(tx, id, attempt, schema);
205
+ doc.lastRequest = attempt;
206
+ return undefined;
207
+ }, context);
208
+ if (saved !== undefined)
209
+ return saved;
210
+ await conversation.submit({ ...input, requestId: attempt }, context);
211
+ return withRespond(await settled(context, attempt), attempt, schema, context);
212
+ }
213
+ return {
214
+ id,
215
+ lineage: childLineage,
216
+ conversation,
217
+ send: turns.send,
218
+ ask,
219
+ settled,
220
+ lastRequest: async (context) => (await lastRequest(context)) ?? null,
221
+ async state(context) {
222
+ if ((await place.store.snapshot(ChildDoc, id, context))?.closed === true)
223
+ return "closed";
224
+ return turns.state(context);
225
+ },
226
+ async usage(context) {
227
+ const sum = { models: {}, tools: {} };
228
+ for (const member of await subtree(place, id, context)) {
229
+ const usage = await place.store.snapshot(durable.UsageDoc, member, context);
230
+ if (usage !== undefined)
231
+ addUsageState(sum, usage);
232
+ }
233
+ return sum;
234
+ },
235
+ async close(context, options) {
236
+ const ids = (await subtree(place, id, context)).slice(options?.below === true ? 1 : 0);
237
+ for (const member of ids) {
238
+ await (await place.store.conversation(member, context))?.abort(context, { background: true });
239
+ await place.store.commit(async (tx) => {
240
+ (await tx.doc(ChildDoc, member)).closed = true;
241
+ return undefined;
242
+ }, context);
243
+ }
244
+ return ids;
245
+ },
246
+ };
247
+ }
248
+ const find = async (place, owner, which, context) => {
249
+ const id = "key" in which
250
+ ? (await place.store.snapshot(Keys, which.key, context))?.conversation
251
+ : conversationIdOf(which.id);
252
+ if (id === undefined || id === 0)
253
+ return undefined;
254
+ const doc = await childDoc(place.store, id, context);
255
+ const conversation = await place.store.conversation(id, context);
256
+ if (doc === undefined || conversation === undefined)
257
+ return undefined;
258
+ if (!sameOwner(doc.owner, ownerState(owner)))
259
+ throw new Error(`The child ${id} belongs to another owner`);
260
+ return makeChild(place, id, lineageOf(doc), conversation);
261
+ };
262
+ return {
263
+ extension,
264
+ answerExtension: answers.extension,
265
+ lineage,
266
+ find,
267
+ turn,
268
+ async open(place, owner, key, spec, context, setup) {
269
+ const existing = await find(place, owner, { key }, context);
270
+ if (existing !== undefined)
271
+ return existing;
272
+ const ownerConversation = owner.kind === "task" ? await place.store.conversation(owner.conversation, context) : owner.root;
273
+ const agent = await ownerConversation?.agent(context);
274
+ const { model, thinkingLevel } = resolveModel(place.models, agent, spec);
275
+ const cwd = spec.cwd ?? agent?.cwd;
276
+ let tools;
277
+ if (spec.tools !== undefined) {
278
+ const installed = place.registry.tools();
279
+ tools = spec.tools.map((name) => {
280
+ const found = installed.findLast(({ tool }) => tool.name === name);
281
+ if (found === undefined)
282
+ throw new Error(`Unknown tool: ${name}`);
283
+ return found.tool;
284
+ });
285
+ }
286
+ const parentId = spec.parent === undefined ? undefined : conversationIdOf(spec.parent);
287
+ const parent = spec.parent === undefined ? undefined : await lineage(place.store, spec.parent, context);
288
+ const depth = (parent?.depth ?? 0) + 1;
289
+ const limit = parent?.limit ?? DEFAULT_DEPTH_LIMIT;
290
+ if (depth > limit)
291
+ throw new Error("Nested subagents are disabled");
292
+ const root = parent?.root ?? spec.root ?? spec.parent;
293
+ if (root === undefined)
294
+ throw new Error("A child needs a root or a parent");
295
+ const create = async (tx, ownerTask) => {
296
+ const known = await tx.doc(Keys, key, null);
297
+ if (known.conversation !== 0)
298
+ return undefined;
299
+ const child = await tx.createConversation({ ownership: { kind: "task", taskId: ownerTask } });
300
+ let change = { model, thinkingLevel };
301
+ if (cwd !== undefined)
302
+ change = { ...change, cwd };
303
+ if (tools !== undefined)
304
+ change = { ...change, tools };
305
+ if (spec.instructions !== undefined)
306
+ change = { ...change, instructions: spec.instructions };
307
+ await durable.configure(tx, child.id, change);
308
+ // The child keeps the extensions of the owner's agent and selects the answer extension too.
309
+ const agent = await tx.doc(durable.AgentDoc, child.id);
310
+ const answerName = answers.extension.name;
311
+ const selection = agent.extensions;
312
+ if (Array.isArray(selection)) {
313
+ if (!selection.includes(answerName))
314
+ selection.push(answerName);
315
+ }
316
+ else if (!selection?.add?.includes(answerName)) {
317
+ agent.extensions = { ...selection, add: [...(selection?.add ?? []), answerName] };
318
+ }
319
+ const state = {
320
+ key,
321
+ root,
322
+ parent: spec.parent ?? null,
323
+ depth,
324
+ limit,
325
+ owner: ownerState(owner),
326
+ children: [],
327
+ lastRequest: null,
328
+ closed: false,
329
+ };
330
+ Object.assign(await tx.doc(ChildDoc, child.id), state);
331
+ // A parent that the library opened lists the child, so closing the parent closes it too.
332
+ if (parent !== undefined && parentId !== undefined)
333
+ (await tx.doc(ChildDoc, parentId)).children.push(child.id);
334
+ known.conversation = child.id;
335
+ await setup?.(tx, child.id);
336
+ return undefined;
337
+ };
338
+ if (owner.kind === "detached") {
339
+ await owner.root.commit(async (tx) => {
340
+ if ((await tx.doc(Keys, key, null)).conversation !== 0)
341
+ return undefined;
342
+ const anchor = await tx.createTask(Anchor, null, { ownership: { kind: "conversation" }, background: true });
343
+ return create(tx, anchor);
344
+ }, context);
345
+ }
346
+ else {
347
+ await place.store.commit((tx) => create(tx, owner.taskId), context);
348
+ }
349
+ const opened = await find(place, owner, { key }, context);
350
+ if (opened === undefined)
351
+ throw new Error(`The child of ${key} is missing`);
352
+ return opened;
353
+ },
354
+ };
355
+ }
356
+ /**
357
+ * The model and thinking level of `request`; a field left out comes from `fallback`. An explicit `thinking` that the
358
+ * model does not have is refused. Without it, the level is `fallbackThinking`, else the fallback's, clamped to the
359
+ * nearest level that the model has.
360
+ */
361
+ export function resolveModel(models, fallback, request) {
362
+ const provider = request.provider ?? fallback?.model?.provider;
363
+ const modelId = request.model ?? fallback?.model?.modelId;
364
+ if (provider === undefined || modelId === undefined) {
365
+ throw new Error("No model: neither the spec nor the owner's agent names one");
366
+ }
367
+ const model = models.getModel(provider, modelId);
368
+ if (model === undefined)
369
+ throw new Error(`Unknown model: ${provider}/${modelId}`);
370
+ const levels = getSupportedThinkingLevels(model);
371
+ if (request.thinking !== undefined) {
372
+ const thinkingLevel = levels.find((level) => level === request.thinking);
373
+ if (thinkingLevel === undefined)
374
+ throw new Error(`${provider}/${modelId} has no thinking level ${request.thinking}`);
375
+ return { model: { provider, modelId }, thinkingLevel };
376
+ }
377
+ const inherited = request.fallbackThinking ?? fallback?.thinkingLevel ?? "off";
378
+ const known = MODEL_THINKING_LEVELS.find((level) => level === inherited);
379
+ // `clampThinkingLevel` gives the model's first level for a name that it does not know.
380
+ return {
381
+ model: { provider, modelId },
382
+ thinkingLevel: known === undefined ? (levels[0] ?? "off") : clampThinkingLevel(model, known),
383
+ };
384
+ }
385
+ /** The conversation ID that `value` spells, as callers write it; nothing for another caller, such as a Pi session. */
386
+ function conversationIdOf(value) {
387
+ if (!/^[1-9][0-9]*$/.test(value) || !Number.isSafeInteger(Number(value)))
388
+ return undefined;
389
+ // SAFETY: a conversation ID is a positive safe integer, and the callers look up the conversation of this one.
390
+ return Number(value);
391
+ }
392
+ /** Adds every counter of `state` to `sum`. A counter that one side leaves out counts as 0. */
393
+ function addUsageState(sum, state) {
394
+ for (const bucket of ["models", "tools"]) {
395
+ const totals = sum[bucket];
396
+ for (const [key, usage] of Object.entries(state[bucket])) {
397
+ const total = Object.hasOwn(totals, key) ? totals[key] : undefined;
398
+ if (total === undefined) {
399
+ // Define rather than assign: assigning a tool named `__proto__` would set the prototype.
400
+ Object.defineProperty(totals, key, { value: structuredClone(usage), enumerable: true, writable: true });
401
+ continue;
402
+ }
403
+ total.input += usage.input;
404
+ total.output += usage.output;
405
+ total.cacheRead += usage.cacheRead;
406
+ total.cacheWrite += usage.cacheWrite;
407
+ total.totalTokens += usage.totalTokens;
408
+ if (usage.cacheWrite1h !== undefined)
409
+ total.cacheWrite1h = (total.cacheWrite1h ?? 0) + usage.cacheWrite1h;
410
+ if (usage.reasoning !== undefined)
411
+ total.reasoning = (total.reasoning ?? 0) + usage.reasoning;
412
+ total.cost.input += usage.cost.input;
413
+ total.cost.output += usage.cost.output;
414
+ total.cost.cacheRead += usage.cost.cacheRead;
415
+ total.cost.cacheWrite += usage.cost.cacheWrite;
416
+ total.cost.total += usage.cost.total;
417
+ }
418
+ }
419
+ }
@@ -0,0 +1,18 @@
1
+ import { type ConversationView } from "@earendil-works/pi-durable";
2
+ import { type Component } from "@earendil-works/pi-tui";
3
+ import type { ChildInfo } from "./protocol.ts";
4
+ /** One line, cut to the width, with `hints` at the right end: the first hints go when they do not all fit. */
5
+ export declare class Line implements Component {
6
+ text: string;
7
+ hints: readonly string[];
8
+ style: (text: string) => string;
9
+ render(width: number): string[];
10
+ invalidate(): void;
11
+ }
12
+ /** `harness │ <title> │ <cwd> │ <state>`. */
13
+ export declare function headerText(child: ChildInfo, title: string): string;
14
+ /**
15
+ * `<provider>/<model> · <thinking> │ <tokens> tok $<cost> │ <used>%/<window> ctx`. The context part shows when
16
+ * `contextWindow` is known and the child has answered once.
17
+ */
18
+ export declare function footerText(child: ChildInfo, view: ConversationView, contextWindow: number | undefined): string;