pi-herdr-agents 0.0.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/AGENTS.md +116 -0
- package/CONTEXT.md +159 -0
- package/LICENSE +21 -0
- package/README.md +874 -0
- package/RELEASING.md +139 -0
- package/agents/adversarial-reviewer.md +80 -0
- package/agents/claude-reviewer.md +23 -0
- package/agents/planner.md +539 -0
- package/agents/poteto.md +32 -0
- package/agents/reviewer.md +164 -0
- package/agents/scout.md +106 -0
- package/agents/visual-tester.md +224 -0
- package/agents/worker.md +132 -0
- package/config.json.example +8 -0
- package/docs/README.md +42 -0
- package/docs/adr/0001-btw-ephemeral-side-questions.md +142 -0
- package/docs/adr/0002-agent-workflow-skill-runtime-taxonomy.md +265 -0
- package/docs/adr/0003-installable-role-packs.md +135 -0
- package/docs/adr/0004-require-active-user-approval-for-workflow-execution.md +17 -0
- package/docs/adr/0005-parent-owns-workflow-script-authority.md +17 -0
- package/docs/adr/0006-limit-v1-execution-effects-to-isolated-worktrees.md +18 -0
- package/docs/adr/0007-require-fresh-review-for-workflow-scripts.md +19 -0
- package/docs/orchestrated-review-workflow-plan.md +479 -0
- package/docs/research/pdw-architecture-assessment.md +525 -0
- package/docs/research/pi-workflows-sol-advisor.md +255 -0
- package/docs/research/worktree-subagent-orchestration.md +317 -0
- package/docs/worktree-subagents.md +196 -0
- package/examples/role-pack/extension.ts +18 -0
- package/examples/role-pack/package.json +16 -0
- package/examples/role-pack/roles/example-reviewer.md +12 -0
- package/package.json +58 -0
- package/pi-extension/subagents/activity.ts +511 -0
- package/pi-extension/subagents/completion.ts +177 -0
- package/pi-extension/subagents/herdr.ts +541 -0
- package/pi-extension/subagents/index.ts +4730 -0
- package/pi-extension/subagents/lifecycle.ts +477 -0
- package/pi-extension/subagents/model-config.ts +95 -0
- package/pi-extension/subagents/plan-skill.md +262 -0
- package/pi-extension/subagents/plugin/.claude-plugin/plugin.json +5 -0
- package/pi-extension/subagents/plugin/hooks/hooks.json +15 -0
- package/pi-extension/subagents/plugin/hooks/on-stop.sh +68 -0
- package/pi-extension/subagents/runtime-routing.ts +313 -0
- package/pi-extension/subagents/session.ts +216 -0
- package/pi-extension/subagents/status.ts +513 -0
- package/pi-extension/subagents/subagent-done.ts +326 -0
- package/pi-extension/subagents/terminal.ts +163 -0
- package/pi-extension/subagents/workflow-worker.js +56 -0
- package/pi-extension/subagents/workflow.ts +1210 -0
- package/skills/orchestrate/SKILL.md +184 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plan
|
|
3
|
+
description: >
|
|
4
|
+
Planning workflow. Runs a pre-flight scout, then spawns the planner agent
|
|
5
|
+
which clarifies WHAT to build and figures out HOW, with the ability to
|
|
6
|
+
spawn its own scouts mid-session. Use when asked to "plan",
|
|
7
|
+
"brainstorm", "I want to build X", or "let's design". Requires the
|
|
8
|
+
subagents extension running inside herdr.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Plan
|
|
12
|
+
|
|
13
|
+
A planning workflow. A scout maps the relevant codebase, then an interactive planner clarifies intent + requirements and designs the technical approach, producing a `plan.md` with ordered implementation tasks.
|
|
14
|
+
|
|
15
|
+
**Announce at start:** "Let me take a quick look, then I'll send a scout to map the codebase before we start the planning session."
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## The Flow
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
Phase 1: Quick Assessment (main session — 30s orientation)
|
|
23
|
+
↓
|
|
24
|
+
Phase 2: Scout (autonomous — codebase context)
|
|
25
|
+
↓
|
|
26
|
+
Phase 3: Spawn Planner Agent (interactive — clarifies WHAT, plans HOW, writes ordered tasks)
|
|
27
|
+
↓
|
|
28
|
+
(Planner may spawn its own scouts mid-session as needed)
|
|
29
|
+
↓
|
|
30
|
+
Phase 4: Review Plan (main session)
|
|
31
|
+
↓
|
|
32
|
+
Phase 5: Execute Plan Tasks (sequential workers, or isolated parallel workers when independent)
|
|
33
|
+
↓
|
|
34
|
+
Phase 6: Integrate Worktree Results (parent-owned, one at a time)
|
|
35
|
+
↓
|
|
36
|
+
Phase 7: Review
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Fire-and-forget completion
|
|
42
|
+
|
|
43
|
+
`subagent` is fire-and-forget. After each spawn:
|
|
44
|
+
|
|
45
|
+
1. End the parent turn.
|
|
46
|
+
2. Let automatic completion delivery resume the parent with the child's result.
|
|
47
|
+
3. Continue from that delivered result.
|
|
48
|
+
|
|
49
|
+
Do **not** poll, check, list, sleep, tail sessions, or wait-loop for child status.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Phase 1: Quick Assessment
|
|
54
|
+
|
|
55
|
+
Quick orientation — just enough to give the scout a focused mission:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
ls -la
|
|
59
|
+
find . -type f -name "*.ts" | head -20 # or relevant extension
|
|
60
|
+
cat package.json 2>/dev/null | head -30
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Spend ~30 seconds. Tech stack, project shape, and the area relevant to the user's request. This tells you what to ask the scout to focus on.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Artifact Paths
|
|
68
|
+
|
|
69
|
+
For a planning run, pick a short `<name>` (e.g. `auth-redesign`) and use a shared directory under `.pi/plans/YYYY-MM-DD-<name>/` for artifacts the parent or planner chooses to write. Pass explicit paths in each subagent's task when a file is required. Scout and reviewer deliver their findings in their final assistant messages; the planner writes `plan.md`.
|
|
70
|
+
|
|
71
|
+
Standard filenames:
|
|
72
|
+
|
|
73
|
+
- `.pi/plans/YYYY-MM-DD-<name>/plan.md`
|
|
74
|
+
- `.pi/plans/YYYY-MM-DD-<name>/review.md` (optional, only if a later step materializes reviewer output)
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Phase 2: Scout
|
|
79
|
+
|
|
80
|
+
**Always spawn a scout before the planner.** The scout's final message feeds into the planning session — it lets the planner skip re-asking questions whose answers live in the code, and gives it a solid base to design from.
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
subagent({
|
|
84
|
+
name: "🔍 Scout",
|
|
85
|
+
agent: "scout",
|
|
86
|
+
task: `Analyze the codebase for [user's request area]. Map file structure, key modules, patterns, conventions, and existing code related to [feature area]. Focus on what a planner would need to understand before designing this feature.
|
|
87
|
+
|
|
88
|
+
Put your full findings in your final assistant message.`,
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**End the parent turn.** When completion delivery resumes you, use the scout's final message as planner context. The scout is source-read-only; do not require it to write a file.
|
|
93
|
+
|
|
94
|
+
The planner can spawn **additional** scouts mid-session if it hits a local codebase gap. That's expected — don't try to pre-scout every possible area.
|
|
95
|
+
|
|
96
|
+
External facts are not handled by a bundled researcher. Supply or materialize them in the parent when needed, use only capabilities the parent actually has, or leave them as open questions for the planner.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Phase 3: Spawn Planner Agent
|
|
101
|
+
|
|
102
|
+
Spawn the interactive planner with the scout's context and the user's request. The planner handles everything from here: clarifying intent, compact requirements engineering, ISC, approach exploration, design validation, premortem, and the plan artifact with ordered tasks.
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
subagent({
|
|
106
|
+
name: "💬 Planner",
|
|
107
|
+
agent: "planner",
|
|
108
|
+
interactive: true,
|
|
109
|
+
task: `Plan: [what the user wants to build]
|
|
110
|
+
|
|
111
|
+
Scout context:
|
|
112
|
+
[paste scout findings here — file structure, conventions, patterns, relevant code]
|
|
113
|
+
|
|
114
|
+
Save the final plan to: .pi/plans/YYYY-MM-DD-<name>/plan.md
|
|
115
|
+
Include ordered implementation tasks inside the plan (no todo API).`,
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
**The user works with the planner.** It will clarify requirements lightly (1-2 rounds of questions, not a deep spec session), propose approaches, validate the design, run a premortem, and write the plan with complete worker briefs.
|
|
120
|
+
|
|
121
|
+
When done, the user presses Ctrl+D and the plan is returned to the main session.
|
|
122
|
+
|
|
123
|
+
### The planner may spawn its own scouts
|
|
124
|
+
|
|
125
|
+
During the session, the planner can spawn **`scout`** when a design decision depends on existing code it hasn't read. These are internal to the planning session. You'll see them in herdr but don't need to intervene.
|
|
126
|
+
|
|
127
|
+
### Optional: extra scout after planning
|
|
128
|
+
|
|
129
|
+
If the planner significantly changed scope (new subsystems, areas the original scout didn't cover), spawn another scout targeting the new areas before workers start:
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
subagent({
|
|
133
|
+
name: "🔍 Scout (updated scope)",
|
|
134
|
+
agent: "scout",
|
|
135
|
+
task: "The plan changed scope. Gather context for [new areas]. Read the plan at [plan path]. Focus on [specific files/modules the planner identified that weren't in the original scout]. Put findings in your final assistant message.",
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
End the parent turn and fold the delivered scout message into worker tasks.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Phase 4: Review Plan
|
|
144
|
+
|
|
145
|
+
Once the planner closes, read the plan artifact:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
# or use the read tool
|
|
149
|
+
cat .pi/plans/YYYY-MM-DD-<name>/plan.md
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Review with the user:
|
|
153
|
+
|
|
154
|
+
> "Here's what the planner produced: [brief summary]. Ready to execute, or anything to adjust?"
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Phase 5: Execute Plan Tasks
|
|
159
|
+
|
|
160
|
+
Choose the workspace mode deliberately:
|
|
161
|
+
|
|
162
|
+
- **Shared checkout, sequential workers (default):** use when tasks overlap, depend on prior uncommitted work, or are faster to integrate directly.
|
|
163
|
+
- **Managed worktrees, parallel workers:** use only when writing tasks are independent and can start from committed state. Give every worker a unique branch.
|
|
164
|
+
- **Ordinary panes, parallel agents:** use for read-only scouts and reviewers; they do not need worktrees.
|
|
165
|
+
|
|
166
|
+
Pass each worker a **complete task or plan section** — goal, files, constraints, examples/references, acceptance criteria, and whether to commit. Do not pass todo IDs or expect a todo API.
|
|
167
|
+
|
|
168
|
+
Sequential example:
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
subagent({
|
|
172
|
+
name: "🔨 Worker 1/N",
|
|
173
|
+
agent: "worker",
|
|
174
|
+
task: `Implement Task 1 from the plan.
|
|
175
|
+
|
|
176
|
+
Plan: [plan path]
|
|
177
|
+
|
|
178
|
+
[Paste the full Task 1 section: goal, files, example/reference, constraints, acceptance]
|
|
179
|
+
|
|
180
|
+
Run relevant tests. Commit only if this task explicitly requires a commit. Report the commit SHA if you commit.`,
|
|
181
|
+
});
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
For independent writing tasks, first ensure their shared base is committed. The parent checkout's uncommitted files are not copied. Then launch each worker with complete context and a unique branch:
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
subagent({
|
|
188
|
+
name: "🔨 Task-1",
|
|
189
|
+
agent: "worker",
|
|
190
|
+
cwd: "/absolute/path/to/source-repo",
|
|
191
|
+
worktree: { branch: "plan-name/task-1", base: "HEAD" },
|
|
192
|
+
task: `Implement Task 1.
|
|
193
|
+
|
|
194
|
+
Plan and scout context: [paste everything needed, or provide an absolute plan path and paste the full task section]
|
|
195
|
+
|
|
196
|
+
Run relevant tests. Commit only if the task explicitly requires a commit. Report the commit SHA if you commit. Do not push, merge, switch branches, or remove the worktree.`,
|
|
197
|
+
});
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Launch other independent tasks the same way without waiting. Do not parallelize tasks that edit the same behavior or require another worker's output. For a dependency, integrate the prerequisite first or use its committed SHA as the dependent worker's base.
|
|
201
|
+
|
|
202
|
+
After each spawn, end the parent turn and continue from automatic completion delivery.
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## Phase 6: Integrate Worktree Results
|
|
207
|
+
|
|
208
|
+
A worktree completion is a review handoff, not acceptance. For every result:
|
|
209
|
+
|
|
210
|
+
1. Use the returned worktree path, workspace ID, branch, and base/head SHAs.
|
|
211
|
+
2. Inspect `git status`, `git log <base>..HEAD`, and `git diff <base>...HEAD` in the worktree.
|
|
212
|
+
3. Run the relevant tests there.
|
|
213
|
+
4. Resolve dirty/conflicted work before integration.
|
|
214
|
+
5. Merge or cherry-pick according to repository policy, one result at a time.
|
|
215
|
+
6. Re-run affected tests on the destination branch after each integration.
|
|
216
|
+
7. Keep the worktree until the result is accepted and preserved; cleanup is explicit.
|
|
217
|
+
|
|
218
|
+
The extension does not push, create PRs, merge, or remove worktrees automatically. `subagent_resume` does not reattach worktree tracking; continue follow-up in the retained workspace. See `docs/worktree-subagents.md` when this package's guide is available.
|
|
219
|
+
|
|
220
|
+
Skip this phase when all workers used the shared checkout.
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## Phase 7: Review
|
|
225
|
+
|
|
226
|
+
After all shared-checkout changes and accepted worktree results are integrated:
|
|
227
|
+
|
|
228
|
+
```typescript
|
|
229
|
+
subagent({
|
|
230
|
+
name: "Reviewer",
|
|
231
|
+
agent: "reviewer",
|
|
232
|
+
interactive: false,
|
|
233
|
+
task: "Review the recent changes. Plan: [plan path]. Put the full review in your final assistant message.",
|
|
234
|
+
});
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
End the parent turn. Use the delivered review message.
|
|
238
|
+
|
|
239
|
+
Triage findings:
|
|
240
|
+
|
|
241
|
+
- **P0** — Real bugs, security issues → fix now
|
|
242
|
+
- **P1** — Genuine traps, maintenance dangers → fix before merging
|
|
243
|
+
- **P2** — Minor issues → fix if quick, note otherwise
|
|
244
|
+
- **P3** — Nits → skip
|
|
245
|
+
|
|
246
|
+
Spawn workers with complete fix tasks for P0/P1. Re-review only if fixes were substantial.
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## Completion Checklist
|
|
251
|
+
|
|
252
|
+
Before reporting done:
|
|
253
|
+
|
|
254
|
+
1. Scout ran before the planner?
|
|
255
|
+
2. Scout context was passed to the planner?
|
|
256
|
+
3. Parallel writers used unique worktree branches from committed bases?
|
|
257
|
+
4. Every worktree result was inspected, tested, and integrated deliberately?
|
|
258
|
+
5. Every plan task was executed or explicitly deferred?
|
|
259
|
+
6. Every requested commit is polished and its SHA reported?
|
|
260
|
+
7. Retained worktrees were kept or explicitly cleaned up only after review?
|
|
261
|
+
8. Reviewer ran against the integrated result?
|
|
262
|
+
9. Reviewer findings were triaged and addressed?
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Stop hook for pi-spawned Claude sessions.
|
|
3
|
+
# Writes a sentinel file when Claude completes autonomously (no user interjection).
|
|
4
|
+
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
# Read JSON input from stdin
|
|
8
|
+
input=$(cat)
|
|
9
|
+
|
|
10
|
+
# Guard: if stop_hook_active is true, we're in a loop — bail out
|
|
11
|
+
stop_hook_active=$(echo "$input" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('stop_hook_active', False))" 2>/dev/null || echo "False")
|
|
12
|
+
if [ "$stop_hook_active" = "True" ]; then
|
|
13
|
+
exit 0
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
# Guard: only act for pi-spawned sessions
|
|
17
|
+
if [ -z "${PI_CLAUDE_SENTINEL:-}" ]; then
|
|
18
|
+
exit 0
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
# Get transcript path
|
|
22
|
+
transcript_path=$(echo "$input" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('transcript_path', ''))" 2>/dev/null || echo "")
|
|
23
|
+
if [ -z "$transcript_path" ] || [ ! -f "$transcript_path" ]; then
|
|
24
|
+
exit 0
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
# Count real human messages in transcript (not tool results)
|
|
28
|
+
# Claude's transcript format:
|
|
29
|
+
# Human message: {"type": "user", "message": {"role": "user", "content": "..."}}
|
|
30
|
+
# Tool result: {"type": "user", "message": {"role": "user", "content": [{"type": "tool_result", ...}]}}
|
|
31
|
+
# We only count entries where content is a string (real human input)
|
|
32
|
+
user_msg_count=$(python3 - "$transcript_path" <<'EOF'
|
|
33
|
+
import sys, json
|
|
34
|
+
|
|
35
|
+
transcript_path = sys.argv[1]
|
|
36
|
+
count = 0
|
|
37
|
+
with open(transcript_path, 'r') as f:
|
|
38
|
+
for line in f:
|
|
39
|
+
line = line.strip()
|
|
40
|
+
if not line:
|
|
41
|
+
continue
|
|
42
|
+
try:
|
|
43
|
+
entry = json.loads(line)
|
|
44
|
+
if entry.get('type') != 'user':
|
|
45
|
+
continue
|
|
46
|
+
content = entry.get('message', {}).get('content', '')
|
|
47
|
+
# Real human messages have string content
|
|
48
|
+
# Tool results have array content with tool_result blocks
|
|
49
|
+
if isinstance(content, str):
|
|
50
|
+
count += 1
|
|
51
|
+
except (json.JSONDecodeError, AttributeError):
|
|
52
|
+
pass
|
|
53
|
+
print(count)
|
|
54
|
+
EOF
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Always write transcript path so the watcher can copy the session file
|
|
58
|
+
if [ -n "$transcript_path" ]; then
|
|
59
|
+
echo "$transcript_path" > "${PI_CLAUDE_SENTINEL}.transcript" 2>/dev/null || true
|
|
60
|
+
fi
|
|
61
|
+
|
|
62
|
+
# If exactly 1 user message (the initial prompt), this was autonomous — signal completion
|
|
63
|
+
if [ "$user_msg_count" -eq 1 ]; then
|
|
64
|
+
# Write last_assistant_message to sentinel so the watcher gets a clean result
|
|
65
|
+
echo "$input" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('last_assistant_message', ''))" > "$PI_CLAUDE_SENTINEL" 2>/dev/null || touch "$PI_CLAUDE_SENTINEL"
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
exit 0
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clampThinkingLevel,
|
|
3
|
+
getSupportedThinkingLevels,
|
|
4
|
+
type Model,
|
|
5
|
+
type ModelThinkingLevel,
|
|
6
|
+
} from "@earendil-works/pi-ai";
|
|
7
|
+
|
|
8
|
+
export const THINKING_LEVELS = [
|
|
9
|
+
"off",
|
|
10
|
+
"minimal",
|
|
11
|
+
"low",
|
|
12
|
+
"medium",
|
|
13
|
+
"high",
|
|
14
|
+
"xhigh",
|
|
15
|
+
"max",
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
export function isThinkingLevel(value: string): value is ThinkingLevel {
|
|
19
|
+
return (THINKING_LEVELS as readonly string[]).includes(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
|
|
23
|
+
export type RuntimeSource = "request" | "agent" | "parent";
|
|
24
|
+
|
|
25
|
+
export interface RuntimeRequest {
|
|
26
|
+
model?: string;
|
|
27
|
+
thinking?: ThinkingLevel;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ParentRuntime {
|
|
31
|
+
provider: string;
|
|
32
|
+
modelId: string;
|
|
33
|
+
thinking: ThinkingLevel;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RoutingModel {
|
|
37
|
+
provider: string;
|
|
38
|
+
id: string;
|
|
39
|
+
reasoning: boolean;
|
|
40
|
+
thinkingLevelMap?: Model<any>["thinkingLevelMap"];
|
|
41
|
+
input?: string[];
|
|
42
|
+
contextWindow?: number;
|
|
43
|
+
maxTokens?: number;
|
|
44
|
+
cost?: {
|
|
45
|
+
input?: number;
|
|
46
|
+
output?: number;
|
|
47
|
+
cacheRead?: number;
|
|
48
|
+
cacheWrite?: number;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ModelRegistryAdapter {
|
|
53
|
+
find(provider: string, modelId: string): RoutingModel | undefined;
|
|
54
|
+
available(): RoutingModel[];
|
|
55
|
+
hasConfiguredAuth(model: { provider: string; id: string }): boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ResolvedRuntimePlan {
|
|
59
|
+
provider: string;
|
|
60
|
+
modelId: string;
|
|
61
|
+
model: string;
|
|
62
|
+
thinking: ThinkingLevel;
|
|
63
|
+
modelSource: RuntimeSource;
|
|
64
|
+
thinkingSource: RuntimeSource;
|
|
65
|
+
requestedModel?: string;
|
|
66
|
+
requestedThinking?: ThinkingLevel;
|
|
67
|
+
thinkingAdjustment?: {
|
|
68
|
+
from: ThinkingLevel;
|
|
69
|
+
to: ThinkingLevel;
|
|
70
|
+
reason: "non-reasoning" | "inherited-clamp";
|
|
71
|
+
};
|
|
72
|
+
observed?: {
|
|
73
|
+
model?: string;
|
|
74
|
+
thinking?: ThinkingLevel;
|
|
75
|
+
};
|
|
76
|
+
runtimeMismatch?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export class RuntimeResolutionError extends Error {
|
|
80
|
+
constructor(message: string) {
|
|
81
|
+
super(message);
|
|
82
|
+
this.name = "RuntimeResolutionError";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function parseExactModelRef(
|
|
87
|
+
reference: string,
|
|
88
|
+
): { provider: string; modelId: string } | undefined {
|
|
89
|
+
const trimmed = reference.trim();
|
|
90
|
+
const separator = trimmed.indexOf("/");
|
|
91
|
+
if (separator <= 0 || separator === trimmed.length - 1) return undefined;
|
|
92
|
+
const provider = trimmed.slice(0, separator).trim();
|
|
93
|
+
const modelId = trimmed.slice(separator + 1).trim();
|
|
94
|
+
return provider && modelId ? { provider, modelId } : undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function toRoutingModel(value: any): RoutingModel | undefined {
|
|
98
|
+
if (!value || typeof value.provider !== "string" || typeof value.id !== "string") {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
provider: value.provider,
|
|
103
|
+
id: value.id,
|
|
104
|
+
reasoning: value.reasoning ?? false,
|
|
105
|
+
thinkingLevelMap: value.thinkingLevelMap,
|
|
106
|
+
input: Array.isArray(value.input) ? value.input : undefined,
|
|
107
|
+
contextWindow: typeof value.contextWindow === "number" ? value.contextWindow : undefined,
|
|
108
|
+
maxTokens: typeof value.maxTokens === "number" ? value.maxTokens : undefined,
|
|
109
|
+
cost: value.cost,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function wrapPiModelRegistry(registry: {
|
|
114
|
+
find(provider: string, modelId: string): any;
|
|
115
|
+
getAvailable?: () => any[];
|
|
116
|
+
getAll?: () => any[];
|
|
117
|
+
hasConfiguredAuth?: (model: any) => boolean;
|
|
118
|
+
}): ModelRegistryAdapter {
|
|
119
|
+
return {
|
|
120
|
+
find(provider, modelId) {
|
|
121
|
+
return toRoutingModel(registry.find(provider, modelId));
|
|
122
|
+
},
|
|
123
|
+
available() {
|
|
124
|
+
const direct = registry.getAvailable?.() ?? [];
|
|
125
|
+
const source = direct.length > 0 ? direct : registry.getAll?.() ?? [];
|
|
126
|
+
const models: RoutingModel[] = [];
|
|
127
|
+
const seen = new Set<string>();
|
|
128
|
+
for (const raw of source) {
|
|
129
|
+
const candidate = toRoutingModel(raw);
|
|
130
|
+
if (!candidate) continue;
|
|
131
|
+
if (direct.length === 0 && registry.hasConfiguredAuth && !registry.hasConfiguredAuth(raw)) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const ref = `${candidate.provider}/${candidate.id}`;
|
|
135
|
+
if (seen.has(ref)) continue;
|
|
136
|
+
seen.add(ref);
|
|
137
|
+
models.push(candidate);
|
|
138
|
+
}
|
|
139
|
+
return models;
|
|
140
|
+
},
|
|
141
|
+
hasConfiguredAuth(model) {
|
|
142
|
+
if (!registry.hasConfiguredAuth) {
|
|
143
|
+
return (registry.getAvailable?.() ?? []).some(
|
|
144
|
+
(candidate) => candidate.provider === model.provider && candidate.id === model.id,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
const original = registry.find(model.provider, model.id);
|
|
148
|
+
return !!original && registry.hasConfiguredAuth(original);
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function asPiModel(model: RoutingModel): Model<any> {
|
|
154
|
+
return {
|
|
155
|
+
provider: model.provider,
|
|
156
|
+
id: model.id,
|
|
157
|
+
name: model.id,
|
|
158
|
+
api: "openai-completions",
|
|
159
|
+
baseUrl: "",
|
|
160
|
+
reasoning: model.reasoning,
|
|
161
|
+
thinkingLevelMap: model.thinkingLevelMap,
|
|
162
|
+
input: (model.input?.filter((entry): entry is "text" | "image" =>
|
|
163
|
+
entry === "text" || entry === "image") ?? ["text"]),
|
|
164
|
+
contextWindow: model.contextWindow ?? 0,
|
|
165
|
+
maxTokens: model.maxTokens ?? 0,
|
|
166
|
+
cost: {
|
|
167
|
+
input: model.cost?.input ?? 0,
|
|
168
|
+
output: model.cost?.output ?? 0,
|
|
169
|
+
cacheRead: model.cost?.cacheRead ?? 0,
|
|
170
|
+
cacheWrite: model.cost?.cacheWrite ?? 0,
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function formatSupported(model: RoutingModel): string {
|
|
176
|
+
const levels = getSupportedThinkingLevels(asPiModel(model));
|
|
177
|
+
return levels.length > 0 ? levels.join(", ") : "(none)";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function selectField(
|
|
181
|
+
requestValue: string | undefined,
|
|
182
|
+
agentValue: string | undefined,
|
|
183
|
+
): { value?: string; source: RuntimeSource } {
|
|
184
|
+
if (requestValue != null && requestValue !== "") {
|
|
185
|
+
return { value: requestValue, source: "request" };
|
|
186
|
+
}
|
|
187
|
+
if (agentValue != null && agentValue !== "") {
|
|
188
|
+
return { value: agentValue, source: "agent" };
|
|
189
|
+
}
|
|
190
|
+
return { source: "parent" };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function resolveRuntimePlan(
|
|
194
|
+
request: RuntimeRequest,
|
|
195
|
+
agentDefaults: RuntimeRequest,
|
|
196
|
+
parent: ParentRuntime,
|
|
197
|
+
registry: ModelRegistryAdapter,
|
|
198
|
+
): ResolvedRuntimePlan {
|
|
199
|
+
const modelSelection = selectField(request.model, agentDefaults.model);
|
|
200
|
+
let provider = parent.provider;
|
|
201
|
+
let modelId = parent.modelId;
|
|
202
|
+
let selectedModel = registry.find(provider, modelId);
|
|
203
|
+
|
|
204
|
+
if (modelSelection.value) {
|
|
205
|
+
const parsed = parseExactModelRef(modelSelection.value);
|
|
206
|
+
if (!parsed) {
|
|
207
|
+
throw new RuntimeResolutionError(
|
|
208
|
+
`model ${JSON.stringify(modelSelection.value)} must be an exact authenticated provider/model-id`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
const found = registry.find(parsed.provider, parsed.modelId);
|
|
212
|
+
if (!found) {
|
|
213
|
+
const alternatives = registry.available().map((model) => `${model.provider}/${model.id}`);
|
|
214
|
+
throw new RuntimeResolutionError(
|
|
215
|
+
`unknown model ${JSON.stringify(modelSelection.value)}; exact registry match required. Available: ${alternatives.join(", ") || "(none)"}`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (!registry.hasConfiguredAuth(found)) {
|
|
219
|
+
throw new RuntimeResolutionError(
|
|
220
|
+
`model ${JSON.stringify(modelSelection.value)} has no configured authentication`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
provider = found.provider;
|
|
224
|
+
modelId = found.id;
|
|
225
|
+
selectedModel = found;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const thinkingSelection = selectField(request.thinking, agentDefaults.thinking);
|
|
229
|
+
const preferredThinking = thinkingSelection.value ?? parent.thinking;
|
|
230
|
+
if (!isThinkingLevel(preferredThinking)) {
|
|
231
|
+
throw new RuntimeResolutionError(
|
|
232
|
+
`thinking ${JSON.stringify(preferredThinking)} must be one of: ${THINKING_LEVELS.join(", ")}`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let thinking = preferredThinking;
|
|
237
|
+
let thinkingAdjustment: ResolvedRuntimePlan["thinkingAdjustment"];
|
|
238
|
+
if (thinkingSelection.source !== "parent") {
|
|
239
|
+
if (!selectedModel) {
|
|
240
|
+
throw new RuntimeResolutionError(
|
|
241
|
+
`model capability information is unavailable; cannot validate explicit thinking ${JSON.stringify(preferredThinking)}`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
const supported = getSupportedThinkingLevels(asPiModel(selectedModel));
|
|
245
|
+
if (!supported.includes(preferredThinking as ModelThinkingLevel)) {
|
|
246
|
+
throw new RuntimeResolutionError(
|
|
247
|
+
`thinking ${JSON.stringify(preferredThinking)} is not supported by ${JSON.stringify(`${provider}/${modelId}`)}; supported: ${formatSupported(selectedModel)}`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
} else if (selectedModel) {
|
|
251
|
+
const clamped = clampThinkingLevel(asPiModel(selectedModel), preferredThinking);
|
|
252
|
+
thinking = clamped as ThinkingLevel;
|
|
253
|
+
if (thinking !== preferredThinking) {
|
|
254
|
+
thinkingAdjustment = {
|
|
255
|
+
from: preferredThinking,
|
|
256
|
+
to: thinking,
|
|
257
|
+
reason: selectedModel.reasoning ? "inherited-clamp" : "non-reasoning",
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
provider,
|
|
264
|
+
modelId,
|
|
265
|
+
model: `${provider}/${modelId}`,
|
|
266
|
+
thinking,
|
|
267
|
+
modelSource: modelSelection.source,
|
|
268
|
+
thinkingSource: thinkingSelection.source,
|
|
269
|
+
...(modelSelection.value ? { requestedModel: modelSelection.value } : {}),
|
|
270
|
+
...(thinkingSelection.value ? { requestedThinking: preferredThinking } : {}),
|
|
271
|
+
...(thinkingAdjustment ? { thinkingAdjustment } : {}),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function formatTokenCount(value: number | undefined): string | undefined {
|
|
276
|
+
if (!value || value <= 0) return undefined;
|
|
277
|
+
if (value >= 1_000_000) return `${Number((value / 1_000_000).toFixed(1))}m`;
|
|
278
|
+
if (value >= 1_000) return `${Math.round(value / 1_000)}k`;
|
|
279
|
+
return String(value);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function buildAuthenticatedModelCatalog(
|
|
283
|
+
registry: ModelRegistryAdapter,
|
|
284
|
+
limit = 24,
|
|
285
|
+
): string {
|
|
286
|
+
const models = registry.available().sort((a, b) =>
|
|
287
|
+
`${a.provider}/${a.id}`.localeCompare(`${b.provider}/${b.id}`),
|
|
288
|
+
);
|
|
289
|
+
const visibleModels = models.slice(0, limit);
|
|
290
|
+
const lines = [
|
|
291
|
+
"Authenticated subagent models (use exact provider/model-id only):",
|
|
292
|
+
];
|
|
293
|
+
for (const model of visibleModels) {
|
|
294
|
+
const supportedThinking = getSupportedThinkingLevels(asPiModel(model));
|
|
295
|
+
const facts = [
|
|
296
|
+
model.reasoning
|
|
297
|
+
? `reasoning (${supportedThinking.join("/") || "no thinking levels"})`
|
|
298
|
+
: "non-reasoning",
|
|
299
|
+
model.input?.includes("image") ? "text+image" : "text",
|
|
300
|
+
formatTokenCount(model.contextWindow) ? `${formatTokenCount(model.contextWindow)} context` : undefined,
|
|
301
|
+
formatTokenCount(model.maxTokens) ? `${formatTokenCount(model.maxTokens)} max output` : undefined,
|
|
302
|
+
].filter(Boolean);
|
|
303
|
+
lines.push(`- ${model.provider}/${model.id} — ${facts.join(", ")}`);
|
|
304
|
+
}
|
|
305
|
+
if (models.length === 0) lines.push("- none discovered; inherit the parent runtime");
|
|
306
|
+
if (models.length > visibleModels.length) {
|
|
307
|
+
lines.push(`- … ${models.length - visibleModels.length} more authenticated models omitted`);
|
|
308
|
+
}
|
|
309
|
+
lines.push(
|
|
310
|
+
"Default: inherit the parent model and thinking. Override thinking first; override model only when task capability, speed, cost, modality, or context warrants it.",
|
|
311
|
+
);
|
|
312
|
+
return lines.join("\n");
|
|
313
|
+
}
|