@zachwill/pi-orchestrate 0.1.0 → 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.
- package/README.md +8 -3
- package/extension/catalog.ts +176 -157
- package/extension/contract.ts +3 -1
- package/extension/delivery.ts +137 -89
- package/extension/domain.ts +7 -1
- package/extension/host.ts +41 -5
- package/extension/presentation.ts +331 -454
- package/extension/runtime.ts +338 -88
- package/extension/scheduler.ts +44 -25
- package/extension/tools.ts +337 -20
- package/extension/worker-session.ts +488 -290
- package/package.json +1 -1
package/extension/scheduler.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Cause,
|
|
1
|
+
import { Cause, Context, Effect, FiberMap, Layer, ManagedRuntime } from "effect";
|
|
2
2
|
|
|
3
3
|
export type WorkflowDefectHandler = (error: unknown) => void;
|
|
4
4
|
|
|
@@ -6,7 +6,7 @@ export interface WorkflowScheduler<Key> {
|
|
|
6
6
|
/** Starts a workflow immediately, interrupting and replacing the previous workflow at the key. */
|
|
7
7
|
start(
|
|
8
8
|
key: Key,
|
|
9
|
-
workflow:
|
|
9
|
+
workflow: Effect.Effect<void, never>,
|
|
10
10
|
onDefect: WorkflowDefectHandler,
|
|
11
11
|
): void;
|
|
12
12
|
/** Interrupts the current workflow at the key and waits for its fiber to settle. */
|
|
@@ -15,48 +15,67 @@ export interface WorkflowScheduler<Key> {
|
|
|
15
15
|
close(): Promise<void>;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
interface WorkflowSupervisorService {
|
|
19
|
+
readonly start: (
|
|
20
|
+
key: unknown,
|
|
21
|
+
workflow: Effect.Effect<void, never>,
|
|
22
|
+
) => void;
|
|
23
|
+
readonly remove: (key: unknown) => Effect.Effect<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class WorkflowSupervisor extends Context.Service<
|
|
27
|
+
WorkflowSupervisor,
|
|
28
|
+
WorkflowSupervisorService
|
|
29
|
+
>()("@zachwill/pi-orchestrate/WorkflowSupervisor") {}
|
|
30
|
+
|
|
31
|
+
const workflowSupervisorLayer = Layer.effect(
|
|
32
|
+
WorkflowSupervisor,
|
|
33
|
+
Effect.gen(function* () {
|
|
34
|
+
const fibers = yield* FiberMap.make<unknown, void, never>();
|
|
35
|
+
const run = yield* FiberMap.runtime(fibers)<never>();
|
|
36
|
+
return WorkflowSupervisor.of({
|
|
37
|
+
start(key, workflow) {
|
|
38
|
+
run(key, workflow);
|
|
39
|
+
},
|
|
40
|
+
remove: (key) => FiberMap.remove(fibers, key),
|
|
41
|
+
});
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
|
|
18
45
|
class EffectWorkflowScheduler<Key> implements WorkflowScheduler<Key> {
|
|
19
|
-
private readonly
|
|
20
|
-
private readonly
|
|
46
|
+
private readonly managedRuntime = ManagedRuntime.make(workflowSupervisorLayer);
|
|
47
|
+
private readonly supervisor = this.managedRuntime.runSync(WorkflowSupervisor);
|
|
21
48
|
private closePromise: Promise<void> | undefined;
|
|
22
49
|
|
|
23
|
-
constructor() {
|
|
24
|
-
this.fibers = Effect.runSync(
|
|
25
|
-
Scope.provide(this.scope)(FiberMap.make<Key, void, never>()),
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
50
|
start(
|
|
30
51
|
key: Key,
|
|
31
|
-
workflow:
|
|
52
|
+
workflow: Effect.Effect<void, never>,
|
|
32
53
|
onDefect: WorkflowDefectHandler,
|
|
33
54
|
): void {
|
|
34
|
-
const supervised =
|
|
55
|
+
const supervised = workflow.pipe(
|
|
35
56
|
Effect.catchCause((cause) => {
|
|
36
57
|
if (!Cause.hasInterruptsOnly(cause)) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
58
|
+
return Effect.sync(() => {
|
|
59
|
+
try {
|
|
60
|
+
onDefect(Cause.squash(cause));
|
|
61
|
+
} catch {
|
|
62
|
+
// Defect reporting must not become another unsupervised defect.
|
|
63
|
+
}
|
|
64
|
+
});
|
|
42
65
|
}
|
|
43
66
|
return Effect.void;
|
|
44
67
|
}),
|
|
45
68
|
);
|
|
46
69
|
|
|
47
|
-
|
|
48
|
-
FiberMap.run(this.fibers, key, supervised, { startImmediately: true }),
|
|
49
|
-
);
|
|
70
|
+
this.supervisor.start(key, supervised);
|
|
50
71
|
}
|
|
51
72
|
|
|
52
|
-
|
|
53
|
-
|
|
73
|
+
remove(key: Key): Promise<void> {
|
|
74
|
+
return this.managedRuntime.runPromise(this.supervisor.remove(key));
|
|
54
75
|
}
|
|
55
76
|
|
|
56
77
|
close(): Promise<void> {
|
|
57
|
-
|
|
58
|
-
this.closePromise = Effect.runPromise(Scope.close(this.scope, Exit.void));
|
|
59
|
-
}
|
|
78
|
+
this.closePromise ??= this.managedRuntime.dispose();
|
|
60
79
|
return this.closePromise;
|
|
61
80
|
}
|
|
62
81
|
}
|
package/extension/tools.ts
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
|
+
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
1
2
|
import type {
|
|
2
3
|
ExtensionAPI,
|
|
3
4
|
ExtensionContext,
|
|
5
|
+
Theme,
|
|
4
6
|
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import {
|
|
8
|
+
Container,
|
|
9
|
+
Markdown,
|
|
10
|
+
Spacer,
|
|
11
|
+
Text,
|
|
12
|
+
truncateToWidth,
|
|
13
|
+
wrapTextWithAnsi,
|
|
14
|
+
type Component,
|
|
15
|
+
} from "@earendil-works/pi-tui";
|
|
5
16
|
import {
|
|
6
17
|
formatSize,
|
|
7
18
|
getAgentDir,
|
|
19
|
+
getMarkdownTheme,
|
|
20
|
+
keyHint,
|
|
8
21
|
truncateHead,
|
|
9
22
|
} from "@earendil-works/pi-coding-agent";
|
|
10
23
|
import { Type } from "typebox";
|
|
@@ -32,6 +45,7 @@ import type {
|
|
|
32
45
|
|
|
33
46
|
const STRICT_OBJECT = { additionalProperties: false } as const;
|
|
34
47
|
const MAX_TASKS_PER_WAVE = 12;
|
|
48
|
+
const MAX_INSTRUCTION_PREVIEW_LINES = 2;
|
|
35
49
|
|
|
36
50
|
const taskSchema = Type.Object(
|
|
37
51
|
{
|
|
@@ -110,15 +124,30 @@ export function registerOrchestrationTools(
|
|
|
110
124
|
"Use orchestrate for one independent worker wave, with a complete brief for every task.",
|
|
111
125
|
],
|
|
112
126
|
parameters: orchestrateSchema,
|
|
113
|
-
|
|
127
|
+
renderCall(args, theme, { expanded }) {
|
|
128
|
+
return renderDispatchCall(theme, args.tasks, expanded);
|
|
129
|
+
},
|
|
130
|
+
renderResult(result, { isPartial, expanded }, theme, context) {
|
|
131
|
+
return renderOrchestrationResult(result, isPartial, expanded, theme, context.lastComponent);
|
|
132
|
+
},
|
|
133
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
114
134
|
const mode = deps.getDispatchMode(toolCallId);
|
|
115
135
|
const runtimeContext = await buildRuntimeContext(ctx, deps);
|
|
136
|
+
const settlements: unknown[] = [];
|
|
137
|
+
const onSettlement = mode === "inline" ? (settlement: unknown) => {
|
|
138
|
+
settlements.push(settlement);
|
|
139
|
+
onUpdate?.({
|
|
140
|
+
content: [{ type: "text", text: `${settlements.length} worker response(s) received.` }],
|
|
141
|
+
details: { mode: "inline", settlements: [...settlements] },
|
|
142
|
+
});
|
|
143
|
+
} : undefined;
|
|
116
144
|
const wave = await orchestrateWithMode(
|
|
117
145
|
deps.runtime,
|
|
118
146
|
runtimeContext,
|
|
119
147
|
params.tasks,
|
|
120
148
|
mode,
|
|
121
149
|
signal,
|
|
150
|
+
onSettlement,
|
|
122
151
|
);
|
|
123
152
|
|
|
124
153
|
if (mode === "async") {
|
|
@@ -163,6 +192,12 @@ export function registerOrchestrationTools(
|
|
|
163
192
|
"Use orchestration_status only for diagnostics or recovery; never poll it for completion.",
|
|
164
193
|
],
|
|
165
194
|
parameters: statusSchema,
|
|
195
|
+
renderCall(_args, theme) {
|
|
196
|
+
return new Text(theme.fg("toolTitle", theme.bold("orchestration_status")), 0, 0);
|
|
197
|
+
},
|
|
198
|
+
renderResult(result, { isPartial }, theme) {
|
|
199
|
+
return renderDiagnosticsResult(result, isPartial, theme);
|
|
200
|
+
},
|
|
166
201
|
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
167
202
|
const ownerSessionId = requireNonblank(
|
|
168
203
|
"owner session ID",
|
|
@@ -195,10 +230,24 @@ export function registerOrchestrationTools(
|
|
|
195
230
|
"Use worker_send only for follow-up work on an owned ready reusable worker.",
|
|
196
231
|
],
|
|
197
232
|
parameters: workerSendSchema,
|
|
198
|
-
|
|
233
|
+
renderCall(args, theme, { expanded }) {
|
|
234
|
+
return renderWorkerMessageCall(theme, "worker_send", args.worker_id, args.instructions, expanded);
|
|
235
|
+
},
|
|
236
|
+
renderResult(result, { isPartial, expanded }, theme, context) {
|
|
237
|
+
return renderOrchestrationResult(result, isPartial, expanded, theme, context.lastComponent);
|
|
238
|
+
},
|
|
239
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
199
240
|
const workerId = asWorkerId(params.worker_id);
|
|
200
241
|
const mode = deps.getDispatchMode(toolCallId);
|
|
201
242
|
const runtimeContext = await buildRuntimeContext(ctx, deps);
|
|
243
|
+
const settlements: unknown[] = [];
|
|
244
|
+
const onSettlement = mode === "inline" ? (settlement: unknown) => {
|
|
245
|
+
settlements.push(settlement);
|
|
246
|
+
onUpdate?.({
|
|
247
|
+
content: [{ type: "text", text: `${settlements.length} worker response(s) received.` }],
|
|
248
|
+
details: { mode: "inline", settlements: [...settlements] },
|
|
249
|
+
});
|
|
250
|
+
} : undefined;
|
|
202
251
|
const wave = await sendWithMode(
|
|
203
252
|
deps.runtime,
|
|
204
253
|
runtimeContext,
|
|
@@ -206,6 +255,7 @@ export function registerOrchestrationTools(
|
|
|
206
255
|
params.instructions,
|
|
207
256
|
mode,
|
|
208
257
|
signal,
|
|
258
|
+
onSettlement,
|
|
209
259
|
);
|
|
210
260
|
|
|
211
261
|
if (mode === "async") {
|
|
@@ -250,6 +300,17 @@ export function registerOrchestrationTools(
|
|
|
250
300
|
"Use worker_abort only for active work; use worker_close for a ready reusable worker.",
|
|
251
301
|
],
|
|
252
302
|
parameters: workerAbortSchema,
|
|
303
|
+
renderCall(args, theme) {
|
|
304
|
+
const target = "wave_id" in args
|
|
305
|
+
? args.wave_id
|
|
306
|
+
: "worker_ids" in args
|
|
307
|
+
? `${args.worker_ids.length} worker${args.worker_ids.length === 1 ? "" : "s"}`
|
|
308
|
+
: "all workers";
|
|
309
|
+
return renderCompactCall(theme, "worker_abort", target);
|
|
310
|
+
},
|
|
311
|
+
renderResult(result, { isPartial }, theme) {
|
|
312
|
+
return renderSimpleResult(result, isPartial ? "Requesting worker stop…" : "Worker stop requested", theme, "warning");
|
|
313
|
+
},
|
|
253
314
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
254
315
|
const ownerSessionId = requireNonblank(
|
|
255
316
|
"owner session ID",
|
|
@@ -279,6 +340,12 @@ export function registerOrchestrationTools(
|
|
|
279
340
|
"Use worker_close when an owned ready reusable worker is finished.",
|
|
280
341
|
],
|
|
281
342
|
parameters: workerCloseSchema,
|
|
343
|
+
renderCall(args, theme) {
|
|
344
|
+
return renderCompactCall(theme, "worker_close", args.worker_id);
|
|
345
|
+
},
|
|
346
|
+
renderResult(result, { isPartial }, theme) {
|
|
347
|
+
return renderSimpleResult(result, isPartial ? "Closing worker…" : "✓ Worker closed", theme);
|
|
348
|
+
},
|
|
282
349
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
283
350
|
const ownerSessionId = requireNonblank(
|
|
284
351
|
"owner session ID",
|
|
@@ -325,16 +392,10 @@ function orchestrateWithMode(
|
|
|
325
392
|
tasks: readonly OrchestrateTaskInput[],
|
|
326
393
|
mode: "async" | "inline",
|
|
327
394
|
signal: AbortSignal | undefined,
|
|
395
|
+
onSettlement?: (settlement: unknown) => void,
|
|
328
396
|
): Promise<AcceptedWave | CompletedWave> {
|
|
329
397
|
if (mode === "async") return runtime.orchestrate(context, tasks, "async");
|
|
330
|
-
|
|
331
|
-
const orchestrateInline = runtime.orchestrate as unknown as (
|
|
332
|
-
context: OrchestrationContext,
|
|
333
|
-
tasks: readonly OrchestrateTaskInput[],
|
|
334
|
-
mode: "inline",
|
|
335
|
-
signal?: AbortSignal,
|
|
336
|
-
) => Promise<CompletedWave>;
|
|
337
|
-
return orchestrateInline.call(runtime, context, tasks, "inline", signal);
|
|
398
|
+
return runtime.orchestrate(context, tasks, "inline", signal, onSettlement);
|
|
338
399
|
}
|
|
339
400
|
|
|
340
401
|
function sendWithMode(
|
|
@@ -344,17 +405,10 @@ function sendWithMode(
|
|
|
344
405
|
instructions: string,
|
|
345
406
|
mode: "async" | "inline",
|
|
346
407
|
signal: AbortSignal | undefined,
|
|
408
|
+
onSettlement?: (settlement: unknown) => void,
|
|
347
409
|
): Promise<AcceptedWave | CompletedWave> {
|
|
348
410
|
if (mode === "async") return runtime.send(context, workerId, instructions, "async");
|
|
349
|
-
|
|
350
|
-
const sendInline = runtime.send as unknown as (
|
|
351
|
-
context: OrchestrationContext,
|
|
352
|
-
workerId: WorkerId,
|
|
353
|
-
instructions: string,
|
|
354
|
-
mode: "inline",
|
|
355
|
-
signal?: AbortSignal,
|
|
356
|
-
) => Promise<CompletedWave>;
|
|
357
|
-
return sendInline.call(runtime, context, workerId, instructions, "inline", signal);
|
|
411
|
+
return runtime.send(context, workerId, instructions, "inline", signal, onSettlement);
|
|
358
412
|
}
|
|
359
413
|
|
|
360
414
|
function requireNonblank(name: string, value: string): string {
|
|
@@ -467,7 +521,7 @@ function catalogWorkerDetails(worker: WorkerDefinition) {
|
|
|
467
521
|
file_path: worker.source.filePath,
|
|
468
522
|
},
|
|
469
523
|
tools: [...worker.tools],
|
|
470
|
-
skills: [...worker.skills],
|
|
524
|
+
skills: worker.skills === undefined ? undefined : [...worker.skills],
|
|
471
525
|
model: worker.model
|
|
472
526
|
? { provider: worker.model.provider, model_id: worker.model.modelId }
|
|
473
527
|
: undefined,
|
|
@@ -557,3 +611,266 @@ function readableDetails(title: string, details: unknown): string {
|
|
|
557
611
|
|
|
558
612
|
return `${truncation.content}\n\n[Output truncated: ${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}. Full structured details remain available.]`;
|
|
559
613
|
}
|
|
614
|
+
|
|
615
|
+
interface RenderableTask {
|
|
616
|
+
readonly worker?: unknown;
|
|
617
|
+
readonly title?: unknown;
|
|
618
|
+
readonly instructions?: unknown;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function renderDispatchCall(
|
|
622
|
+
theme: Theme,
|
|
623
|
+
tasks: readonly RenderableTask[] | undefined,
|
|
624
|
+
expanded: boolean,
|
|
625
|
+
): Component {
|
|
626
|
+
const container = new Container();
|
|
627
|
+
const renderableTasks = Array.isArray(tasks) ? tasks : [];
|
|
628
|
+
const count = renderableTasks.length;
|
|
629
|
+
container.addChild(new Text(
|
|
630
|
+
theme.fg("toolTitle", theme.bold("orchestrate ")) + theme.fg("muted", `${count} worker${count === 1 ? "" : "s"}`),
|
|
631
|
+
0, 0,
|
|
632
|
+
));
|
|
633
|
+
if (expanded) {
|
|
634
|
+
for (const task of renderableTasks) {
|
|
635
|
+
container.addChild(new Spacer(1));
|
|
636
|
+
container.addChild(new Text(`${theme.fg("accent", "→")} ${theme.fg("muted", safeTerminalText(task.worker))} · ${theme.fg("text", theme.bold(safeTerminalText(task.title)))}`, 0, 0));
|
|
637
|
+
container.addChild(new Text(safeTerminalText(task.instructions), 2, 0));
|
|
638
|
+
}
|
|
639
|
+
return new WidthBoundComponent(container);
|
|
640
|
+
}
|
|
641
|
+
container.addChild(new InstructionPreview(renderableTasks, theme));
|
|
642
|
+
container.addChild(new Text(theme.fg("dim", keyHint("app.tools.expand", "to inspect full instructions")), 0, 0));
|
|
643
|
+
return new WidthBoundComponent(container);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
class InstructionPreview implements Component {
|
|
647
|
+
constructor(
|
|
648
|
+
private readonly tasks: readonly RenderableTask[],
|
|
649
|
+
private readonly theme: Theme,
|
|
650
|
+
) {}
|
|
651
|
+
render(width: number): string[] {
|
|
652
|
+
const bounded = Math.max(1, width);
|
|
653
|
+
const lines: string[] = [];
|
|
654
|
+
for (const task of this.tasks) {
|
|
655
|
+
const heading = `${this.theme.fg("accent", "→")} ${this.theme.fg("muted", safeTerminalText(task.worker))} · ${this.theme.fg("text", this.theme.bold(safeTerminalText(task.title)))}`;
|
|
656
|
+
lines.push(truncateToWidth(heading, bounded, "…"));
|
|
657
|
+
|
|
658
|
+
const contentWidth = Math.max(1, bounded - 2);
|
|
659
|
+
const characterLimit = Math.max(256, Math.min(4096, contentWidth * 3));
|
|
660
|
+
const preview = compactInstructionPreview(task.instructions, characterLimit);
|
|
661
|
+
if (!preview.text) continue;
|
|
662
|
+
const wrapped = wrapTextWithAnsi(preview.text, contentWidth);
|
|
663
|
+
const previewLines = wrapped.slice(0, MAX_INSTRUCTION_PREVIEW_LINES);
|
|
664
|
+
if (preview.truncated || wrapped.length > MAX_INSTRUCTION_PREVIEW_LINES) {
|
|
665
|
+
const lastIndex = previewLines.length - 1;
|
|
666
|
+
previewLines[lastIndex] = truncateToWidth(`${previewLines[lastIndex] ?? ""}…`, contentWidth, "…");
|
|
667
|
+
}
|
|
668
|
+
for (const line of previewLines) lines.push(this.theme.fg("dim", ` ${line}`));
|
|
669
|
+
}
|
|
670
|
+
return lines.map((line) => truncateToWidth(line, bounded, "…"));
|
|
671
|
+
}
|
|
672
|
+
invalidate(): void {}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function renderWorkerMessageCall(
|
|
676
|
+
theme: Theme,
|
|
677
|
+
tool: string,
|
|
678
|
+
workerId: unknown,
|
|
679
|
+
instructions: unknown,
|
|
680
|
+
expanded: boolean,
|
|
681
|
+
): Component {
|
|
682
|
+
const container = new Container();
|
|
683
|
+
container.addChild(new Text(theme.fg("toolTitle", theme.bold(`${tool} `)) + theme.fg("muted", safeTerminalText(workerId)), 0, 0));
|
|
684
|
+
if (expanded) container.addChild(new Text(safeTerminalText(instructions), 2, 0));
|
|
685
|
+
else {
|
|
686
|
+
container.addChild(new Text(`${theme.fg("accent", "→")} ${truncateInstruction(instructions, 240)}`, 0, 0));
|
|
687
|
+
container.addChild(new Text(theme.fg("dim", keyHint("app.tools.expand", "to inspect full message")), 0, 0));
|
|
688
|
+
}
|
|
689
|
+
return new WidthBoundComponent(container);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
class WidthBoundComponent implements Component {
|
|
693
|
+
constructor(private readonly child: Component, private readonly maxLines?: number) {}
|
|
694
|
+
render(width: number): string[] {
|
|
695
|
+
const bounded = Math.max(1, Math.floor(width));
|
|
696
|
+
const lines = this.child.render(bounded);
|
|
697
|
+
return (this.maxLines === undefined ? lines : lines.slice(0, this.maxLines))
|
|
698
|
+
.map((line) => truncateToWidth(line, bounded, "…"));
|
|
699
|
+
}
|
|
700
|
+
invalidate(): void { this.child.invalidate(); }
|
|
701
|
+
dispose(): void { (this.child as Component & { dispose?: () => void }).dispose?.(); }
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function safeTerminalText(value: unknown): string {
|
|
705
|
+
const text = typeof value === "string" ? value : value == null ? "" : String(value);
|
|
706
|
+
return text.replace(/\r\n?/g, "\n").replace(/\t/g, " ").replace(/[\x00-\x08\x0B-\x1F\x7F]/g, (character) => {
|
|
707
|
+
const code = character.charCodeAt(0);
|
|
708
|
+
return code === 0x7f ? "␡" : String.fromCodePoint(0x2400 + code);
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function compactInstructionPreview(instructions: unknown, characterLimit: number): { text: string; truncated: boolean } {
|
|
713
|
+
const text = typeof instructions === "string" ? instructions : instructions == null ? "" : String(instructions);
|
|
714
|
+
const source = text.slice(0, characterLimit);
|
|
715
|
+
return {
|
|
716
|
+
text: safeTerminalText(source).replace(/\s+/g, " ").trim(),
|
|
717
|
+
truncated: source.length < text.length,
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function firstInstructionLine(instructions: unknown): string | undefined {
|
|
722
|
+
const text = typeof instructions === "string" ? instructions : instructions == null ? "" : String(instructions);
|
|
723
|
+
return text.split(/\r\n?|\n/).find((line) => line.trim().length > 0);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function truncateInstruction(instructions: unknown, limit: number): string {
|
|
727
|
+
const first = firstInstructionLine(instructions) ?? "";
|
|
728
|
+
return first.length > limit ? `${first.slice(0, limit - 1)}…` : first;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function renderCompactCall(theme: Theme, tool: string, target: unknown): Text {
|
|
732
|
+
return new Text(
|
|
733
|
+
theme.fg("toolTitle", theme.bold(`${tool} `)) + theme.fg("muted", safeTerminalText(target)),
|
|
734
|
+
0,
|
|
735
|
+
0,
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function renderOrchestrationResult(
|
|
740
|
+
result: AgentToolResult<unknown>,
|
|
741
|
+
isPartial: boolean,
|
|
742
|
+
expanded: boolean,
|
|
743
|
+
theme: Theme,
|
|
744
|
+
lastComponent: unknown,
|
|
745
|
+
): Component {
|
|
746
|
+
const details = result.details;
|
|
747
|
+
if (isRecord(details) && typeof details.id === "string" && Array.isArray(details.workerIds) && details.workerIds.every((id) => typeof id === "string")) {
|
|
748
|
+
const count = details.workerIds.length;
|
|
749
|
+
return new WidthBoundComponent(new Text(theme.fg("success", `Sent to ${count} worker${count === 1 ? "" : "s"}`) + theme.fg("dim", " · responses arrive as they complete"), 0, 0));
|
|
750
|
+
}
|
|
751
|
+
const settlements = inlineSettlements(details);
|
|
752
|
+
if (settlements.length > 0) {
|
|
753
|
+
const component = lastComponent instanceof InlineResultComponent
|
|
754
|
+
? lastComponent
|
|
755
|
+
: new InlineResultComponent(theme);
|
|
756
|
+
component.update(settlements, isPartial, expanded);
|
|
757
|
+
return component;
|
|
758
|
+
}
|
|
759
|
+
if (isRecord(details) && (Array.isArray(details.settlements) || Array.isArray(details.results) || "workerIds" in details)) {
|
|
760
|
+
return new WidthBoundComponent(new Text(theme.fg("warning", "Worker result details unavailable"), 0, 0));
|
|
761
|
+
}
|
|
762
|
+
if (isPartial) return new WidthBoundComponent(new Text(theme.fg("warning", "Sending work…"), 0, 0));
|
|
763
|
+
return new WidthBoundComponent(renderSimpleResult(result, firstResultLine(result) || "Work sent", theme, "warning"));
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
interface InlineSettlement {
|
|
767
|
+
worker: string;
|
|
768
|
+
title: string;
|
|
769
|
+
status: "completed" | "ready" | "failed" | "aborted";
|
|
770
|
+
response: string;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
class InlineResultComponent implements Component {
|
|
774
|
+
private settlements: readonly InlineSettlement[] = [];
|
|
775
|
+
private partial = false;
|
|
776
|
+
private expanded = false;
|
|
777
|
+
private child: Component = new Container();
|
|
778
|
+
constructor(private readonly theme: Theme) {}
|
|
779
|
+
update(settlements: readonly InlineSettlement[], partial: boolean, expanded: boolean): void {
|
|
780
|
+
this.settlements = settlements;
|
|
781
|
+
this.partial = partial;
|
|
782
|
+
this.expanded = expanded;
|
|
783
|
+
this.rebuild();
|
|
784
|
+
}
|
|
785
|
+
render(width: number): string[] { return new WidthBoundComponent(this.child).render(width); }
|
|
786
|
+
invalidate(): void { this.rebuild(); }
|
|
787
|
+
dispose(): void { (this.child as Component & { dispose?: () => void }).dispose?.(); }
|
|
788
|
+
private rebuild(): void {
|
|
789
|
+
(this.child as Component & { dispose?: () => void }).dispose?.();
|
|
790
|
+
const container = new Container();
|
|
791
|
+
for (const settlement of this.settlements) {
|
|
792
|
+
const failed = settlement.status === "failed";
|
|
793
|
+
const aborted = settlement.status === "aborted";
|
|
794
|
+
const color = failed ? "error" : aborted ? "warning" : "success";
|
|
795
|
+
const icon = failed ? "✗" : aborted ? "■" : "✓";
|
|
796
|
+
container.addChild(new WidthBoundComponent(new Text(this.theme.fg(color, this.theme.bold(`${icon} ${settlement.worker} · ${settlement.title} · ${settlement.status}`)), 0, 0), 1));
|
|
797
|
+
if (settlement.response) {
|
|
798
|
+
const markdown = new Markdown(settlement.response, this.expanded ? 2 : 0, 0, getMarkdownTheme());
|
|
799
|
+
container.addChild(new WidthBoundComponent(markdown, this.expanded ? undefined : 2));
|
|
800
|
+
}
|
|
801
|
+
container.addChild(new Spacer(1));
|
|
802
|
+
}
|
|
803
|
+
if (this.partial) container.addChild(new Text(this.theme.fg("warning", "Waiting for remaining workers…"), 0, 0));
|
|
804
|
+
else if (!this.expanded) container.addChild(new Text(this.theme.fg("dim", keyHint("app.tools.expand", "to inspect full responses")), 0, 0));
|
|
805
|
+
this.child = container;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function inlineSettlements(details: unknown): InlineSettlement[] {
|
|
810
|
+
if (!isRecord(details)) return [];
|
|
811
|
+
const values = Array.isArray(details.settlements) ? details.settlements : Array.isArray(details.results) ? details.results : [];
|
|
812
|
+
const parsed: InlineSettlement[] = [];
|
|
813
|
+
for (const value of values) {
|
|
814
|
+
const settlement = readInlineSettlement(value);
|
|
815
|
+
if (settlement) parsed.push(settlement);
|
|
816
|
+
}
|
|
817
|
+
return parsed;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function readInlineSettlement(value: unknown): InlineSettlement | undefined {
|
|
821
|
+
if (!isRecord(value) || typeof value.worker !== "string" || typeof value.title !== "string" || !isRecord(value.outcome)) return undefined;
|
|
822
|
+
const outcome = value.outcome;
|
|
823
|
+
const statuses = ["completed", "ready", "failed", "aborted"] as const;
|
|
824
|
+
const status = statuses.find((item) => item === value.status);
|
|
825
|
+
const outcomeStatus = statuses.find((item) => item === outcome.status);
|
|
826
|
+
if (!status || outcomeStatus !== status) return undefined;
|
|
827
|
+
const message = outcome.message;
|
|
828
|
+
const camelAssistant = outcome.assistantText;
|
|
829
|
+
const snakeAssistant = outcome.assistant_text;
|
|
830
|
+
if (message !== undefined && typeof message !== "string") return undefined;
|
|
831
|
+
if (camelAssistant !== undefined && typeof camelAssistant !== "string") return undefined;
|
|
832
|
+
if (snakeAssistant !== undefined && typeof snakeAssistant !== "string") return undefined;
|
|
833
|
+
const assistantText = typeof camelAssistant === "string" ? camelAssistant : snakeAssistant;
|
|
834
|
+
if ((status === "completed" || status === "ready") && typeof assistantText !== "string") return undefined;
|
|
835
|
+
if (status === "failed" && typeof message !== "string") return undefined;
|
|
836
|
+
return {
|
|
837
|
+
worker: value.worker,
|
|
838
|
+
title: value.title,
|
|
839
|
+
status,
|
|
840
|
+
response: [message, assistantText].filter((item): item is string => typeof item === "string" && item.length > 0).join("\n\n"),
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function renderDiagnosticsResult(result: AgentToolResult<unknown>, isPartial: boolean, theme: Theme): Text {
|
|
845
|
+
if (isPartial) return new Text(theme.fg("muted", "Reading orchestration diagnostics…"), 0, 0);
|
|
846
|
+
const details = result.details;
|
|
847
|
+
if (isRecord(details) && isRecord(details.snapshot) && Array.isArray(details.snapshot.workers)) {
|
|
848
|
+
const workers = details.snapshot.workers.filter(isRecord);
|
|
849
|
+
const active = workers.filter((worker) => ["starting", "running", "stopping"].includes(String(worker.status))).length;
|
|
850
|
+
const ready = workers.filter((worker) => worker.status === "ready").length;
|
|
851
|
+
const diagnostics = isRecord(details.catalog) && Array.isArray(details.catalog.diagnostics) ? details.catalog.diagnostics.length : 0;
|
|
852
|
+
const facts = [active ? `${active} active` : "No active workers", ready ? `${ready} available for follow-up` : undefined, diagnostics ? `${diagnostics} catalog diagnostic${diagnostics === 1 ? "" : "s"}` : undefined].filter(Boolean);
|
|
853
|
+
return new Text(theme.fg("muted", facts.join(" · ")), 0, 0);
|
|
854
|
+
}
|
|
855
|
+
return new Text(theme.fg("muted", firstResultLine(result) || "Diagnostics unavailable"), 0, 0);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function renderSimpleResult(
|
|
859
|
+
result: AgentToolResult<unknown>,
|
|
860
|
+
message: string,
|
|
861
|
+
theme: Theme,
|
|
862
|
+
normalColor: "success" | "warning" = "success",
|
|
863
|
+
): Text {
|
|
864
|
+
const failed = "isError" in result && result.isError === true;
|
|
865
|
+
return new Text(theme.fg(failed ? "error" : normalColor, failed ? firstResultLine(result) || message : message), 0, 0);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function firstResultLine(result: AgentToolResult<unknown>): string | undefined {
|
|
869
|
+
const first = result.content[0];
|
|
870
|
+
if (first?.type !== "text") return undefined;
|
|
871
|
+
return first.text.split("\n").find((line) => line.trim())?.trim();
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
875
|
+
return typeof value === "object" && value !== null;
|
|
876
|
+
}
|