@gotgenes/pi-subagents 4.0.0 → 4.1.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 +26 -0
- package/docs/plans/0053-extract-model-resolution-from-execute.md +181 -0
- package/docs/plans/0054-decompose-index-into-modules.md +302 -0
- package/docs/retro/0053-extract-model-resolution-from-execute.md +30 -0
- package/package.json +2 -2
- package/src/index.ts +87 -1443
- package/src/model-resolver.ts +39 -0
- package/src/notification.ts +188 -0
- package/src/renderer.ts +67 -0
- package/src/tools/agent-tool.ts +634 -0
- package/src/tools/get-result-tool.ts +99 -0
- package/src/tools/helpers.ts +21 -0
- package/src/tools/steer-tool.ts +83 -0
- package/src/ui/agent-menu.ts +685 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import type { AgentRecord } from "../types.js";
|
|
3
|
+
import { formatDuration, getDisplayName } from "../ui/agent-widget.js";
|
|
4
|
+
import { getSessionContextPercent } from "../usage.js";
|
|
5
|
+
import { formatLifetimeTokens, textResult } from "./helpers.js";
|
|
6
|
+
|
|
7
|
+
/** Narrow deps — only the methods this tool's execute callback calls. */
|
|
8
|
+
export interface GetResultDeps {
|
|
9
|
+
getRecord: (id: string) => AgentRecord | undefined;
|
|
10
|
+
cancelNudge: (key: string) => void;
|
|
11
|
+
getConversation: (session: unknown) => string | undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Create the get_subagent_result tool definition (without Pi SDK wrapper). */
|
|
15
|
+
export function createGetResultTool(deps: GetResultDeps) {
|
|
16
|
+
return {
|
|
17
|
+
name: "get_subagent_result" as const,
|
|
18
|
+
label: "Get Agent Result",
|
|
19
|
+
description:
|
|
20
|
+
"Check status and retrieve results from a background agent. Use the agent ID returned by Agent with run_in_background.",
|
|
21
|
+
parameters: Type.Object({
|
|
22
|
+
agent_id: Type.String({
|
|
23
|
+
description: "The agent ID to check.",
|
|
24
|
+
}),
|
|
25
|
+
wait: Type.Optional(
|
|
26
|
+
Type.Boolean({
|
|
27
|
+
description: "If true, wait for the agent to complete before returning. Default: false.",
|
|
28
|
+
}),
|
|
29
|
+
),
|
|
30
|
+
verbose: Type.Optional(
|
|
31
|
+
Type.Boolean({
|
|
32
|
+
description:
|
|
33
|
+
"If true, include the agent's full conversation (messages + tool calls). Default: false.",
|
|
34
|
+
}),
|
|
35
|
+
),
|
|
36
|
+
}),
|
|
37
|
+
execute: async (
|
|
38
|
+
_toolCallId: string,
|
|
39
|
+
params: { agent_id: string; wait?: boolean; verbose?: boolean },
|
|
40
|
+
_signal: AbortSignal,
|
|
41
|
+
_onUpdate: unknown,
|
|
42
|
+
_ctx: unknown,
|
|
43
|
+
) => {
|
|
44
|
+
const record = deps.getRecord(params.agent_id);
|
|
45
|
+
if (!record) {
|
|
46
|
+
return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Wait for completion if requested.
|
|
50
|
+
// Pre-mark resultConsumed BEFORE awaiting: onComplete fires inside .then()
|
|
51
|
+
// (attached earlier at spawn time) and always runs before this await resumes.
|
|
52
|
+
// Setting the flag here prevents a redundant follow-up notification.
|
|
53
|
+
if (params.wait && record.status === "running" && record.promise) {
|
|
54
|
+
record.resultConsumed = true;
|
|
55
|
+
deps.cancelNudge(params.agent_id);
|
|
56
|
+
await record.promise;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const displayName = getDisplayName(record.type);
|
|
60
|
+
const duration = formatDuration(record.startedAt, record.completedAt);
|
|
61
|
+
const tokens = formatLifetimeTokens(record);
|
|
62
|
+
const contextPercent = getSessionContextPercent(record.session);
|
|
63
|
+
const statsParts = [`Tool uses: ${record.toolUses}`];
|
|
64
|
+
if (tokens) statsParts.push(tokens);
|
|
65
|
+
if (contextPercent !== null) statsParts.push(`Context: ${Math.round(contextPercent)}%`);
|
|
66
|
+
if (record.compactionCount) statsParts.push(`Compactions: ${record.compactionCount}`);
|
|
67
|
+
statsParts.push(`Duration: ${duration}`);
|
|
68
|
+
|
|
69
|
+
let output =
|
|
70
|
+
`Agent: ${record.id}\n` +
|
|
71
|
+
`Type: ${displayName} | Status: ${record.status} | ${statsParts.join(" | ")}\n` +
|
|
72
|
+
`Description: ${record.description}\n\n`;
|
|
73
|
+
|
|
74
|
+
if (record.status === "running") {
|
|
75
|
+
output += "Agent is still running. Use wait: true or check back later.";
|
|
76
|
+
} else if (record.status === "error") {
|
|
77
|
+
output += `Error: ${record.error}`;
|
|
78
|
+
} else {
|
|
79
|
+
output += record.result?.trim() || "No output.";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Mark result as consumed — suppresses the completion notification
|
|
83
|
+
if (record.status !== "running" && record.status !== "queued") {
|
|
84
|
+
record.resultConsumed = true;
|
|
85
|
+
deps.cancelNudge(params.agent_id);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Verbose: include full conversation
|
|
89
|
+
if (params.verbose && record.session) {
|
|
90
|
+
const conversation = deps.getConversation(record.session);
|
|
91
|
+
if (conversation) {
|
|
92
|
+
output += `\n\n--- Agent Conversation ---\n${conversation}`;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return textResult(output);
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { formatTokens } from "../ui/agent-widget.js";
|
|
2
|
+
import { getLifetimeTotal, type LifetimeUsage } from "../usage.js";
|
|
3
|
+
|
|
4
|
+
/** Tool execute return value for a text response. */
|
|
5
|
+
export function textResult(msg: string, details?: unknown) {
|
|
6
|
+
return { content: [{ type: "text" as const, text: msg }], details: details as any };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Format an agent's lifetime token total, or "" when zero. */
|
|
10
|
+
export function formatLifetimeTokens(o: { lifetimeUsage: LifetimeUsage }): string {
|
|
11
|
+
const t = getLifetimeTotal(o.lifetimeUsage);
|
|
12
|
+
return t > 0 ? formatTokens(t) : "";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Derive a short model label from a model string. */
|
|
16
|
+
export function getModelLabelFromConfig(model: string): string {
|
|
17
|
+
// Strip provider prefix (e.g. "anthropic/claude-sonnet-4-6" → "claude-sonnet-4-6")
|
|
18
|
+
const name = model.includes("/") ? model.split("/").pop()! : model;
|
|
19
|
+
// Strip trailing date suffix (e.g. "claude-haiku-4-5-20251001" → "claude-haiku-4-5")
|
|
20
|
+
return name.replace(/-\d{8}$/, "");
|
|
21
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import type { AgentRecord } from "../types.js";
|
|
3
|
+
import { getSessionContextPercent } from "../usage.js";
|
|
4
|
+
import { formatLifetimeTokens, textResult } from "./helpers.js";
|
|
5
|
+
|
|
6
|
+
/** Narrow deps — only the methods this tool's execute callback calls. */
|
|
7
|
+
export interface SteerToolDeps {
|
|
8
|
+
getRecord: (id: string) => AgentRecord | undefined;
|
|
9
|
+
emitEvent: (name: string, data: unknown) => void;
|
|
10
|
+
steerAgent: (session: unknown, message: string) => Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Create the steer_subagent tool definition (without Pi SDK wrapper). */
|
|
14
|
+
export function createSteerTool(deps: SteerToolDeps) {
|
|
15
|
+
return {
|
|
16
|
+
name: "steer_subagent" as const,
|
|
17
|
+
label: "Steer Agent",
|
|
18
|
+
description:
|
|
19
|
+
"Send a steering message to a running agent. The message will interrupt the agent after its current tool execution " +
|
|
20
|
+
"and be injected into its conversation, allowing you to redirect its work mid-run. Only works on running agents.",
|
|
21
|
+
parameters: Type.Object({
|
|
22
|
+
agent_id: Type.String({
|
|
23
|
+
description: "The agent ID to steer (must be currently running).",
|
|
24
|
+
}),
|
|
25
|
+
message: Type.String({
|
|
26
|
+
description:
|
|
27
|
+
"The steering message to send. This will appear as a user message in the agent's conversation.",
|
|
28
|
+
}),
|
|
29
|
+
}),
|
|
30
|
+
execute: async (
|
|
31
|
+
_toolCallId: string,
|
|
32
|
+
params: { agent_id: string; message: string },
|
|
33
|
+
_signal: AbortSignal,
|
|
34
|
+
_onUpdate: unknown,
|
|
35
|
+
_ctx: unknown,
|
|
36
|
+
) => {
|
|
37
|
+
const record = deps.getRecord(params.agent_id);
|
|
38
|
+
if (!record) {
|
|
39
|
+
return textResult(
|
|
40
|
+
`Agent not found: "${params.agent_id}". It may have been cleaned up.`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
if (record.status !== "running") {
|
|
44
|
+
return textResult(
|
|
45
|
+
`Agent "${params.agent_id}" is not running (status: ${record.status}). Cannot steer a non-running agent.`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
if (!record.session) {
|
|
49
|
+
// Session not ready yet — queue the steer for delivery once initialized
|
|
50
|
+
if (!record.pendingSteers) record.pendingSteers = [];
|
|
51
|
+
record.pendingSteers.push(params.message);
|
|
52
|
+
deps.emitEvent("subagents:steered", { id: record.id, message: params.message });
|
|
53
|
+
return textResult(
|
|
54
|
+
`Steering message queued for agent ${record.id}. It will be delivered once the session initializes.`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
await deps.steerAgent(record.session, params.message);
|
|
60
|
+
deps.emitEvent("subagents:steered", { id: record.id, message: params.message });
|
|
61
|
+
const tokens = formatLifetimeTokens(record);
|
|
62
|
+
const contextPercent = getSessionContextPercent(record.session);
|
|
63
|
+
const stateParts: string[] = [];
|
|
64
|
+
if (tokens) stateParts.push(tokens);
|
|
65
|
+
stateParts.push(`${record.toolUses} tool ${record.toolUses === 1 ? "use" : "uses"}`);
|
|
66
|
+
if (contextPercent !== null)
|
|
67
|
+
stateParts.push(`context ${Math.round(contextPercent)}% full`);
|
|
68
|
+
if (record.compactionCount)
|
|
69
|
+
stateParts.push(
|
|
70
|
+
`${record.compactionCount} compaction${record.compactionCount === 1 ? "" : "s"}`,
|
|
71
|
+
);
|
|
72
|
+
return textResult(
|
|
73
|
+
`Steering message sent to agent ${record.id}. The agent will process it after its current tool execution.\n` +
|
|
74
|
+
`Current state: ${stateParts.join(" · ")}`,
|
|
75
|
+
);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return textResult(
|
|
78
|
+
`Failed to steer agent: ${err instanceof Error ? err.message : String(err)}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|