@yandy0725/pi-subagents 0.1.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 +155 -0
- package/README.zh.md +155 -0
- package/index.ts +1 -0
- package/package.json +49 -0
- package/src/config/agent-types.ts +127 -0
- package/src/config/custom-agents.ts +109 -0
- package/src/config/default-agents.ts +117 -0
- package/src/config/invocation-config.ts +30 -0
- package/src/debug.ts +14 -0
- package/src/handlers/index.ts +3 -0
- package/src/handlers/interrupt.ts +49 -0
- package/src/handlers/lifecycle.ts +63 -0
- package/src/handlers/tool-start.ts +32 -0
- package/src/index.ts +186 -0
- package/src/layered-settings.ts +105 -0
- package/src/lifecycle/child-lifecycle.ts +88 -0
- package/src/lifecycle/concurrency-limiter.ts +55 -0
- package/src/lifecycle/create-subagent-session.ts +240 -0
- package/src/lifecycle/parent-snapshot.ts +45 -0
- package/src/lifecycle/run-listeners.ts +37 -0
- package/src/lifecycle/subagent-manager.ts +353 -0
- package/src/lifecycle/subagent-session.ts +232 -0
- package/src/lifecycle/subagent-state.ts +216 -0
- package/src/lifecycle/subagent.ts +498 -0
- package/src/lifecycle/turn-limits.ts +13 -0
- package/src/lifecycle/usage.ts +65 -0
- package/src/lifecycle/workspace-bracket.ts +59 -0
- package/src/lifecycle/workspace.ts +47 -0
- package/src/observation/composite-subagent-observer.ts +49 -0
- package/src/observation/notification-state.ts +27 -0
- package/src/observation/notification.ts +186 -0
- package/src/observation/record-observer.ts +75 -0
- package/src/observation/renderer.ts +63 -0
- package/src/observation/subagent-events-observer.ts +94 -0
- package/src/runtime.ts +77 -0
- package/src/service/service-adapter.ts +131 -0
- package/src/service/service.ts +123 -0
- package/src/session/content-items.ts +51 -0
- package/src/session/context.ts +78 -0
- package/src/session/conversation.ts +44 -0
- package/src/session/env.ts +40 -0
- package/src/session/model-resolver.ts +121 -0
- package/src/session/prompts.ts +83 -0
- package/src/session/session-config.ts +172 -0
- package/src/session/session-dir.ts +38 -0
- package/src/settings.ts +227 -0
- package/src/tools/agent-tool.ts +220 -0
- package/src/tools/background-spawner.ts +66 -0
- package/src/tools/foreground-runner.ts +114 -0
- package/src/tools/get-result-tool.ts +120 -0
- package/src/tools/helpers.ts +105 -0
- package/src/tools/result-renderer.ts +109 -0
- package/src/tools/spawn-config.ts +150 -0
- package/src/tools/steer-tool.ts +90 -0
- package/src/types.ts +115 -0
- package/src/ui/agent-widget.ts +311 -0
- package/src/ui/display.ts +174 -0
- package/src/ui/session-navigation.ts +147 -0
- package/src/ui/session-navigator.ts +406 -0
- package/src/ui/subagents-settings.ts +77 -0
- package/src/ui/widget-renderer.ts +296 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-base-to-string, @typescript-eslint/restrict-template-expressions -- Pi SDK types are not fully exported; see upstream Pi SDK for type improvements */
|
|
2
|
+
import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
5
|
+
import { Type } from "@sinclair/typebox";
|
|
6
|
+
import type { AgentTypeRegistry } from "../config/agent-types";
|
|
7
|
+
import type { ParentSnapshot } from "../lifecycle/parent-snapshot";
|
|
8
|
+
import type { AgentSpawnConfig } from "../lifecycle/subagent-manager";
|
|
9
|
+
import { spawnBackground } from "../tools/background-spawner";
|
|
10
|
+
import { runForeground } from "../tools/foreground-runner";
|
|
11
|
+
import { buildDetails, buildTypeListText, textResult } from "../tools/helpers";
|
|
12
|
+
import { renderAgentResult } from "../tools/result-renderer";
|
|
13
|
+
import { type ModelInfo, resolveSpawnConfig } from "../tools/spawn-config";
|
|
14
|
+
import type { ParentSessionInfo, Subagent } from "../types";
|
|
15
|
+
import { type AgentDetails, getDisplayName } from "../ui/display";
|
|
16
|
+
|
|
17
|
+
// ---- Deps interfaces ----
|
|
18
|
+
|
|
19
|
+
/** Narrow manager interface — only the methods the Agent tool calls. */
|
|
20
|
+
export interface AgentToolManager {
|
|
21
|
+
spawn: (snapshot: ParentSnapshot, type: string, prompt: string, opts: AgentSpawnConfig) => string;
|
|
22
|
+
spawnAndWait: (
|
|
23
|
+
snapshot: ParentSnapshot,
|
|
24
|
+
type: string,
|
|
25
|
+
prompt: string,
|
|
26
|
+
opts: Omit<AgentSpawnConfig, "isBackground">,
|
|
27
|
+
) => Promise<Subagent>;
|
|
28
|
+
resume: (id: string, prompt: string, signal: AbortSignal) => Promise<Subagent | undefined>;
|
|
29
|
+
getRecord: (id: string) => Subagent | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Narrow runtime interface — the Agent tool's slice of SubagentRuntime. */
|
|
33
|
+
export interface AgentToolRuntime {
|
|
34
|
+
buildSnapshot(inheritContext: boolean): ParentSnapshot;
|
|
35
|
+
getModelInfo(): ModelInfo;
|
|
36
|
+
getSessionInfo(): { parentSessionFile: string; parentSessionId: string };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Narrow settings accessor — only the fields the Agent tool reads. */
|
|
40
|
+
export type AgentToolSettings = {
|
|
41
|
+
readonly defaultMaxTurns: number | undefined;
|
|
42
|
+
readonly maxConcurrent: number;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ---- Class ----
|
|
46
|
+
|
|
47
|
+
export class AgentTool {
|
|
48
|
+
private readonly typeListText: string;
|
|
49
|
+
private readonly availableTypesText: string;
|
|
50
|
+
|
|
51
|
+
constructor(
|
|
52
|
+
private readonly manager: AgentToolManager,
|
|
53
|
+
private readonly runtime: AgentToolRuntime,
|
|
54
|
+
private readonly settings: AgentToolSettings,
|
|
55
|
+
private readonly registry: AgentTypeRegistry,
|
|
56
|
+
private readonly agentDir: string,
|
|
57
|
+
) {
|
|
58
|
+
this.typeListText = buildTypeListText(registry, agentDir);
|
|
59
|
+
this.availableTypesText = registry.getAvailableTypes().join(", ");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async execute(
|
|
63
|
+
toolCallId: string,
|
|
64
|
+
params: Record<string, unknown>,
|
|
65
|
+
signal: AbortSignal | undefined,
|
|
66
|
+
onUpdate: ((update: AgentToolResult<any>) => void) | undefined,
|
|
67
|
+
_ctx: any,
|
|
68
|
+
) {
|
|
69
|
+
// Reload custom agents so new .pi/agents/*.md files are picked up without restart
|
|
70
|
+
this.registry.reload();
|
|
71
|
+
|
|
72
|
+
// ---- Config resolution (pure) ----
|
|
73
|
+
const config = resolveSpawnConfig(params, this.registry, this.runtime.getModelInfo(), this.settings);
|
|
74
|
+
if ("error" in config) return textResult(config.error);
|
|
75
|
+
|
|
76
|
+
// ---- Boundary extraction (after config so inheritContext is resolved) ----
|
|
77
|
+
const snapshot = this.runtime.buildSnapshot(config.execution.inheritContext);
|
|
78
|
+
const { parentSessionFile, parentSessionId } = this.runtime.getSessionInfo();
|
|
79
|
+
const parentSession: ParentSessionInfo = { parentSessionFile, parentSessionId, toolCallId };
|
|
80
|
+
|
|
81
|
+
// ---- Resume existing agent ----
|
|
82
|
+
if (params.resume) {
|
|
83
|
+
const existing = this.manager.getRecord(params.resume as string);
|
|
84
|
+
if (!existing) {
|
|
85
|
+
return textResult(`Agent not found: "${params.resume}". It may have been cleaned up.`);
|
|
86
|
+
}
|
|
87
|
+
if (!existing.isSessionReady()) {
|
|
88
|
+
return textResult(`Agent "${params.resume}" has no active session to resume.`);
|
|
89
|
+
}
|
|
90
|
+
const record = await this.manager.resume(
|
|
91
|
+
params.resume as string,
|
|
92
|
+
params.prompt as string,
|
|
93
|
+
signal ?? new AbortController().signal,
|
|
94
|
+
);
|
|
95
|
+
if (!record) {
|
|
96
|
+
return textResult(`Failed to resume agent "${params.resume}".`);
|
|
97
|
+
}
|
|
98
|
+
return textResult(
|
|
99
|
+
record.result?.trim() ?? record.error?.trim() ?? "No output.",
|
|
100
|
+
buildDetails(config.presentation.detailBase, record),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---- Background execution ----
|
|
105
|
+
if (config.execution.runInBackground) {
|
|
106
|
+
return spawnBackground(this.manager, { config, snapshot, parentSession, settings: this.settings });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---- Foreground execution — stream progress via onUpdate ----
|
|
110
|
+
return runForeground(this.manager, { config, snapshot, parentSession }, signal, onUpdate);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
toToolDefinition() {
|
|
114
|
+
const typeListText = this.typeListText;
|
|
115
|
+
const availableTypesText = this.availableTypesText;
|
|
116
|
+
const agentDir = this.agentDir;
|
|
117
|
+
const registry = this.registry;
|
|
118
|
+
|
|
119
|
+
return defineTool({
|
|
120
|
+
name: "subagent" as const,
|
|
121
|
+
label: "Subagent",
|
|
122
|
+
promptSnippet: "subagent: Launch a specialized agent for complex, multi-step tasks.",
|
|
123
|
+
description: `Launch a new agent to handle complex, multi-step tasks autonomously.
|
|
124
|
+
|
|
125
|
+
The subagent tool launches specialized agents that autonomously handle complex tasks. Each agent type has specific capabilities and tools available to it.
|
|
126
|
+
|
|
127
|
+
Available agent types:
|
|
128
|
+
${typeListText}
|
|
129
|
+
|
|
130
|
+
Guidelines:
|
|
131
|
+
- For parallel work, use run_in_background: true on each agent. Foreground calls run sequentially — only one executes at a time.
|
|
132
|
+
- Use Explore for codebase searches and code understanding.
|
|
133
|
+
- Use Plan for architecture and implementation planning.
|
|
134
|
+
- Use general-purpose for complex tasks that need file editing.
|
|
135
|
+
- Provide clear, detailed prompts so the agent can work autonomously.
|
|
136
|
+
- Subagent results are returned as text — summarize them for the user.
|
|
137
|
+
- Use run_in_background for work you don't need immediately. You will be notified when it completes.
|
|
138
|
+
- Use resume with an agent ID to continue a previous agent's work.
|
|
139
|
+
- Use steer_subagent to send mid-run messages to a running background agent.
|
|
140
|
+
- Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
|
|
141
|
+
- Use thinking to control extended thinking level.
|
|
142
|
+
- Use inherit_context if the agent needs the parent conversation history.
|
|
143
|
+
`,
|
|
144
|
+
parameters: Type.Object({
|
|
145
|
+
prompt: Type.String({
|
|
146
|
+
description: "The task for the agent to perform.",
|
|
147
|
+
}),
|
|
148
|
+
description: Type.String({
|
|
149
|
+
description: "A short (3-5 word) description of the task (shown in UI).",
|
|
150
|
+
}),
|
|
151
|
+
subagent_type: Type.String({
|
|
152
|
+
description: `The type of specialized agent to use. Available types: ${availableTypesText}. Custom agents from .pi/agents/<name>.md (project) or ${agentDir}/agents/<name>.md (global) are also available.`,
|
|
153
|
+
}),
|
|
154
|
+
model: Type.Optional(
|
|
155
|
+
Type.String({
|
|
156
|
+
description:
|
|
157
|
+
'Optional model override. Accepts "provider/modelId" or fuzzy name (e.g. "haiku", "sonnet"). Omit to use the agent type\'s default.',
|
|
158
|
+
}),
|
|
159
|
+
),
|
|
160
|
+
thinking: Type.Optional(
|
|
161
|
+
Type.String({
|
|
162
|
+
description: "Thinking level: off, minimal, low, medium, high, xhigh. Overrides agent default.",
|
|
163
|
+
}),
|
|
164
|
+
),
|
|
165
|
+
max_turns: Type.Optional(
|
|
166
|
+
Type.Number({
|
|
167
|
+
description: "Maximum number of agentic turns before stopping. Omit for unlimited (default).",
|
|
168
|
+
minimum: 1,
|
|
169
|
+
}),
|
|
170
|
+
),
|
|
171
|
+
run_in_background: Type.Optional(
|
|
172
|
+
Type.Boolean({
|
|
173
|
+
description:
|
|
174
|
+
"Set to true to run in background. Returns agent ID immediately. You will be notified when it completes.",
|
|
175
|
+
}),
|
|
176
|
+
),
|
|
177
|
+
resume: Type.Optional(
|
|
178
|
+
Type.String({
|
|
179
|
+
description: "Optional agent ID to resume from. Continues from previous context.",
|
|
180
|
+
}),
|
|
181
|
+
),
|
|
182
|
+
inherit_context: Type.Optional(
|
|
183
|
+
Type.Boolean({
|
|
184
|
+
description: "If true, fork parent conversation into the agent. Default: false (fresh context).",
|
|
185
|
+
}),
|
|
186
|
+
),
|
|
187
|
+
}),
|
|
188
|
+
|
|
189
|
+
// ---- Custom rendering: inline subagent results ----
|
|
190
|
+
|
|
191
|
+
renderCall(args: Record<string, unknown>, theme: any) {
|
|
192
|
+
const displayName = args.subagent_type ? getDisplayName(args.subagent_type as string, registry) : "Subagent";
|
|
193
|
+
const desc = (args.description as string | undefined) ?? "";
|
|
194
|
+
return new Text(
|
|
195
|
+
"▸ " + theme.fg("toolTitle", theme.bold(displayName)) + (desc ? " " + theme.fg("muted", desc) : ""),
|
|
196
|
+
0,
|
|
197
|
+
0,
|
|
198
|
+
);
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
renderResult(result: any, { expanded, isPartial }: any, theme: any) {
|
|
202
|
+
const details = result.details as AgentDetails | undefined;
|
|
203
|
+
if (!details) {
|
|
204
|
+
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
205
|
+
return new Text(text, 0, 0);
|
|
206
|
+
}
|
|
207
|
+
const resultText = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
208
|
+
return new Text(renderAgentResult(details, resultText, expanded, isPartial, theme), 0, 0);
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
execute: (
|
|
212
|
+
toolCallId: string,
|
|
213
|
+
params: Record<string, unknown>,
|
|
214
|
+
signal: AbortSignal | undefined,
|
|
215
|
+
onUpdate: ((update: AgentToolResult<any>) => void) | undefined,
|
|
216
|
+
ctx: any,
|
|
217
|
+
) => this.execute(toolCallId, params, signal, onUpdate, ctx),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ParentSnapshot } from "../lifecycle/parent-snapshot";
|
|
2
|
+
import type { AgentSpawnConfig } from "../lifecycle/subagent-manager";
|
|
3
|
+
import { textResult } from "../tools/helpers";
|
|
4
|
+
import type { ResolvedSpawnConfig } from "../tools/spawn-config";
|
|
5
|
+
import type { ParentSessionInfo, Subagent } from "../types";
|
|
6
|
+
|
|
7
|
+
/** Narrow manager interface for the background spawner. */
|
|
8
|
+
export interface BackgroundManagerDeps {
|
|
9
|
+
spawn(snapshot: ParentSnapshot, type: string, prompt: string, opts: AgentSpawnConfig): string;
|
|
10
|
+
getRecord(id: string): Subagent | undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** All values the background spawner needs beyond the resolved config. */
|
|
14
|
+
export interface BackgroundParams {
|
|
15
|
+
config: ResolvedSpawnConfig;
|
|
16
|
+
snapshot: ParentSnapshot;
|
|
17
|
+
parentSession: ParentSessionInfo;
|
|
18
|
+
settings: { readonly maxConcurrent: number };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Spawn a background agent and return the tool result immediately.
|
|
23
|
+
* Owns: launch message formatting.
|
|
24
|
+
*/
|
|
25
|
+
export function spawnBackground(manager: BackgroundManagerDeps, params: BackgroundParams) {
|
|
26
|
+
const { identity, execution, presentation } = params.config;
|
|
27
|
+
|
|
28
|
+
let id: string;
|
|
29
|
+
try {
|
|
30
|
+
id = manager.spawn(params.snapshot, identity.subagentType, execution.prompt, {
|
|
31
|
+
parentSession: params.parentSession,
|
|
32
|
+
description: execution.description,
|
|
33
|
+
model: execution.model,
|
|
34
|
+
maxTurns: execution.effectiveMaxTurns,
|
|
35
|
+
inheritContext: execution.inheritContext,
|
|
36
|
+
thinkingLevel: execution.thinking,
|
|
37
|
+
isBackground: true,
|
|
38
|
+
invocation: execution.agentInvocation,
|
|
39
|
+
});
|
|
40
|
+
} catch (err) {
|
|
41
|
+
return textResult(err instanceof Error ? err.message : String(err));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const record = manager.getRecord(id);
|
|
45
|
+
|
|
46
|
+
const isQueued = record?.status === "queued";
|
|
47
|
+
return textResult(
|
|
48
|
+
`Agent ${isQueued ? "queued" : "started"} in background.\n` +
|
|
49
|
+
`Agent ID: ${id}\n` +
|
|
50
|
+
`Type: ${identity.displayName}\n` +
|
|
51
|
+
`Description: ${execution.description}\n` +
|
|
52
|
+
(record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
|
|
53
|
+
(isQueued ? `Position: queued (max ${params.settings.maxConcurrent} concurrent)\n` : "") +
|
|
54
|
+
`\nYou will be notified when this agent completes.\n` +
|
|
55
|
+
`Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
|
|
56
|
+
`Do not duplicate this agent's work.`,
|
|
57
|
+
{
|
|
58
|
+
...presentation.detailBase,
|
|
59
|
+
toolUses: 0,
|
|
60
|
+
tokens: "",
|
|
61
|
+
durationMs: 0,
|
|
62
|
+
status: "background" as const,
|
|
63
|
+
agentId: id,
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ParentSnapshot } from "../lifecycle/parent-snapshot";
|
|
3
|
+
import type { AgentSpawnConfig } from "../lifecycle/subagent-manager";
|
|
4
|
+
import { buildDetails, formatLifetimeTokens, getStatusNote, textResult } from "../tools/helpers";
|
|
5
|
+
import type { ResolvedSpawnConfig } from "../tools/spawn-config";
|
|
6
|
+
import type { ParentSessionInfo, Subagent } from "../types";
|
|
7
|
+
import { type AgentDetails, describeActivity, formatMs, SPINNER } from "../ui/display";
|
|
8
|
+
|
|
9
|
+
/** Narrow manager interface for the foreground runner. */
|
|
10
|
+
export interface ForegroundManagerDeps {
|
|
11
|
+
spawnAndWait(
|
|
12
|
+
snapshot: ParentSnapshot,
|
|
13
|
+
type: string,
|
|
14
|
+
prompt: string,
|
|
15
|
+
opts: Omit<AgentSpawnConfig, "isBackground">,
|
|
16
|
+
): Promise<Subagent>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** All values the foreground runner needs beyond the resolved config. */
|
|
20
|
+
export interface ForegroundParams {
|
|
21
|
+
config: ResolvedSpawnConfig;
|
|
22
|
+
snapshot: ParentSnapshot;
|
|
23
|
+
parentSession: ParentSessionInfo;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Run an agent synchronously in the foreground, streaming spinner updates.
|
|
28
|
+
* Owns: spinner interval, streaming onUpdate callbacks, cleanup, and result formatting.
|
|
29
|
+
*/
|
|
30
|
+
export async function runForeground(
|
|
31
|
+
manager: ForegroundManagerDeps,
|
|
32
|
+
params: ForegroundParams,
|
|
33
|
+
signal: AbortSignal | undefined,
|
|
34
|
+
onUpdate: ((update: AgentToolResult<any>) => void) | undefined,
|
|
35
|
+
) {
|
|
36
|
+
const { identity, execution, presentation } = params.config;
|
|
37
|
+
let spinnerFrame = 0;
|
|
38
|
+
const startedAt = Date.now();
|
|
39
|
+
|
|
40
|
+
let recordRef: Subagent | undefined;
|
|
41
|
+
|
|
42
|
+
const streamUpdate = () => {
|
|
43
|
+
const toolUses = recordRef?.toolUses ?? 0;
|
|
44
|
+
const details: AgentDetails = {
|
|
45
|
+
...presentation.detailBase,
|
|
46
|
+
toolUses,
|
|
47
|
+
tokens: recordRef ? formatLifetimeTokens(recordRef) : "",
|
|
48
|
+
// Read activity off the record; fall back to safe defaults before onSessionCreated fires
|
|
49
|
+
turnCount: recordRef?.turnCount ?? 1,
|
|
50
|
+
maxTurns: recordRef?.maxTurns ?? execution.effectiveMaxTurns,
|
|
51
|
+
durationMs: Date.now() - startedAt,
|
|
52
|
+
status: "running",
|
|
53
|
+
activity: describeActivity(recordRef?.activeTools ?? new Map(), recordRef?.responseText ?? ""),
|
|
54
|
+
spinnerFrame: spinnerFrame % SPINNER.length,
|
|
55
|
+
};
|
|
56
|
+
onUpdate?.({
|
|
57
|
+
content: [{ type: "text", text: `${toolUses} tool uses...` }],
|
|
58
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Pi SDK ToolCallUpdate details type is not exported
|
|
59
|
+
details: details as any,
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Animate spinner at ~80ms (smooth rotation through 10 braille frames)
|
|
64
|
+
const spinnerInterval = setInterval(() => {
|
|
65
|
+
spinnerFrame++;
|
|
66
|
+
streamUpdate();
|
|
67
|
+
}, 80);
|
|
68
|
+
|
|
69
|
+
streamUpdate();
|
|
70
|
+
|
|
71
|
+
let record: Subagent;
|
|
72
|
+
try {
|
|
73
|
+
record = await manager.spawnAndWait(params.snapshot, identity.subagentType, execution.prompt, {
|
|
74
|
+
description: execution.description,
|
|
75
|
+
model: execution.model,
|
|
76
|
+
maxTurns: execution.effectiveMaxTurns,
|
|
77
|
+
inheritContext: execution.inheritContext,
|
|
78
|
+
thinkingLevel: execution.thinking,
|
|
79
|
+
invocation: execution.agentInvocation,
|
|
80
|
+
signal,
|
|
81
|
+
parentSession: params.parentSession,
|
|
82
|
+
observer: {
|
|
83
|
+
onSessionCreated: (agent) => {
|
|
84
|
+
recordRef = agent;
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
} catch (err) {
|
|
89
|
+
clearInterval(spinnerInterval);
|
|
90
|
+
return textResult(err instanceof Error ? err.message : String(err));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
clearInterval(spinnerInterval);
|
|
94
|
+
|
|
95
|
+
const tokenText = formatLifetimeTokens(record);
|
|
96
|
+
const details = buildDetails(presentation.detailBase, record, { tokens: tokenText });
|
|
97
|
+
|
|
98
|
+
const fallbackNote = identity.fellBack
|
|
99
|
+
? `Note: Unknown agent type "${identity.rawType}" — using general-purpose.\n\n`
|
|
100
|
+
: "";
|
|
101
|
+
|
|
102
|
+
if (record.status === "error") {
|
|
103
|
+
return textResult(`${fallbackNote}Agent failed: ${record.error}`, details);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
|
|
107
|
+
const statsParts = [`${record.toolUses} tool uses`];
|
|
108
|
+
if (tokenText) statsParts.push(tokenText);
|
|
109
|
+
return textResult(
|
|
110
|
+
`${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getStatusNote(record.status)}.\n\n` +
|
|
111
|
+
(record.result?.trim() ?? "No output."),
|
|
112
|
+
details,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import type { AgentConfigLookup } from "../config/agent-types";
|
|
4
|
+
import { formatLifetimeTokens, textResult } from "../tools/helpers";
|
|
5
|
+
import type { Subagent } from "../types";
|
|
6
|
+
import { formatDuration, getDisplayName } from "../ui/display";
|
|
7
|
+
|
|
8
|
+
// ---- Deps interfaces ----
|
|
9
|
+
|
|
10
|
+
export interface GetResultToolManager {
|
|
11
|
+
getRecord(id: string): Subagent | undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface GetResultToolNotifications {
|
|
15
|
+
cancelNudge(key: string): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ---- Class ----
|
|
19
|
+
|
|
20
|
+
export class GetResultTool {
|
|
21
|
+
constructor(
|
|
22
|
+
private readonly manager: GetResultToolManager,
|
|
23
|
+
private readonly notifications: GetResultToolNotifications,
|
|
24
|
+
private readonly registry: AgentConfigLookup,
|
|
25
|
+
) {}
|
|
26
|
+
|
|
27
|
+
async execute(
|
|
28
|
+
_toolCallId: string,
|
|
29
|
+
params: { agent_id: string; wait?: boolean; verbose?: boolean },
|
|
30
|
+
_signal: AbortSignal,
|
|
31
|
+
_onUpdate: unknown,
|
|
32
|
+
_ctx: unknown,
|
|
33
|
+
) {
|
|
34
|
+
const record = this.manager.getRecord(params.agent_id);
|
|
35
|
+
if (!record) {
|
|
36
|
+
return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Wait for completion if requested.
|
|
40
|
+
// Pre-mark resultConsumed BEFORE awaiting: onComplete fires inside .then()
|
|
41
|
+
// (attached earlier at spawn time) and always runs before this await resumes.
|
|
42
|
+
// Setting the flag here prevents a redundant follow-up notification.
|
|
43
|
+
if (params.wait && record.status === "running" && record.promise) {
|
|
44
|
+
// Pre-mark consumed BEFORE awaiting — onComplete fires inside .then() and
|
|
45
|
+
// always runs before this await resumes. Prevents a redundant notification.
|
|
46
|
+
record.notification?.markConsumed();
|
|
47
|
+
this.notifications.cancelNudge(params.agent_id);
|
|
48
|
+
await record.promise;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const displayName = getDisplayName(record.type, this.registry);
|
|
52
|
+
const duration = formatDuration(record.startedAt, record.completedAt);
|
|
53
|
+
const tokens = formatLifetimeTokens(record);
|
|
54
|
+
const contextPercent = record.getContextPercent();
|
|
55
|
+
const statsParts = [`Tool uses: ${record.toolUses}`];
|
|
56
|
+
if (tokens) statsParts.push(tokens);
|
|
57
|
+
if (contextPercent !== null) statsParts.push(`Context: ${Math.round(contextPercent)}%`);
|
|
58
|
+
if (record.compactionCount) statsParts.push(`Compactions: ${record.compactionCount}`);
|
|
59
|
+
statsParts.push(`Duration: ${duration}`);
|
|
60
|
+
|
|
61
|
+
let output =
|
|
62
|
+
`Agent: ${record.id}\n` +
|
|
63
|
+
`Type: ${displayName} | Status: ${record.status} | ${statsParts.join(" | ")}\n` +
|
|
64
|
+
`Description: ${record.description}\n\n`;
|
|
65
|
+
|
|
66
|
+
if (record.status === "running") {
|
|
67
|
+
output += "Agent is still running. Use wait: true or check back later.";
|
|
68
|
+
} else if (record.status === "error") {
|
|
69
|
+
output += `Error: ${record.error}`;
|
|
70
|
+
} else {
|
|
71
|
+
output += record.result?.trim() ?? "No output.";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Mark result as consumed — suppresses the completion notification
|
|
75
|
+
if (record.status !== "running" && record.status !== "queued") {
|
|
76
|
+
record.notification?.markConsumed();
|
|
77
|
+
this.notifications.cancelNudge(params.agent_id);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Verbose: include full conversation
|
|
81
|
+
const conversation = params.verbose ? record.getConversation() : undefined;
|
|
82
|
+
if (conversation) {
|
|
83
|
+
output += `\n\n--- Agent Conversation ---\n${conversation}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return textResult(output);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
toToolDefinition() {
|
|
90
|
+
return defineTool({
|
|
91
|
+
name: "get_subagent_result" as const,
|
|
92
|
+
label: "Get Agent Result",
|
|
93
|
+
promptSnippet: "get_subagent_result: Check status and retrieve results from a background agent.",
|
|
94
|
+
description:
|
|
95
|
+
"Check status and retrieve results from a background agent. Use the agent ID returned by Agent with run_in_background.",
|
|
96
|
+
parameters: Type.Object({
|
|
97
|
+
agent_id: Type.String({
|
|
98
|
+
description: "The agent ID to check.",
|
|
99
|
+
}),
|
|
100
|
+
wait: Type.Optional(
|
|
101
|
+
Type.Boolean({
|
|
102
|
+
description: "If true, wait for the agent to complete before returning. Default: false.",
|
|
103
|
+
}),
|
|
104
|
+
),
|
|
105
|
+
verbose: Type.Optional(
|
|
106
|
+
Type.Boolean({
|
|
107
|
+
description: "If true, include the agent's full conversation (messages + tool calls). Default: false.",
|
|
108
|
+
}),
|
|
109
|
+
),
|
|
110
|
+
}),
|
|
111
|
+
execute: (
|
|
112
|
+
toolCallId: string,
|
|
113
|
+
params: { agent_id: string; wait?: boolean; verbose?: boolean },
|
|
114
|
+
signal: AbortSignal,
|
|
115
|
+
onUpdate: unknown,
|
|
116
|
+
ctx: unknown,
|
|
117
|
+
) => this.execute(toolCallId, params, signal, onUpdate, ctx),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { AgentConfigLookup } from "../config/agent-types";
|
|
2
|
+
import { getLifetimeTotal, type LifetimeUsage } from "../lifecycle/usage";
|
|
3
|
+
import { type AgentDetails, formatTokens } from "../ui/display";
|
|
4
|
+
|
|
5
|
+
/** Parenthetical status note for completed agent result text. */
|
|
6
|
+
export function getStatusNote(status: string): string {
|
|
7
|
+
switch (status) {
|
|
8
|
+
case "aborted":
|
|
9
|
+
return " (aborted \u2014 max turns exceeded, output may be incomplete)";
|
|
10
|
+
case "steered":
|
|
11
|
+
return " (wrapped up \u2014 reached turn limit)";
|
|
12
|
+
case "stopped":
|
|
13
|
+
return " (stopped by user)";
|
|
14
|
+
default:
|
|
15
|
+
return "";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Build AgentDetails from a base + record-specific fields. */
|
|
20
|
+
export function buildDetails(
|
|
21
|
+
base: Pick<AgentDetails, "displayName" | "description" | "subagentType" | "modelName" | "tags">,
|
|
22
|
+
record: {
|
|
23
|
+
toolUses: number;
|
|
24
|
+
startedAt: number;
|
|
25
|
+
completedAt?: number;
|
|
26
|
+
status: string;
|
|
27
|
+
error?: string;
|
|
28
|
+
id?: string;
|
|
29
|
+
lifetimeUsage: LifetimeUsage;
|
|
30
|
+
/** Live-activity counters — exposed as getters on Subagent (Phase 18 Step 2). */
|
|
31
|
+
turnCount?: number;
|
|
32
|
+
maxTurns?: number;
|
|
33
|
+
},
|
|
34
|
+
overrides?: Partial<AgentDetails>,
|
|
35
|
+
): AgentDetails {
|
|
36
|
+
return {
|
|
37
|
+
...base,
|
|
38
|
+
toolUses: record.toolUses,
|
|
39
|
+
tokens: formatLifetimeTokens(record),
|
|
40
|
+
turnCount: record.turnCount,
|
|
41
|
+
maxTurns: record.maxTurns,
|
|
42
|
+
durationMs: (record.completedAt ?? Date.now()) - record.startedAt,
|
|
43
|
+
status: record.status as AgentDetails["status"],
|
|
44
|
+
agentId: record.id,
|
|
45
|
+
error: record.error,
|
|
46
|
+
...overrides,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Tool execute return value for a text response. */
|
|
51
|
+
export function textResult(msg: string, details?: unknown) {
|
|
52
|
+
return { content: [{ type: "text" as const, text: msg }], details };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Format an agent's lifetime token total, or "" when zero. */
|
|
56
|
+
export function formatLifetimeTokens(o: { lifetimeUsage: LifetimeUsage }): string {
|
|
57
|
+
const t = getLifetimeTotal(o.lifetimeUsage);
|
|
58
|
+
return t > 0 ? formatTokens(t) : "";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Narrow registry interface needed by buildTypeListText.
|
|
63
|
+
* Extends AgentConfigLookup with the two name-listing methods.
|
|
64
|
+
*/
|
|
65
|
+
export interface TypeListRegistry extends AgentConfigLookup {
|
|
66
|
+
getDefaultAgentNames(): string[];
|
|
67
|
+
getUserAgentNames(): string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build the full agent-type list text for the Agent tool description.
|
|
72
|
+
* Extracted from index.ts so it can be called inside createAgentTool.
|
|
73
|
+
*/
|
|
74
|
+
export function buildTypeListText(registry: TypeListRegistry, agentDir: string): string {
|
|
75
|
+
const isEnabled = (name: string) => registry.resolveAgentConfig(name).enabled !== false;
|
|
76
|
+
const defaultNames = registry.getDefaultAgentNames().filter(isEnabled);
|
|
77
|
+
const userNames = registry.getUserAgentNames().filter(isEnabled);
|
|
78
|
+
|
|
79
|
+
const defaultDescs = defaultNames.map((name) => {
|
|
80
|
+
const cfg = registry.resolveAgentConfig(name);
|
|
81
|
+
const modelSuffix = cfg.model ? ` (${getModelLabelFromConfig(cfg.model)})` : "";
|
|
82
|
+
return `- ${name}: ${cfg.description}${modelSuffix}`;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const customDescs = userNames.map((name) => {
|
|
86
|
+
const cfg = registry.resolveAgentConfig(name);
|
|
87
|
+
return `- ${name}: ${cfg.description}`;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return [
|
|
91
|
+
"Default agents:",
|
|
92
|
+
...defaultDescs,
|
|
93
|
+
...(customDescs.length > 0 ? ["", "Custom agents:", ...customDescs] : []),
|
|
94
|
+
"",
|
|
95
|
+
`Custom agents can be defined in .pi/agents/<name>.md (project) or ${agentDir}/agents/<name>.md (global) — they are picked up automatically. Project-level agents override global ones. Creating a .md file with the same name as a default agent overrides it.`,
|
|
96
|
+
].join("\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Derive a short model label from a model string. */
|
|
100
|
+
export function getModelLabelFromConfig(model: string): string {
|
|
101
|
+
// Strip provider prefix (e.g. "anthropic/claude-sonnet-4-6" → "claude-sonnet-4-6")
|
|
102
|
+
const name = model.includes("/") ? model.split("/").pop()! : model;
|
|
103
|
+
// Strip trailing date suffix (e.g. "claude-haiku-4-5-20251001" → "claude-haiku-4-5")
|
|
104
|
+
return name.replace(/-\d{8}$/, "");
|
|
105
|
+
}
|