@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.
@@ -1,102 +1,127 @@
1
- import type { AgentSession } from "@earendil-works/pi-coding-agent";
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
- /** Create the get_subagent_result tool definition (without Pi SDK wrapper). */
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
- // Wait for completion if requested.
51
- // Pre-mark resultConsumed BEFORE awaiting: onComplete fires inside .then()
52
- // (attached earlier at spawn time) and always runs before this await resumes.
53
- // Setting the flag here prevents a redundant follow-up notification.
54
- if (params.wait && record.status === "running" && record.promise) {
55
- // Pre-mark consumed BEFORE awaiting — onComplete fires inside .then() and
56
- // always runs before this await resumes. Prevents a redundant notification.
57
- record.notification?.markConsumed();
58
- cancelNudge(params.agent_id);
59
- await record.promise;
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
- const displayName = getDisplayName(record.type, registry);
63
- const duration = formatDuration(record.startedAt, record.completedAt);
64
- const tokens = formatLifetimeTokens(record);
65
- const contextPercent = getSessionContextPercent(record.session);
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
- let output =
73
- `Agent: ${record.id}\n` +
74
- `Type: ${displayName} | Status: ${record.status} | ${statsParts.join(" | ")}\n` +
75
- `Description: ${record.description}\n\n`;
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
- if (record.status === "running") {
78
- output += "Agent is still running. Use wait: true or check back later.";
79
- } else if (record.status === "error") {
80
- output += `Error: ${record.error}`;
81
- } else {
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
- // Mark result as consumed — suppresses the completion notification
86
- if (record.status !== "running" && record.status !== "queued") {
87
- record.notification?.markConsumed();
88
- cancelNudge(params.agent_id);
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
- // Verbose: include full conversation
92
- if (params.verbose && record.session) {
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
- return textResult(output);
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
  }
@@ -1,84 +1,106 @@
1
- import type { AgentSession } from "@earendil-works/pi-coding-agent";
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
- /** Create the steer_subagent tool definition (without Pi SDK wrapper). */
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
- try {
60
- await steerAgent(session, params.message);
61
- emitEvent("subagents:steered", { id: record.id, message: params.message });
62
- const tokens = formatLifetimeTokens(record);
63
- const contextPercent = getSessionContextPercent(session);
64
- const stateParts: string[] = [];
65
- if (tokens) stateParts.push(tokens);
66
- stateParts.push(`${record.toolUses} tool ${record.toolUses === 1 ? "use" : "uses"}`);
67
- if (contextPercent !== null)
68
- stateParts.push(`context ${Math.round(contextPercent)}% full`);
69
- if (record.compactionCount)
70
- stateParts.push(
71
- `${record.compactionCount} compaction${record.compactionCount === 1 ? "" : "s"}`,
72
- );
73
- return textResult(
74
- `Steering message sent to agent ${record.id}. The agent will process it after its current tool execution.\n` +
75
- `Current state: ${stateParts.join(" · ")}`,
76
- );
77
- } catch (err) {
78
- return textResult(
79
- `Failed to steer agent: ${err instanceof Error ? err.message : String(err)}`,
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
  }