pi-crew 0.9.12 → 0.9.14
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 +86 -0
- package/README.md +1 -135
- package/docs/bugs/bug-021-notification-badge-counter-misleading.md +293 -0
- package/docs/bugs/bug-023-chain-windows-path-resolution.md +106 -0
- package/package.json +1 -1
- package/src/config/defaults.ts +3 -1
- package/src/extension/knowledge-injection.ts +236 -15
- package/src/extension/pi-api.ts +1 -1
- package/src/extension/team-tool/chain-dispatch.ts +103 -0
- package/src/extension/team-tool/chain-executor.ts +400 -0
- package/src/extension/team-tool/run.ts +9 -0
- package/src/runtime/agent-memory.ts +5 -2
- package/src/runtime/background-runner.ts +49 -0
- package/src/runtime/chain-runner.ts +47 -8
- package/src/runtime/child-pi.ts +119 -0
- package/src/runtime/goal-achievement.ts +131 -0
- package/src/runtime/handoff-manager.ts +7 -0
- package/src/runtime/live-session-runtime.ts +10 -4
- package/src/runtime/pi-json-output.ts +5 -1
- package/src/runtime/recovery-recipes.ts +35 -0
- package/src/runtime/task-output-context.ts +36 -7
- package/src/runtime/task-runner/prompt-builder.ts +5 -1
- package/src/runtime/task-runner.ts +13 -0
- package/src/runtime/team-runner.ts +47 -2
- package/src/schema/team-tool-schema.ts +9 -0
- package/src/state/types.ts +3 -0
- package/src/ui/widget/index.ts +1 -1
- package/src/ui/widget/widget-formatters.ts +14 -1
- package/src/ui/widget/widget-renderer.ts +13 -2
- package/src/utils/redaction.ts +20 -0
package/package.json
CHANGED
package/src/config/defaults.ts
CHANGED
|
@@ -15,7 +15,9 @@ export const DEFAULT_CHILD_PI: Readonly<{
|
|
|
15
15
|
// Child workers can spend more than a few seconds in provider calls or long-running tools without emitting stdout.
|
|
16
16
|
// Keep this as a coarse stuck-worker guard rather than a short per-message latency budget.
|
|
17
17
|
responseTimeoutMs: 5 * 60_000,
|
|
18
|
-
|
|
18
|
+
// #3 unresponsive worker hardening: increased from 256KB to 512KB so critical
|
|
19
|
+
// diagnostic stderr is less likely to be silently truncated during hang analysis.
|
|
20
|
+
maxCaptureBytes: 512 * 1024,
|
|
19
21
|
// L4 output-handling: thresholds sized from real worker-output data
|
|
20
22
|
// (27 result artifacts measured: max 9226 bytes, median 8272, 100% < 16KB).
|
|
21
23
|
// Previous values (8192/1024/4096) truncated 62% of real results.
|
|
@@ -9,6 +9,16 @@
|
|
|
9
9
|
* Philosophy (Round 6 stress-test): radically downsized. Just a Markdown
|
|
10
10
|
* file the user can edit, surfaced into every run. No vector DB, no
|
|
11
11
|
* embeddings, no graph. Simple = trustworthy.
|
|
12
|
+
*
|
|
13
|
+
* B2 section-aware injection (2026-06-28): knowledge.md splits cleanly into
|
|
14
|
+
* CONVENTIONS (always-relevant: Code Style, Environment, Architecture,
|
|
15
|
+
* Testing, Release Process — ~2.5KB) and SESSION-LOG (per-version post-
|
|
16
|
+
* mortems, incidents, fix detail — ~29KB, rarely relevant). Conventions
|
|
17
|
+
* are ALWAYS injected; session-log sections are injected on-demand via
|
|
18
|
+
* header-token IDF scoring against the task/goal, capped at
|
|
19
|
+
* MAX_SESSION_LOG_BYTES. Non-matched session-log is summarized as a section
|
|
20
|
+
* index with a `read` path-hint so the worker can recover any omitted topic.
|
|
21
|
+
* Design doc: research-findings/b2-section-aware-design.md.
|
|
12
22
|
*/
|
|
13
23
|
import * as fs from "node:fs";
|
|
14
24
|
import * as path from "node:path";
|
|
@@ -17,20 +27,179 @@ import { projectCrewRoot } from "../utils/paths.ts";
|
|
|
17
27
|
|
|
18
28
|
/** The knowledge file, relative to the project crew root. */
|
|
19
29
|
export const KNOWLEDGE_FILENAME = "knowledge.md";
|
|
20
|
-
|
|
21
|
-
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Session-log injection cap (B2). Conventions are always injected in full;
|
|
33
|
+
* matched session-log sections are capped here to bound total prompt size.
|
|
34
|
+
* Sized so 2-3 typical sections (~1-2.5KB each) fit, while the largest single
|
|
35
|
+
* outlier (v0.9.10 IN PROGRESS, ~7KB) is excluded from a full-match scenario.
|
|
36
|
+
*/
|
|
37
|
+
const MAX_SESSION_LOG_BYTES = 5_000;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Relevance context for section-aware injection. When omitted (or when goal/
|
|
41
|
+
* taskText are both absent), injection falls back to conventions-only — no
|
|
42
|
+
* IDF computation, no session-log body. This keeps callers without query
|
|
43
|
+
* context (main-session hook, legacy tests) stable.
|
|
44
|
+
*/
|
|
45
|
+
export interface KnowledgeQuery {
|
|
46
|
+
/** The team run goal (broad topic signal). */
|
|
47
|
+
goal?: string;
|
|
48
|
+
/** The step/task instruction text (narrow topic signal). */
|
|
49
|
+
taskText?: string;
|
|
50
|
+
/** The worker role (reserved for future role-floor/boost; not scored yet). */
|
|
51
|
+
role?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Headers of sections always treated as CONVENTIONS (universal project rules).
|
|
56
|
+
* Anything NOT matching these (after the convention run) is SESSION-LOG.
|
|
57
|
+
* Header-matching is prefix + case-insensitive against the H2 title.
|
|
58
|
+
*/
|
|
59
|
+
const CONVENTION_HEADERS = ["Code Style", "Environment", "Architecture", "Testing", "Release Process"];
|
|
22
60
|
|
|
23
61
|
/** Resolve the knowledge file path for a cwd (may not exist yet). */
|
|
24
62
|
export function knowledgePath(cwd: string): string {
|
|
25
63
|
return path.join(projectCrewRoot(cwd), KNOWLEDGE_FILENAME);
|
|
26
64
|
}
|
|
27
65
|
|
|
28
|
-
|
|
29
|
-
|
|
66
|
+
interface KnowledgeSection {
|
|
67
|
+
/** H2 header text (without leading `## `). */
|
|
68
|
+
header: string;
|
|
69
|
+
/** Full section body including the `## ` header line. */
|
|
70
|
+
body: string;
|
|
71
|
+
/** Header tokens (lowercased, stopword-filtered) for IDF scoring. */
|
|
72
|
+
headerTokens: Set<string>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const STOPWORDS = new Set([
|
|
76
|
+
"the", "a", "an", "is", "are", "of", "to", "in", "for", "and", "or", "not",
|
|
77
|
+
"with", "this", "that", "it", "be", "on", "at", "by", "do", "does", "how",
|
|
78
|
+
"what", "when", "from", "fix", "fixes", "fixed", "v0", "v1", "released",
|
|
79
|
+
"progress", "uncommitted", "not", "pushed", "published", "session", "post",
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
/** Tokenize header text into a Set of lowercased non-stopword tokens. */
|
|
83
|
+
function tokenizeHeader(header: string): Set<string> {
|
|
84
|
+
const tokens = new Set<string>();
|
|
85
|
+
for (const raw of header.toLowerCase().split(/[^a-z0-9.]+/)) {
|
|
86
|
+
// keep dot-paths (e.g. "run.lock", "config.ts") and alphanumerics >= 2 chars
|
|
87
|
+
const t = raw.replace(/^\.+|\.+$/g, "");
|
|
88
|
+
if (t.length >= 2 && !STOPWORDS.has(t) && !/^\d+$/.test(t)) tokens.add(t);
|
|
89
|
+
}
|
|
90
|
+
return tokens;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Parse knowledge.md into (conventions, sessionLog) sections by H2 header. */
|
|
94
|
+
function parseKnowledgeSections(content: string): { conventions: KnowledgeSection[]; sessionLog: KnowledgeSection[] } {
|
|
95
|
+
const lines = content.split(/\r?\n/);
|
|
96
|
+
const sections: KnowledgeSection[] = [];
|
|
97
|
+
let current: { header: string; lines: string[] } | null = null;
|
|
98
|
+
for (const line of lines) {
|
|
99
|
+
const m = /^##\s+(.+?)\s*$/.exec(line);
|
|
100
|
+
if (m) {
|
|
101
|
+
if (current) sections.push({ header: current.header, body: current.lines.join("\n"), headerTokens: tokenizeHeader(current.header) });
|
|
102
|
+
current = { header: m[1]!, lines: [line] };
|
|
103
|
+
} else if (current) {
|
|
104
|
+
current.lines.push(line);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (current) sections.push({ header: current.header, body: current.lines.join("\n"), headerTokens: tokenizeHeader(current.header) });
|
|
108
|
+
|
|
109
|
+
// Classify: a section is a CONVENTION if its header starts-with one of the
|
|
110
|
+
// known convention titles. Once we leave the convention run (first non-
|
|
111
|
+
// matching H2 after conventions), everything else is SESSION-LOG. This is
|
|
112
|
+
// self-healing: if knowledge.md is restructured, the classifier adapts.
|
|
113
|
+
const conventions: KnowledgeSection[] = [];
|
|
114
|
+
const sessionLog: KnowledgeSection[] = [];
|
|
115
|
+
let leftConventionRun = false;
|
|
116
|
+
for (const sec of sections) {
|
|
117
|
+
const isConvention = !leftConventionRun && CONVENTION_HEADERS.some((c) => sec.header.toLowerCase().startsWith(c.toLowerCase()));
|
|
118
|
+
if (isConvention) conventions.push(sec);
|
|
119
|
+
else {
|
|
120
|
+
leftConventionRun = true;
|
|
121
|
+
sessionLog.push(sec);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { conventions, sessionLog };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Compute IDF (inverse document frequency) over session-log header tokens. */
|
|
128
|
+
function computeIdf(sections: KnowledgeSection[]): Map<string, number> {
|
|
129
|
+
const N = sections.length;
|
|
130
|
+
const df = new Map<string, number>();
|
|
131
|
+
for (const s of sections) for (const token of s.headerTokens) df.set(token, (df.get(token) ?? 0) + 1);
|
|
132
|
+
const idf = new Map<string, number>();
|
|
133
|
+
for (const [token, freq] of df) idf.set(token, Math.log(N / freq)); // standard IDF; freq >= 1
|
|
134
|
+
return idf;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Tokenize a free-form query (goal/taskText) the same way as headers. */
|
|
138
|
+
function tokenizeQuery(query: string): Set<string> {
|
|
139
|
+
const tokens = new Set<string>();
|
|
140
|
+
for (const raw of query.toLowerCase().split(/[^a-z0-9.]+/)) {
|
|
141
|
+
const t = raw.replace(/^\.+|\.+$/g, "");
|
|
142
|
+
if (t.length >= 2 && !STOPWORDS.has(t) && !/^\d+$/.test(t)) tokens.add(t);
|
|
143
|
+
}
|
|
144
|
+
return tokens;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Score a section by summed IDF of query tokens present in its header. */
|
|
148
|
+
function scoreSection(queryTokens: Set<string>, headerTokens: Set<string>, idf: Map<string, number>): number {
|
|
149
|
+
let score = 0;
|
|
150
|
+
for (const token of queryTokens) if (headerTokens.has(token)) score += idf.get(token) ?? 0;
|
|
151
|
+
return score;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Select session-log sections relevant to the query, capped at budgetBytes.
|
|
156
|
+
* Greedy by descending score (then original order), drop-whole policy with a
|
|
157
|
+
* head-slice fallback for the single best match if nothing else fits (so a
|
|
158
|
+
* matched query never returns zero bytes).
|
|
159
|
+
*/
|
|
160
|
+
function selectSessionLog(query: string, sessionLog: KnowledgeSection[], budgetBytes: number): KnowledgeSection[] {
|
|
161
|
+
if (sessionLog.length === 0 || !query.trim()) return [];
|
|
162
|
+
const idf = computeIdf(sessionLog);
|
|
163
|
+
const queryTokens = tokenizeQuery(query);
|
|
164
|
+
// Match = ANY header-token overlap with the query. IDF still drives RANKING
|
|
165
|
+
// (rarer overlap ranks first), but a token that happens to appear in every
|
|
166
|
+
// section (IDF=0) must still count as a match — otherwise a query for a
|
|
167
|
+
// common-but-relevant keyword (e.g. "redaction") would return nothing.
|
|
168
|
+
const scored = sessionLog
|
|
169
|
+
.map((sec, idx) => {
|
|
170
|
+
const overlap = [...queryTokens].filter((t) => sec.headerTokens.has(t));
|
|
171
|
+
return { sec, score: overlap.reduce((sum, t) => sum + (idf.get(t) ?? 0), 0), idx, hasOverlap: overlap.length > 0 };
|
|
172
|
+
})
|
|
173
|
+
.filter((x) => x.hasOverlap);
|
|
174
|
+
if (scored.length === 0) return [];
|
|
175
|
+
scored.sort((a, b) => b.score - a.score || a.idx - b.idx);
|
|
176
|
+
|
|
177
|
+
const selected: KnowledgeSection[] = [];
|
|
178
|
+
let used = 0;
|
|
179
|
+
let bestMatch: { sec: KnowledgeSection; score: number } | undefined;
|
|
180
|
+
for (const { sec, score } of scored) {
|
|
181
|
+
if (score > (bestMatch?.score ?? -1)) bestMatch = { sec, score };
|
|
182
|
+
if (used + sec.body.length <= budgetBytes) {
|
|
183
|
+
selected.push(sec);
|
|
184
|
+
used += sec.body.length;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Head-slice fallback: if the best match didn't fit (or nothing fit), inject
|
|
188
|
+
// a head-sliced copy of the single best match so the query isn't empty.
|
|
189
|
+
if (selected.length === 0 && bestMatch) {
|
|
190
|
+
const sliced = bestMatch.sec.body.slice(0, Math.max(0, budgetBytes));
|
|
191
|
+
selected.push({ ...bestMatch.sec, body: `${sliced}\n\n<!-- section truncated (session-log budget ${budgetBytes} bytes). Full file: use \`read\`. -->` });
|
|
192
|
+
}
|
|
193
|
+
return selected;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Read knowledge content. "" if absent/empty. */
|
|
197
|
+
export function readKnowledge(cwd: string, query?: KnowledgeQuery): string {
|
|
30
198
|
try {
|
|
31
199
|
const p = knowledgePath(cwd);
|
|
32
200
|
const stat = tryStat(p);
|
|
33
201
|
if (!stat) {
|
|
202
|
+
sectionCache.delete(p);
|
|
34
203
|
knowledgeCache.delete(p);
|
|
35
204
|
return "";
|
|
36
205
|
}
|
|
@@ -39,14 +208,46 @@ export function readKnowledge(cwd: string): string {
|
|
|
39
208
|
// For a run with N workers this is N redundant readFileSync of the same
|
|
40
209
|
// file. Cache by (mtimeMs, size) and only re-read when the file changes.
|
|
41
210
|
const cacheKey = `${stat.mtimeMs}:${stat.size}`;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
211
|
+
|
|
212
|
+
// B2: when no query context is provided (main-session hook, legacy
|
|
213
|
+
// callers, tests), keep the simple head-only path. This preserves the
|
|
214
|
+
// 4 existing unit tests exactly (they call readKnowledge(cwd)).
|
|
215
|
+
if (!query || (!query.goal && !query.taskText)) {
|
|
216
|
+
const cached = knowledgeCache.get(p);
|
|
217
|
+
if (cached && cached.key === cacheKey) return cached.content;
|
|
218
|
+
let content = fs.readFileSync(p, "utf8").trim();
|
|
219
|
+
if (content.length > MAX_KNOWLEDGE_HEAD_BYTES) {
|
|
220
|
+
content = `${content.slice(0, MAX_KNOWLEDGE_HEAD_BYTES)}\n\n<!-- knowledge.md truncated at ${MAX_KNOWLEDGE_HEAD_BYTES} bytes (head shown). Full file: ${p} — use the \`read\` tool if you need sections beyond the head. -->`;
|
|
221
|
+
}
|
|
222
|
+
knowledgeCache.set(p, { key: cacheKey, content });
|
|
223
|
+
return content;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Section-aware path: cache parsed sections, select per-query.
|
|
227
|
+
const cachedSections = sectionCache.get(p);
|
|
228
|
+
let parsed: { conventions: KnowledgeSection[]; sessionLog: KnowledgeSection[] };
|
|
229
|
+
if (cachedSections && cachedSections.key === cacheKey) {
|
|
230
|
+
parsed = { conventions: cachedSections.conventions, sessionLog: cachedSections.sessionLog };
|
|
231
|
+
} else {
|
|
232
|
+
const content = fs.readFileSync(p, "utf8").trim();
|
|
233
|
+
parsed = parseKnowledgeSections(content);
|
|
234
|
+
sectionCache.set(p, { key: cacheKey, conventions: parsed.conventions, sessionLog: parsed.sessionLog });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const queryText = [query.goal, query.taskText].filter(Boolean).join(" \n ");
|
|
238
|
+
const matchedSessionLog = selectSessionLog(queryText, parsed.sessionLog, MAX_SESSION_LOG_BYTES);
|
|
239
|
+
|
|
240
|
+
const parts: string[] = [];
|
|
241
|
+
// Conventions: always full.
|
|
242
|
+
for (const sec of parsed.conventions) parts.push(sec.body);
|
|
243
|
+
// Matched session-log (drop-whole, budget-capped).
|
|
244
|
+
for (const sec of matchedSessionLog) parts.push(sec.body);
|
|
245
|
+
// Always: section-index of ALL session-log headers (recovery safety net).
|
|
246
|
+
if (parsed.sessionLog.length > 0) {
|
|
247
|
+
const indexLines = parsed.sessionLog.map((s) => ` - ${s.header}`);
|
|
248
|
+
parts.push(`<!-- Session-log sections in knowledge.md (not injected unless matched above — use \`read\` for detail):\n${indexLines.join("\n")}\nFull file: ${p} -->`);
|
|
47
249
|
}
|
|
48
|
-
|
|
49
|
-
return content;
|
|
250
|
+
return parts.join("\n").trim();
|
|
50
251
|
} catch {
|
|
51
252
|
return "";
|
|
52
253
|
}
|
|
@@ -68,9 +269,19 @@ interface CachedKnowledge {
|
|
|
68
269
|
}
|
|
69
270
|
const knowledgeCache = new Map<string, CachedKnowledge>();
|
|
70
271
|
|
|
272
|
+
interface CachedSections {
|
|
273
|
+
key: string;
|
|
274
|
+
conventions: KnowledgeSection[];
|
|
275
|
+
sessionLog: KnowledgeSection[];
|
|
276
|
+
}
|
|
277
|
+
const sectionCache = new Map<string, CachedSections>();
|
|
278
|
+
|
|
279
|
+
/** Head cap for the no-query (legacy / main-session) path. */
|
|
280
|
+
const MAX_KNOWLEDGE_HEAD_BYTES = 2_000;
|
|
281
|
+
|
|
71
282
|
/** Build the injected prompt fragment (empty if no knowledge). */
|
|
72
|
-
export function buildKnowledgeFragment(cwd: string): string {
|
|
73
|
-
const content = readKnowledge(cwd);
|
|
283
|
+
export function buildKnowledgeFragment(cwd: string, query?: KnowledgeQuery): string {
|
|
284
|
+
const content = readKnowledge(cwd, query);
|
|
74
285
|
if (!content) return "";
|
|
75
286
|
return [
|
|
76
287
|
"",
|
|
@@ -85,8 +296,18 @@ export function buildKnowledgeFragment(cwd: string): string {
|
|
|
85
296
|
|
|
86
297
|
/**
|
|
87
298
|
* Register the knowledge-injection hook. Appends project knowledge to the
|
|
88
|
-
* system prompt on
|
|
89
|
-
*
|
|
299
|
+
* MAIN session's system prompt on `before_agent_start`. This hook does NOT
|
|
300
|
+
* fire for crew workers: they are spawned with `--no-extensions`, so the
|
|
301
|
+
* extension layer (and this hook) never loads in their process. Workers
|
|
302
|
+
* instead receive knowledge via `buildKnowledgeFragment(task.cwd)` injected
|
|
303
|
+
* into their prompt stablePrefix by `prompt-builder.ts`. Do NOT "fix" this
|
|
304
|
+
* perceived gap by making the hook reach workers — it would cause
|
|
305
|
+
* double-injection. (Verified by research workflow 2026-06-28.)
|
|
306
|
+
*
|
|
307
|
+
* The hook calls buildKnowledgeFragment(cwd) with NO query — so the main
|
|
308
|
+
* session gets conventions-only (no session-log noise), which is the right
|
|
309
|
+
* default for an interactive session that doesn't have a single task focus.
|
|
310
|
+
* Workers (which DO have a task) use the section-aware path via prompt-builder.
|
|
90
311
|
*/
|
|
91
312
|
export function registerKnowledgeInjection(pi: ExtensionAPI): void {
|
|
92
313
|
pi.on("before_agent_start", (event: BeforeAgentStartEvent) => {
|
package/src/extension/pi-api.ts
CHANGED
|
@@ -44,4 +44,4 @@ export { defineTool, createBashTool } from "@earendil-works/pi-coding-agent";
|
|
|
44
44
|
* and to surface version-drift if Pi upgrades introduce breaking changes.
|
|
45
45
|
* Update this when bumping the pi-coding-agent dependency.
|
|
46
46
|
*/
|
|
47
|
-
export const BUILT_AGAINST_PI_VERSION = "0.
|
|
47
|
+
export const BUILT_AGAINST_PI_VERSION = "0.77.0";
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Team-tool entry point for the `chain` feature.
|
|
3
|
+
*
|
|
4
|
+
* Dispatched from `handleRun` (run.ts) via an early-return guard:
|
|
5
|
+
* if (params.chain) → handleChainRun(params, ctx, handleRun)
|
|
6
|
+
*
|
|
7
|
+
* `handleRun` is passed by reference (NOT imported) to break the
|
|
8
|
+
* run.ts ↔ chain-dispatch.ts import cycle. This file imports only
|
|
9
|
+
* chain-runner, handoff-manager, chain-executor, and the shared
|
|
10
|
+
* tool-result helpers — none of which import run.ts.
|
|
11
|
+
*
|
|
12
|
+
* @see src/extension/team-tool/chain-executor.ts (ChainTeamRunExecutor)
|
|
13
|
+
* @see src/runtime/chain-runner.ts (ChainRunner.runChain, parseChainString)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { ChainRunner, parseChainString } from "../../runtime/chain-runner.ts";
|
|
17
|
+
import { HandoffManager } from "../../runtime/handoff-manager.ts";
|
|
18
|
+
import { ChainTeamRunExecutor, type HandleRunFn } from "./chain-executor.ts";
|
|
19
|
+
import { result, type TeamContext } from "./context.ts";
|
|
20
|
+
import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
|
|
21
|
+
import type { PiTeamsToolResult } from "../tool-result.ts";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Execute a chain expression (`"a -> b -> c"`). Each step runs a real team run
|
|
25
|
+
* via the injected `handleRun`, with handoff context passed forward through the
|
|
26
|
+
* goal text. Returns a multi-line summary plus structured `data` for the TUI.
|
|
27
|
+
*
|
|
28
|
+
* @param params - Must contain `chain`. `team`/`workflow`/`model` are forwarded
|
|
29
|
+
* as per-step overrides when a step does not declare its own.
|
|
30
|
+
* @param ctx - The team tool context (cwd is used to load run manifests).
|
|
31
|
+
* @param handleRun - The run.ts `handleRun` function reference (injected).
|
|
32
|
+
*/
|
|
33
|
+
export async function handleChainRun(
|
|
34
|
+
params: TeamToolParamsValue,
|
|
35
|
+
ctx: TeamContext,
|
|
36
|
+
handleRun: HandleRunFn,
|
|
37
|
+
): Promise<PiTeamsToolResult> {
|
|
38
|
+
const chainString = params.chain;
|
|
39
|
+
if (!chainString || chainString.trim().length === 0) {
|
|
40
|
+
return result("Chain expression is empty.", { action: "run", status: "error" }, true);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const spec = parseChainString(chainString);
|
|
44
|
+
if (spec.steps.length === 0) {
|
|
45
|
+
return result(
|
|
46
|
+
"Chain must contain at least one step. Use the form: \"step1 -> step2 -> step3\".",
|
|
47
|
+
{ action: "run", status: "error" },
|
|
48
|
+
true,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Construct the concrete executor with per-step overrides forwarded from the
|
|
53
|
+
// chain invocation (overridden by any step that parsed to a @team reference).
|
|
54
|
+
const executor = new ChainTeamRunExecutor({
|
|
55
|
+
handleRun,
|
|
56
|
+
ctx,
|
|
57
|
+
overrides: {
|
|
58
|
+
team: params.team,
|
|
59
|
+
workflow: params.workflow,
|
|
60
|
+
model: params.model,
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Surface the global continueOnError from the parsed spec (e.g. --continue-on-error=true).
|
|
65
|
+
const runner = new ChainRunner(executor, new HandoffManager());
|
|
66
|
+
const chainResult = await runner.runChain(spec, {}, undefined);
|
|
67
|
+
|
|
68
|
+
// Build a readable multi-line summary.
|
|
69
|
+
const lines: string[] = [
|
|
70
|
+
`Chain ${chainResult.success ? "completed" : "ended"}: ${spec.steps.length} step(s), ${chainResult.totalHandoffs.length} handoff(s).`,
|
|
71
|
+
"",
|
|
72
|
+
];
|
|
73
|
+
for (const s of chainResult.steps) {
|
|
74
|
+
const runId = executor.stepRunIds[s.step - 1];
|
|
75
|
+
const tag =
|
|
76
|
+
s.outcome === "success" ? "✓"
|
|
77
|
+
: s.outcome === "failure" ? "✗"
|
|
78
|
+
: s.outcome === "partial" ? "≈"
|
|
79
|
+
: "⊘";
|
|
80
|
+
lines.push(
|
|
81
|
+
`${tag} Step ${s.step} [${s.name}]: ${s.outcome} (${s.duration}ms)${runId ? ` runId=${runId}` : ""}${s.error ? ` | ${s.error}` : ""}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
lines.push("");
|
|
85
|
+
lines.push(
|
|
86
|
+
`Total: ${chainResult.totalDuration}ms${chainResult.totalTokens !== undefined ? `, ${chainResult.totalTokens} tokens` : ""}`,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return result(
|
|
90
|
+
lines.join("\n"),
|
|
91
|
+
{
|
|
92
|
+
action: "run",
|
|
93
|
+
status: chainResult.success ? "ok" : "error",
|
|
94
|
+
data: {
|
|
95
|
+
chain: true,
|
|
96
|
+
steps: chainResult.steps.length,
|
|
97
|
+
totalTokens: chainResult.totalTokens,
|
|
98
|
+
runIds: executor.stepRunIds,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
!chainResult.success,
|
|
102
|
+
);
|
|
103
|
+
}
|