@gotgenes/pi-subagents 6.8.3 → 6.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/CHANGELOG.md +23 -0
- package/docs/architecture/architecture.md +39 -28
- package/docs/plans/0114-narrow-agent-tool-menu-deps.md +279 -0
- package/docs/plans/0115-decompose-agent-tool.md +337 -0
- package/docs/retro/0113-disambiguate-spawn-options.md +29 -0
- package/docs/retro/0114-narrow-agent-tool-menu-deps.md +38 -0
- package/package.json +1 -1
- package/src/agent-manager.ts +16 -3
- package/src/index.ts +9 -32
- package/src/tools/agent-tool.ts +48 -215
- package/src/tools/background-spawner.ts +116 -0
- package/src/tools/foreground-runner.ts +175 -0
- package/src/tools/helpers.ts +83 -1
- package/src/ui/agent-menu.ts +9 -2
package/src/tools/agent-tool.ts
CHANGED
|
@@ -7,13 +7,11 @@ import { AgentTypeRegistry } from "../agent-types.js";
|
|
|
7
7
|
import { resolveAgentInvocationConfig } from "../invocation-config.js";
|
|
8
8
|
import { resolveInvocationModel } from "../model-resolver.js";
|
|
9
9
|
|
|
10
|
-
import { NotificationState } from "../notification-state.js";
|
|
11
10
|
import type { AgentInvocation, AgentRecord, SubagentType } from "../types.js";
|
|
12
11
|
import { AgentActivityTracker } from "../ui/agent-activity-tracker.js";
|
|
13
12
|
import {
|
|
14
13
|
type AgentDetails,
|
|
15
14
|
buildInvocationTags,
|
|
16
|
-
describeActivity,
|
|
17
15
|
formatMs,
|
|
18
16
|
formatTurns,
|
|
19
17
|
getDisplayName,
|
|
@@ -21,55 +19,9 @@ import {
|
|
|
21
19
|
SPINNER,
|
|
22
20
|
type UICtx,
|
|
23
21
|
} from "../ui/agent-widget.js";
|
|
24
|
-
import {
|
|
25
|
-
import
|
|
26
|
-
import { formatLifetimeTokens, textResult } from "./helpers.js";
|
|
27
|
-
|
|
28
|
-
// ---- Agent-tool-specific helpers ----
|
|
29
|
-
|
|
30
|
-
/** Parenthetical status note for completed agent result text. */
|
|
31
|
-
export function getStatusNote(status: string): string {
|
|
32
|
-
switch (status) {
|
|
33
|
-
case "aborted":
|
|
34
|
-
return " (aborted — max turns exceeded, output may be incomplete)";
|
|
35
|
-
case "steered":
|
|
36
|
-
return " (wrapped up — reached turn limit)";
|
|
37
|
-
case "stopped":
|
|
38
|
-
return " (stopped by user)";
|
|
39
|
-
default:
|
|
40
|
-
return "";
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Build AgentDetails from a base + record-specific fields. */
|
|
45
|
-
export function buildDetails(
|
|
46
|
-
base: Pick<AgentDetails, "displayName" | "description" | "subagentType" | "modelName" | "tags">,
|
|
47
|
-
record: {
|
|
48
|
-
toolUses: number;
|
|
49
|
-
startedAt: number;
|
|
50
|
-
completedAt?: number;
|
|
51
|
-
status: string;
|
|
52
|
-
error?: string;
|
|
53
|
-
id?: string;
|
|
54
|
-
session?: any;
|
|
55
|
-
lifetimeUsage: LifetimeUsage;
|
|
56
|
-
},
|
|
57
|
-
activity?: AgentActivityTracker,
|
|
58
|
-
overrides?: Partial<AgentDetails>,
|
|
59
|
-
): AgentDetails {
|
|
60
|
-
return {
|
|
61
|
-
...base,
|
|
62
|
-
toolUses: record.toolUses,
|
|
63
|
-
tokens: formatLifetimeTokens(record),
|
|
64
|
-
turnCount: activity?.turnCount,
|
|
65
|
-
maxTurns: activity?.maxTurns,
|
|
66
|
-
durationMs: (record.completedAt ?? Date.now()) - record.startedAt,
|
|
67
|
-
status: record.status as AgentDetails["status"],
|
|
68
|
-
agentId: record.id,
|
|
69
|
-
error: record.error,
|
|
70
|
-
...overrides,
|
|
71
|
-
};
|
|
72
|
-
}
|
|
22
|
+
import { spawnBackground } from "./background-spawner.js";
|
|
23
|
+
import { runForeground } from "./foreground-runner.js";
|
|
24
|
+
import { buildDetails, buildTypeListText, formatLifetimeTokens, getStatusNote, textResult } from "./helpers.js";
|
|
73
25
|
|
|
74
26
|
// ---- Deps interface ----
|
|
75
27
|
|
|
@@ -80,7 +32,6 @@ export interface AgentToolManager {
|
|
|
80
32
|
resume: (id: string, prompt: string, signal: AbortSignal) => Promise<AgentRecord | undefined>;
|
|
81
33
|
getRecord: (id: string) => AgentRecord | undefined;
|
|
82
34
|
getMaxConcurrent: () => number;
|
|
83
|
-
listAgents: () => AgentRecord[];
|
|
84
35
|
}
|
|
85
36
|
|
|
86
37
|
/** Narrow widget interface — only the methods the Agent tool calls. */
|
|
@@ -91,14 +42,21 @@ export interface AgentToolWidget {
|
|
|
91
42
|
markFinished: (id: string) => void;
|
|
92
43
|
}
|
|
93
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Narrow read/write interface for the agent-tool's agentActivity access.
|
|
47
|
+
* The full Map satisfies this structurally — no wrapper needed.
|
|
48
|
+
*/
|
|
49
|
+
export interface AgentActivityAccess {
|
|
50
|
+
get(id: string): AgentActivityTracker | undefined;
|
|
51
|
+
set(id: string, tracker: AgentActivityTracker): void;
|
|
52
|
+
delete(id: string): void;
|
|
53
|
+
}
|
|
54
|
+
|
|
94
55
|
export interface AgentToolDeps {
|
|
95
56
|
manager: AgentToolManager;
|
|
96
57
|
widget: AgentToolWidget;
|
|
97
|
-
agentActivity:
|
|
98
|
-
emitEvent: (name: string, data: unknown) => void;
|
|
58
|
+
agentActivity: AgentActivityAccess;
|
|
99
59
|
registry: AgentTypeRegistry;
|
|
100
|
-
typeListText: string;
|
|
101
|
-
availableTypesText: string;
|
|
102
60
|
agentDir: string;
|
|
103
61
|
/** Narrow settings accessor — only the default max turns is needed here. */
|
|
104
62
|
settings: { readonly defaultMaxTurns: number | undefined };
|
|
@@ -108,6 +66,8 @@ export interface AgentToolDeps {
|
|
|
108
66
|
|
|
109
67
|
/** Create the Agent tool definition (without Pi SDK wrapper). */
|
|
110
68
|
export function createAgentTool(deps: AgentToolDeps) {
|
|
69
|
+
const typeListText = buildTypeListText(deps.registry, deps.agentDir);
|
|
70
|
+
const availableTypesText = deps.registry.getAvailableTypes().join(", ");
|
|
111
71
|
return {
|
|
112
72
|
name: "Agent" as const,
|
|
113
73
|
label: "Agent",
|
|
@@ -116,7 +76,7 @@ export function createAgentTool(deps: AgentToolDeps) {
|
|
|
116
76
|
The Agent tool launches specialized agents that autonomously handle complex tasks. Each agent type has specific capabilities and tools available to it.
|
|
117
77
|
|
|
118
78
|
Available agent types:
|
|
119
|
-
${
|
|
79
|
+
${typeListText}
|
|
120
80
|
|
|
121
81
|
Guidelines:
|
|
122
82
|
- For parallel work, use run_in_background: true on each agent. Foreground calls run sequentially — only one executes at a time.
|
|
@@ -140,7 +100,7 @@ Guidelines:
|
|
|
140
100
|
description: "A short (3-5 word) description of the task (shown in UI).",
|
|
141
101
|
}),
|
|
142
102
|
subagent_type: Type.String({
|
|
143
|
-
description: `The type of specialized agent to use. Available types: ${
|
|
103
|
+
description: `The type of specialized agent to use. Available types: ${availableTypesText}. Custom agents from .pi/agents/<name>.md (project) or ${deps.agentDir}/agents/<name>.md (global) are also available.`,
|
|
144
104
|
}),
|
|
145
105
|
model: Type.Optional(
|
|
146
106
|
Type.String({
|
|
@@ -403,175 +363,48 @@ Guidelines:
|
|
|
403
363
|
|
|
404
364
|
// Background execution
|
|
405
365
|
if (runInBackground) {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
parentSessionFile: ctx.sessionManager.getSessionFile(),
|
|
413
|
-
parentSessionId: ctx.sessionManager.getSessionId(),
|
|
366
|
+
return spawnBackground(
|
|
367
|
+
{ manager: deps.manager, widget: deps.widget, agentActivity: deps.agentActivity },
|
|
368
|
+
{
|
|
369
|
+
ctx,
|
|
370
|
+
subagentType,
|
|
371
|
+
prompt: params.prompt as string,
|
|
414
372
|
description: params.description as string,
|
|
373
|
+
displayName,
|
|
374
|
+
toolCallId,
|
|
375
|
+
detailBase,
|
|
415
376
|
model,
|
|
416
|
-
|
|
377
|
+
effectiveMaxTurns,
|
|
417
378
|
isolated,
|
|
418
379
|
inheritContext,
|
|
419
|
-
|
|
420
|
-
isBackground: true,
|
|
380
|
+
thinking,
|
|
421
381
|
isolation,
|
|
422
|
-
|
|
423
|
-
onSessionCreated: (session: any) => {
|
|
424
|
-
bgState.setSession(session);
|
|
425
|
-
subscribeUIObserver(session, bgState);
|
|
426
|
-
},
|
|
427
|
-
});
|
|
428
|
-
} catch (err) {
|
|
429
|
-
return textResult(err instanceof Error ? err.message : String(err));
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
const record = deps.manager.getRecord(id);
|
|
433
|
-
if (record) {
|
|
434
|
-
// Born complete: notification-state object owns toolCallId + resultConsumed.
|
|
435
|
-
record.notification = new NotificationState(toolCallId);
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
deps.agentActivity.set(id, bgState);
|
|
439
|
-
deps.widget.ensureTimer();
|
|
440
|
-
deps.widget.update();
|
|
441
|
-
|
|
442
|
-
// Emit created event
|
|
443
|
-
deps.emitEvent("subagents:created", {
|
|
444
|
-
id,
|
|
445
|
-
type: subagentType,
|
|
446
|
-
description: params.description,
|
|
447
|
-
isBackground: true,
|
|
448
|
-
});
|
|
449
|
-
|
|
450
|
-
const isQueued = record?.status === "queued";
|
|
451
|
-
return textResult(
|
|
452
|
-
`Agent ${isQueued ? "queued" : "started"} in background.\n` +
|
|
453
|
-
`Agent ID: ${id}\n` +
|
|
454
|
-
`Type: ${displayName}\n` +
|
|
455
|
-
`Description: ${params.description}\n` +
|
|
456
|
-
(record?.execution?.outputFile ? `Output file: ${record.execution.outputFile}\n` : "") +
|
|
457
|
-
(isQueued
|
|
458
|
-
? `Position: queued (max ${deps.manager.getMaxConcurrent()} concurrent)\n`
|
|
459
|
-
: "") +
|
|
460
|
-
`\nYou will be notified when this agent completes.\n` +
|
|
461
|
-
`Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
|
|
462
|
-
`Do not duplicate this agent's work.`,
|
|
463
|
-
{
|
|
464
|
-
...detailBase,
|
|
465
|
-
toolUses: 0,
|
|
466
|
-
tokens: "",
|
|
467
|
-
durationMs: 0,
|
|
468
|
-
status: "background" as const,
|
|
469
|
-
agentId: id,
|
|
382
|
+
agentInvocation,
|
|
470
383
|
},
|
|
471
384
|
);
|
|
472
385
|
}
|
|
473
386
|
|
|
474
387
|
// Foreground (synchronous) execution — stream progress via onUpdate
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
const fgState = new AgentActivityTracker(effectiveMaxTurns);
|
|
480
|
-
let unsubUI: (() => void) | undefined;
|
|
481
|
-
|
|
482
|
-
const streamUpdate = () => {
|
|
483
|
-
const details: AgentDetails = {
|
|
484
|
-
...detailBase,
|
|
485
|
-
toolUses: fgState.toolUses,
|
|
486
|
-
tokens: formatLifetimeTokens(fgState),
|
|
487
|
-
turnCount: fgState.turnCount,
|
|
488
|
-
maxTurns: fgState.maxTurns,
|
|
489
|
-
durationMs: Date.now() - startedAt,
|
|
490
|
-
status: "running",
|
|
491
|
-
activity: describeActivity(fgState.activeTools, fgState.responseText),
|
|
492
|
-
spinnerFrame: spinnerFrame % SPINNER.length,
|
|
493
|
-
};
|
|
494
|
-
onUpdate?.({
|
|
495
|
-
content: [{ type: "text", text: `${fgState.toolUses} tool uses...` }],
|
|
496
|
-
details: details as any,
|
|
497
|
-
});
|
|
498
|
-
};
|
|
499
|
-
|
|
500
|
-
// Animate spinner at ~80ms (smooth rotation through 10 braille frames)
|
|
501
|
-
const spinnerInterval = setInterval(() => {
|
|
502
|
-
spinnerFrame++;
|
|
503
|
-
streamUpdate();
|
|
504
|
-
}, 80);
|
|
505
|
-
|
|
506
|
-
streamUpdate();
|
|
507
|
-
|
|
508
|
-
let record: AgentRecord;
|
|
509
|
-
try {
|
|
510
|
-
record = await deps.manager.spawnAndWait(
|
|
388
|
+
return runForeground(
|
|
389
|
+
{ manager: deps.manager, widget: deps.widget, agentActivity: deps.agentActivity },
|
|
390
|
+
{
|
|
511
391
|
ctx,
|
|
512
392
|
subagentType,
|
|
513
|
-
params.prompt as string,
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
unsubUI = subscribeUIObserver(session, fgState, streamUpdate);
|
|
529
|
-
for (const a of deps.manager.listAgents()) {
|
|
530
|
-
if (a.execution?.session === session) {
|
|
531
|
-
fgId = a.id;
|
|
532
|
-
deps.agentActivity.set(a.id, fgState);
|
|
533
|
-
deps.widget.ensureTimer();
|
|
534
|
-
break;
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
},
|
|
538
|
-
},
|
|
539
|
-
);
|
|
540
|
-
} catch (err) {
|
|
541
|
-
clearInterval(spinnerInterval);
|
|
542
|
-
unsubUI?.();
|
|
543
|
-
return textResult(err instanceof Error ? err.message : String(err));
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
clearInterval(spinnerInterval);
|
|
547
|
-
unsubUI?.();
|
|
548
|
-
|
|
549
|
-
// Clean up foreground agent from widget
|
|
550
|
-
if (fgId) {
|
|
551
|
-
deps.agentActivity.delete(fgId);
|
|
552
|
-
deps.widget.markFinished(fgId);
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
// Get final token count
|
|
556
|
-
const tokenText = formatLifetimeTokens(fgState);
|
|
557
|
-
|
|
558
|
-
const details = buildDetails(detailBase, record, fgState, { tokens: tokenText });
|
|
559
|
-
|
|
560
|
-
const fallbackNote = fellBack
|
|
561
|
-
? `Note: Unknown agent type "${rawType}" — using general-purpose.\n\n`
|
|
562
|
-
: "";
|
|
563
|
-
|
|
564
|
-
if (record.status === "error") {
|
|
565
|
-
return textResult(`${fallbackNote}Agent failed: ${record.error}`, details);
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
|
|
569
|
-
const statsParts = [`${record.toolUses} tool uses`];
|
|
570
|
-
if (tokenText) statsParts.push(tokenText);
|
|
571
|
-
return textResult(
|
|
572
|
-
`${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getStatusNote(record.status)}.\n\n` +
|
|
573
|
-
(record.result?.trim() || "No output."),
|
|
574
|
-
details,
|
|
393
|
+
prompt: params.prompt as string,
|
|
394
|
+
description: params.description as string,
|
|
395
|
+
detailBase,
|
|
396
|
+
rawType,
|
|
397
|
+
fellBack,
|
|
398
|
+
model,
|
|
399
|
+
effectiveMaxTurns,
|
|
400
|
+
isolated,
|
|
401
|
+
inheritContext,
|
|
402
|
+
thinking,
|
|
403
|
+
isolation,
|
|
404
|
+
agentInvocation,
|
|
405
|
+
},
|
|
406
|
+
signal,
|
|
407
|
+
onUpdate,
|
|
575
408
|
);
|
|
576
409
|
},
|
|
577
410
|
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { AgentSpawnConfig } from "../agent-manager.js";
|
|
3
|
+
import type { AgentInvocation, AgentRecord, IsolationMode, ThinkingLevel } from "../types.js";
|
|
4
|
+
import { AgentActivityTracker } from "../ui/agent-activity-tracker.js";
|
|
5
|
+
import type { AgentDetails } from "../ui/agent-widget.js";
|
|
6
|
+
import { subscribeUIObserver } from "../ui/ui-observer.js";
|
|
7
|
+
import type { AgentActivityAccess } from "./agent-tool.js";
|
|
8
|
+
import { textResult } from "./helpers.js";
|
|
9
|
+
|
|
10
|
+
/** Narrow manager interface for the background spawner. */
|
|
11
|
+
export interface BackgroundManagerDeps {
|
|
12
|
+
spawn(ctx: any, type: string, prompt: string, opts: AgentSpawnConfig): string;
|
|
13
|
+
getRecord(id: string): AgentRecord | undefined;
|
|
14
|
+
getMaxConcurrent(): number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Narrow widget interface for the background spawner. */
|
|
18
|
+
export interface BackgroundWidgetDeps {
|
|
19
|
+
ensureTimer(): void;
|
|
20
|
+
update(): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Injected collaborators for spawnBackground. */
|
|
24
|
+
export interface BackgroundDeps {
|
|
25
|
+
manager: BackgroundManagerDeps;
|
|
26
|
+
widget: BackgroundWidgetDeps;
|
|
27
|
+
agentActivity: AgentActivityAccess;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** All values the background spawner needs, bundled from shared execute setup. */
|
|
31
|
+
export interface BackgroundParams {
|
|
32
|
+
ctx: {
|
|
33
|
+
sessionManager: {
|
|
34
|
+
getSessionFile(): string;
|
|
35
|
+
getSessionId(): string;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
subagentType: string;
|
|
39
|
+
prompt: string;
|
|
40
|
+
description: string;
|
|
41
|
+
displayName: string;
|
|
42
|
+
toolCallId: string;
|
|
43
|
+
detailBase: Pick<AgentDetails, "displayName" | "description" | "subagentType" | "modelName" | "tags">;
|
|
44
|
+
model: Model<any> | undefined;
|
|
45
|
+
effectiveMaxTurns: number | undefined;
|
|
46
|
+
isolated: boolean | undefined;
|
|
47
|
+
inheritContext: boolean | undefined;
|
|
48
|
+
thinking: ThinkingLevel | undefined;
|
|
49
|
+
isolation: IsolationMode | undefined;
|
|
50
|
+
agentInvocation: AgentInvocation;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Spawn a background agent and return the tool result immediately.
|
|
55
|
+
* Owns: activity tracker creation, UI observer subscription, activity map
|
|
56
|
+
* registration, widget update, and launch message formatting.
|
|
57
|
+
*/
|
|
58
|
+
export function spawnBackground(
|
|
59
|
+
deps: BackgroundDeps,
|
|
60
|
+
params: BackgroundParams,
|
|
61
|
+
) {
|
|
62
|
+
const bgState = new AgentActivityTracker(params.effectiveMaxTurns);
|
|
63
|
+
|
|
64
|
+
let id: string;
|
|
65
|
+
try {
|
|
66
|
+
id = deps.manager.spawn(params.ctx, params.subagentType, params.prompt, {
|
|
67
|
+
parentSessionFile: params.ctx.sessionManager.getSessionFile(),
|
|
68
|
+
parentSessionId: params.ctx.sessionManager.getSessionId(),
|
|
69
|
+
description: params.description,
|
|
70
|
+
model: params.model,
|
|
71
|
+
maxTurns: params.effectiveMaxTurns,
|
|
72
|
+
isolated: params.isolated,
|
|
73
|
+
inheritContext: params.inheritContext,
|
|
74
|
+
thinkingLevel: params.thinking,
|
|
75
|
+
isBackground: true,
|
|
76
|
+
isolation: params.isolation,
|
|
77
|
+
invocation: params.agentInvocation,
|
|
78
|
+
toolCallId: params.toolCallId,
|
|
79
|
+
onSessionCreated: (session) => {
|
|
80
|
+
bgState.setSession(session);
|
|
81
|
+
subscribeUIObserver(session, bgState);
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
} catch (err) {
|
|
85
|
+
return textResult(err instanceof Error ? err.message : String(err));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const record = deps.manager.getRecord(id);
|
|
89
|
+
|
|
90
|
+
deps.agentActivity.set(id, bgState);
|
|
91
|
+
deps.widget.ensureTimer();
|
|
92
|
+
deps.widget.update();
|
|
93
|
+
|
|
94
|
+
const isQueued = record?.status === "queued";
|
|
95
|
+
return textResult(
|
|
96
|
+
`Agent ${isQueued ? "queued" : "started"} in background.\n` +
|
|
97
|
+
`Agent ID: ${id}\n` +
|
|
98
|
+
`Type: ${params.displayName}\n` +
|
|
99
|
+
`Description: ${params.description}\n` +
|
|
100
|
+
(record?.execution?.outputFile ? `Output file: ${record.execution.outputFile}\n` : "") +
|
|
101
|
+
(isQueued
|
|
102
|
+
? `Position: queued (max ${deps.manager.getMaxConcurrent()} concurrent)\n`
|
|
103
|
+
: "") +
|
|
104
|
+
`\nYou will be notified when this agent completes.\n` +
|
|
105
|
+
`Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
|
|
106
|
+
`Do not duplicate this agent's work.`,
|
|
107
|
+
{
|
|
108
|
+
...params.detailBase,
|
|
109
|
+
toolUses: 0,
|
|
110
|
+
tokens: "",
|
|
111
|
+
durationMs: 0,
|
|
112
|
+
status: "background" as const,
|
|
113
|
+
agentId: id,
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { AgentSpawnConfig } from "../agent-manager.js";
|
|
4
|
+
import type { AgentInvocation, AgentRecord, IsolationMode, ThinkingLevel } from "../types.js";
|
|
5
|
+
import { AgentActivityTracker } from "../ui/agent-activity-tracker.js";
|
|
6
|
+
import {
|
|
7
|
+
type AgentDetails,
|
|
8
|
+
describeActivity,
|
|
9
|
+
formatMs,
|
|
10
|
+
SPINNER,
|
|
11
|
+
} from "../ui/agent-widget.js";
|
|
12
|
+
import { subscribeUIObserver } from "../ui/ui-observer.js";
|
|
13
|
+
import type { AgentActivityAccess } from "./agent-tool.js";
|
|
14
|
+
import {
|
|
15
|
+
buildDetails,
|
|
16
|
+
formatLifetimeTokens,
|
|
17
|
+
getStatusNote,
|
|
18
|
+
textResult,
|
|
19
|
+
} from "./helpers.js";
|
|
20
|
+
|
|
21
|
+
/** Narrow manager interface for the foreground runner. */
|
|
22
|
+
export interface ForegroundManagerDeps {
|
|
23
|
+
spawnAndWait(
|
|
24
|
+
ctx: any,
|
|
25
|
+
type: string,
|
|
26
|
+
prompt: string,
|
|
27
|
+
opts: Omit<AgentSpawnConfig, "isBackground">,
|
|
28
|
+
): Promise<AgentRecord>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Narrow widget interface for the foreground runner. */
|
|
32
|
+
export interface ForegroundWidgetDeps {
|
|
33
|
+
ensureTimer(): void;
|
|
34
|
+
markFinished(id: string): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Injected collaborators for runForeground. */
|
|
38
|
+
export interface ForegroundDeps {
|
|
39
|
+
manager: ForegroundManagerDeps;
|
|
40
|
+
widget: ForegroundWidgetDeps;
|
|
41
|
+
agentActivity: AgentActivityAccess;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** All values the foreground runner needs, bundled from shared execute setup. */
|
|
45
|
+
export interface ForegroundParams {
|
|
46
|
+
ctx: {
|
|
47
|
+
sessionManager: {
|
|
48
|
+
getSessionFile(): string;
|
|
49
|
+
getSessionId(): string;
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
subagentType: string;
|
|
53
|
+
prompt: string;
|
|
54
|
+
description: string;
|
|
55
|
+
detailBase: Pick<
|
|
56
|
+
AgentDetails,
|
|
57
|
+
"displayName" | "description" | "subagentType" | "modelName" | "tags"
|
|
58
|
+
>;
|
|
59
|
+
rawType: string;
|
|
60
|
+
fellBack: boolean;
|
|
61
|
+
model: Model<any> | undefined;
|
|
62
|
+
effectiveMaxTurns: number | undefined;
|
|
63
|
+
isolated: boolean | undefined;
|
|
64
|
+
inheritContext: boolean | undefined;
|
|
65
|
+
thinking: ThinkingLevel | undefined;
|
|
66
|
+
isolation: IsolationMode | undefined;
|
|
67
|
+
agentInvocation: AgentInvocation;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Run an agent synchronously in the foreground, streaming spinner updates.
|
|
72
|
+
* Owns: spinner interval, AgentActivityTracker creation, UI observer subscription,
|
|
73
|
+
* streaming onUpdate callbacks, cleanup, and result formatting.
|
|
74
|
+
*/
|
|
75
|
+
export async function runForeground(
|
|
76
|
+
deps: ForegroundDeps,
|
|
77
|
+
params: ForegroundParams,
|
|
78
|
+
signal: AbortSignal | undefined,
|
|
79
|
+
onUpdate: ((update: AgentToolResult<any>) => void) | undefined,
|
|
80
|
+
) {
|
|
81
|
+
let spinnerFrame = 0;
|
|
82
|
+
const startedAt = Date.now();
|
|
83
|
+
let fgId: string | undefined;
|
|
84
|
+
|
|
85
|
+
const fgState = new AgentActivityTracker(params.effectiveMaxTurns);
|
|
86
|
+
let unsubUI: (() => void) | undefined;
|
|
87
|
+
|
|
88
|
+
const streamUpdate = () => {
|
|
89
|
+
const details: AgentDetails = {
|
|
90
|
+
...params.detailBase,
|
|
91
|
+
toolUses: fgState.toolUses,
|
|
92
|
+
tokens: formatLifetimeTokens(fgState),
|
|
93
|
+
turnCount: fgState.turnCount,
|
|
94
|
+
maxTurns: fgState.maxTurns,
|
|
95
|
+
durationMs: Date.now() - startedAt,
|
|
96
|
+
status: "running",
|
|
97
|
+
activity: describeActivity(fgState.activeTools, fgState.responseText),
|
|
98
|
+
spinnerFrame: spinnerFrame % SPINNER.length,
|
|
99
|
+
};
|
|
100
|
+
onUpdate?.({
|
|
101
|
+
content: [{ type: "text", text: `${fgState.toolUses} tool uses...` }],
|
|
102
|
+
details: details as any,
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Animate spinner at ~80ms (smooth rotation through 10 braille frames)
|
|
107
|
+
const spinnerInterval = setInterval(() => {
|
|
108
|
+
spinnerFrame++;
|
|
109
|
+
streamUpdate();
|
|
110
|
+
}, 80);
|
|
111
|
+
|
|
112
|
+
streamUpdate();
|
|
113
|
+
|
|
114
|
+
let record: AgentRecord;
|
|
115
|
+
try {
|
|
116
|
+
record = await deps.manager.spawnAndWait(
|
|
117
|
+
params.ctx,
|
|
118
|
+
params.subagentType,
|
|
119
|
+
params.prompt,
|
|
120
|
+
{
|
|
121
|
+
description: params.description,
|
|
122
|
+
model: params.model,
|
|
123
|
+
maxTurns: params.effectiveMaxTurns,
|
|
124
|
+
isolated: params.isolated,
|
|
125
|
+
inheritContext: params.inheritContext,
|
|
126
|
+
thinkingLevel: params.thinking,
|
|
127
|
+
isolation: params.isolation,
|
|
128
|
+
invocation: params.agentInvocation,
|
|
129
|
+
signal,
|
|
130
|
+
parentSessionFile: params.ctx.sessionManager.getSessionFile(),
|
|
131
|
+
parentSessionId: params.ctx.sessionManager.getSessionId(),
|
|
132
|
+
onSessionCreated: (session, record) => {
|
|
133
|
+
fgState.setSession(session);
|
|
134
|
+
unsubUI = subscribeUIObserver(session, fgState, streamUpdate);
|
|
135
|
+
fgId = record.id;
|
|
136
|
+
deps.agentActivity.set(record.id, fgState);
|
|
137
|
+
deps.widget.ensureTimer();
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
clearInterval(spinnerInterval);
|
|
143
|
+
unsubUI?.();
|
|
144
|
+
return textResult(err instanceof Error ? err.message : String(err));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
clearInterval(spinnerInterval);
|
|
148
|
+
unsubUI?.();
|
|
149
|
+
|
|
150
|
+
// Clean up foreground agent from widget
|
|
151
|
+
if (fgId) {
|
|
152
|
+
deps.agentActivity.delete(fgId);
|
|
153
|
+
deps.widget.markFinished(fgId);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const tokenText = formatLifetimeTokens(fgState);
|
|
157
|
+
const details = buildDetails(params.detailBase, record, fgState, { tokens: tokenText });
|
|
158
|
+
|
|
159
|
+
const fallbackNote = params.fellBack
|
|
160
|
+
? `Note: Unknown agent type "${params.rawType}" \u2014 using general-purpose.\n\n`
|
|
161
|
+
: "";
|
|
162
|
+
|
|
163
|
+
if (record.status === "error") {
|
|
164
|
+
return textResult(`${fallbackNote}Agent failed: ${record.error}`, details);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
|
|
168
|
+
const statsParts = [`${record.toolUses} tool uses`];
|
|
169
|
+
if (tokenText) statsParts.push(tokenText);
|
|
170
|
+
return textResult(
|
|
171
|
+
`${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getStatusNote(record.status)}.\n\n` +
|
|
172
|
+
(record.result?.trim() || "No output."),
|
|
173
|
+
details,
|
|
174
|
+
);
|
|
175
|
+
}
|