@vzlentin/harness 0.2.3 → 0.3.1

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.
@@ -0,0 +1,67 @@
1
+ import * as durable from "@earendil-works/pi-durable";
2
+ import { type Conversation, type ConversationId, type Harness } from "@earendil-works/pi-durable";
3
+ import { type Child, type Input, type Owner, type Place } from "./children.ts";
4
+ import { type ChildInfo, type Target } from "./protocol.ts";
5
+ /** What the targets use of the host. */
6
+ export type TargetHost = {
7
+ readonly harness: Harness;
8
+ /** The host's store, models and tools, for the children library. */
9
+ readonly place: Place;
10
+ readonly owner: Owner;
11
+ readonly project: string;
12
+ /** The project agent: the root conversation, with a model and the project directory from its first use. */
13
+ readonly agent: () => Promise<Conversation>;
14
+ };
15
+ export type ChildRecord = {
16
+ name?: string;
17
+ /** `PI_SESSION_ID` of the spawner: a Pi session, the conversation ID of another child, or `cli`. */
18
+ caller: string;
19
+ createdAt: string;
20
+ conversationId: ConversationId;
21
+ /** The request IDs of the spawn and of each `send` of the caller, in order. */
22
+ sent?: string[];
23
+ /** How many requests of `sent` a `wait` gave the answer of. */
24
+ read?: number;
25
+ };
26
+ export type ChildrenState = {
27
+ children: Record<string, ChildRecord>;
28
+ requests: Record<string, string>;
29
+ };
30
+ /**
31
+ * The children by handle, and the handle of each spawn request ID. Version 2 leaves each child's last request to the
32
+ * children library; a store with version 1 has children that the library does not know.
33
+ */
34
+ export declare const Children: durable.SessionDocToken<ChildrenState>;
35
+ /** The children library, on this process's pi-durable. */
36
+ export declare const library: import("./children.ts").Children;
37
+ /**
38
+ * The conversation of `ref`: a child or the project agent by handle, or a conversation of the children library by ID,
39
+ * with its info, its input, and the record of a child.
40
+ */
41
+ export declare function target(handler: TargetHost, ref: Target): Promise<{
42
+ conversation: Conversation;
43
+ info: () => Promise<ChildInfo>;
44
+ send: (input: Input) => Promise<void>;
45
+ record: undefined;
46
+ } | {
47
+ conversation: Conversation;
48
+ info: () => Promise<ChildInfo>;
49
+ send: (input: Input) => Promise<void>;
50
+ record: ChildRecord;
51
+ }>;
52
+ export type Found = Awaited<ReturnType<typeof target>>;
53
+ /** The target of the child `handle`, whose record is `record`. */
54
+ export declare function childTarget(handler: TargetHost, handle: string, record: ChildRecord): Promise<{
55
+ conversation: Conversation;
56
+ info: () => Promise<ChildInfo>;
57
+ send: (input: Input) => Promise<void>;
58
+ record: ChildRecord;
59
+ }>;
60
+ export declare function childRecord(harness: Harness, handle: string): Promise<ChildRecord>;
61
+ /** The record of `handle`, which must belong to `caller`. */
62
+ export declare function ownRecord(harness: Harness, handle: string, caller: string): Promise<ChildRecord>;
63
+ export declare function childOf(handler: Pick<TargetHost, "place" | "owner">, record: ChildRecord): Promise<Child>;
64
+ /** The info of `handle`. A caller that already has the record gives it as `record`. */
65
+ export declare function childInfo(handler: Pick<TargetHost, "harness" | "place" | "owner">, handle: string, record?: ChildRecord): Promise<ChildInfo>;
66
+ /** The record of `handle` in the `Children` value `state`. */
67
+ export declare function recordOf(state: Readonly<ChildrenState> | null | undefined, handle: string): ChildRecord | undefined;
@@ -0,0 +1,140 @@
1
+ /**
2
+ * The targets of the host's requests: the children by handle, the project agent, and the conversations of the children
3
+ * library by ID, with the `Children` records of the host.
4
+ */
5
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
6
+ import * as durable from "@earendil-works/pi-durable";
7
+ import { defineDoc } from "@earendil-works/pi-durable";
8
+ import { createChildren } from "./children.js";
9
+ import { AGENT_HANDLE } from "./protocol.js";
10
+ const context = BACKGROUND_CONTEXT;
11
+ /**
12
+ * The children by handle, and the handle of each spawn request ID. Version 2 leaves each child's last request to the
13
+ * children library; a store with version 1 has children that the library does not know.
14
+ */
15
+ export const Children = defineDoc({
16
+ kind: "subagent.children",
17
+ version: 2,
18
+ scope: "session",
19
+ initial: () => ({ children: {}, requests: {} }),
20
+ });
21
+ /** The request ID of the project agent's newest input, which its `error` state comes from. */
22
+ const AgentState = defineDoc({
23
+ kind: "harness.agent",
24
+ version: 1,
25
+ scope: "conversation",
26
+ history: "latest",
27
+ fork: "initial",
28
+ initial: () => ({ lastRequest: null }),
29
+ });
30
+ /** The children library, on this process's pi-durable. */
31
+ export const library = createChildren(durable);
32
+ /**
33
+ * The conversation of `ref`: a child or the project agent by handle, or a conversation of the children library by ID,
34
+ * with its info, its input, and the record of a child.
35
+ */
36
+ export async function target(handler, ref) {
37
+ if (ref.conversation !== undefined)
38
+ return conversationTarget(handler, ref.conversation);
39
+ const { handle } = ref;
40
+ if (handle !== AGENT_HANDLE)
41
+ return childTarget(handler, handle, await childRecord(handler.harness, handle));
42
+ const root = await handler.agent();
43
+ const { harness, place, project } = handler;
44
+ const turn = library.turn(place, root.id, root, async (readContext) => (await harness.snapshot(AgentState, root.id, readContext))?.lastRequest, async (tx, requestId) => {
45
+ (await tx.doc(AgentState, root.id)).lastRequest = requestId;
46
+ });
47
+ return {
48
+ conversation: root,
49
+ info: async () => ({
50
+ ...(await agentFields(root)),
51
+ handle,
52
+ name: `${project} agent`,
53
+ state: await turn.state(context),
54
+ }),
55
+ send: (input) => turn.send(input, context),
56
+ record: undefined,
57
+ };
58
+ }
59
+ /**
60
+ * The target of the library conversation `id`, of any owner, such as a child of a task. It has no record, and it takes
61
+ * input only while it works.
62
+ */
63
+ async function conversationTarget(handler, id) {
64
+ const child = await library.inspect(handler.place, id, context);
65
+ if (child === undefined)
66
+ throw new Error(`Unknown conversation: ${id}`);
67
+ const { conversation, lineage } = child;
68
+ const info = async () => {
69
+ const childState = await child.state(context);
70
+ const state = childState === "closed" ? "idle" : childState;
71
+ return { handle: id, conversation: id, name: lineage.key, state, ...(await agentFields(conversation)) };
72
+ };
73
+ return {
74
+ conversation,
75
+ info,
76
+ send: async (input) => {
77
+ if ((await child.state(context)) !== "busy")
78
+ throw new Error(`${lineage.key} takes input only while it works`);
79
+ // Small race: a turn that ends between the check and the submit starts a new turn; needs busy-only admission in
80
+ // pi-durable.
81
+ await conversation.submit({ ...input, whenBusy: "steer" }, context);
82
+ },
83
+ record: undefined,
84
+ };
85
+ }
86
+ /** The target of the child `handle`, whose record is `record`. */
87
+ export async function childTarget(handler, handle, record) {
88
+ const child = await childOf(handler, record);
89
+ return {
90
+ conversation: child.conversation,
91
+ info: () => childInfo(handler, handle, record),
92
+ send: (input) => child.send(input, context),
93
+ record,
94
+ };
95
+ }
96
+ export async function childRecord(harness, handle) {
97
+ const state = await harness.snapshot(Children, context);
98
+ const record = recordOf(state, handle);
99
+ if (record === undefined)
100
+ throw new Error(`Unknown subagent: ${handle}`);
101
+ return record;
102
+ }
103
+ /** The record of `handle`, which must belong to `caller`. */
104
+ export async function ownRecord(harness, handle, caller) {
105
+ const record = await childRecord(harness, handle);
106
+ if (record.caller !== caller)
107
+ throw new Error(`Subagent ${handle} belongs to another session`);
108
+ return record;
109
+ }
110
+ export async function childOf(handler, record) {
111
+ const child = await library.find(handler.place, handler.owner, { id: String(record.conversationId) }, context);
112
+ if (child === undefined)
113
+ throw new Error(`Missing conversation ${record.conversationId}`);
114
+ return child;
115
+ }
116
+ /** The info of `handle`. A caller that already has the record gives it as `record`. */
117
+ export async function childInfo(handler, handle, record) {
118
+ record ??= await childRecord(handler.harness, handle);
119
+ const child = await childOf(handler, record);
120
+ const childState = await child.state(context);
121
+ // A closed child is idle: the watch can still send it before its record goes and the `stopped` line follows.
122
+ const state = childState === "closed" ? "idle" : childState;
123
+ const fields = await agentFields(child.conversation);
124
+ return record.name === undefined ? { handle, state, ...fields } : { handle, name: record.name, state, ...fields };
125
+ }
126
+ /** The model, thinking level and directory of the agent of `conversation`, for its info. */
127
+ async function agentFields(conversation) {
128
+ const agent = await conversation.agent(context);
129
+ return {
130
+ provider: agent.model?.provider ?? "",
131
+ model: agent.model?.modelId ?? "",
132
+ thinking: agent.thinkingLevel,
133
+ // `spawnChild` refuses a spawn without `cwd`; the fallback only satisfies pi-durable's optional type.
134
+ cwd: agent.cwd ?? "",
135
+ };
136
+ }
137
+ /** The record of `handle` in the `Children` value `state`. */
138
+ export function recordOf(state, handle) {
139
+ return state != null && Object.hasOwn(state.children, handle) ? state.children[handle] : undefined;
140
+ }
package/dist/tool.js CHANGED
@@ -30,7 +30,7 @@ export function toolSchemas(Type) {
30
30
  model: text("spawn: the model; default yours"),
31
31
  thinking: text("spawn: off, minimal, low, medium, high, xhigh or max; default yours"),
32
32
  cwd: text("spawn: the child's working directory; default yours"),
33
- tools: texts("spawn: the only tools the child gets; default read, grep, find, ls, write, edit, bash and the package tools"),
33
+ tools: texts("spawn: the only tools the child gets; default read, grep, find, ls, write, edit, bash, codemode and the package tools"),
34
34
  prompts: texts("spawn: the task, in parts"),
35
35
  files: texts("spawn: files whose text goes before the task"),
36
36
  handle: text("send, wait, status, rename, stop: the hex ID that spawn prints in parentheses, not the name"),
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The change of a watched conversation view that a watch line carries, and its two ends: the host makes it from the
3
+ * chord ops of a commit, and a client applies it, so the client needs no chord. It imports only types, so the Pi
4
+ * extension can load it.
5
+ *
6
+ * The entries of a change are the entries from one index to the end. This is correct because no op moves the entries
7
+ * before the index where it starts: a splice starts at its index, a change in an entry at that entry, and a reorder or
8
+ * a new array at the first entry.
9
+ */
10
+ import type { Op } from "@earendil-works/chord/delta";
11
+ import type { ConversationRecord, ConversationView, EntryRecord, JsonObject } from "@earendil-works/pi-durable";
12
+ /**
13
+ * The parts of a watched view that changed, as whole values: the entries from index `from` on, the documents that
14
+ * changed by kind (`null` for a removed document), and the conversation record. A part that did not change is absent.
15
+ */
16
+ export type ViewParts = {
17
+ readonly entries?: {
18
+ readonly from: number;
19
+ readonly items: readonly EntryRecord[];
20
+ };
21
+ readonly docs?: Readonly<Record<string, JsonObject | null>>;
22
+ readonly conversation?: ConversationRecord;
23
+ };
24
+ /**
25
+ * What one commit changed in a watched view, so a client keeps the view without chord: some parts, or the whole view,
26
+ * as after a watch that fell behind.
27
+ */
28
+ export type ViewChange = ViewParts | {
29
+ readonly view: ConversationView;
30
+ };
31
+ /**
32
+ * What `ops` changed in a watched view, as whole values of `view`, the view after them: the entries from the first one
33
+ * that an op reached, each document that an op reached, and the conversation record. An op on the root, or on a part
34
+ * that this does not know, gives the whole view.
35
+ */
36
+ export declare function viewChange(view: ConversationView, ops: readonly Op[]): ViewChange;
37
+ /** `view` after `change`. A client of a watch keeps its view up to date with it. */
38
+ export declare function applyChange(view: ConversationView, change: ViewChange): ConversationView;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * What `ops` changed in a watched view, as whole values of `view`, the view after them: the entries from the first one
3
+ * that an op reached, each document that an op reached, and the conversation record. An op on the root, or on a part
4
+ * that this does not know, gives the whole view.
5
+ */
6
+ export function viewChange(view, ops) {
7
+ let from;
8
+ const kinds = new Set();
9
+ let conversation = false;
10
+ for (const op of ops) {
11
+ if (op[0] === "r")
12
+ return { view };
13
+ const [part, next] = op[1];
14
+ if (part === "entries") {
15
+ const index = next !== undefined ? Number(next) : op[0] === "p" ? op[2] : 0;
16
+ from = Math.min(from ?? index, index);
17
+ }
18
+ else if (part === "docs" && next !== undefined) {
19
+ kinds.add(String(next));
20
+ }
21
+ else if (part === "conversation") {
22
+ conversation = true;
23
+ }
24
+ else {
25
+ return { view };
26
+ }
27
+ }
28
+ const parts = {};
29
+ if (from !== undefined)
30
+ parts.entries = { from, items: view.entries.slice(from) };
31
+ if (kinds.size > 0) {
32
+ parts.docs = Object.fromEntries([...kinds].map((kind) => [kind, view.docs[kind] ?? null]));
33
+ }
34
+ if (conversation)
35
+ parts.conversation = view.conversation;
36
+ return parts;
37
+ }
38
+ /** `view` after `change`. A client of a watch keeps its view up to date with it. */
39
+ export function applyChange(view, change) {
40
+ if ("view" in change)
41
+ return change.view;
42
+ const { entries, docs = {}, conversation } = change;
43
+ const changedDocs = { ...view.docs };
44
+ for (const [kind, doc] of Object.entries(docs)) {
45
+ if (doc === null)
46
+ delete changedDocs[kind];
47
+ else
48
+ changedDocs[kind] = doc;
49
+ }
50
+ return {
51
+ conversation: conversation ?? view.conversation,
52
+ entries: entries === undefined ? view.entries : [...view.entries.slice(0, entries.from), ...entries.items],
53
+ docs: changedDocs,
54
+ };
55
+ }
@@ -1,19 +1,35 @@
1
1
  /**
2
- * What the viewer reads from the documents of a durable conversation view: its live state, its inbox and the texts of
3
- * its user messages.
2
+ * What the Pi extension reads from a durable conversation view: its live state, its inbox, its usage and
3
+ * the texts of its user messages. It imports only types, so the Pi extension can load it.
4
4
  */
5
5
  import type { JsonRepresentation } from "@earendil-works/chord";
6
- import type { UserMessage } from "@earendil-works/pi-ai";
7
- import { type ConversationDocToken, type ConversationView, type InboxState, type JsonObject, type LiveState } from "@earendil-works/pi-durable";
8
- /** The built-in document of `token` in `view`, if the conversation has it. */
9
- export declare function docOf<T extends JsonObject>(view: ConversationView, token: ConversationDocToken<T>): T | undefined;
6
+ import type { Usage, UserMessage } from "@earendil-works/pi-ai";
7
+ import type { ConversationView, InboxState, LiveState, UsageState } from "@earendil-works/pi-durable";
10
8
  export declare function liveOf(view: ConversationView): LiveState;
11
9
  export declare function inboxOf(view: ConversationView): InboxState;
10
+ export declare function usageOf(view: ConversationView): UsageState | undefined;
11
+ /** The tokens and the cost of a conversation. */
12
+ export type UsageTotals = {
13
+ input: number;
14
+ output: number;
15
+ cacheRead: number;
16
+ cacheWrite: number;
17
+ cost: number;
18
+ };
19
+ /** The sums of the usage of the model requests and of the tool results of the conversation, as Pi's footer counts. */
20
+ export declare function usageTotals(view: ConversationView): UsageTotals;
21
+ /**
22
+ * The usage of the last answer of the active transcript, which tells how full the context is. An aborted answer and an
23
+ * answer that failed do not count.
24
+ */
25
+ export declare function lastAnswerUsage(view: ConversationView): Usage | undefined;
12
26
  export declare function userText(content: JsonRepresentation<UserMessage["content"]>): string;
13
27
  /** The queued steers and follow-ups, in inbox order. */
14
28
  export declare function queued(inbox: InboxState): {
15
29
  mode: "steer" | "followUp";
16
30
  text: string;
17
31
  }[];
32
+ /** The texts of the queued messages that an abort withdraws, steers first, as they go back to the editor. */
33
+ export declare function withdrawnTexts(inbox: InboxState): string[];
18
34
  /** What the conversation does now, as Pi's working indicator says it; empty when it does nothing. */
19
35
  export declare function statusText(live: LiveState): string;
@@ -1,14 +1,44 @@
1
- import { InboxDoc, LiveDoc, } from "@earendil-works/pi-durable";
2
- /** The built-in document of `token` in `view`, if the conversation has it. */
3
- export function docOf(view, token) {
4
- // SAFETY: pi-durable keeps each built-in conversation document in `docs` under the kind of its token.
5
- return view.docs[token.definition.kind];
1
+ /** The built-in document of the kind `kind` in `view`, if the conversation has it. */
2
+ function docOf(view, kind) {
3
+ // SAFETY: pi-durable keeps each built-in conversation document in `docs` under its kind: `pi.live` holds a
4
+ // `LiveState`, `pi.inbox` an `InboxState` and `pi.usage` a `UsageState`. Each caller passes the kind of its type.
5
+ return view.docs[kind];
6
6
  }
7
7
  export function liveOf(view) {
8
- return docOf(view, LiveDoc) ?? {};
8
+ return docOf(view, "pi.live") ?? {};
9
9
  }
10
10
  export function inboxOf(view) {
11
- return docOf(view, InboxDoc) ?? { items: [] };
11
+ return docOf(view, "pi.inbox") ?? { items: [] };
12
+ }
13
+ export function usageOf(view) {
14
+ return docOf(view, "pi.usage");
15
+ }
16
+ /** The sums of the usage of the model requests and of the tool results of the conversation, as Pi's footer counts. */
17
+ export function usageTotals(view) {
18
+ const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
19
+ const usage = usageOf(view);
20
+ for (const item of [...Object.values(usage?.models ?? {}), ...Object.values(usage?.tools ?? {})]) {
21
+ totals.input += item.input;
22
+ totals.output += item.output;
23
+ totals.cacheRead += item.cacheRead;
24
+ totals.cacheWrite += item.cacheWrite;
25
+ totals.cost += item.cost.total;
26
+ }
27
+ return totals;
28
+ }
29
+ /**
30
+ * The usage of the last answer of the active transcript, which tells how full the context is. An aborted answer and an
31
+ * answer that failed do not count.
32
+ */
33
+ export function lastAnswerUsage(view) {
34
+ for (const entry of view.entries.toReversed()) {
35
+ const message = entry.model?.[0];
36
+ if (entry.kind !== "pi.assistant" || message?.role !== "assistant")
37
+ continue;
38
+ if (message.stopReason !== "aborted" && message.stopReason !== "error")
39
+ return message.usage;
40
+ }
41
+ return undefined;
12
42
  }
13
43
  export function userText(content) {
14
44
  if (!Array.isArray(content))
@@ -22,6 +52,14 @@ export function userText(content) {
22
52
  export function queued(inbox) {
23
53
  return inbox.items.flatMap((item) => item.mode === "write" ? [] : [{ mode: item.mode, text: userText(item.content) }]);
24
54
  }
55
+ /** The texts of the queued messages that an abort withdraws, steers first, as they go back to the editor. */
56
+ export function withdrawnTexts(inbox) {
57
+ const inputs = queued(inbox);
58
+ return [
59
+ ...inputs.filter((input) => input.mode === "steer"),
60
+ ...inputs.filter((input) => input.mode === "followUp"),
61
+ ].map((input) => input.text);
62
+ }
25
63
  /** What the conversation does now, as Pi's working indicator says it; empty when it does nothing. */
26
64
  export function statusText(live) {
27
65
  const generation = live.generation;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzlentin/harness",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
4
4
  "description": "A durable agent harness on Pi, starting with observable, persistent subagents",
5
5
  "repository": {
6
6
  "type": "git",
@@ -35,6 +35,7 @@
35
35
  "dependencies": {
36
36
  "@earendil-works/chord": "1.3.0",
37
37
  "@earendil-works/pi-ai": "1.3.0",
38
+ "@earendil-works/pi-codemode": "1.3.0",
38
39
  "@earendil-works/pi-coding-agent": "1.3.0",
39
40
  "@earendil-works/pi-durable": "1.3.0",
40
41
  "@earendil-works/pi-tui": "1.3.0",