@fradser/pi-agent-teams 0.8.3 → 0.9.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.
- package/.memory/MEMORY.md +1 -1
- package/.memory/follow-up-queue.md +8 -11
- package/.memory/team-centric-design.md +18 -14
- package/LICENSE +21 -0
- package/README.md +119 -157
- package/package.json +2 -2
- package/references/agent-roles.md +57 -12
- package/src/activity.ts +12 -33
- package/src/agent-actions.ts +101 -0
- package/src/agents.ts +1 -6
- package/src/guidance.ts +114 -199
- package/src/index.ts +117 -101
- package/src/leader-reports.ts +92 -0
- package/src/recipient.ts +44 -0
- package/src/spawner.ts +293 -38
- package/src/state.ts +250 -69
- package/src/statefile.ts +74 -31
- package/src/team-machine.ts +984 -424
- package/src/tool-copy.ts +374 -0
- package/src/tool-render.ts +47 -22
- package/src/tools.ts +182 -161
- package/src/types.ts +104 -65
- package/src/ui.ts +32 -56
- package/src/work-context.ts +88 -0
- package/src/worker-tools.ts +4 -0
- package/src/worker.ts +251 -184
- package/src/follow-up-queue.ts +0 -301
package/src/tool-copy.ts
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-facing transcript copy for the coordination tools.
|
|
3
|
+
*
|
|
4
|
+
* The model keeps the exact handles it needs — `session:<name>:<spawn>`,
|
|
5
|
+
* `work:<uuid>`, `direct:<uuid>` — in tool content. A person reading the
|
|
6
|
+
* transcript should instead see the Agent name, the text they passed, the Work
|
|
7
|
+
* subject, and a plain state word. `plainText` is the safety net for free-form
|
|
8
|
+
* strings (errors, harness events) whose identifiers cannot be re-derived.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { contentDetailLines, detailField, displayText, fieldBlock, fieldLine, safeDisplayText, scrubHandles } from "@fradser/pi-kit";
|
|
12
|
+
import { resolveAgent } from "./agents.ts";
|
|
13
|
+
import { runningTeammateActivity } from "./activity.ts";
|
|
14
|
+
import { parseExactSessionRoute } from "./recipient.ts";
|
|
15
|
+
import { getTask, getTeammate } from "./state.ts";
|
|
16
|
+
import { resolveWorkerTools } from "./spawner.ts";
|
|
17
|
+
import type { BoardTask, Teammate } from "./types.ts";
|
|
18
|
+
|
|
19
|
+
/** One rendered lifecycle row: the collapsed line plus its expanded body. */
|
|
20
|
+
export interface CoordinationRow {
|
|
21
|
+
subject: string;
|
|
22
|
+
/** Replace the collapsed subject inline, retaining its semantic line breaks. */
|
|
23
|
+
expandedSubject?: string;
|
|
24
|
+
body: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const COLLAPSED_TASK_LIMIT = 90;
|
|
28
|
+
/** A kickoff prompt is shown in full up to this size, then clipped. */
|
|
29
|
+
const FIELD_LIMIT = 2000;
|
|
30
|
+
|
|
31
|
+
/** Package resolver: a known Work handle becomes its subject. Only prefixed
|
|
32
|
+
* handles are machine identifiers; a bare UUID can be a person's own text
|
|
33
|
+
* (a commit, an id under review) and survives verbatim inside kit. */
|
|
34
|
+
function workHandle(handle: string): string | undefined {
|
|
35
|
+
return getTask(handle)?.subject;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Scalar model text only: a renderer must not stringify an object argument
|
|
39
|
+
* into the transcript, and identifiers become the words a person expects. */
|
|
40
|
+
export function plainText(value: unknown): string {
|
|
41
|
+
return scrubHandles(value, workHandle)
|
|
42
|
+
.replace(/"\s*"/g, "")
|
|
43
|
+
.replace(/[ \t]+([.,;:!?])/g, "$1");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function collapse(value: unknown): string {
|
|
47
|
+
return displayText(value).replace(/\s+/g, " ").trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function oneLine(value: unknown): string {
|
|
51
|
+
return collapse(plainText(value));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function clipLines(value: unknown): string {
|
|
55
|
+
return displayText(value).split("\n").map((line) => plainText(line)).join("\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function shortTask(value: unknown): string {
|
|
59
|
+
const text = displayText(value);
|
|
60
|
+
const firstLine = text.split("\n").map((l) => plainText(l).trim()).find(Boolean) ?? "";
|
|
61
|
+
const line = collapse(firstLine);
|
|
62
|
+
return line.length <= COLLAPSED_TASK_LIMIT ? line : `${line.slice(0, COLLAPSED_TASK_LIMIT - 1).trimEnd()}…`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Array fields also arrive untyped from a model call. */
|
|
66
|
+
function strings(value: unknown): string[] {
|
|
67
|
+
return (Array.isArray(value) ? value : value === undefined || value === null ? [] : [value]).map((entry) => oneLine(entry)).filter(Boolean);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function labeled(label: string, value: unknown): string {
|
|
71
|
+
return fieldLine(label, oneLine(value));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Multi-line field: the label heads the first line, the rest follow verbatim. */
|
|
75
|
+
function labeledBlock(label: string, value: unknown, limit = FIELD_LIMIT): string[] {
|
|
76
|
+
return fieldBlock(label, clipLines(value), limit);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function bodyLines(value: unknown): string[] {
|
|
80
|
+
return contentDetailLines({ content: [{ type: "text", text: clipLines(value) }] })
|
|
81
|
+
.map((line) => dedupeSegments(collapse(line)))
|
|
82
|
+
.filter(Boolean);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Scrubbing a Work id into its subject can repeat what the line already says. */
|
|
86
|
+
function dedupeSegments(line: string): string {
|
|
87
|
+
const parts = line.split(" · ");
|
|
88
|
+
return parts.filter((part, index) => parts.indexOf(part) === index).join(" · ");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const STATUS_WORDS: Record<string, string> = {
|
|
92
|
+
starting: "starting",
|
|
93
|
+
working: "working",
|
|
94
|
+
idle: "idle",
|
|
95
|
+
stopped: "stopped",
|
|
96
|
+
pending: "pending",
|
|
97
|
+
claimed: "claimed",
|
|
98
|
+
completed: "completed",
|
|
99
|
+
superseded: "superseded",
|
|
100
|
+
sent: "sent",
|
|
101
|
+
routed: "delivered",
|
|
102
|
+
queued: "queued",
|
|
103
|
+
"not-sent": "not delivered",
|
|
104
|
+
"fresh-session-pending": "waiting for a fresh session",
|
|
105
|
+
created: "created",
|
|
106
|
+
assigned: "assigned",
|
|
107
|
+
released: "released",
|
|
108
|
+
reopened: "reopened",
|
|
109
|
+
listed: "listed",
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
function stateWord(value: unknown): string {
|
|
113
|
+
const key = oneLine(value);
|
|
114
|
+
return STATUS_WORDS[key] ?? key.replace(/[_-]+/g, " ");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function workOf(teammate: Teammate | undefined, task?: BoardTask): string | undefined {
|
|
118
|
+
const source = task ?? (teammate?.workId ? getTask(teammate.workId) : undefined);
|
|
119
|
+
if (!source) return undefined;
|
|
120
|
+
const owner = source.claimedBy ? ` · @${source.claimedBy}` : "";
|
|
121
|
+
return `${shortTask(source.subject)} · ${stateWord(source.status)}${owner}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── agent ─────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
export interface AgentRowArgs {
|
|
127
|
+
action?: string;
|
|
128
|
+
name?: string;
|
|
129
|
+
prompt?: string;
|
|
130
|
+
session?: string;
|
|
131
|
+
model?: string;
|
|
132
|
+
resources?: string[];
|
|
133
|
+
verify?: string;
|
|
134
|
+
fork?: boolean;
|
|
135
|
+
definition?: {
|
|
136
|
+
description?: string;
|
|
137
|
+
tools?: string[];
|
|
138
|
+
model?: string;
|
|
139
|
+
verify?: string;
|
|
140
|
+
worktree?: boolean;
|
|
141
|
+
persist?: boolean;
|
|
142
|
+
persistScope?: string;
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function agentName(args: AgentRowArgs, details: unknown): string {
|
|
147
|
+
if (args.name) return args.name;
|
|
148
|
+
const route = detailField<{ id?: string }>(details, "session")?.id ?? args.session ?? "";
|
|
149
|
+
return parseExactSessionRoute(route)?.name ?? "agent";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function roleLine(args: AgentRowArgs, name: string): string | undefined {
|
|
153
|
+
const generated = args.definition;
|
|
154
|
+
if (generated?.description) {
|
|
155
|
+
return generated.persist
|
|
156
|
+
? `${oneLine(generated.description)} · saved as ${generated.persistScope ?? "project-local"} role`
|
|
157
|
+
: `${oneLine(generated.description)} · new role for this session`;
|
|
158
|
+
}
|
|
159
|
+
const resolved = resolveAgent(name);
|
|
160
|
+
return resolved ? `${oneLine(resolved.description) || "role"} · ${resolved.scope} role` : undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function agentStateWord(args: AgentRowArgs, teammate: Teammate | undefined, details: unknown, flags: { isError?: boolean; isPartial?: boolean }): string {
|
|
164
|
+
if (flags.isError) return "failed";
|
|
165
|
+
const action = args.action ?? "agent";
|
|
166
|
+
const live = teammate ? stateWord(teammate.status) : undefined;
|
|
167
|
+
if (flags.isPartial) {
|
|
168
|
+
if (action === "stop") return "stopping";
|
|
169
|
+
if (action === "inspect") return live ?? "checking";
|
|
170
|
+
return "starting";
|
|
171
|
+
}
|
|
172
|
+
if (action === "inspect") {
|
|
173
|
+
if (teammate?.status === "working") return "working";
|
|
174
|
+
const sessions = detailField<Array<{ status?: string }>>(details, "sessions");
|
|
175
|
+
const sessionStatus = sessions?.[0]?.status;
|
|
176
|
+
if (sessionStatus) return stateWord(sessionStatus);
|
|
177
|
+
return live ?? "no living session";
|
|
178
|
+
}
|
|
179
|
+
if (action === "stop") return "stopped";
|
|
180
|
+
return "started";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** One `agent` tool row: who acted, what state they reached, and the task text. */
|
|
184
|
+
export function agentRow(
|
|
185
|
+
args: AgentRowArgs,
|
|
186
|
+
details: unknown,
|
|
187
|
+
flags: { isError?: boolean; isPartial?: boolean } = {},
|
|
188
|
+
): CoordinationRow {
|
|
189
|
+
const name = agentName(args, details);
|
|
190
|
+
const teammate = getTeammate(name);
|
|
191
|
+
const action = args.action ?? "agent";
|
|
192
|
+
let task: string | undefined;
|
|
193
|
+
if (action === "delegate") {
|
|
194
|
+
task = args.prompt || (teammate?.workId ? getTask(teammate.workId)?.subject : undefined)
|
|
195
|
+
|| args.definition?.description || resolveAgent(name)?.description;
|
|
196
|
+
} else if (action === "start") {
|
|
197
|
+
task = args.definition?.description || resolveAgent(name)?.description;
|
|
198
|
+
} else if (action === "inspect") {
|
|
199
|
+
const sessions = detailField<Array<{ status?: string; workId?: string }>>(details, "sessions");
|
|
200
|
+
const sessStatus = sessions?.[0]?.status ?? teammate?.status;
|
|
201
|
+
const workId = sessions?.[0]?.workId ?? teammate?.workId;
|
|
202
|
+
if ((sessStatus === "working" || teammate?.status === "working") && workId) {
|
|
203
|
+
task = getTask(workId)?.subject;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const subject = [`@${name}`, agentStateWord(args, teammate, details, flags), task ? shortTask(task) : ""]
|
|
207
|
+
.filter(Boolean)
|
|
208
|
+
.join(" · ");
|
|
209
|
+
return { subject, body: agentBody(args, name, teammate, details, flags) };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function agentBody(
|
|
213
|
+
args: AgentRowArgs,
|
|
214
|
+
name: string,
|
|
215
|
+
teammate: Teammate | undefined,
|
|
216
|
+
details: unknown,
|
|
217
|
+
flags: { isError?: boolean },
|
|
218
|
+
): string[] {
|
|
219
|
+
if (flags.isError) return [];
|
|
220
|
+
const action = args.action ?? "agent";
|
|
221
|
+
const work = workOf(teammate, teammate?.workId ? getTask(teammate.workId) : undefined);
|
|
222
|
+
if (action === "inspect") {
|
|
223
|
+
const sessions = detailField<Array<{ tools?: string[]; id?: string; status?: string; warning?: string; workId?: string }>>(details, "sessions");
|
|
224
|
+
const sess = sessions?.[0];
|
|
225
|
+
const status = sess?.status ?? teammate?.status;
|
|
226
|
+
const isWorking = status === "working" || teammate?.status === "working";
|
|
227
|
+
const sessionTools = sess?.tools ?? teammate?.tools;
|
|
228
|
+
const warning = sess?.warning;
|
|
229
|
+
// The row's own state word already leads the line; repeating it as a body
|
|
230
|
+
// field would duplicate the header, and could contradict it when the live
|
|
231
|
+
// teammate and the inspected snapshot disagree.
|
|
232
|
+
return [
|
|
233
|
+
...(isWorking && teammate ? [labeled("now", shortTask(runningTeammateActivity(teammate)))] : []),
|
|
234
|
+
...(work && isWorking ? [labeled("work", work)] : []),
|
|
235
|
+
...(sessionTools ? [labeled("tools", sessionTools.join(", "))] : []),
|
|
236
|
+
...(warning ? [labeled("warning", warning)] : []),
|
|
237
|
+
];
|
|
238
|
+
}
|
|
239
|
+
if (action === "stop") {
|
|
240
|
+
return bodyLines(detailField<string>(details, "body") ?? teammate?.error ?? "");
|
|
241
|
+
}
|
|
242
|
+
const role = roleLine(args, name);
|
|
243
|
+
const resources = strings(args.resources);
|
|
244
|
+
const granted = teammate?.tools ?? resolveWorkerTools(strings(args.definition?.tools).length > 0 ? strings(args.definition?.tools) : resolveAgent(name)?.tools);
|
|
245
|
+
// The spawn receipt carries the same coordination-only warning as inspection,
|
|
246
|
+
// so delegate and start rows state the narrow grant they actually produced.
|
|
247
|
+
const sessionWarning = detailField<string>(detailField(details, "session"), "warning");
|
|
248
|
+
return [
|
|
249
|
+
...(role ? labeledBlock("role", role) : []),
|
|
250
|
+
...(args.prompt ? labeledBlock("task", args.prompt) : []),
|
|
251
|
+
labeled("model", teammate?.model ?? args.model ?? args.definition?.model ?? "team default"),
|
|
252
|
+
labeled("tools", granted.join(", ")),
|
|
253
|
+
...(sessionWarning ? [labeled("warning", sessionWarning)] : []),
|
|
254
|
+
...(teammate?.isolation === "worktree" ? [labeled("isolation", "dedicated git worktree")] : []),
|
|
255
|
+
...(teammate?.context === "fork" || args.fork ? [labeled("context", "forked from this session")] : []),
|
|
256
|
+
...(resources.length > 0 ? [labeled("resources", resources.join(", "))] : []),
|
|
257
|
+
...(() => {
|
|
258
|
+
const verify = args.verify ?? resolveAgent(name)?.verify;
|
|
259
|
+
return verify ? labeledBlock("verify", verify) : [];
|
|
260
|
+
})(),
|
|
261
|
+
...(!args.prompt && work ? [labeled("work", work)] : []),
|
|
262
|
+
];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── work ──────────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
/** One leader `work` row: the Work subject leads, identifiers never appear. */
|
|
268
|
+
export function leaderWorkRow(
|
|
269
|
+
args: Record<string, unknown>,
|
|
270
|
+
details: unknown,
|
|
271
|
+
flags: { isError?: boolean } = {},
|
|
272
|
+
): CoordinationRow {
|
|
273
|
+
const action = detailField<string>(details, "action") ?? String(args.action ?? "work");
|
|
274
|
+
const works = detailField<Array<{ id: string; subject: string; state: string; claimedBy?: string }>>(details, "works");
|
|
275
|
+
if (action === "list") {
|
|
276
|
+
const items = Array.isArray(works) ? works : [];
|
|
277
|
+
return {
|
|
278
|
+
subject: `${items.length} work item${items.length === 1 ? "" : "s"}`,
|
|
279
|
+
body: items.map((item) => `- ${shortTask(item.subject)} · ${stateWord(item.state)}${item.claimedBy ? ` · @${item.claimedBy}` : ""}`),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
const work = detailField<{ subject?: string; state?: string; resources?: string[] }>(details, "work");
|
|
283
|
+
const referenced = [...strings(args.dependsOn), ...strings(args.supersedes)];
|
|
284
|
+
const requested = typeof args.subject === "string" ? args.subject : undefined;
|
|
285
|
+
const subject = requested ?? work?.subject
|
|
286
|
+
?? (typeof args.id === "string" ? getTask(args.id)?.subject : undefined)
|
|
287
|
+
?? referenced.map((id) => getTask(id)?.subject).find(Boolean)
|
|
288
|
+
?? "Work";
|
|
289
|
+
const outcome = detailField<string>(details, "outcome") ?? action;
|
|
290
|
+
const notified = strings(detailField<unknown>(details, "notifiedTeammates"));
|
|
291
|
+
const assignment = detailField<{ owner?: string }>(details, "assignment");
|
|
292
|
+
const superseded = strings(detailField<unknown>(details, "supersededWorkIds"));
|
|
293
|
+
const workResources = strings(detailField<unknown>(work, "resources"));
|
|
294
|
+
const stillRunning = detailField<string>(details, "holderStillRunning");
|
|
295
|
+
return {
|
|
296
|
+
subject: `${shortTask(subject)} · ${flags.isError ? "failed" : stateWord(outcome)}`,
|
|
297
|
+
body: [
|
|
298
|
+
...(work?.state ? [labeled("state", stateWord(work.state))] : []),
|
|
299
|
+
...(stillRunning ? [labeled("risk", `@${stillRunning} was still working; an in-flight batch may still write inside the released scope`)] : []),
|
|
300
|
+
...(action === "create" || action === "supersede"
|
|
301
|
+
? [labeled("routing", notified.length > 0 ? notified.map((entry) => `@${entry}`).join(", ") : "no living teammate notified")]
|
|
302
|
+
: []),
|
|
303
|
+
...(assignment?.owner ? [labeled("owner", `@${assignment.owner}`)] : []),
|
|
304
|
+
...(superseded.length > 0
|
|
305
|
+
? [labeled("replaces", superseded.map((id) => oneLine(getTask(id)?.subject ?? "Work")).join(", "))]
|
|
306
|
+
: []),
|
|
307
|
+
...(workResources.length > 0 ? [labeled("resources", workResources.join(", "))] : []),
|
|
308
|
+
...(typeof args.reason === "string" ? labeledBlock("reason", args.reason) : []),
|
|
309
|
+
],
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── agent_event ───────────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
/** One `agent_event` row: who receives it and what was actually said. */
|
|
316
|
+
export function messageRow(
|
|
317
|
+
args: { to?: string; message?: string; intent?: string },
|
|
318
|
+
details: unknown,
|
|
319
|
+
options: { isError?: boolean } = {},
|
|
320
|
+
): CoordinationRow {
|
|
321
|
+
const to = oneLine(detailField<string>(details, "to") ?? args.to ?? "leader").replace(/^@+/, "");
|
|
322
|
+
const outcome = detailField<string>(details, "outcome");
|
|
323
|
+
// Messages are literal readbacks, not prose to clean up. Only terminal
|
|
324
|
+
// controls and runtime handles change; the shared renderer owns truncation.
|
|
325
|
+
const message = scrubHandles(safeDisplayText(displayText(args.message)), workHandle);
|
|
326
|
+
const prefix = [`to @${to}`, options.isError ? "failed" : outcome ? stateWord(outcome) : undefined]
|
|
327
|
+
.filter(Boolean).join(" · ");
|
|
328
|
+
return {
|
|
329
|
+
subject: [prefix, collapse(message)].filter(Boolean).join(" · "),
|
|
330
|
+
expandedSubject: [prefix, message].filter(Boolean).join(" · "),
|
|
331
|
+
body: detailField<string>(details, "terminalReport") ? [labeled("note", "terminal report recorded for this Work")] : [],
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ── worker surfaces ───────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
/** One worker `work` row: the task subject leads, never the task id. */
|
|
338
|
+
export function workerWorkRow(
|
|
339
|
+
args: { action?: string; id?: string },
|
|
340
|
+
details: unknown,
|
|
341
|
+
content: string,
|
|
342
|
+
flags: { isError?: boolean } = {},
|
|
343
|
+
): CoordinationRow {
|
|
344
|
+
const action = String(args.action ?? detailField<string>(details, "action") ?? "work");
|
|
345
|
+
const owned = detailField<string>(details, "taskId") ?? detailField<string>(details, "id");
|
|
346
|
+
const subject = detailField<string>(details, "subject") ?? (owned ? getTask(owned)?.subject : undefined);
|
|
347
|
+
const outcome = detailField<string>(details, "outcome") ?? detailField<string>(details, "status");
|
|
348
|
+
const leads = action === "claim" && !args.id
|
|
349
|
+
? "next claimable Work"
|
|
350
|
+
: action === "list" || action === "submit" || action === "release"
|
|
351
|
+
? "current Work"
|
|
352
|
+
: undefined;
|
|
353
|
+
return {
|
|
354
|
+
subject: `${shortTask(subject ?? leads ?? "Work")} · ${flags.isError ? "failed" : workerStateWord(action, outcome)}`,
|
|
355
|
+
body: bodyLines(content).slice(1),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const WORKER_QUEUE_WORDS: Record<string, string> = {
|
|
360
|
+
claim: "claim queued",
|
|
361
|
+
submit: "submission queued",
|
|
362
|
+
release: "released",
|
|
363
|
+
list: "listed",
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
function workerStateWord(action: string, outcome: string | undefined): string {
|
|
367
|
+
if (outcome === "queued") return WORKER_QUEUE_WORDS[action] ?? "queued";
|
|
368
|
+
return stateWord(outcome ?? action);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Render-ready body for a failed call, whose details never reach the renderer. */
|
|
372
|
+
export function failureBody(result: { content: Array<{ type: string; text?: string }> }): string[] {
|
|
373
|
+
return bodyLines(result.content.find((part) => part.type === "text")?.text ?? "");
|
|
374
|
+
}
|
package/src/tool-render.ts
CHANGED
|
@@ -1,48 +1,73 @@
|
|
|
1
1
|
import { keyHint, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
3
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
bindLifecycleRenderers,
|
|
5
|
+
contentDetailLines,
|
|
6
|
+
emptyToolCall as kitEmptyToolCall,
|
|
7
|
+
eventToolLifecycle,
|
|
6
8
|
type ToolLifecycleSpec,
|
|
7
9
|
} from "@fradser/pi-kit";
|
|
10
|
+
import { plainText, failureBody, type CoordinationRow } from "./tool-copy.ts";
|
|
8
11
|
|
|
9
12
|
export interface ToolResultText {
|
|
10
13
|
content: Array<{ type: string; text?: string }>;
|
|
11
14
|
details?: unknown;
|
|
12
15
|
}
|
|
13
16
|
|
|
17
|
+
/** Geometry bound once: every row below shares hint and wrapping. */
|
|
18
|
+
const rows = bindLifecycleRenderers({
|
|
19
|
+
fit: truncateToWidth,
|
|
20
|
+
visibleWidth,
|
|
21
|
+
wrapDetail: (line, width) => wrapTextWithAnsi(line, Math.max(1, width)),
|
|
22
|
+
expandHint: () => keyHint("app.tools.expand", "to expand"),
|
|
23
|
+
});
|
|
24
|
+
|
|
14
25
|
export function textOf(result: ToolResultText): string {
|
|
15
26
|
return result.content.find((part) => part.type === "text")?.text ?? "";
|
|
16
27
|
}
|
|
17
28
|
|
|
18
|
-
export function emptyToolCall():
|
|
19
|
-
return
|
|
29
|
+
export function emptyToolCall(): { render: () => string[]; invalidate: () => void } {
|
|
30
|
+
return kitEmptyToolCall();
|
|
20
31
|
}
|
|
21
32
|
|
|
22
33
|
export function renderLifecycleResult(
|
|
23
34
|
result: ToolResultText,
|
|
24
|
-
options: { expanded?: boolean },
|
|
35
|
+
options: { expanded?: boolean; isPartial?: boolean },
|
|
25
36
|
theme: Pick<Theme, "fg" | "bold" | "bg">,
|
|
26
37
|
context: { isError?: boolean },
|
|
27
38
|
spec: ToolLifecycleSpec,
|
|
28
39
|
details?: readonly string[],
|
|
29
|
-
): { render: (width: number) => string[]; invalidate: () => void }
|
|
40
|
+
): { render: (width: number) => string[]; invalidate: () => void } {
|
|
30
41
|
const text = textOf(result);
|
|
31
|
-
// One expansion-body rule: detail lines derive from the model-facing content
|
|
32
|
-
//
|
|
33
|
-
// differ from what the model sees
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
return
|
|
37
|
-
createSpec: () => ({ ...spec, details: effectiveDetails }),
|
|
38
|
-
expandHint: keyHint("app.tools.expand", "to expand"),
|
|
39
|
-
fit: truncateToWidth,
|
|
40
|
-
visibleWidth,
|
|
41
|
-
wrapDetail: (line, width) => wrapTextWithAnsi(line, Math.max(1, width)),
|
|
42
|
-
renderError: (line, currentTheme) => new Text(currentTheme.fg("error", line), 0, 0),
|
|
43
|
-
})(result, options, theme, context);
|
|
42
|
+
// One expansion-body rule: detail lines derive from the model-facing content,
|
|
43
|
+
// scrubbed of the identifiers a person should not read, unless a caller
|
|
44
|
+
// passes explicit lines for a body that must differ from what the model sees.
|
|
45
|
+
const derived = contentDetailLines({ content: [{ type: "text", text }] });
|
|
46
|
+
const effectiveDetails = details ?? derived.map((line) => plainText(line)).filter((line) => line.trim());
|
|
47
|
+
return rows.result(() => ({ ...spec, details: effectiveDetails }))(result, options, theme, context);
|
|
44
48
|
}
|
|
45
49
|
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Render one coordination row (`agent`, `work`, `agent_event`) from its
|
|
52
|
+
* human-facing subject and body. Model content keeps the exact handles; the
|
|
53
|
+
* transcript only ever shows names, subjects, and the text that was passed.
|
|
54
|
+
* A failed call goes through the shared failure band, which renders the result
|
|
55
|
+
* text verbatim, so the display copy is scrubbed here.
|
|
56
|
+
*/
|
|
57
|
+
export function renderCoordinationRow(
|
|
58
|
+
result: ToolResultText,
|
|
59
|
+
options: { expanded?: boolean; isPartial?: boolean },
|
|
60
|
+
theme: Pick<Theme, "fg" | "bold" | "bg">,
|
|
61
|
+
context: { args?: unknown; isError?: boolean },
|
|
62
|
+
tool: string,
|
|
63
|
+
row: CoordinationRow,
|
|
64
|
+
): { render: (width: number) => string[]; invalidate: () => void } {
|
|
65
|
+
if (!context.isError) {
|
|
66
|
+
return renderLifecycleResult(result, options, theme, context, eventToolLifecycle(tool, row.subject, {
|
|
67
|
+
expandedSubject: row.expandedSubject,
|
|
68
|
+
}), row.body);
|
|
69
|
+
}
|
|
70
|
+
const failure = failureBody(result);
|
|
71
|
+
const display: ToolResultText = { ...result, content: [{ type: "text", text: failure.join(" ") }] };
|
|
72
|
+
return renderLifecycleResult(display, options, theme, context, eventToolLifecycle(tool, row.subject), []);
|
|
48
73
|
}
|