@gotgenes/pi-subagents 18.0.2 → 18.0.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 +8 -0
- package/docs/architecture/architecture.md +14 -5
- package/package.json +2 -2
- package/src/config/default-agents.ts +3 -0
- package/src/lifecycle/subagent.ts +20 -5
- package/src/runtime.ts +2 -1
- package/src/service/service-adapter.ts +20 -17
- package/src/session/model-resolver.ts +31 -30
- package/src/tools/agent-tool.ts +17 -13
- package/src/tools/helpers.ts +21 -5
- package/src/tools/spawn-config.ts +5 -5
- package/src/tools/steer-tool.ts +37 -27
- package/src/types.ts +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [18.0.3](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v18.0.2...pi-subagents-v18.0.3) (2026-07-15)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-subagents:** omit empty Default agents section header ([a29c324](https://github.com/gotgenes/pi-packages/commit/a29c32498caa4e29f0cc3292d5a6be4f4126c5f8)), closes [#594](https://github.com/gotgenes/pi-packages/issues/594)
|
|
14
|
+
* **pi-subagents:** source subagent guideline copy from agent config ([a2b41a6](https://github.com/gotgenes/pi-packages/commit/a2b41a665586a271b6453b89dabec0bd94be394c)), closes [#594](https://github.com/gotgenes/pi-packages/issues/594)
|
|
15
|
+
|
|
8
16
|
## [18.0.2](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v18.0.1...pi-subagents-v18.0.2) (2026-07-14)
|
|
9
17
|
|
|
10
18
|
|
|
@@ -120,7 +120,7 @@ classDiagram
|
|
|
120
120
|
+run()
|
|
121
121
|
+resume(prompt, signal)
|
|
122
122
|
+abort(): boolean
|
|
123
|
-
+steer(message): Promise~
|
|
123
|
+
+steer(message): Promise~SteerOutcome~
|
|
124
124
|
+isSessionReady(): boolean
|
|
125
125
|
+getConversation(): string | undefined
|
|
126
126
|
+getContextPercent(): number | null
|
|
@@ -977,7 +977,7 @@ Landed: `src/tools/get-result-report.ts` added — `AgentReport` value object pl
|
|
|
977
977
|
|
|
978
978
|
`Release: batch "result-delivery"`
|
|
979
979
|
|
|
980
|
-
#### Step 3 — `Subagent.steer` returns an outcome ([#537])
|
|
980
|
+
#### ✅ Step 3 — `Subagent.steer` returns an outcome ([#537])
|
|
981
981
|
|
|
982
982
|
Smell: Category C (ask-then-tell) — coordinators pre-check status before telling.
|
|
983
983
|
Target files:
|
|
@@ -987,9 +987,13 @@ Target files:
|
|
|
987
987
|
|
|
988
988
|
Outcome: zero steer status pre-checks outside `Subagent.steer`; `steer-tool.execute` cyclomatic drops below 10.
|
|
989
989
|
|
|
990
|
+
Landed: `Subagent.steer` returns a discriminated `SteerOutcome` (`delivered` / `buffered` / `rejected` with the observed status) and owns the non-running rejection as its first guard; `SteerOutcome` is exported from `subagent.ts` and re-exported via `types.ts`.
|
|
991
|
+
`SteerTool.execute` and `SubagentsServiceAdapter.steer` dropped their `status !== "running"` pre-checks and switch on the outcome — the adapter maps `outcome.kind !== "rejected"` to the unchanged public boolean, and the tool's delivered-path stats moved into a private `renderDelivered` helper.
|
|
992
|
+
Zero steer status pre-checks remain outside `Subagent.steer`.
|
|
993
|
+
|
|
990
994
|
`Release: independent`
|
|
991
995
|
|
|
992
|
-
#### Step 4 — Type the model boundary ([#538])
|
|
996
|
+
#### ✅ Step 4 — Type the model boundary ([#538])
|
|
993
997
|
|
|
994
998
|
Smell: Category C (platform type threading) — `ModelRegistry.find/getAll/getAvailable` return `any`, forcing `any`/`unknown` model threading through `model-resolver`, `spawn-config`, `service-adapter`, and `parent-snapshot`.
|
|
995
999
|
Target files:
|
|
@@ -1000,6 +1004,11 @@ Target files:
|
|
|
1000
1004
|
|
|
1001
1005
|
Outcome: `model-resolver.ts` file-level eslint-disable removed; `service-adapter.spawn` off the HIGH CRAP list; `any` model returns eliminated from the resolver.
|
|
1002
1006
|
|
|
1007
|
+
Landed: `ModelRegistry.find/getAll/getAvailable` and `resolveModel`'s return are typed against `Model<any>` from `@earendil-works/pi-ai`; the file-level eslint-disable headers on `model-resolver.ts` (2 rules) and `spawn-config.ts` (4 rules) are both removed — running disable-header tally 5 → 3 (`agent-tool` 6, `agent-widget` 4, `index` 1 remain, all Step 5 scope).
|
|
1008
|
+
`resolveModel`'s fuzzy-scoring loop was extracted to a private `findBestFuzzyMatch` helper, dropping `resolveModel` off the complexity list entirely.
|
|
1009
|
+
`service-adapter.spawn`'s model-resolution branch was extracted to a private `resolveModelOption`, dropping `spawn` from 16 cyclomatic / CRAP 71.3 (HIGH) to 13 cyclomatic / CRAP 49.5 (moderate) — off the HIGH-CRAP list; running HIGH-CRAP tally 2 → 1 remaining (the notification renderer arrow, untouched — Step 7 scope).
|
|
1010
|
+
`resolveInvocationModel` gained a `registry: ModelRegistry | undefined` guard (typed error instead of a crash when a model override is requested with no registry present); the residual `unknown` thread through `ParentSnapshot.model` / `SessionContext.model` is a separate SDK-boundary gap, deferred.
|
|
1011
|
+
|
|
1003
1012
|
`Release: independent`
|
|
1004
1013
|
|
|
1005
1014
|
#### Step 5 — Narrow `tui`/`theme` render interfaces ([#539])
|
|
@@ -1072,8 +1081,8 @@ Outcome: in-package clone groups 9 → ≤ 5; duplicated lines 81 → ≤ 40.
|
|
|
1072
1081
|
flowchart LR
|
|
1073
1082
|
S1["✅ Step 1 (#535)<br/>Result delivery off Subagent"] --> S2["✅ Step 2 (#536)<br/>Decompose get-result-tool"]
|
|
1074
1083
|
S1 -.soft.-> S7["Step 7 (#541)<br/>Decompose notification renderer"]
|
|
1075
|
-
S3["Step 3 (#537)<br/>Steer returns an outcome"]
|
|
1076
|
-
S4["Step 4 (#538)<br/>Type the model boundary"]
|
|
1084
|
+
S3["✅ Step 3 (#537)<br/>Steer returns an outcome"]
|
|
1085
|
+
S4["✅ Step 4 (#538)<br/>Type the model boundary"]
|
|
1077
1086
|
S5["Step 5 (#539)<br/>Narrow tui/theme interfaces"]
|
|
1078
1087
|
S6["Step 6 (#540)<br/>Table-driven settings handler"]
|
|
1079
1088
|
S8["Step 8 (#542)<br/>Full-value SubagentStateInit"] --> S9["Step 9 (#543)<br/>Consolidate test clones"]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotgenes/pi-subagents",
|
|
3
|
-
"version": "18.0.
|
|
3
|
+
"version": "18.0.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"@earendil-works/pi-coding-agent": "0.79.1",
|
|
67
67
|
"@earendil-works/pi-tui": "0.79.1",
|
|
68
68
|
"@types/node": "^22.15.3",
|
|
69
|
-
"rollup": "^4.
|
|
69
|
+
"rollup": "^4.62.2",
|
|
70
70
|
"rollup-plugin-dts": "^6.4.1",
|
|
71
71
|
"rumdl": "^0.2.10",
|
|
72
72
|
"typescript": "^6.0.3",
|
|
@@ -15,6 +15,7 @@ export const DEFAULT_AGENTS: Map<string, AgentConfig> = new Map([
|
|
|
15
15
|
name: "general-purpose",
|
|
16
16
|
displayName: "Agent",
|
|
17
17
|
description: "General-purpose agent for complex, multi-step tasks",
|
|
18
|
+
toolGuideline: "- Use general-purpose for complex tasks that need file editing.",
|
|
18
19
|
// builtinToolNames omitted — means "all available tools" (resolved at lookup time)
|
|
19
20
|
// inheritContext / runInBackground omitted — strategy fields, callers decide per-call.
|
|
20
21
|
// Setting them to false would lock callsite intent (see resolveAgentInvocationConfig in invocation-config.ts).
|
|
@@ -29,6 +30,7 @@ export const DEFAULT_AGENTS: Map<string, AgentConfig> = new Map([
|
|
|
29
30
|
name: "Explore",
|
|
30
31
|
displayName: "Explore",
|
|
31
32
|
description: "Fast codebase exploration agent (read-only)",
|
|
33
|
+
toolGuideline: "- Use Explore for codebase searches and code understanding.",
|
|
32
34
|
builtinToolNames: READ_ONLY_TOOLS,
|
|
33
35
|
model: "anthropic/claude-haiku-4-5-20251001",
|
|
34
36
|
systemPrompt: `# CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS
|
|
@@ -69,6 +71,7 @@ Use Bash ONLY for read-only operations: ls, git status, git log, git diff, find,
|
|
|
69
71
|
name: "Plan",
|
|
70
72
|
displayName: "Plan",
|
|
71
73
|
description: "Software architect for implementation planning (read-only)",
|
|
74
|
+
toolGuideline: "- Use Plan for architecture and implementation planning.",
|
|
72
75
|
builtinToolNames: READ_ONLY_TOOLS,
|
|
73
76
|
systemPrompt: `# CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS
|
|
74
77
|
You are a software architect and planning specialist.
|
|
@@ -35,6 +35,16 @@ export interface SubagentLifecycleObserver {
|
|
|
35
35
|
|
|
36
36
|
export type { SubagentStatus } from "#src/lifecycle/subagent-state";
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The result of a steer attempt. `Subagent.steer` owns the non-running
|
|
40
|
+
* rejection rule and reports it here, so coordinators switch on the outcome
|
|
41
|
+
* instead of pre-checking status (tell by id, with outcomes).
|
|
42
|
+
*/
|
|
43
|
+
export type SteerOutcome =
|
|
44
|
+
| { kind: "delivered" }
|
|
45
|
+
| { kind: "buffered" }
|
|
46
|
+
| { kind: "rejected"; status: SubagentStatus };
|
|
47
|
+
|
|
38
48
|
/**
|
|
39
49
|
* The execution machinery a Subagent needs to run. A single mandatory
|
|
40
50
|
* collaborator: production (SubagentManager.spawn) always supplies it, so run()
|
|
@@ -129,16 +139,21 @@ export class Subagent {
|
|
|
129
139
|
}
|
|
130
140
|
|
|
131
141
|
/**
|
|
132
|
-
*
|
|
133
|
-
* Returns
|
|
142
|
+
* Steer a running agent, owning the non-running rejection rule.
|
|
143
|
+
* Returns a `rejected` outcome (with the observed status) when the agent is
|
|
144
|
+
* not running, a `buffered` outcome when the session is not yet ready, or a
|
|
145
|
+
* `delivered` outcome once the message reaches the session.
|
|
134
146
|
*/
|
|
135
|
-
async steer(message: string): Promise<
|
|
147
|
+
async steer(message: string): Promise<SteerOutcome> {
|
|
148
|
+
if (this.status !== "running") {
|
|
149
|
+
return { kind: "rejected", status: this.status };
|
|
150
|
+
}
|
|
136
151
|
if (!this.subagentSession) {
|
|
137
152
|
this.queueSteer(message);
|
|
138
|
-
return
|
|
153
|
+
return { kind: "buffered" };
|
|
139
154
|
}
|
|
140
155
|
await this.subagentSession.steer(message);
|
|
141
|
-
return
|
|
156
|
+
return { kind: "delivered" };
|
|
142
157
|
}
|
|
143
158
|
|
|
144
159
|
/** Return the session conversation as formatted text, or undefined if no session. */
|
package/src/runtime.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* Follows the same pattern as pi-permission-system's ExtensionRuntime.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
9
10
|
import { buildParentSnapshot, type ParentSnapshot } from "#src/lifecycle/parent-snapshot";
|
|
10
11
|
import type { ModelInfo } from "#src/tools/spawn-config";
|
|
11
12
|
import type { SessionContext } from "#src/types";
|
|
@@ -54,7 +55,7 @@ export class SubagentRuntime {
|
|
|
54
55
|
/** Extract model info from the current session context. */
|
|
55
56
|
getModelInfo(): ModelInfo {
|
|
56
57
|
return {
|
|
57
|
-
parentModel: this.currentCtx?.model as
|
|
58
|
+
parentModel: this.currentCtx?.model as Model<any> | undefined,
|
|
58
59
|
modelRegistry: this.currentCtx?.modelRegistry,
|
|
59
60
|
};
|
|
60
61
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* (stripping non-serializable fields), and session gating.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
8
9
|
import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
|
|
9
10
|
import type { WorkspaceProvider } from "#src/lifecycle/workspace";
|
|
10
11
|
import type { SpawnOptions, SubagentRecord, SubagentsService } from "#src/service/service";
|
|
@@ -35,7 +36,7 @@ export interface ServiceRuntimeLike {
|
|
|
35
36
|
export class SubagentsServiceAdapter implements SubagentsService {
|
|
36
37
|
constructor(
|
|
37
38
|
private readonly manager: SubagentManagerLike,
|
|
38
|
-
private readonly resolveModel: (input: string, registry: ModelRegistry) =>
|
|
39
|
+
private readonly resolveModel: (input: string, registry: ModelRegistry) => Model<any> | string,
|
|
39
40
|
private readonly runtime: ServiceRuntimeLike,
|
|
40
41
|
) {}
|
|
41
42
|
|
|
@@ -44,19 +45,7 @@ export class SubagentsServiceAdapter implements SubagentsService {
|
|
|
44
45
|
throw new Error("No active session — cannot spawn agents outside a session.");
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
|
|
48
|
-
if (options?.model) {
|
|
49
|
-
const registry = this.runtime.currentCtx.modelRegistry;
|
|
50
|
-
if (!registry) {
|
|
51
|
-
throw new Error("No model registry available.");
|
|
52
|
-
}
|
|
53
|
-
const resolved = this.resolveModel(options.model, registry);
|
|
54
|
-
if (typeof resolved === "string") {
|
|
55
|
-
throw new Error(resolved);
|
|
56
|
-
}
|
|
57
|
-
model = resolved;
|
|
58
|
-
}
|
|
59
|
-
|
|
48
|
+
const model = this.resolveModelOption(options?.model);
|
|
60
49
|
const description = options?.description ?? prompt.slice(0, 80);
|
|
61
50
|
const isBackground = !(options?.foreground ?? false);
|
|
62
51
|
|
|
@@ -87,11 +76,11 @@ export class SubagentsServiceAdapter implements SubagentsService {
|
|
|
87
76
|
|
|
88
77
|
async steer(id: string, message: string): Promise<boolean> {
|
|
89
78
|
const record = this.manager.getRecord(id);
|
|
90
|
-
if (record
|
|
79
|
+
if (!record) {
|
|
91
80
|
return false;
|
|
92
81
|
}
|
|
93
|
-
await record.steer(message);
|
|
94
|
-
return
|
|
82
|
+
const outcome = await record.steer(message);
|
|
83
|
+
return outcome.kind !== "rejected";
|
|
95
84
|
}
|
|
96
85
|
|
|
97
86
|
async waitForAll(): Promise<void> {
|
|
@@ -105,6 +94,20 @@ export class SubagentsServiceAdapter implements SubagentsService {
|
|
|
105
94
|
registerWorkspaceProvider(provider: WorkspaceProvider): () => void {
|
|
106
95
|
return this.manager.registerWorkspaceProvider(provider);
|
|
107
96
|
}
|
|
97
|
+
|
|
98
|
+
/** Resolve an optional model-string override against the current session's registry. */
|
|
99
|
+
private resolveModelOption(modelInput: string | undefined): Model<any> | undefined {
|
|
100
|
+
if (!modelInput) return undefined;
|
|
101
|
+
const registry = this.runtime.currentCtx?.modelRegistry;
|
|
102
|
+
if (!registry) {
|
|
103
|
+
throw new Error("No model registry available.");
|
|
104
|
+
}
|
|
105
|
+
const resolved = this.resolveModel(modelInput, registry);
|
|
106
|
+
if (typeof resolved === "string") {
|
|
107
|
+
throw new Error(resolved);
|
|
108
|
+
}
|
|
109
|
+
return resolved;
|
|
110
|
+
}
|
|
108
111
|
}
|
|
109
112
|
|
|
110
113
|
/**
|
|
@@ -1,24 +1,17 @@
|
|
|
1
|
-
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-redundant-type-constituents -- Pi SDK types are not fully exported; see upstream Pi SDK for type improvements */
|
|
2
1
|
/**
|
|
3
2
|
* Model resolution: exact match ("provider/modelId") with fuzzy fallback.
|
|
4
3
|
*/
|
|
5
|
-
|
|
6
|
-
export interface ModelEntry {
|
|
7
|
-
id: string;
|
|
8
|
-
name: string;
|
|
9
|
-
provider: string;
|
|
10
|
-
}
|
|
4
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
11
5
|
|
|
12
6
|
export interface ModelRegistry {
|
|
13
|
-
find(provider: string, modelId: string): any;
|
|
14
|
-
getAll(): any[];
|
|
15
|
-
getAvailable?(): any[];
|
|
7
|
+
find(provider: string, modelId: string): Model<any> | undefined;
|
|
8
|
+
getAll(): Model<any>[];
|
|
9
|
+
getAvailable?(): Model<any>[];
|
|
16
10
|
}
|
|
17
11
|
|
|
18
12
|
/** Successful model resolution — `model` is the resolved or inherited model instance. */
|
|
19
13
|
export interface ModelResolutionResult {
|
|
20
|
-
|
|
21
|
-
model: any;
|
|
14
|
+
model: Model<any> | undefined;
|
|
22
15
|
error?: undefined;
|
|
23
16
|
}
|
|
24
17
|
|
|
@@ -42,12 +35,13 @@ export type ModelResolution = ModelResolutionResult | ModelResolutionError;
|
|
|
42
35
|
* - `modelFromParams` false → silent fallback to `parentModel`.
|
|
43
36
|
*/
|
|
44
37
|
export function resolveInvocationModel(
|
|
45
|
-
parentModel:
|
|
38
|
+
parentModel: Model<any> | undefined,
|
|
46
39
|
modelInput: string | undefined,
|
|
47
40
|
modelFromParams: boolean,
|
|
48
|
-
registry: ModelRegistry,
|
|
41
|
+
registry: ModelRegistry | undefined,
|
|
49
42
|
): ModelResolution {
|
|
50
43
|
if (!modelInput) return { model: parentModel };
|
|
44
|
+
if (!registry) return { error: "No model registry available." };
|
|
51
45
|
const resolved = resolveModel(modelInput, registry);
|
|
52
46
|
if (typeof resolved !== "string") return { model: resolved };
|
|
53
47
|
if (modelFromParams) return { error: resolved };
|
|
@@ -62,9 +56,9 @@ export function resolveInvocationModel(
|
|
|
62
56
|
export function resolveModel(
|
|
63
57
|
input: string,
|
|
64
58
|
registry: ModelRegistry,
|
|
65
|
-
): any | string {
|
|
59
|
+
): Model<any> | string {
|
|
66
60
|
// Available models (those with auth configured)
|
|
67
|
-
const all =
|
|
61
|
+
const all = registry.getAvailable?.() ?? registry.getAll();
|
|
68
62
|
const availableSet = new Set(all.map(m => `${m.provider}/${m.id}`.toLowerCase()));
|
|
69
63
|
|
|
70
64
|
// 1. Exact match: "provider/modelId" — only if available (has auth)
|
|
@@ -79,10 +73,27 @@ export function resolveModel(
|
|
|
79
73
|
}
|
|
80
74
|
|
|
81
75
|
// 2. Fuzzy match against available models
|
|
82
|
-
const
|
|
76
|
+
const bestMatch = findBestFuzzyMatch(all, input.toLowerCase());
|
|
77
|
+
if (bestMatch) {
|
|
78
|
+
const found = registry.find(bestMatch.provider, bestMatch.id);
|
|
79
|
+
if (found) return found;
|
|
80
|
+
}
|
|
83
81
|
|
|
84
|
-
//
|
|
85
|
-
|
|
82
|
+
// 3. No match — list available models
|
|
83
|
+
const modelList = all
|
|
84
|
+
.map(m => ` ${m.provider}/${m.id}`)
|
|
85
|
+
.sort()
|
|
86
|
+
.join("\n");
|
|
87
|
+
return `Model not found: "${input}".\n\nAvailable models:\n${modelList}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Score each candidate model — prefer exact id match > id contains > name
|
|
92
|
+
* contains > provider+id contains — and return the best match at or above
|
|
93
|
+
* the acceptance threshold (20), or undefined if nothing scores high enough.
|
|
94
|
+
*/
|
|
95
|
+
function findBestFuzzyMatch(all: Model<any>[], query: string): Model<any> | undefined {
|
|
96
|
+
let bestMatch: Model<any> | undefined;
|
|
86
97
|
let bestScore = 0;
|
|
87
98
|
|
|
88
99
|
for (const m of all) {
|
|
@@ -107,15 +118,5 @@ export function resolveModel(
|
|
|
107
118
|
}
|
|
108
119
|
}
|
|
109
120
|
|
|
110
|
-
|
|
111
|
-
const found = registry.find(bestMatch.provider, bestMatch.id);
|
|
112
|
-
if (found) return found;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// 3. No match — list available models
|
|
116
|
-
const modelList = all
|
|
117
|
-
.map(m => ` ${m.provider}/${m.id}`)
|
|
118
|
-
.sort()
|
|
119
|
-
.join("\n");
|
|
120
|
-
return `Model not found: "${input}".\n\nAvailable models:\n${modelList}`;
|
|
121
|
+
return bestScore >= 20 ? bestMatch : undefined;
|
|
121
122
|
}
|
package/src/tools/agent-tool.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
|
|
|
8
8
|
import type { AgentSpawnConfig } from "#src/lifecycle/subagent-manager";
|
|
9
9
|
import { spawnBackground } from "#src/tools/background-spawner";
|
|
10
10
|
import { runForeground } from "#src/tools/foreground-runner";
|
|
11
|
-
import { buildDetails, buildTypeListText, textResult } from "#src/tools/helpers";
|
|
11
|
+
import { buildAgentGuidelines, buildDetails, buildTypeListText, textResult } from "#src/tools/helpers";
|
|
12
12
|
import { renderAgentResult } from "#src/tools/result-renderer";
|
|
13
13
|
import { type ModelInfo, resolveSpawnConfig } from "#src/tools/spawn-config";
|
|
14
14
|
import type { ParentSessionInfo, Subagent } from "#src/types";
|
|
@@ -42,6 +42,7 @@ export type AgentToolSettings = {
|
|
|
42
42
|
export class AgentTool {
|
|
43
43
|
private readonly typeListText: string;
|
|
44
44
|
private readonly availableTypesText: string;
|
|
45
|
+
private readonly agentGuidelines: string[];
|
|
45
46
|
|
|
46
47
|
constructor(
|
|
47
48
|
private readonly manager: AgentToolManager,
|
|
@@ -52,6 +53,7 @@ export class AgentTool {
|
|
|
52
53
|
) {
|
|
53
54
|
this.typeListText = buildTypeListText(registry, agentDir);
|
|
54
55
|
this.availableTypesText = registry.getAvailableTypes().join(", ");
|
|
56
|
+
this.agentGuidelines = buildAgentGuidelines(registry);
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
async execute(
|
|
@@ -128,6 +130,19 @@ export class AgentTool {
|
|
|
128
130
|
const agentDir = this.agentDir;
|
|
129
131
|
const registry = this.registry;
|
|
130
132
|
|
|
133
|
+
const guidelines = [
|
|
134
|
+
"- For parallel work, use run_in_background: true on each agent. Foreground calls run sequentially — only one executes at a time.",
|
|
135
|
+
...this.agentGuidelines,
|
|
136
|
+
"- Provide clear, detailed prompts so the agent can work autonomously.",
|
|
137
|
+
"- Subagent results are returned as text — summarize them for the user.",
|
|
138
|
+
"- Use run_in_background for work you don't need immediately. You will be notified when it completes.",
|
|
139
|
+
"- Use resume with an agent ID to continue a previous agent's work.",
|
|
140
|
+
"- Use steer_subagent to send mid-run messages to a running background agent.",
|
|
141
|
+
'- Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").',
|
|
142
|
+
"- Use thinking to control extended thinking level.",
|
|
143
|
+
"- Use inherit_context if the agent needs the parent conversation history.",
|
|
144
|
+
].join("\n");
|
|
145
|
+
|
|
131
146
|
return defineTool({
|
|
132
147
|
name: "subagent" as const,
|
|
133
148
|
label: "Subagent",
|
|
@@ -140,18 +155,7 @@ Available agent types:
|
|
|
140
155
|
${typeListText}
|
|
141
156
|
|
|
142
157
|
Guidelines:
|
|
143
|
-
|
|
144
|
-
- Use Explore for codebase searches and code understanding.
|
|
145
|
-
- Use Plan for architecture and implementation planning.
|
|
146
|
-
- Use general-purpose for complex tasks that need file editing.
|
|
147
|
-
- Provide clear, detailed prompts so the agent can work autonomously.
|
|
148
|
-
- Subagent results are returned as text — summarize them for the user.
|
|
149
|
-
- Use run_in_background for work you don't need immediately. You will be notified when it completes.
|
|
150
|
-
- Use resume with an agent ID to continue a previous agent's work.
|
|
151
|
-
- Use steer_subagent to send mid-run messages to a running background agent.
|
|
152
|
-
- Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
|
|
153
|
-
- Use thinking to control extended thinking level.
|
|
154
|
-
- Use inherit_context if the agent needs the parent conversation history.
|
|
158
|
+
${guidelines}
|
|
155
159
|
`,
|
|
156
160
|
parameters: Type.Object({
|
|
157
161
|
prompt: Type.String({
|
package/src/tools/helpers.ts
CHANGED
|
@@ -72,9 +72,8 @@ export interface TypeListRegistry extends AgentConfigLookup {
|
|
|
72
72
|
* Extracted from index.ts so it can be called inside createAgentTool.
|
|
73
73
|
*/
|
|
74
74
|
export function buildTypeListText(registry: TypeListRegistry, agentDir: string): string {
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
const userNames = registry.getUserAgentNames().filter(isEnabled);
|
|
75
|
+
const defaultNames = registry.getDefaultAgentNames().filter((name) => isEnabledAgent(registry, name));
|
|
76
|
+
const userNames = registry.getUserAgentNames().filter((name) => isEnabledAgent(registry, name));
|
|
78
77
|
|
|
79
78
|
const defaultDescs = defaultNames.map((name) => {
|
|
80
79
|
const cfg = registry.resolveAgentConfig(name);
|
|
@@ -88,14 +87,31 @@ export function buildTypeListText(registry: TypeListRegistry, agentDir: string):
|
|
|
88
87
|
});
|
|
89
88
|
|
|
90
89
|
return [
|
|
91
|
-
"Default agents:",
|
|
92
|
-
...defaultDescs,
|
|
90
|
+
...(defaultDescs.length > 0 ? ["Default agents:", ...defaultDescs] : []),
|
|
93
91
|
...(customDescs.length > 0 ? ["", "Custom agents:", ...customDescs] : []),
|
|
94
92
|
"",
|
|
95
93
|
`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
94
|
].join("\n");
|
|
97
95
|
}
|
|
98
96
|
|
|
97
|
+
/** True when an agent config is present and not explicitly disabled. */
|
|
98
|
+
function isEnabledAgent(registry: AgentConfigLookup, name: string): boolean {
|
|
99
|
+
return registry.resolveAgentConfig(name).enabled !== false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Collect the per-agent usage guidelines for the subagent tool's Guidelines: block.
|
|
104
|
+
* Sourced from each enabled default agent's `toolGuideline`, in registry order,
|
|
105
|
+
* so a disabled built-in drops its guideline automatically.
|
|
106
|
+
*/
|
|
107
|
+
export function buildAgentGuidelines(registry: TypeListRegistry): string[] {
|
|
108
|
+
return registry
|
|
109
|
+
.getDefaultAgentNames()
|
|
110
|
+
.filter((name) => isEnabledAgent(registry, name))
|
|
111
|
+
.map((name) => registry.resolveAgentConfig(name).toolGuideline)
|
|
112
|
+
.filter((line): line is string => line !== undefined);
|
|
113
|
+
}
|
|
114
|
+
|
|
99
115
|
/** Derive a short model label from a model string. */
|
|
100
116
|
export function getModelLabelFromConfig(model: string): string {
|
|
101
117
|
// Strip provider prefix (e.g. "anthropic/claude-sonnet-4-6" → "claude-sonnet-4-6")
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-argument -- Pi SDK types are not fully exported; see upstream Pi SDK for type improvements */
|
|
2
1
|
/**
|
|
3
2
|
* spawn-config.ts — Pure config resolution for the Agent tool.
|
|
4
3
|
*
|
|
@@ -11,6 +10,7 @@ import type { Model } from "@earendil-works/pi-ai";
|
|
|
11
10
|
import type { AgentTypeRegistry } from "#src/config/agent-types";
|
|
12
11
|
import { resolveAgentInvocationConfig } from "#src/config/invocation-config";
|
|
13
12
|
import { normalizeMaxTurns } from "#src/lifecycle/turn-limits";
|
|
13
|
+
import type { ModelRegistry } from "#src/session/model-resolver";
|
|
14
14
|
import { resolveInvocationModel } from "#src/session/model-resolver";
|
|
15
15
|
import type { AgentInvocation, SubagentType, ThinkingLevel } from "#src/types";
|
|
16
16
|
import {
|
|
@@ -22,8 +22,8 @@ import {
|
|
|
22
22
|
|
|
23
23
|
/** Model info extracted from the parent session context. */
|
|
24
24
|
export interface ModelInfo {
|
|
25
|
-
parentModel:
|
|
26
|
-
modelRegistry:
|
|
25
|
+
parentModel: Model<any> | undefined;
|
|
26
|
+
modelRegistry: ModelRegistry | undefined;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/** Identity: who is being spawned. */
|
|
@@ -99,7 +99,7 @@ export function resolveSpawnConfig(
|
|
|
99
99
|
modelInfo.parentModel,
|
|
100
100
|
resolvedConfig.modelInput,
|
|
101
101
|
resolvedConfig.modelFromParams,
|
|
102
|
-
modelInfo.modelRegistry
|
|
102
|
+
modelInfo.modelRegistry,
|
|
103
103
|
);
|
|
104
104
|
if (resolution.error) return { error: resolution.error };
|
|
105
105
|
const model = resolution.model;
|
|
@@ -113,7 +113,7 @@ export function resolveSpawnConfig(
|
|
|
113
113
|
const effectiveModelId = model?.id;
|
|
114
114
|
const modelName =
|
|
115
115
|
effectiveModelId && effectiveModelId !== parentModelId
|
|
116
|
-
?
|
|
116
|
+
? model.name.replace(/^Claude\s+/i, "").toLowerCase()
|
|
117
117
|
: undefined;
|
|
118
118
|
|
|
119
119
|
const effectiveMaxTurns = normalizeMaxTurns(
|
package/src/tools/steer-tool.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "@sinclair/typebox";
|
|
3
3
|
import { formatLifetimeTokens, textResult } from "#src/tools/helpers";
|
|
4
|
-
import type { Subagent } from "#src/types";
|
|
4
|
+
import type { SteerOutcome, Subagent } from "#src/types";
|
|
5
5
|
|
|
6
6
|
// ---- Deps interfaces ----
|
|
7
7
|
|
|
@@ -34,41 +34,51 @@ export class SteerTool {
|
|
|
34
34
|
`Agent not found: "${params.agent_id}". It may have been cleaned up.`,
|
|
35
35
|
);
|
|
36
36
|
}
|
|
37
|
-
|
|
37
|
+
|
|
38
|
+
let outcome: SteerOutcome;
|
|
39
|
+
try {
|
|
40
|
+
outcome = await record.steer(params.message);
|
|
41
|
+
} catch (err) {
|
|
38
42
|
return textResult(
|
|
39
|
-
`
|
|
43
|
+
`Failed to steer agent: ${err instanceof Error ? err.message : String(err)}`,
|
|
40
44
|
);
|
|
41
45
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (!delivered) {
|
|
46
|
+
|
|
47
|
+
switch (outcome.kind) {
|
|
48
|
+
case "rejected":
|
|
46
49
|
return textResult(
|
|
47
|
-
`
|
|
50
|
+
`Agent "${params.agent_id}" is not running (status: ${outcome.status}). Cannot steer a non-running agent.`,
|
|
48
51
|
);
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (tokens) stateParts.push(tokens);
|
|
54
|
-
stateParts.push(`${record.toolUses} tool ${record.toolUses === 1 ? "use" : "uses"}`);
|
|
55
|
-
if (contextPercent !== null)
|
|
56
|
-
stateParts.push(`context ${Math.round(contextPercent)}% full`);
|
|
57
|
-
if (record.compactionCount)
|
|
58
|
-
stateParts.push(
|
|
59
|
-
`${record.compactionCount} compaction${record.compactionCount === 1 ? "" : "s"}`,
|
|
52
|
+
case "buffered":
|
|
53
|
+
this.events.emit("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.`,
|
|
60
56
|
);
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
);
|
|
65
|
-
} catch (err) {
|
|
66
|
-
return textResult(
|
|
67
|
-
`Failed to steer agent: ${err instanceof Error ? err.message : String(err)}`,
|
|
68
|
-
);
|
|
57
|
+
case "delivered":
|
|
58
|
+
this.events.emit("subagents:steered", { id: record.id, message: params.message });
|
|
59
|
+
return this.renderDelivered(record);
|
|
69
60
|
}
|
|
70
61
|
}
|
|
71
62
|
|
|
63
|
+
/** Render the success message with live state for a delivered steer. */
|
|
64
|
+
private renderDelivered(record: Subagent) {
|
|
65
|
+
const tokens = formatLifetimeTokens(record);
|
|
66
|
+
const contextPercent = record.getContextPercent();
|
|
67
|
+
const stateParts: string[] = [];
|
|
68
|
+
if (tokens) stateParts.push(tokens);
|
|
69
|
+
stateParts.push(`${record.toolUses} tool ${record.toolUses === 1 ? "use" : "uses"}`);
|
|
70
|
+
if (contextPercent !== null)
|
|
71
|
+
stateParts.push(`context ${Math.round(contextPercent)}% full`);
|
|
72
|
+
if (record.compactionCount)
|
|
73
|
+
stateParts.push(
|
|
74
|
+
`${record.compactionCount} compaction${record.compactionCount === 1 ? "" : "s"}`,
|
|
75
|
+
);
|
|
76
|
+
return textResult(
|
|
77
|
+
`Steering message sent to agent ${record.id}. The agent will process it after its current tool execution.\n` +
|
|
78
|
+
`Current state: ${stateParts.join(" · ")}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
72
82
|
toToolDefinition() {
|
|
73
83
|
return defineTool({
|
|
74
84
|
name: "steer_subagent" as const,
|
package/src/types.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { AgentSessionEvent, SessionContext as SdkSessionContext } from "@ea
|
|
|
7
7
|
import type { ModelRegistry } from "#src/session/model-resolver";
|
|
8
8
|
|
|
9
9
|
|
|
10
|
+
export type { SteerOutcome } from "#src/lifecycle/subagent";
|
|
10
11
|
export { Subagent } from "#src/lifecycle/subagent";
|
|
11
12
|
export type { AgentSessionEvent, ThinkingLevel };
|
|
12
13
|
|
|
@@ -55,6 +56,8 @@ export interface AgentConfig extends AgentIdentity, AgentPromptConfig {
|
|
|
55
56
|
inheritContext?: boolean;
|
|
56
57
|
/** Default for spawn: run in background. undefined = caller decides. */
|
|
57
58
|
runInBackground?: boolean;
|
|
59
|
+
/** One-line usage guideline for the subagent tool's Guidelines: block. Omitted — no guideline line. */
|
|
60
|
+
toolGuideline?: string;
|
|
58
61
|
/** true = this is an embedded default agent (informational) */
|
|
59
62
|
isDefault?: boolean;
|
|
60
63
|
/** false = agent is hidden from the registry */
|