@gotgenes/pi-subagents 7.2.1 → 7.2.3
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 +5 -5
- package/docs/plans/0195-convert-tool-factories-to-classes.md +264 -0
- package/docs/retro/0194-align-tool-interfaces-for-structural-typing.md +26 -0
- package/docs/retro/0195-convert-tool-factories-to-classes.md +42 -0
- package/package.json +1 -1
- package/src/index.ts +8 -42
- package/src/lifecycle/agent-runner.ts +0 -11
- package/src/tools/agent-tool.ts +228 -216
- package/src/tools/get-result-tool.ts +112 -87
- package/src/tools/steer-tool.ts +99 -77
|
@@ -1,102 +1,127 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "@sinclair/typebox";
|
|
3
3
|
import type { AgentConfigLookup } from "#src/config/agent-types";
|
|
4
|
+
import { getAgentConversation } from "#src/lifecycle/agent-runner";
|
|
4
5
|
import { getSessionContextPercent } from "#src/lifecycle/usage";
|
|
5
6
|
import { formatLifetimeTokens, textResult } from "#src/tools/helpers";
|
|
6
7
|
import type { AgentRecord } from "#src/types";
|
|
7
8
|
import { formatDuration, getDisplayName } from "#src/ui/display";
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
export function createGetResultTool(
|
|
11
|
-
getRecord: (id: string) => AgentRecord | undefined,
|
|
12
|
-
cancelNudge: (key: string) => void,
|
|
13
|
-
getConversation: (session: AgentSession) => string | undefined,
|
|
14
|
-
registry: AgentConfigLookup,
|
|
15
|
-
) {
|
|
16
|
-
return {
|
|
17
|
-
name: "get_subagent_result" as const,
|
|
18
|
-
label: "Get Agent Result",
|
|
19
|
-
promptSnippet: "get_subagent_result: Check status and retrieve results from a background agent.",
|
|
20
|
-
description:
|
|
21
|
-
"Check status and retrieve results from a background agent. Use the agent ID returned by Agent with run_in_background.",
|
|
22
|
-
parameters: Type.Object({
|
|
23
|
-
agent_id: Type.String({
|
|
24
|
-
description: "The agent ID to check.",
|
|
25
|
-
}),
|
|
26
|
-
wait: Type.Optional(
|
|
27
|
-
Type.Boolean({
|
|
28
|
-
description: "If true, wait for the agent to complete before returning. Default: false.",
|
|
29
|
-
}),
|
|
30
|
-
),
|
|
31
|
-
verbose: Type.Optional(
|
|
32
|
-
Type.Boolean({
|
|
33
|
-
description:
|
|
34
|
-
"If true, include the agent's full conversation (messages + tool calls). Default: false.",
|
|
35
|
-
}),
|
|
36
|
-
),
|
|
37
|
-
}),
|
|
38
|
-
execute: async (
|
|
39
|
-
_toolCallId: string,
|
|
40
|
-
params: { agent_id: string; wait?: boolean; verbose?: boolean },
|
|
41
|
-
_signal: AbortSignal,
|
|
42
|
-
_onUpdate: unknown,
|
|
43
|
-
_ctx: unknown,
|
|
44
|
-
) => {
|
|
45
|
-
const record = getRecord(params.agent_id);
|
|
46
|
-
if (!record) {
|
|
47
|
-
return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
|
|
48
|
-
}
|
|
10
|
+
// ---- Deps interfaces ----
|
|
49
11
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
12
|
+
export interface GetResultToolManager {
|
|
13
|
+
getRecord(id: string): AgentRecord | undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface GetResultToolNotifications {
|
|
17
|
+
cancelNudge(key: string): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ---- Class ----
|
|
21
|
+
|
|
22
|
+
export class GetResultTool {
|
|
23
|
+
constructor(
|
|
24
|
+
private readonly manager: GetResultToolManager,
|
|
25
|
+
private readonly notifications: GetResultToolNotifications,
|
|
26
|
+
private readonly registry: AgentConfigLookup,
|
|
27
|
+
) {}
|
|
28
|
+
|
|
29
|
+
async execute(
|
|
30
|
+
_toolCallId: string,
|
|
31
|
+
params: { agent_id: string; wait?: boolean; verbose?: boolean },
|
|
32
|
+
_signal: AbortSignal,
|
|
33
|
+
_onUpdate: unknown,
|
|
34
|
+
_ctx: unknown,
|
|
35
|
+
) {
|
|
36
|
+
const record = this.manager.getRecord(params.agent_id);
|
|
37
|
+
if (!record) {
|
|
38
|
+
return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Wait for completion if requested.
|
|
42
|
+
// Pre-mark resultConsumed BEFORE awaiting: onComplete fires inside .then()
|
|
43
|
+
// (attached earlier at spawn time) and always runs before this await resumes.
|
|
44
|
+
// Setting the flag here prevents a redundant follow-up notification.
|
|
45
|
+
if (params.wait && record.status === "running" && record.promise) {
|
|
46
|
+
// Pre-mark consumed BEFORE awaiting — onComplete fires inside .then() and
|
|
47
|
+
// always runs before this await resumes. Prevents a redundant notification.
|
|
48
|
+
record.notification?.markConsumed();
|
|
49
|
+
this.notifications.cancelNudge(params.agent_id);
|
|
50
|
+
await record.promise;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const displayName = getDisplayName(record.type, this.registry);
|
|
54
|
+
const duration = formatDuration(record.startedAt, record.completedAt);
|
|
55
|
+
const tokens = formatLifetimeTokens(record);
|
|
56
|
+
const contextPercent = getSessionContextPercent(record.session);
|
|
57
|
+
const statsParts = [`Tool uses: ${record.toolUses}`];
|
|
58
|
+
if (tokens) statsParts.push(tokens);
|
|
59
|
+
if (contextPercent !== null) statsParts.push(`Context: ${Math.round(contextPercent)}%`);
|
|
60
|
+
if (record.compactionCount) statsParts.push(`Compactions: ${record.compactionCount}`);
|
|
61
|
+
statsParts.push(`Duration: ${duration}`);
|
|
61
62
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const statsParts = [`Tool uses: ${record.toolUses}`];
|
|
67
|
-
if (tokens) statsParts.push(tokens);
|
|
68
|
-
if (contextPercent !== null) statsParts.push(`Context: ${Math.round(contextPercent)}%`);
|
|
69
|
-
if (record.compactionCount) statsParts.push(`Compactions: ${record.compactionCount}`);
|
|
70
|
-
statsParts.push(`Duration: ${duration}`);
|
|
63
|
+
let output =
|
|
64
|
+
`Agent: ${record.id}\n` +
|
|
65
|
+
`Type: ${displayName} | Status: ${record.status} | ${statsParts.join(" | ")}\n` +
|
|
66
|
+
`Description: ${record.description}\n\n`;
|
|
71
67
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
68
|
+
if (record.status === "running") {
|
|
69
|
+
output += "Agent is still running. Use wait: true or check back later.";
|
|
70
|
+
} else if (record.status === "error") {
|
|
71
|
+
output += `Error: ${record.error}`;
|
|
72
|
+
} else {
|
|
73
|
+
output += record.result?.trim() ?? "No output.";
|
|
74
|
+
}
|
|
76
75
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
output += record.result?.trim() ?? "No output.";
|
|
83
|
-
}
|
|
76
|
+
// Mark result as consumed — suppresses the completion notification
|
|
77
|
+
if (record.status !== "running" && record.status !== "queued") {
|
|
78
|
+
record.notification?.markConsumed();
|
|
79
|
+
this.notifications.cancelNudge(params.agent_id);
|
|
80
|
+
}
|
|
84
81
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
82
|
+
// Verbose: include full conversation
|
|
83
|
+
if (params.verbose && record.session) {
|
|
84
|
+
const conversation = getAgentConversation(record.session);
|
|
85
|
+
if (conversation) {
|
|
86
|
+
output += `\n\n--- Agent Conversation ---\n${conversation}`;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
90
89
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const conversation = getConversation(record.session);
|
|
94
|
-
if (conversation) {
|
|
95
|
-
output += `\n\n--- Agent Conversation ---\n${conversation}`;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
90
|
+
return textResult(output);
|
|
91
|
+
}
|
|
98
92
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
93
|
+
toToolDefinition() {
|
|
94
|
+
return defineTool({
|
|
95
|
+
name: "get_subagent_result" as const,
|
|
96
|
+
label: "Get Agent Result",
|
|
97
|
+
promptSnippet:
|
|
98
|
+
"get_subagent_result: Check status and retrieve results from a background agent.",
|
|
99
|
+
description:
|
|
100
|
+
"Check status and retrieve results from a background agent. Use the agent ID returned by Agent with run_in_background.",
|
|
101
|
+
parameters: Type.Object({
|
|
102
|
+
agent_id: Type.String({
|
|
103
|
+
description: "The agent ID to check.",
|
|
104
|
+
}),
|
|
105
|
+
wait: Type.Optional(
|
|
106
|
+
Type.Boolean({
|
|
107
|
+
description:
|
|
108
|
+
"If true, wait for the agent to complete before returning. Default: false.",
|
|
109
|
+
}),
|
|
110
|
+
),
|
|
111
|
+
verbose: Type.Optional(
|
|
112
|
+
Type.Boolean({
|
|
113
|
+
description:
|
|
114
|
+
"If true, include the agent's full conversation (messages + tool calls). Default: false.",
|
|
115
|
+
}),
|
|
116
|
+
),
|
|
117
|
+
}),
|
|
118
|
+
execute: (
|
|
119
|
+
toolCallId: string,
|
|
120
|
+
params: { agent_id: string; wait?: boolean; verbose?: boolean },
|
|
121
|
+
signal: AbortSignal,
|
|
122
|
+
onUpdate: unknown,
|
|
123
|
+
ctx: unknown,
|
|
124
|
+
) => this.execute(toolCallId, params, signal, onUpdate, ctx),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
102
127
|
}
|
package/src/tools/steer-tool.ts
CHANGED
|
@@ -1,84 +1,106 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "@sinclair/typebox";
|
|
3
3
|
import { getSessionContextPercent } from "#src/lifecycle/usage";
|
|
4
4
|
import { formatLifetimeTokens, textResult } from "#src/tools/helpers";
|
|
5
5
|
import type { AgentRecord } from "#src/types";
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
export function createSteerTool(
|
|
9
|
-
getRecord: (id: string) => AgentRecord | undefined,
|
|
10
|
-
emitEvent: (name: string, data: unknown) => void,
|
|
11
|
-
steerAgent: (session: AgentSession, message: string) => Promise<void>,
|
|
12
|
-
/** Buffer a steer for an agent whose session isn't ready yet. */
|
|
13
|
-
queueSteer: (id: string, message: string) => boolean,
|
|
14
|
-
) {
|
|
15
|
-
return {
|
|
16
|
-
name: "steer_subagent" as const,
|
|
17
|
-
label: "Steer Agent",
|
|
18
|
-
promptSnippet: "steer_subagent: Send a mid-run message to redirect a running background agent.",
|
|
19
|
-
description:
|
|
20
|
-
"Send a steering message to a running agent. The message will interrupt the agent after its current tool execution " +
|
|
21
|
-
"and be injected into its conversation, allowing you to redirect its work mid-run. Only works on running agents.",
|
|
22
|
-
parameters: Type.Object({
|
|
23
|
-
agent_id: Type.String({
|
|
24
|
-
description: "The agent ID to steer (must be currently running).",
|
|
25
|
-
}),
|
|
26
|
-
message: Type.String({
|
|
27
|
-
description:
|
|
28
|
-
"The steering message to send. This will appear as a user message in the agent's conversation.",
|
|
29
|
-
}),
|
|
30
|
-
}),
|
|
31
|
-
execute: async (
|
|
32
|
-
_toolCallId: string,
|
|
33
|
-
params: { agent_id: string; message: string },
|
|
34
|
-
_signal: AbortSignal,
|
|
35
|
-
_onUpdate: unknown,
|
|
36
|
-
_ctx: unknown,
|
|
37
|
-
) => {
|
|
38
|
-
const record = getRecord(params.agent_id);
|
|
39
|
-
if (!record) {
|
|
40
|
-
return textResult(
|
|
41
|
-
`Agent not found: "${params.agent_id}". It may have been cleaned up.`,
|
|
42
|
-
);
|
|
43
|
-
}
|
|
44
|
-
if (record.status !== "running") {
|
|
45
|
-
return textResult(
|
|
46
|
-
`Agent "${params.agent_id}" is not running (status: ${record.status}). Cannot steer a non-running agent.`,
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
const session = record.session;
|
|
50
|
-
if (!session) {
|
|
51
|
-
// Session not ready yet — queue via manager for delivery once initialized
|
|
52
|
-
queueSteer(record.id, params.message);
|
|
53
|
-
emitEvent("subagents:steered", { id: record.id, message: params.message });
|
|
54
|
-
return textResult(
|
|
55
|
-
`Steering message queued for agent ${record.id}. It will be delivered once the session initializes.`,
|
|
56
|
-
);
|
|
57
|
-
}
|
|
7
|
+
// ---- Deps interfaces ----
|
|
58
8
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
9
|
+
export interface SteerToolManager {
|
|
10
|
+
getRecord(id: string): AgentRecord | undefined;
|
|
11
|
+
queueSteer(id: string, message: string): boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SteerToolEvents {
|
|
15
|
+
emit(name: string, data: unknown): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ---- Class ----
|
|
19
|
+
|
|
20
|
+
export class SteerTool {
|
|
21
|
+
constructor(
|
|
22
|
+
private readonly manager: SteerToolManager,
|
|
23
|
+
private readonly events: SteerToolEvents,
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
async execute(
|
|
27
|
+
_toolCallId: string,
|
|
28
|
+
params: { agent_id: string; message: string },
|
|
29
|
+
_signal: AbortSignal,
|
|
30
|
+
_onUpdate: unknown,
|
|
31
|
+
_ctx: unknown,
|
|
32
|
+
) {
|
|
33
|
+
const record = this.manager.getRecord(params.agent_id);
|
|
34
|
+
if (!record) {
|
|
35
|
+
return textResult(
|
|
36
|
+
`Agent not found: "${params.agent_id}". It may have been cleaned up.`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
if (record.status !== "running") {
|
|
40
|
+
return textResult(
|
|
41
|
+
`Agent "${params.agent_id}" is not running (status: ${record.status}). Cannot steer a non-running agent.`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
const session = record.session;
|
|
45
|
+
if (!session) {
|
|
46
|
+
// Session not ready yet — queue via manager for delivery once initialized
|
|
47
|
+
this.manager.queueSteer(record.id, params.message);
|
|
48
|
+
this.events.emit("subagents:steered", { id: record.id, message: params.message });
|
|
49
|
+
return textResult(
|
|
50
|
+
`Steering message queued for agent ${record.id}. It will be delivered once the session initializes.`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
await session.steer(params.message);
|
|
56
|
+
this.events.emit("subagents:steered", { id: record.id, message: params.message });
|
|
57
|
+
const tokens = formatLifetimeTokens(record);
|
|
58
|
+
const contextPercent = getSessionContextPercent(session);
|
|
59
|
+
const stateParts: string[] = [];
|
|
60
|
+
if (tokens) stateParts.push(tokens);
|
|
61
|
+
stateParts.push(`${record.toolUses} tool ${record.toolUses === 1 ? "use" : "uses"}`);
|
|
62
|
+
if (contextPercent !== null)
|
|
63
|
+
stateParts.push(`context ${Math.round(contextPercent)}% full`);
|
|
64
|
+
if (record.compactionCount)
|
|
65
|
+
stateParts.push(
|
|
66
|
+
`${record.compactionCount} compaction${record.compactionCount === 1 ? "" : "s"}`,
|
|
67
|
+
);
|
|
68
|
+
return textResult(
|
|
69
|
+
`Steering message sent to agent ${record.id}. The agent will process it after its current tool execution.\n` +
|
|
70
|
+
`Current state: ${stateParts.join(" · ")}`,
|
|
71
|
+
);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
return textResult(
|
|
74
|
+
`Failed to steer agent: ${err instanceof Error ? err.message : String(err)}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
toToolDefinition() {
|
|
80
|
+
return defineTool({
|
|
81
|
+
name: "steer_subagent" as const,
|
|
82
|
+
label: "Steer Agent",
|
|
83
|
+
promptSnippet:
|
|
84
|
+
"steer_subagent: Send a mid-run message to redirect a running background agent.",
|
|
85
|
+
description:
|
|
86
|
+
"Send a steering message to a running agent. The message will interrupt the agent after its current tool execution " +
|
|
87
|
+
"and be injected into its conversation, allowing you to redirect its work mid-run. Only works on running agents.",
|
|
88
|
+
parameters: Type.Object({
|
|
89
|
+
agent_id: Type.String({
|
|
90
|
+
description: "The agent ID to steer (must be currently running).",
|
|
91
|
+
}),
|
|
92
|
+
message: Type.String({
|
|
93
|
+
description:
|
|
94
|
+
"The steering message to send. This will appear as a user message in the agent's conversation.",
|
|
95
|
+
}),
|
|
96
|
+
}),
|
|
97
|
+
execute: (
|
|
98
|
+
toolCallId: string,
|
|
99
|
+
params: { agent_id: string; message: string },
|
|
100
|
+
signal: AbortSignal,
|
|
101
|
+
onUpdate: unknown,
|
|
102
|
+
ctx: unknown,
|
|
103
|
+
) => this.execute(toolCallId, params, signal, onUpdate, ctx),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
84
106
|
}
|