pi-chalin 0.1.0
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/README.md +264 -0
- package/agents/conflict-resolver.md +28 -0
- package/agents/context-builder.md +31 -0
- package/agents/delegate.md +28 -0
- package/agents/oracle.md +28 -0
- package/agents/planner.md +28 -0
- package/agents/researcher.md +29 -0
- package/agents/reviewer.md +30 -0
- package/agents/scout.md +32 -0
- package/agents/worker.md +29 -0
- package/package.json +91 -0
- package/src/agent-overrides.ts +12 -0
- package/src/agents.ts +274 -0
- package/src/artifacts.ts +326 -0
- package/src/autoroute.ts +274 -0
- package/src/budget.ts +333 -0
- package/src/child-sessions.ts +108 -0
- package/src/child-tools.ts +796 -0
- package/src/commands.ts +140 -0
- package/src/config.ts +189 -0
- package/src/discovery.ts +190 -0
- package/src/index.ts +40 -0
- package/src/interview.ts +202 -0
- package/src/kernel.ts +254 -0
- package/src/memory.ts +945 -0
- package/src/model-resolution.ts +106 -0
- package/src/orchestration.ts +99 -0
- package/src/paths.ts +50 -0
- package/src/route-format.ts +149 -0
- package/src/route-guards.ts +92 -0
- package/src/route-widget.ts +219 -0
- package/src/runner-prompt.ts +346 -0
- package/src/runner-state.ts +105 -0
- package/src/runner.ts +1185 -0
- package/src/runtime-state.ts +175 -0
- package/src/schemas.ts +316 -0
- package/src/snapshot.ts +282 -0
- package/src/sql-js-fts5.d.ts +4 -0
- package/src/tools.ts +558 -0
- package/src/ui-agents.ts +338 -0
- package/src/ui-status.ts +87 -0
- package/src/ui.ts +875 -0
- package/src/webfetch.ts +294 -0
- package/src/worktrees.ts +113 -0
package/src/runner.ts
ADDED
|
@@ -0,0 +1,1185 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { AgentDefinition, AgentThinkingLevel } from "./schemas.ts";
|
|
3
|
+
import { evaluateBudgetUsage, policyForStep, recordBudgetCheckpoint, summarizeToolUtility } from "./budget.ts";
|
|
4
|
+
import type { ChalinPathsOptions } from "./paths.ts";
|
|
5
|
+
import { createMemoryCandidate, MemoryStore } from "./memory.ts";
|
|
6
|
+
import type { AgentOutput, AgentStep, MemoryCandidate, RouteDecision, RoutePlan, RunState, RunStepMetrics, RunStepState, TokenUsageSummary } from "./schemas.ts";
|
|
7
|
+
import { createChildToolPolicy, createChildTools, type ChildToolActivity, type ChildToolPolicy } from "./child-tools.ts";
|
|
8
|
+
import { createChalinChildSessionManager } from "./child-sessions.ts";
|
|
9
|
+
import { buildProjectSnapshot, formatProjectSnapshot } from "./snapshot.ts";
|
|
10
|
+
import { ArtifactStore } from "./artifacts.ts";
|
|
11
|
+
import { resolveAgentModel, resolveAgentThinking } from "./model-resolution.ts";
|
|
12
|
+
import { buildSdkPrompt, childToolNames, handoffReviewToolCallLimit, isHandoffGapReadMode, resolveStepCompletionStatus, synthesisCrossStepDuplicateReadLimit, synthesisGapReadLimit, synthesisToolCallLimit, type SdkPromptOptions } from "./runner-prompt.ts";
|
|
13
|
+
import { createRunState, isUsableStepHandoff, persistRun, prepareRunForResume } from "./runner-state.ts";
|
|
14
|
+
import { clearLiveStepSession, setLiveStepSession, type LiveStepSessionRef } from "./runtime-state.ts";
|
|
15
|
+
import { cleanupWorktrees, mergeWorktreeChanges, needsWorktreeIsolation, prepareWorktreeIsolation, type WorktreeIsolationPlan } from "./worktrees.ts";
|
|
16
|
+
|
|
17
|
+
export interface WorkerRunnerContext extends ChalinPathsOptions {
|
|
18
|
+
agents: Map<string, AgentDefinition>;
|
|
19
|
+
modelOverrides?: Record<string, string>;
|
|
20
|
+
thinkingOverrides?: Record<string, AgentThinkingLevel>;
|
|
21
|
+
extensionContext?: ExtensionContext;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
onUpdate?: (run: RunState) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface WorkerRunner {
|
|
27
|
+
run(route: RouteDecision, context: WorkerRunnerContext): Promise<RunState>;
|
|
28
|
+
resume?(run: RunState, context: WorkerRunnerContext): Promise<RunState>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class MockWorkerRunner implements WorkerRunner {
|
|
32
|
+
async run(route: RouteDecision, context: WorkerRunnerContext): Promise<RunState> {
|
|
33
|
+
const run = createRunState(route, context.cwd);
|
|
34
|
+
persistRun(run);
|
|
35
|
+
context.onUpdate?.(run);
|
|
36
|
+
const plan = route.plan;
|
|
37
|
+
if (!plan) return completeRun(run, context);
|
|
38
|
+
if (plan.kind === "parallel" && needsWorktreeIsolation(plan.tasks, context.agents)) {
|
|
39
|
+
const isolation = prepareWorktreeIsolation({ cwd: context.cwd, runId: run.id, steps: plan.tasks, agents: context.agents });
|
|
40
|
+
run.warnings.push(...isolation.warnings);
|
|
41
|
+
if (isolation.enabled) {
|
|
42
|
+
run.warnings.push("Parallel writer worktree isolation active; mock run cleaned isolated worktrees after completion.");
|
|
43
|
+
run.warnings.push(...cleanupWorktrees({ cwd: context.cwd, plan: isolation }));
|
|
44
|
+
} else {
|
|
45
|
+
run.warnings.push(`Parallel writer worktree isolation unavailable: ${isolation.reason}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
throwIfAborted(context.signal);
|
|
51
|
+
if (plan.kind === "single") {
|
|
52
|
+
await runStep(run.steps[0]!, context, undefined, run);
|
|
53
|
+
} else if (plan.kind === "chain") {
|
|
54
|
+
let previous = "";
|
|
55
|
+
for (const step of run.steps) {
|
|
56
|
+
throwIfAborted(context.signal);
|
|
57
|
+
const output = await runStep(step, context, previous, run);
|
|
58
|
+
previous = output.handoff ?? output.text;
|
|
59
|
+
}
|
|
60
|
+
} else if (plan.kind === "parallel") {
|
|
61
|
+
await Promise.all(run.steps.map((step) => runStep(step, context, undefined, run)));
|
|
62
|
+
} else {
|
|
63
|
+
await runMockDag(run, plan.stages, context);
|
|
64
|
+
}
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (!isAbortError(error)) throw error;
|
|
67
|
+
markRunAborted(run, context, errorMessage(error));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return completeRun(run, context);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async resume(run: RunState, context: WorkerRunnerContext): Promise<RunState> {
|
|
74
|
+
prepareRunForResume(run);
|
|
75
|
+
context.onUpdate?.(run);
|
|
76
|
+
const plan = run.route.plan;
|
|
77
|
+
if (!plan) return completeRun(run, context);
|
|
78
|
+
try {
|
|
79
|
+
throwIfAborted(context.signal);
|
|
80
|
+
if (plan.kind === "single" || plan.kind === "chain") {
|
|
81
|
+
let previous = aggregateCompletedHandoffBefore(run.steps, run.steps.length);
|
|
82
|
+
for (const step of run.steps) {
|
|
83
|
+
if (isUsableStepHandoff(step)) {
|
|
84
|
+
previous = aggregateHandoff([{ agent: step.agent, text: step.output?.handoff ?? step.output?.text ?? previous }]);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
throwIfAborted(context.signal);
|
|
88
|
+
const output = await runStep(step, context, previous, run);
|
|
89
|
+
previous = output.handoff ?? output.text;
|
|
90
|
+
}
|
|
91
|
+
} else if (plan.kind === "parallel") {
|
|
92
|
+
await Promise.all(run.steps.filter((step) => !isUsableStepHandoff(step)).map((step) => runStep(step, context, undefined, run)));
|
|
93
|
+
} else {
|
|
94
|
+
await resumeMockDag(run, plan.stages, context);
|
|
95
|
+
}
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (!isAbortError(error)) throw error;
|
|
98
|
+
markRunAborted(run, context, errorMessage(error));
|
|
99
|
+
}
|
|
100
|
+
return completeRun(run, context);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export class SdkWorkerRunner implements WorkerRunner {
|
|
105
|
+
async run(route: RouteDecision, context: WorkerRunnerContext): Promise<RunState> {
|
|
106
|
+
if (shouldUseMockSdkFallback(context)) {
|
|
107
|
+
const mock = new MockWorkerRunner();
|
|
108
|
+
const run = await mock.run(route, context);
|
|
109
|
+
run.warnings.push(mockFallbackReason(context));
|
|
110
|
+
persistRun(run);
|
|
111
|
+
return run;
|
|
112
|
+
}
|
|
113
|
+
const extensionContext = context.extensionContext;
|
|
114
|
+
if (!extensionContext) throw new Error("SDK runner requires an extension context.");
|
|
115
|
+
|
|
116
|
+
const run = createRunState(route, context.cwd);
|
|
117
|
+
persistRun(run);
|
|
118
|
+
context.onUpdate?.(run);
|
|
119
|
+
const plan = route.plan;
|
|
120
|
+
if (!plan) return completeRun(run, context);
|
|
121
|
+
if (plan.kind === "parallel") {
|
|
122
|
+
await runSdkParallelSteps(run, plan.tasks, context, extensionContext);
|
|
123
|
+
} else if (plan.kind === "dag") {
|
|
124
|
+
await runSdkDag(run, plan.stages, context, extensionContext);
|
|
125
|
+
} else {
|
|
126
|
+
let previous = "";
|
|
127
|
+
for (const step of run.steps) {
|
|
128
|
+
const result = await runSdkStep(step, context, extensionContext, run, { previous, cwd: context.cwd });
|
|
129
|
+
if (result.aborted) break;
|
|
130
|
+
previous = result.handoff ?? previous;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return completeRun(run, context);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async resume(run: RunState, context: WorkerRunnerContext): Promise<RunState> {
|
|
138
|
+
if (shouldUseMockSdkFallback(context)) {
|
|
139
|
+
const mock = new MockWorkerRunner();
|
|
140
|
+
const resumed = await mock.resume(run, context);
|
|
141
|
+
resumed.warnings.push(mockFallbackReason(context));
|
|
142
|
+
persistRun(resumed);
|
|
143
|
+
return resumed;
|
|
144
|
+
}
|
|
145
|
+
const extensionContext = context.extensionContext;
|
|
146
|
+
if (!extensionContext) throw new Error("SDK runner requires an extension context.");
|
|
147
|
+
|
|
148
|
+
prepareRunForResume(run);
|
|
149
|
+
context.onUpdate?.(run);
|
|
150
|
+
const plan = run.route.plan;
|
|
151
|
+
if (!plan) return completeRun(run, context);
|
|
152
|
+
if (plan.kind === "parallel") {
|
|
153
|
+
await runSdkParallelSteps(run, plan.tasks, context, extensionContext);
|
|
154
|
+
} else if (plan.kind === "dag") {
|
|
155
|
+
await runSdkDag(run, plan.stages, context, extensionContext);
|
|
156
|
+
} else {
|
|
157
|
+
let previous = "";
|
|
158
|
+
for (const step of run.steps) {
|
|
159
|
+
if (isUsableStepHandoff(step)) {
|
|
160
|
+
previous = step.output?.handoff ?? step.output?.text ?? previous;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const result = await runSdkStep(step, context, extensionContext, run, { previous, cwd: context.cwd });
|
|
164
|
+
if (result.aborted) break;
|
|
165
|
+
previous = result.handoff ?? previous;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return completeRun(run, context);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function runMockDag(run: RunState, stages: Extract<RoutePlan, { kind: "dag" }>["stages"], context: WorkerRunnerContext): Promise<void> {
|
|
174
|
+
let previous = "";
|
|
175
|
+
for (const stage of stages) {
|
|
176
|
+
throwIfAborted(context.signal);
|
|
177
|
+
const stageSteps = run.steps.filter((step) => step.id.startsWith(`${stage.id}:`));
|
|
178
|
+
const outputs = await Promise.all(stageSteps.map((step) => runStep(step, context, previous, run)));
|
|
179
|
+
previous = aggregateHandoff(outputs.map((output) => ({ agent: output.agent, text: output.handoff ?? output.text })));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function resumeMockDag(run: RunState, stages: Extract<RoutePlan, { kind: "dag" }>["stages"], context: WorkerRunnerContext): Promise<void> {
|
|
184
|
+
let previous = "";
|
|
185
|
+
for (const stage of stages) {
|
|
186
|
+
throwIfAborted(context.signal);
|
|
187
|
+
const stageSteps = run.steps.filter((step) => step.id.startsWith(`${stage.id}:`));
|
|
188
|
+
if (stageSteps.every((step) => isUsableStepHandoff(step))) {
|
|
189
|
+
previous = aggregateStageHandoff(stageSteps);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const outputs = await Promise.all(stageSteps
|
|
193
|
+
.filter((step) => !isUsableStepHandoff(step))
|
|
194
|
+
.map((step) => runStep(step, context, previous, run)));
|
|
195
|
+
const completedOutputs = stageSteps
|
|
196
|
+
.filter((step) => isUsableStepHandoff(step))
|
|
197
|
+
.map((step) => ({ agent: step.agent, text: step.output?.handoff ?? step.output?.text ?? "" }));
|
|
198
|
+
previous = aggregateHandoff([...completedOutputs, ...outputs.map((output) => ({ agent: output.agent, text: output.handoff ?? output.text }))]);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function runSdkParallelSteps(
|
|
203
|
+
run: RunState,
|
|
204
|
+
tasks: AgentStep[],
|
|
205
|
+
context: WorkerRunnerContext,
|
|
206
|
+
extensionContext: ExtensionContext,
|
|
207
|
+
): Promise<void> {
|
|
208
|
+
let isolation: WorktreeIsolationPlan | undefined;
|
|
209
|
+
if (needsWorktreeIsolation(tasks, context.agents)) {
|
|
210
|
+
isolation = prepareWorktreeIsolation({ cwd: context.cwd, runId: run.id, steps: tasks, agents: context.agents });
|
|
211
|
+
run.warnings.push(...isolation.warnings);
|
|
212
|
+
if (!isolation.enabled) {
|
|
213
|
+
const reason = `Parallel writer worktree isolation unavailable: ${isolation.reason}`;
|
|
214
|
+
run.warnings.push(reason);
|
|
215
|
+
for (const step of run.steps) {
|
|
216
|
+
step.status = "failed";
|
|
217
|
+
step.error = reason;
|
|
218
|
+
step.endedAt = new Date().toISOString();
|
|
219
|
+
}
|
|
220
|
+
persistRun(run);
|
|
221
|
+
context.onUpdate?.(run);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
run.warnings.push("Parallel writer worktree isolation active; writer agents run in isolated git worktrees and merge back with git apply --3way.");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
await Promise.all(run.steps.map((step) => {
|
|
229
|
+
const worktree = isolation?.worktrees.find((item) => item.stepId === step.id);
|
|
230
|
+
return runSdkStep(step, context, extensionContext, run, { cwd: worktree?.path ?? context.cwd });
|
|
231
|
+
}));
|
|
232
|
+
|
|
233
|
+
if (isolation?.enabled) await mergeIsolatedStage(run, context, extensionContext, isolation);
|
|
234
|
+
} finally {
|
|
235
|
+
if (isolation?.enabled) run.warnings.push(...cleanupWorktrees({ cwd: context.cwd, plan: isolation }));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function mergeIsolatedStage(run: RunState, context: WorkerRunnerContext, extensionContext: ExtensionContext, isolation: WorktreeIsolationPlan): Promise<void> {
|
|
240
|
+
const merge = mergeWorktreeChanges({ cwd: context.cwd, plan: isolation });
|
|
241
|
+
run.warnings.push(...merge.warnings);
|
|
242
|
+
if (merge.applied.length) run.warnings.push(`Merged isolated writer patches: ${merge.applied.join(", ")}.`);
|
|
243
|
+
for (const conflict of merge.conflicts) {
|
|
244
|
+
const step = run.steps.find((item) => item.agent === conflict.agent);
|
|
245
|
+
if (step) {
|
|
246
|
+
step.status = "failed";
|
|
247
|
+
step.error = `Worktree merge conflict: ${conflict.reason}`;
|
|
248
|
+
step.endedAt = new Date().toISOString();
|
|
249
|
+
}
|
|
250
|
+
run.warnings.push(`Worktree merge conflict for ${conflict.agent}: ${conflict.reason}`);
|
|
251
|
+
}
|
|
252
|
+
if (merge.conflicts.length > 0 && context.agents.has("conflict-resolver")) {
|
|
253
|
+
for (const conflict of merge.conflicts) {
|
|
254
|
+
const resolverStep: RunStepState = {
|
|
255
|
+
id: `conflict:${conflict.stepId ?? conflict.agent}`,
|
|
256
|
+
agent: "conflict-resolver",
|
|
257
|
+
task: buildConflictResolverTask(conflict),
|
|
258
|
+
status: "pending",
|
|
259
|
+
};
|
|
260
|
+
run.steps.push(resolverStep);
|
|
261
|
+
run.warnings.push(`Starting conflict-resolver for ${conflict.agent}.`);
|
|
262
|
+
await runSdkStep(resolverStep, context, extensionContext, run, { cwd: context.cwd });
|
|
263
|
+
if (resolverStep.status === "complete") {
|
|
264
|
+
run.warnings.push(`Conflict-resolver completed for ${conflict.agent}; original isolated patch was not auto-applied after conflict.`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
persistRun(run);
|
|
269
|
+
context.onUpdate?.(run);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function buildConflictResolverTask(conflict: { agent: string; reason: string; patch?: string; worktreePath?: string }): string {
|
|
273
|
+
return [
|
|
274
|
+
`Resolve a pi-chalin isolated worktree merge conflict from agent '${conflict.agent}'.`,
|
|
275
|
+
`Conflict reason: ${conflict.reason}`,
|
|
276
|
+
conflict.worktreePath ? `Isolated worktree path for reference: ${conflict.worktreePath}` : undefined,
|
|
277
|
+
"",
|
|
278
|
+
"Apply the intended change surgically to the primary worktree if and only if the intent is clear.",
|
|
279
|
+
"Use read/grep/find/ls/edit; do not rewrite whole existing files and do not modify files through bash.",
|
|
280
|
+
"If the patch intent conflicts with existing local changes or is ambiguous, stop and explain the human decision needed.",
|
|
281
|
+
conflict.patch ? "\nConflicting patch excerpt:" : undefined,
|
|
282
|
+
conflict.patch ? truncateText(conflict.patch, 3000) : undefined,
|
|
283
|
+
].filter((line): line is string => Boolean(line)).join("\n");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function runSdkDag(
|
|
287
|
+
run: RunState,
|
|
288
|
+
stages: Extract<RoutePlan, { kind: "dag" }>["stages"],
|
|
289
|
+
context: WorkerRunnerContext,
|
|
290
|
+
extensionContext: ExtensionContext,
|
|
291
|
+
): Promise<void> {
|
|
292
|
+
let previous = "";
|
|
293
|
+
for (const stage of stages) {
|
|
294
|
+
if (context.signal?.aborted) {
|
|
295
|
+
markRunAborted(run, context, "pi-chalin run stopped by user.");
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
const stageSteps = run.steps.filter((step) => step.id.startsWith(`${stage.id}:`));
|
|
299
|
+
await runSdkStage(run, stage, stageSteps, context, extensionContext, previous);
|
|
300
|
+
previous = aggregateStageHandoff(stageSteps);
|
|
301
|
+
if (shouldStopAfterDagStage(stageSteps, context.agents)) break;
|
|
302
|
+
const failedSteps = stageSteps.filter((step) => step.status === "failed");
|
|
303
|
+
if (failedSteps.length > 0) {
|
|
304
|
+
run.warnings.push(`DAG stage ${stage.id} continued with partial fan-out results after ${failedSteps.length} read-only failure(s).`);
|
|
305
|
+
persistRun(run);
|
|
306
|
+
context.onUpdate?.(run);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function aggregateStageHandoff(stageSteps: RunStepState[]): string {
|
|
312
|
+
return aggregateHandoff(stageSteps.map((step) => {
|
|
313
|
+
if (isUsableStepHandoff(step)) return { agent: step.agent, text: step.output?.handoff ?? step.output?.text ?? "" };
|
|
314
|
+
if (step.status === "failed") return { agent: step.agent, text: `FAILED: ${step.error ?? "unknown error"}. Treat this as a known coverage gap and make it explicit in downstream synthesis.` };
|
|
315
|
+
return { agent: step.agent, text: "" };
|
|
316
|
+
}));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function shouldStopAfterDagStage(stageSteps: Pick<RunStepState, "status" | "agent" | "output" | "error">[], agents: Map<string, AgentDefinition>): boolean {
|
|
320
|
+
if (stageSteps.some((step) => step.status === "paused")) return true;
|
|
321
|
+
const failedSteps = stageSteps.filter((step) => step.status === "failed");
|
|
322
|
+
if (failedSteps.length === 0) return false;
|
|
323
|
+
const usableSteps = stageSteps.filter((step) => step.status === "complete" || step.status === "budget-capped");
|
|
324
|
+
if (usableSteps.length === 0) return true;
|
|
325
|
+
return failedSteps.some((step) => isWriterAgent(agents.get(step.agent)));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function isWriterAgent(agent?: AgentDefinition): boolean {
|
|
329
|
+
if (!agent) return false;
|
|
330
|
+
return agent.concern === "implementation"
|
|
331
|
+
|| agent.concern === "conflict-resolution"
|
|
332
|
+
|| agent.capabilities.includes("edit-files")
|
|
333
|
+
|| agent.capabilities.includes("write-new-files");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function runSdkStage(
|
|
337
|
+
run: RunState,
|
|
338
|
+
stage: Extract<RoutePlan, { kind: "dag" }>["stages"][number],
|
|
339
|
+
stageSteps: RunStepState[],
|
|
340
|
+
context: WorkerRunnerContext,
|
|
341
|
+
extensionContext: ExtensionContext,
|
|
342
|
+
previous: string,
|
|
343
|
+
): Promise<void> {
|
|
344
|
+
let isolation: WorktreeIsolationPlan | undefined;
|
|
345
|
+
const runnableSteps = stageSteps.filter((step) => !isUsableStepHandoff(step));
|
|
346
|
+
if (runnableSteps.length === 0) return;
|
|
347
|
+
if (needsWorktreeIsolation(stage.tasks, context.agents)) {
|
|
348
|
+
isolation = prepareWorktreeIsolation({ cwd: context.cwd, runId: `${run.id}-${stage.id}`, steps: stage.tasks, agents: context.agents });
|
|
349
|
+
run.warnings.push(...isolation.warnings);
|
|
350
|
+
if (!isolation.enabled) {
|
|
351
|
+
const reason = `DAG stage ${stage.id} worktree isolation unavailable: ${isolation.reason}`;
|
|
352
|
+
run.warnings.push(reason);
|
|
353
|
+
for (const step of stageSteps) {
|
|
354
|
+
step.status = "failed";
|
|
355
|
+
step.error = reason;
|
|
356
|
+
step.endedAt = new Date().toISOString();
|
|
357
|
+
}
|
|
358
|
+
persistRun(run);
|
|
359
|
+
context.onUpdate?.(run);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
run.warnings.push(`DAG stage ${stage.id} worktree isolation active.`);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
try {
|
|
366
|
+
await Promise.all(runnableSteps.map((step) => {
|
|
367
|
+
const localStepId = step.id.split(":").at(-1) ?? step.id;
|
|
368
|
+
const worktree = isolation?.worktrees.find((item) => item.stepId === localStepId);
|
|
369
|
+
return runSdkStep(step, context, extensionContext, run, { cwd: worktree?.path ?? context.cwd, previous });
|
|
370
|
+
}));
|
|
371
|
+
if (isolation?.enabled) await mergeIsolatedStage(run, context, extensionContext, isolation);
|
|
372
|
+
} finally {
|
|
373
|
+
if (isolation?.enabled) run.warnings.push(...cleanupWorktrees({ cwd: context.cwd, plan: isolation }));
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function runSdkStep(
|
|
378
|
+
step: RunStepState,
|
|
379
|
+
context: WorkerRunnerContext,
|
|
380
|
+
extensionContext: ExtensionContext,
|
|
381
|
+
run: RunState,
|
|
382
|
+
options: { cwd: string; previous?: string },
|
|
383
|
+
): Promise<{ aborted: boolean; handoff?: string }> {
|
|
384
|
+
if (context.signal?.aborted) {
|
|
385
|
+
markRunAborted(run, context, "pi-chalin run stopped by user.");
|
|
386
|
+
return { aborted: true };
|
|
387
|
+
}
|
|
388
|
+
step.status = "running";
|
|
389
|
+
step.startedAt = new Date().toISOString();
|
|
390
|
+
persistRun(run);
|
|
391
|
+
context.onUpdate?.(run);
|
|
392
|
+
try {
|
|
393
|
+
const stepStartedAtMs = Date.now();
|
|
394
|
+
const agent = context.agents.get(step.agent);
|
|
395
|
+
const selectedModel = resolveAgentModel(agent, step.agent, context);
|
|
396
|
+
step.model = selectedModel.label;
|
|
397
|
+
step.modelResolution = selectedModel.resolution;
|
|
398
|
+
run.warnings.push(...selectedModel.warnings);
|
|
399
|
+
const selectedThinking = resolveAgentThinking(agent, step.agent, context, selectedModel.resolution);
|
|
400
|
+
step.thinkingLevel = selectedThinking.label;
|
|
401
|
+
const promptOptions = buildPromptOptionsForStep(run, step, agent, options.previous);
|
|
402
|
+
const budgetPolicy = budgetPolicyForSdkStep(policyForStep(agent, step, run.route.kind, run.route.risk), agent, step, options.previous);
|
|
403
|
+
promptOptions.memoryContext = await compactMemoryContextForStep(options.cwd, step, agent, options.previous);
|
|
404
|
+
const maxToolCalls = budgetPolicy.caps.maxToolCalls;
|
|
405
|
+
step.budget = budgetPolicy.profile;
|
|
406
|
+
step.maxToolCalls = maxToolCalls;
|
|
407
|
+
const allowedTools = childToolNames(agent, step.task, run.route.needsArtifacts, Boolean(options.previous));
|
|
408
|
+
const activity = createStepActivityMonitor(step, run, context);
|
|
409
|
+
const childPolicy = createChildToolPolicy({
|
|
410
|
+
cwd: options.cwd,
|
|
411
|
+
maxToolCalls,
|
|
412
|
+
budgetPolicy,
|
|
413
|
+
agentName: step.agent,
|
|
414
|
+
allowedTools,
|
|
415
|
+
priorFilesRead: promptOptions.priorFilesRead,
|
|
416
|
+
maxCrossStepDuplicateReads: promptOptions.synthesisGapReadLimit !== undefined ? synthesisCrossStepDuplicateReadLimit(agent) : undefined,
|
|
417
|
+
onActivity: activity.onToolActivity,
|
|
418
|
+
});
|
|
419
|
+
const prompt = buildSdkPrompt(agent, step.task, options.cwd, options.previous, budgetPolicy, "normal", promptOptions);
|
|
420
|
+
const { createAgentSession } = await import("@earendil-works/pi-coding-agent");
|
|
421
|
+
const sessionManager = createChalinChildSessionManager({ cwd: options.cwd, runId: run.id, step, extensionContext });
|
|
422
|
+
const releaseChildEnv = enterChildEnv();
|
|
423
|
+
try {
|
|
424
|
+
const created = await createAgentSession({
|
|
425
|
+
cwd: options.cwd,
|
|
426
|
+
model: selectedModel.model,
|
|
427
|
+
...(selectedThinking.level ? { thinkingLevel: selectedThinking.level as never } : {}),
|
|
428
|
+
modelRegistry: extensionContext.modelRegistry,
|
|
429
|
+
sessionManager,
|
|
430
|
+
tools: allowedTools,
|
|
431
|
+
customTools: createChildTools(childPolicy),
|
|
432
|
+
sessionStartEvent: { type: "session_start", reason: "new" },
|
|
433
|
+
});
|
|
434
|
+
step.thinkingLevel = (created.session.thinkingLevel as AgentThinkingLevel | undefined) ?? step.thinkingLevel;
|
|
435
|
+
const liveRef: LiveStepSessionRef = {
|
|
436
|
+
runId: run.id,
|
|
437
|
+
stepId: step.id,
|
|
438
|
+
agent: step.agent,
|
|
439
|
+
cwd: options.cwd,
|
|
440
|
+
startedAt: new Date().toISOString(),
|
|
441
|
+
getMessages: () => Array.isArray(created.session.state.messages) ? created.session.state.messages as unknown[] : [],
|
|
442
|
+
};
|
|
443
|
+
setLiveStepSession(liveRef);
|
|
444
|
+
let text = "";
|
|
445
|
+
try {
|
|
446
|
+
const abortChild = () => { void created.session.abort(); };
|
|
447
|
+
context.signal?.addEventListener("abort", abortChild, { once: true });
|
|
448
|
+
try {
|
|
449
|
+
await withIdleTimeout(
|
|
450
|
+
created.session.prompt(prompt, { expandPromptTemplates: false, source: "extension" }),
|
|
451
|
+
{
|
|
452
|
+
idleTimeoutMs: sdkStepIdleTimeoutMs(),
|
|
453
|
+
message: `SDK runner idle timed out for ${step.agent}`,
|
|
454
|
+
signal: context.signal,
|
|
455
|
+
activeOperations: activity.activeOperations,
|
|
456
|
+
pollActivitySignature: () => {
|
|
457
|
+
const messages = created.session.state.messages as unknown[];
|
|
458
|
+
activity.onSessionActivity(messages);
|
|
459
|
+
return sessionActivitySignature(messages, childPolicy);
|
|
460
|
+
},
|
|
461
|
+
onTimeout: abortChild,
|
|
462
|
+
},
|
|
463
|
+
);
|
|
464
|
+
} finally {
|
|
465
|
+
context.signal?.removeEventListener("abort", abortChild);
|
|
466
|
+
step.currentTool = undefined;
|
|
467
|
+
}
|
|
468
|
+
activity.onSessionActivity(created.session.state.messages as unknown[]);
|
|
469
|
+
text = extractLastAssistantText(created.session.state.messages as unknown[]);
|
|
470
|
+
step.output = parseAgentOutput(step.agent, text || `SDK run completed for ${step.agent}.`);
|
|
471
|
+
step.metrics = finalizeStepMetrics(
|
|
472
|
+
mergePolicyMetrics(extractSessionMetrics(created.session.state.messages as unknown[], stepStartedAtMs), childPolicy),
|
|
473
|
+
step,
|
|
474
|
+
budgetPolicy,
|
|
475
|
+
promptOptions.priorFilesRead,
|
|
476
|
+
);
|
|
477
|
+
} finally {
|
|
478
|
+
clearLiveStepSession(run.id, step.id, liveRef);
|
|
479
|
+
created.session.dispose();
|
|
480
|
+
}
|
|
481
|
+
} finally {
|
|
482
|
+
releaseChildEnv();
|
|
483
|
+
}
|
|
484
|
+
step.status = resolveStepCompletionStatus(step);
|
|
485
|
+
if (step.status === "budget-capped") {
|
|
486
|
+
run.warnings.push(`${step.agent} reached budget cap; checkpointed partial handoff for continuation.`);
|
|
487
|
+
await recordBudgetCheckpoint(new ArtifactStore({ cwd: context.cwd }), run.id, step, "Budget cap reached during SDK child execution.");
|
|
488
|
+
}
|
|
489
|
+
persistRun(run);
|
|
490
|
+
context.onUpdate?.(run);
|
|
491
|
+
return { aborted: false, handoff: step.output?.handoff ?? step.output?.text };
|
|
492
|
+
} catch (error) {
|
|
493
|
+
if (isAbortError(error)) {
|
|
494
|
+
step.status = "paused";
|
|
495
|
+
step.error = errorMessage(error);
|
|
496
|
+
markRunAborted(run, context, step.error);
|
|
497
|
+
return { aborted: true };
|
|
498
|
+
}
|
|
499
|
+
step.status = "failed";
|
|
500
|
+
step.error = error instanceof Error ? error.message : String(error);
|
|
501
|
+
run.warnings.push(`SDK runner failed for ${step.agent}: ${step.error}`);
|
|
502
|
+
persistRun(run);
|
|
503
|
+
context.onUpdate?.(run);
|
|
504
|
+
return { aborted: false };
|
|
505
|
+
} finally {
|
|
506
|
+
step.endedAt = new Date().toISOString();
|
|
507
|
+
persistRun(run);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const childEnv = { active: 0, previousChild: undefined as string | undefined, previousDisabled: undefined as string | undefined };
|
|
512
|
+
|
|
513
|
+
function enterChildEnv(): () => void {
|
|
514
|
+
if (childEnv.active === 0) {
|
|
515
|
+
childEnv.previousChild = process.env.PI_CHALIN_CHILD;
|
|
516
|
+
childEnv.previousDisabled = process.env.PI_CHALIN_DISABLED;
|
|
517
|
+
process.env.PI_CHALIN_CHILD = "1";
|
|
518
|
+
process.env.PI_CHALIN_DISABLED = "1";
|
|
519
|
+
}
|
|
520
|
+
childEnv.active += 1;
|
|
521
|
+
let released = false;
|
|
522
|
+
return () => {
|
|
523
|
+
if (released) return;
|
|
524
|
+
released = true;
|
|
525
|
+
childEnv.active = Math.max(0, childEnv.active - 1);
|
|
526
|
+
if (childEnv.active > 0) return;
|
|
527
|
+
if (childEnv.previousChild === undefined) delete process.env.PI_CHALIN_CHILD;
|
|
528
|
+
else process.env.PI_CHALIN_CHILD = childEnv.previousChild;
|
|
529
|
+
if (childEnv.previousDisabled === undefined) delete process.env.PI_CHALIN_DISABLED;
|
|
530
|
+
else process.env.PI_CHALIN_DISABLED = childEnv.previousDisabled;
|
|
531
|
+
childEnv.previousChild = undefined;
|
|
532
|
+
childEnv.previousDisabled = undefined;
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function buildPromptOptionsForStep(run: RunState, step: RunStepState, agent: AgentDefinition | undefined, previous?: string): SdkPromptOptions {
|
|
537
|
+
const priorFilesRead = priorFilesReadBeforeStep(run, step);
|
|
538
|
+
return {
|
|
539
|
+
priorFilesRead,
|
|
540
|
+
...(isHandoffGapReadMode(agent, step.task, previous) ? { synthesisGapReadLimit: synthesisGapReadLimit() } : {}),
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function compactMemoryContextForStep(cwd: string, step: RunStepState, agent: AgentDefinition | undefined, previous?: string): Promise<string | undefined> {
|
|
545
|
+
if (!agent?.memory.read || !agent.capabilities.includes("memory-read")) return undefined;
|
|
546
|
+
const query = [step.task, previous ? `Previous handoff: ${previous.slice(0, 700)}` : ""].filter(Boolean).join("\n");
|
|
547
|
+
const bundle = await new MemoryStore({ cwd }).retrieve({
|
|
548
|
+
query,
|
|
549
|
+
sourceAgent: step.agent,
|
|
550
|
+
agentConcern: agent.concern,
|
|
551
|
+
tokenBudget: memoryPromptTokenBudget(agent),
|
|
552
|
+
limit: 8,
|
|
553
|
+
});
|
|
554
|
+
return bundle.text || undefined;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function memoryPromptTokenBudget(agent: AgentDefinition): number {
|
|
558
|
+
if (agent.concern === "review" || agent.concern === "decision-consistency") return 700;
|
|
559
|
+
if (agent.concern === "planning" || agent.concern === "context-building") return 560;
|
|
560
|
+
if (agent.concern === "implementation" || agent.concern === "conflict-resolution") return 420;
|
|
561
|
+
return 320;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function priorFilesReadBeforeStep(run: RunState, currentStep: RunStepState): string[] {
|
|
565
|
+
const index = run.steps.indexOf(currentStep);
|
|
566
|
+
const previousSteps = index >= 0 ? run.steps.slice(0, index) : run.steps.filter((step) => step !== currentStep);
|
|
567
|
+
return [...new Set(previousSteps.flatMap((step) => step.metrics?.filesRead ?? []))].slice(0, 80);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function budgetPolicyForSdkStep(policy: ReturnType<typeof policyForStep>, agent: AgentDefinition | undefined, step: RunStepState, previous?: string): ReturnType<typeof policyForStep> {
|
|
571
|
+
if (!isHandoffGapReadMode(agent, step.task, previous)) return policy;
|
|
572
|
+
const reviewMode = agent?.concern === "review";
|
|
573
|
+
const maxToolCalls = Math.min(policy.caps.maxToolCalls, reviewMode ? handoffReviewToolCallLimit() : synthesisToolCallLimit());
|
|
574
|
+
return {
|
|
575
|
+
...policy,
|
|
576
|
+
id: `${policy.id}:${reviewMode ? "handoff-review" : "handoff-synthesis"}`,
|
|
577
|
+
taskKind: reviewMode ? "review" : "synthesis",
|
|
578
|
+
caps: {
|
|
579
|
+
...policy.caps,
|
|
580
|
+
maxToolCalls,
|
|
581
|
+
maxReadBytes: Math.min(policy.caps.maxReadBytes, reviewMode ? 240_000 : 600_000),
|
|
582
|
+
maxOutputChars: Math.min(policy.caps.maxOutputChars, reviewMode ? 10_000 : 14_000),
|
|
583
|
+
maxTurns: Math.min(policy.caps.maxTurns, reviewMode ? 3 : 4),
|
|
584
|
+
},
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function aggregateCompletedHandoffBefore(steps: RunStepState[], endIndex: number): string {
|
|
589
|
+
return aggregateHandoff(steps
|
|
590
|
+
.slice(0, endIndex)
|
|
591
|
+
.filter((step) => isUsableStepHandoff(step))
|
|
592
|
+
.map((step) => ({ agent: step.agent, text: step.output?.handoff ?? step.output?.text ?? "" })));
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
export function parseAgentOutput(agent: string, raw: string): AgentOutput {
|
|
596
|
+
const warnings: string[] = [];
|
|
597
|
+
let handoff: string | undefined;
|
|
598
|
+
const handoffMatch = raw.match(/##\s*Handoff\s*\n([\s\S]*?)(?:\n##\s|$)/i);
|
|
599
|
+
if (handoffMatch?.[1]) handoff = truncateText(handoffMatch[1].trim(), handoffBudgetChars(agent));
|
|
600
|
+
|
|
601
|
+
const candidates: MemoryCandidate[] = [];
|
|
602
|
+
const memoryBlock = raw.match(/##\s*Memory Candidates?\s*\n([\s\S]*?)(?:\n##\s|$)/i)?.[1];
|
|
603
|
+
if (memoryBlock) {
|
|
604
|
+
for (const line of memoryBlock.split("\n")) {
|
|
605
|
+
const parsed = parseMemoryCandidateLine(line);
|
|
606
|
+
if (!parsed) continue;
|
|
607
|
+
candidates.push(createMemoryCandidate({ category: parsed.category, content: parsed.content, sourceAgent: agent, confidence: parsed.confidence, scope: "project" }));
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (raw.includes("## Memory Candidate") && candidates.length === 0) warnings.push("Memory candidate block was present but no valid bullet candidates were parsed.");
|
|
612
|
+
const compactRaw = truncateText(raw.trim(), rawOutputBudgetChars());
|
|
613
|
+
return { agent, text: compactRaw, handoff, memoryCandidates: candidates.slice(0, memoryCandidateBudget()), raw: compactRaw, warnings };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
function parseMemoryCandidateLine(line: string): { category: string; content: string; confidence: number } | undefined {
|
|
618
|
+
const bullet = line.match(/^\s*[-*]\s+(.+?)\s*$/)?.[1]?.trim();
|
|
619
|
+
if (!bullet || /^none\.?$/i.test(bullet)) return undefined;
|
|
620
|
+
const normalized = bullet
|
|
621
|
+
.replace(/^`+|`+$/g, "")
|
|
622
|
+
.replace(/^["“”']+|["“”']+$/g, "")
|
|
623
|
+
.trim();
|
|
624
|
+
if (!normalized || /^none\.?$/i.test(normalized)) return undefined;
|
|
625
|
+
const tagged = normalized.match(/^(project-fact|pattern|tooling|testing|workflow|bugfix|validation|artifact|decision|preference|architecture|safety|security|failure|agent-note)\s*:\s*(.+)$/i);
|
|
626
|
+
if (tagged?.[1] && tagged[2]) {
|
|
627
|
+
const category = tagged[1].toLowerCase();
|
|
628
|
+
return { category, content: tagged[2].trim(), confidence: category === "agent-note" ? 0.7 : 0.9 };
|
|
629
|
+
}
|
|
630
|
+
return { category: "agent-note", content: normalized, confidence: 0.7 };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function aggregateHandoff(items: Array<{ agent: string; text: string }>): string {
|
|
634
|
+
return items
|
|
635
|
+
.filter((item) => item.text.trim().length > 0)
|
|
636
|
+
.map((item) => `- ${item.agent}: ${truncateText(item.text.trim(), 450)}`)
|
|
637
|
+
.join("\n");
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
async function runStep(step: RunStepState, context: WorkerRunnerContext, previous: string | undefined, run?: RunState): Promise<AgentOutput> {
|
|
641
|
+
step.status = "running";
|
|
642
|
+
step.startedAt = new Date().toISOString();
|
|
643
|
+
if (run) persistRun(run);
|
|
644
|
+
context.onUpdate?.(run ?? { ...createRunState({ kind: "bypass", agents: [], risk: "low", ambiguity: "low", needsMemory: false, needsArtifacts: false, reason: "update" }, context.cwd), steps: [step] });
|
|
645
|
+
await maybeMockDelay(context.signal);
|
|
646
|
+
throwIfAborted(context.signal);
|
|
647
|
+
const agent = context.agents.get(step.agent);
|
|
648
|
+
const model = context.modelOverrides?.[`${agent?.scope ?? "built-in"}/${step.agent}`] ?? context.modelOverrides?.[step.agent] ?? agent?.model;
|
|
649
|
+
step.model = model && model !== "inherit" ? model : "inherit";
|
|
650
|
+
const raw = buildMockOutput(step, context, previous, agent);
|
|
651
|
+
const output = parseAgentOutput(step.agent, raw);
|
|
652
|
+
step.output = output;
|
|
653
|
+
step.status = "complete";
|
|
654
|
+
step.endedAt = new Date().toISOString();
|
|
655
|
+
if (run) persistRun(run);
|
|
656
|
+
context.onUpdate?.(run ?? { ...createRunState({ kind: "bypass", agents: [], risk: "low", ambiguity: "low", needsMemory: false, needsArtifacts: false, reason: "update" }, context.cwd), steps: [step] });
|
|
657
|
+
return output;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function buildMockOutput(step: RunStepState, context: WorkerRunnerContext, previous: string | undefined, agent: AgentDefinition | undefined): string {
|
|
661
|
+
const snapshot = buildProjectSnapshot({ cwd: context.cwd });
|
|
662
|
+
const summary = formatProjectSnapshot(snapshot);
|
|
663
|
+
const gitSummary = snapshot.git ? `Git context: branch=${snapshot.git.branch ?? "unknown"}; recent changed files=${snapshot.git.changedFiles.slice(0, 8).join(", ") || "none"}.` : "";
|
|
664
|
+
const projectFiles = snapshot.highSignalFiles;
|
|
665
|
+
const findings = mockFindings(step, summary, gitSummary, projectFiles, previous);
|
|
666
|
+
const handoff = mockHandoff(step, summary, gitSummary, projectFiles, previous);
|
|
667
|
+
const memories = mockMemoryCandidates(step, summary);
|
|
668
|
+
return [
|
|
669
|
+
`## ${step.agent} result`,
|
|
670
|
+
`Task: ${step.task}`,
|
|
671
|
+
previous ? `Previous handoff: ${truncateText(previous, 500)}` : undefined,
|
|
672
|
+
`Concern: ${agent?.concern ?? "unknown"}`,
|
|
673
|
+
"",
|
|
674
|
+
"## Findings",
|
|
675
|
+
...findings.map((finding) => `- ${finding}`),
|
|
676
|
+
"",
|
|
677
|
+
"## Handoff",
|
|
678
|
+
...handoff.map((line) => `- ${line}`),
|
|
679
|
+
"",
|
|
680
|
+
"## Memory Candidates",
|
|
681
|
+
...(memories.length ? memories.map((memory) => `- ${memory}`) : ["- None."]),
|
|
682
|
+
].filter((line): line is string => line !== undefined).join("\n");
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function mockFindings(step: RunStepState, snapshotSummary: string, gitSummary: string, projectFiles: string[], previous: string | undefined): string[] {
|
|
686
|
+
const findings: string[] = [];
|
|
687
|
+
if (snapshotSummary) findings.push(`Snapshot signals: ${truncateText(snapshotSummary, 320)}`);
|
|
688
|
+
if (gitSummary) findings.push(gitSummary);
|
|
689
|
+
if (projectFiles.length) findings.push(`High-signal files: ${projectFiles.slice(0, 6).join(", ")}.`);
|
|
690
|
+
if (previous) findings.push(`Prior handoff available and should be used instead of re-scanning: ${truncateText(previous, 240)}`);
|
|
691
|
+
if (step.agent === "reviewer") findings.push("Review focus: validate architecture risks from scout evidence, not generic advice.");
|
|
692
|
+
if (step.agent === "planner") findings.push("Planning focus: produce phased migration/implementation steps with tests and rollback points.");
|
|
693
|
+
if (step.agent === "worker") findings.push("Implementation focus: make bounded file changes and add or update tests before reporting complete.");
|
|
694
|
+
return findings.slice(0, 5);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function mockHandoff(step: RunStepState, snapshotSummary: string, gitSummary: string, projectFiles: string[], previous: string | undefined): string[] {
|
|
698
|
+
const handoff: string[] = [];
|
|
699
|
+
if (step.agent === "context-builder") {
|
|
700
|
+
handoff.push(`Project context: ${snapshotSummary || "no stack metadata found"}`);
|
|
701
|
+
if (gitSummary) handoff.push(gitSummary);
|
|
702
|
+
if (projectFiles.length) handoff.push(`Inspect these first: ${projectFiles.slice(0, 5).join(", ")}.`);
|
|
703
|
+
handoff.push("Answer should summarize purpose, modules, changed areas, and risks from the gathered context.");
|
|
704
|
+
} else if (step.agent === "reviewer") {
|
|
705
|
+
handoff.push(previous ? `Use scout evidence: ${truncateText(previous, 420)}` : "Review should first anchor claims in project files.");
|
|
706
|
+
handoff.push("Likely risk areas: auth/session behavior, test coverage around changed behavior, and legacy component patterns.");
|
|
707
|
+
handoff.push("Final answer should prioritize actionable risks and avoid generic architecture advice.");
|
|
708
|
+
} else if (step.agent === "planner") {
|
|
709
|
+
handoff.push(previous ? `Plan from evidence: ${truncateText(previous, 420)}` : "Plan should begin with inventory and risk slicing.");
|
|
710
|
+
handoff.push("Recommended order: inventory → low-risk components → shared UI/composables → high-risk flows → regression tests.");
|
|
711
|
+
} else if (step.agent === "worker") {
|
|
712
|
+
handoff.push("Apply only the planned bounded change, keep diffs small, and run the nearest test command.");
|
|
713
|
+
} else {
|
|
714
|
+
handoff.push(`Mapped context for task: ${step.task}`);
|
|
715
|
+
if (snapshotSummary) handoff.push(snapshotSummary);
|
|
716
|
+
if (gitSummary) handoff.push(gitSummary);
|
|
717
|
+
if (projectFiles.length) handoff.push(`High-signal files: ${projectFiles.slice(0, 5).join(", ")}.`);
|
|
718
|
+
}
|
|
719
|
+
return handoff.slice(0, 6);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function mockMemoryCandidates(step: RunStepState, snapshotSummary: string): string[] {
|
|
723
|
+
if (step.agent !== "scout" && step.agent !== "context-builder") return [];
|
|
724
|
+
if (!snapshotSummary) return [];
|
|
725
|
+
return [`tooling: ${truncateText(snapshotSummary, 420)}`];
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
async function maybeMockDelay(signal?: AbortSignal): Promise<void> {
|
|
729
|
+
const parsed = Number(process.env.PI_CHALIN_MOCK_STEP_DELAY_MS);
|
|
730
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return;
|
|
731
|
+
await abortableSleep(parsed, signal);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function completeRun(run: RunState, context: WorkerRunnerContext): RunState {
|
|
735
|
+
run.status = hasUnrecoverableFailedSteps(run, context.agents)
|
|
736
|
+
? "failed"
|
|
737
|
+
: run.steps.some((step) => step.status === "paused")
|
|
738
|
+
? "paused"
|
|
739
|
+
: run.steps.some((step) => step.status === "budget-capped")
|
|
740
|
+
? "budget-capped"
|
|
741
|
+
: "complete";
|
|
742
|
+
run.endedAt = new Date().toISOString();
|
|
743
|
+
run.metrics = summarizeRunMetrics(run);
|
|
744
|
+
persistRun(run);
|
|
745
|
+
context.onUpdate?.(run);
|
|
746
|
+
return run;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
export function hasUnrecoverableFailedSteps(run: Pick<RunState, "steps">, agents: Map<string, AgentDefinition>): boolean {
|
|
750
|
+
const failedIndexes = run.steps
|
|
751
|
+
.map((step, index) => ({ step, index }))
|
|
752
|
+
.filter(({ step }) => step.status === "failed");
|
|
753
|
+
if (failedIndexes.length === 0) return false;
|
|
754
|
+
if (failedIndexes.some(({ step }) => isWriterAgent(agents.get(step.agent)))) return true;
|
|
755
|
+
|
|
756
|
+
const lastFailedIndex = Math.max(...failedIndexes.map(({ index }) => index));
|
|
757
|
+
const failedStageIds = new Set(failedIndexes.map(({ step }) => stageIdForStep(step.id)));
|
|
758
|
+
return !run.steps.some((step, index) => (
|
|
759
|
+
index > lastFailedIndex
|
|
760
|
+
&& isUsableStepHandoff(step)
|
|
761
|
+
&& !failedStageIds.has(stageIdForStep(step.id))
|
|
762
|
+
));
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function stageIdForStep(stepId: string): string {
|
|
766
|
+
return stepId.includes(":") ? stepId.split(":")[0] ?? stepId : stepId;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function shouldUseMockSdkFallback(context: WorkerRunnerContext): boolean {
|
|
770
|
+
return process.env.PI_CHALIN_RUNNER === "mock" || process.env.PI_OFFLINE === "1" || !context.extensionContext?.model;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function mockFallbackReason(context: WorkerRunnerContext): string {
|
|
774
|
+
if (process.env.PI_CHALIN_RUNNER === "mock") return "SDK runner fallback: PI_CHALIN_RUNNER=mock requested.";
|
|
775
|
+
if (process.env.PI_OFFLINE === "1") return "SDK runner fallback: PI_OFFLINE=1 avoids model calls during smoke tests.";
|
|
776
|
+
if (!context.extensionContext?.model) return "SDK runner fallback: no active Pi model is available in extension context.";
|
|
777
|
+
return "SDK runner fallback requested.";
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function createStepActivityMonitor(step: RunStepState, run: RunState, context: WorkerRunnerContext) {
|
|
781
|
+
let activeTools = 0;
|
|
782
|
+
let lastActivityAt = Date.now();
|
|
783
|
+
let lastActivitySignature = "";
|
|
784
|
+
return {
|
|
785
|
+
onToolActivity(activity: ChildToolActivity) {
|
|
786
|
+
lastActivityAt = activity.at;
|
|
787
|
+
if (activity.phase === "start") {
|
|
788
|
+
activeTools += 1;
|
|
789
|
+
step.currentTool = activity.toolName;
|
|
790
|
+
} else if (activity.phase === "end") {
|
|
791
|
+
activeTools = Math.max(0, activeTools - 1);
|
|
792
|
+
if (activeTools === 0) step.currentTool = undefined;
|
|
793
|
+
}
|
|
794
|
+
context.onUpdate?.(run);
|
|
795
|
+
},
|
|
796
|
+
onSessionActivity(messages: unknown[]) {
|
|
797
|
+
const signature = sessionActivityMarker(messages);
|
|
798
|
+
if (signature === lastActivitySignature) return;
|
|
799
|
+
lastActivitySignature = signature;
|
|
800
|
+
lastActivityAt = Date.now();
|
|
801
|
+
},
|
|
802
|
+
activeOperations() {
|
|
803
|
+
return activeTools;
|
|
804
|
+
},
|
|
805
|
+
lastActivityAt() {
|
|
806
|
+
return lastActivityAt;
|
|
807
|
+
},
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function sessionActivityMarker(messages: unknown[]): string {
|
|
812
|
+
const last = messages.at(-1);
|
|
813
|
+
const lastText = typeof last === "object" && last !== null ? JSON.stringify(last).slice(-512) : String(last ?? "");
|
|
814
|
+
return `${messages.length}:${lastText.length}:${lastText}`;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function sessionActivitySignature(messages: unknown[], policy: ChildToolPolicy): string {
|
|
818
|
+
const last = messages.at(-1);
|
|
819
|
+
const lastText = typeof last === "object" && last !== null ? JSON.stringify(last).slice(-512) : String(last ?? "");
|
|
820
|
+
const metrics = policy.metrics();
|
|
821
|
+
return `${messages.length}:${lastText.length}:${metrics.toolCalls}:${metrics.outputChars}:${metrics.readBytes}`;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function sdkStepIdleTimeoutMs(): number {
|
|
825
|
+
const parsed = Number(process.env.PI_CHALIN_SDK_STEP_IDLE_TIMEOUT_MS ?? process.env.PI_CHALIN_SDK_STEP_TIMEOUT_MS);
|
|
826
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 180_000;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
export async function withIdleTimeout<T>(
|
|
830
|
+
promise: Promise<T>,
|
|
831
|
+
options: {
|
|
832
|
+
idleTimeoutMs: number;
|
|
833
|
+
message: string;
|
|
834
|
+
signal?: AbortSignal;
|
|
835
|
+
activeOperations?: () => number;
|
|
836
|
+
pollActivitySignature?: () => string;
|
|
837
|
+
onTimeout?: () => void;
|
|
838
|
+
pollMs?: number;
|
|
839
|
+
},
|
|
840
|
+
): Promise<T> {
|
|
841
|
+
let lastActivityAt = Date.now();
|
|
842
|
+
let lastSignature = options.pollActivitySignature?.();
|
|
843
|
+
const pollMs = Math.max(10, Math.min(options.pollMs ?? 1_000, Math.max(10, Math.floor(options.idleTimeoutMs / 4))));
|
|
844
|
+
|
|
845
|
+
return await new Promise<T>((resolve, reject) => {
|
|
846
|
+
let settled = false;
|
|
847
|
+
const finish = (callback: () => void) => {
|
|
848
|
+
if (settled) return;
|
|
849
|
+
settled = true;
|
|
850
|
+
clearInterval(timer);
|
|
851
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
852
|
+
callback();
|
|
853
|
+
};
|
|
854
|
+
const onAbort = () => finish(() => reject(new Error("pi-chalin run stopped by user.")));
|
|
855
|
+
const timer = setInterval(() => {
|
|
856
|
+
const signature = options.pollActivitySignature?.();
|
|
857
|
+
if (signature !== undefined && signature !== lastSignature) {
|
|
858
|
+
lastSignature = signature;
|
|
859
|
+
lastActivityAt = Date.now();
|
|
860
|
+
}
|
|
861
|
+
const activeOperations = options.activeOperations?.() ?? 0;
|
|
862
|
+
if (activeOperations > 0) {
|
|
863
|
+
lastActivityAt = Date.now();
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
if (Date.now() - lastActivityAt >= options.idleTimeoutMs) {
|
|
867
|
+
options.onTimeout?.();
|
|
868
|
+
finish(() => reject(new Error(`${options.message} after ${options.idleTimeoutMs}ms without activity`)));
|
|
869
|
+
}
|
|
870
|
+
}, pollMs);
|
|
871
|
+
timer.unref?.();
|
|
872
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
873
|
+
promise.then(
|
|
874
|
+
(value) => finish(() => resolve(value)),
|
|
875
|
+
(error) => finish(() => reject(error)),
|
|
876
|
+
);
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
function markRunAborted(run: RunState, context: WorkerRunnerContext, reason: string): void {
|
|
882
|
+
for (const step of run.steps) {
|
|
883
|
+
if (step.status === "running" || step.status === "pending") {
|
|
884
|
+
step.status = "paused";
|
|
885
|
+
step.error = reason;
|
|
886
|
+
step.endedAt = new Date().toISOString();
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
run.status = "paused";
|
|
890
|
+
run.endedAt = new Date().toISOString();
|
|
891
|
+
if (!run.warnings.includes(reason)) run.warnings.push(reason);
|
|
892
|
+
persistRun(run);
|
|
893
|
+
context.onUpdate?.(run);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function throwIfAborted(signal?: AbortSignal): void {
|
|
897
|
+
if (signal?.aborted) throw new Error("pi-chalin run stopped by user.");
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function isAbortError(error: unknown): boolean {
|
|
901
|
+
const message = errorMessage(error).toLowerCase();
|
|
902
|
+
return message.includes("abort") || message.includes("stopped by user");
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function errorMessage(error: unknown): string {
|
|
906
|
+
return error instanceof Error ? error.message : String(error);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
910
|
+
return new Promise((resolve, reject) => {
|
|
911
|
+
if (signal?.aborted) {
|
|
912
|
+
reject(new Error("pi-chalin run stopped by user."));
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
const timeout = setTimeout(() => {
|
|
916
|
+
signal?.removeEventListener("abort", onAbort);
|
|
917
|
+
resolve();
|
|
918
|
+
}, ms);
|
|
919
|
+
const onAbort = () => {
|
|
920
|
+
clearTimeout(timeout);
|
|
921
|
+
reject(new Error("pi-chalin run stopped by user."));
|
|
922
|
+
};
|
|
923
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function extractLastAssistantText(messages: unknown[]): string {
|
|
928
|
+
for (const message of [...messages].reverse()) {
|
|
929
|
+
if (!message || typeof message !== "object") continue;
|
|
930
|
+
const maybe = message as { role?: unknown; content?: unknown };
|
|
931
|
+
if (maybe.role !== "assistant") continue;
|
|
932
|
+
if (typeof maybe.content === "string") return maybe.content;
|
|
933
|
+
if (Array.isArray(maybe.content)) {
|
|
934
|
+
return maybe.content.map((part) => typeof part?.text === "string" ? part.text : "").join("\n").trim();
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
return "";
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function extractSessionMetrics(messages: unknown[], startedAtMs: number): RunStepMetrics {
|
|
941
|
+
const responseIds = new Set<string>();
|
|
942
|
+
const usage = emptyUsage();
|
|
943
|
+
const toolCallsByName: Record<string, number> = {};
|
|
944
|
+
const filesRead: string[] = [];
|
|
945
|
+
const policyViolations: string[] = [];
|
|
946
|
+
let toolCalls = 0;
|
|
947
|
+
for (const message of messages) {
|
|
948
|
+
if (!isRecord(message) || message.role !== "assistant") continue;
|
|
949
|
+
const responseId = typeof message.responseId === "string" ? message.responseId : undefined;
|
|
950
|
+
if (responseId && responseIds.has(responseId)) continue;
|
|
951
|
+
if (responseId) responseIds.add(responseId);
|
|
952
|
+
addUsage(usage, usageFromMessage(message));
|
|
953
|
+
for (const call of toolCallRecords(message)) {
|
|
954
|
+
const name = call.name;
|
|
955
|
+
toolCalls += 1;
|
|
956
|
+
toolCallsByName[name] = (toolCallsByName[name] ?? 0) + 1;
|
|
957
|
+
const path = typeof call.args.path === "string" ? call.args.path : undefined;
|
|
958
|
+
if (name === "read" && path) filesRead.push(path);
|
|
959
|
+
policyViolations.push(...policyViolationsForCall(name, call.args));
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
const duplicateReadCount = filesRead.length - new Set(filesRead).size;
|
|
963
|
+
return {
|
|
964
|
+
durationMs: Date.now() - startedAtMs,
|
|
965
|
+
usage,
|
|
966
|
+
toolCalls,
|
|
967
|
+
toolCallsByName,
|
|
968
|
+
...(policyViolations.length ? { policyViolations } : {}),
|
|
969
|
+
...(duplicateReadCount > 0 ? { duplicateReadCount } : {}),
|
|
970
|
+
...(filesRead.length ? { filesRead: [...new Set(filesRead)].slice(0, 30) } : {}),
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function mergePolicyMetrics(metrics: RunStepMetrics, policy: ChildToolPolicy): RunStepMetrics {
|
|
975
|
+
const policyMetrics = policy.metrics();
|
|
976
|
+
const toolCallsByName = { ...metrics.toolCallsByName };
|
|
977
|
+
for (const [name, count] of Object.entries(policyMetrics.toolCallsByName)) {
|
|
978
|
+
toolCallsByName[name] = Math.max(toolCallsByName[name] ?? 0, count);
|
|
979
|
+
}
|
|
980
|
+
const policyViolations = [...(metrics.policyViolations ?? []), ...policyMetrics.policyViolations];
|
|
981
|
+
const filesRead = [...new Set([...(metrics.filesRead ?? []), ...policyMetrics.filesRead])];
|
|
982
|
+
const duplicateReadCount = Math.max(metrics.duplicateReadCount ?? 0, policyMetrics.duplicateReadCount);
|
|
983
|
+
const budgetStopCount = (metrics.budgetStopCount ?? 0) + policyMetrics.budgetStopCount;
|
|
984
|
+
return {
|
|
985
|
+
...metrics,
|
|
986
|
+
toolCalls: Math.max(metrics.toolCalls, policyMetrics.toolCalls),
|
|
987
|
+
maxToolCalls: policy.maxToolCalls,
|
|
988
|
+
toolCallsByName,
|
|
989
|
+
...(policyViolations.length ? { policyViolations } : {}),
|
|
990
|
+
...(budgetStopCount > 0 ? { budgetStopCount } : {}),
|
|
991
|
+
...(duplicateReadCount > 0 ? { duplicateReadCount } : {}),
|
|
992
|
+
...(filesRead.length ? { filesRead: filesRead.slice(0, 50) } : {}),
|
|
993
|
+
readBytes: Math.max(metrics.readBytes ?? 0, policyMetrics.readBytes),
|
|
994
|
+
outputChars: Math.max(metrics.outputChars ?? 0, policyMetrics.outputChars),
|
|
995
|
+
outputTruncatedCount: Math.max(metrics.outputTruncatedCount ?? 0, policyMetrics.outputTruncatedCount),
|
|
996
|
+
filesTouched: [...new Set([...(metrics.filesTouched ?? []), ...policyMetrics.filesTouched])].slice(0, 50),
|
|
997
|
+
retriesByTool: { ...(metrics.retriesByTool ?? {}), ...policyMetrics.retriesByTool },
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function finalizeStepMetrics(metrics: RunStepMetrics, step: RunStepState, budgetPolicy: ReturnType<typeof policyForStep>, priorFilesRead: string[] = []): RunStepMetrics {
|
|
1002
|
+
const utility = summarizeToolUtility({
|
|
1003
|
+
findings: extractFindingLines(step.output?.text ?? ""),
|
|
1004
|
+
toolCalls: metrics.toolCalls,
|
|
1005
|
+
filesRead: metrics.filesRead ?? [],
|
|
1006
|
+
firstSignalToolCall: firstSignalToolCall(metrics),
|
|
1007
|
+
verificationDone: Boolean((metrics.toolCallsByName.bash ?? 0) > 0 || /validat|test|passed|verified/i.test(step.output?.text ?? "")),
|
|
1008
|
+
memoryCandidates: (step.output?.memoryCandidates ?? []).map((candidate) => ({ content: candidate.content, category: candidate.category, confidence: candidate.confidence })),
|
|
1009
|
+
});
|
|
1010
|
+
const health = evaluateBudgetUsage(budgetPolicy, {
|
|
1011
|
+
elapsedMs: metrics.durationMs,
|
|
1012
|
+
toolCalls: metrics.toolCalls,
|
|
1013
|
+
totalCostUsd: metrics.usage.cost.total,
|
|
1014
|
+
turns: Math.max(1, Math.ceil(metrics.usage.output / 4000)),
|
|
1015
|
+
outputChars: metrics.outputChars ?? step.output?.text.length ?? 0,
|
|
1016
|
+
readBytes: metrics.readBytes ?? 0,
|
|
1017
|
+
filesTouched: metrics.filesTouched?.length ?? 0,
|
|
1018
|
+
retriesByTool: metrics.retriesByTool ?? {},
|
|
1019
|
+
});
|
|
1020
|
+
const prior = new Set(priorFilesRead);
|
|
1021
|
+
const crossStepDuplicateReads = [...new Set((metrics.filesRead ?? []).filter((file) => prior.has(file)))];
|
|
1022
|
+
return {
|
|
1023
|
+
...metrics,
|
|
1024
|
+
utility,
|
|
1025
|
+
...(crossStepDuplicateReads.length ? {
|
|
1026
|
+
crossStepDuplicateReadCount: crossStepDuplicateReads.length,
|
|
1027
|
+
crossStepDuplicateReads: crossStepDuplicateReads.slice(0, 30),
|
|
1028
|
+
} : {}),
|
|
1029
|
+
...(health.status === "budget-capped" || health.status === "warn" ? { budgetStopCount: Math.max(metrics.budgetStopCount ?? 0, health.status === "budget-capped" ? 1 : 0) } : {}),
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function extractFindingLines(text: string): string[] {
|
|
1034
|
+
const block = text.match(/##\s*Findings\s*\n([\s\S]*?)(?:\n##\s|$)/i)?.[1] ?? text;
|
|
1035
|
+
return block.split("\n")
|
|
1036
|
+
.map((line) => line.replace(/^\s*[-*]\s*/, "").trim())
|
|
1037
|
+
.filter((line) => line.length > 20)
|
|
1038
|
+
.slice(0, 12);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function firstSignalToolCall(metrics: RunStepMetrics): number {
|
|
1042
|
+
const readCalls = metrics.toolCallsByName.read ?? 0;
|
|
1043
|
+
const snapshotCalls = metrics.toolCallsByName.chalin_project_snapshot ?? 0;
|
|
1044
|
+
if ((metrics.filesRead?.length ?? 0) > 0 || snapshotCalls > 0) return Math.max(1, Math.min(metrics.toolCalls, snapshotCalls || readCalls || 1));
|
|
1045
|
+
return metrics.toolCalls;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function summarizeRunMetrics(run: RunState): RunState["metrics"] {
|
|
1049
|
+
const usage = emptyUsage();
|
|
1050
|
+
const toolCallsByName: Record<string, number> = {};
|
|
1051
|
+
const policyViolations: string[] = [];
|
|
1052
|
+
const filesRead: string[] = [];
|
|
1053
|
+
const crossStepDuplicateReads: string[] = [];
|
|
1054
|
+
let toolCalls = 0;
|
|
1055
|
+
let duplicateReadCount = 0;
|
|
1056
|
+
let crossStepDuplicateReadCount = 0;
|
|
1057
|
+
let budgetStopCount = 0;
|
|
1058
|
+
for (const step of run.steps) {
|
|
1059
|
+
if (!step.metrics) continue;
|
|
1060
|
+
addUsage(usage, step.metrics.usage);
|
|
1061
|
+
toolCalls += step.metrics.toolCalls;
|
|
1062
|
+
duplicateReadCount += step.metrics.duplicateReadCount ?? 0;
|
|
1063
|
+
crossStepDuplicateReadCount += step.metrics.crossStepDuplicateReadCount ?? 0;
|
|
1064
|
+
budgetStopCount += step.metrics.budgetStopCount ?? 0;
|
|
1065
|
+
policyViolations.push(...(step.metrics.policyViolations ?? []));
|
|
1066
|
+
filesRead.push(...(step.metrics.filesRead ?? []));
|
|
1067
|
+
crossStepDuplicateReads.push(...(step.metrics.crossStepDuplicateReads ?? []));
|
|
1068
|
+
for (const [name, count] of Object.entries(step.metrics.toolCallsByName)) {
|
|
1069
|
+
toolCallsByName[name] = (toolCallsByName[name] ?? 0) + count;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
return {
|
|
1073
|
+
durationMs: durationMs(run.startedAt, run.endedAt),
|
|
1074
|
+
usage,
|
|
1075
|
+
toolCalls,
|
|
1076
|
+
toolCallsByName,
|
|
1077
|
+
...(policyViolations.length ? { policyViolations } : {}),
|
|
1078
|
+
...(budgetStopCount > 0 ? { budgetStopCount } : {}),
|
|
1079
|
+
...(duplicateReadCount > 0 ? { duplicateReadCount } : {}),
|
|
1080
|
+
...(crossStepDuplicateReadCount > 0 ? { crossStepDuplicateReadCount, crossStepDuplicateReads: [...new Set(crossStepDuplicateReads)].slice(0, 50) } : {}),
|
|
1081
|
+
...(filesRead.length ? { filesRead: [...new Set(filesRead)].slice(0, 50) } : {}),
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function usageFromMessage(message: Record<string, unknown>): TokenUsageSummary {
|
|
1086
|
+
const raw = isRecord(message.usage) ? message.usage : {};
|
|
1087
|
+
const cost = isRecord(raw.cost) ? raw.cost : {};
|
|
1088
|
+
return {
|
|
1089
|
+
input: numberValue(raw.input),
|
|
1090
|
+
output: numberValue(raw.output),
|
|
1091
|
+
cacheRead: numberValue(raw.cacheRead),
|
|
1092
|
+
cacheWrite: numberValue(raw.cacheWrite),
|
|
1093
|
+
totalTokens: numberValue(raw.totalTokens) || numberValue(raw.input) + numberValue(raw.output) + numberValue(raw.cacheRead) + numberValue(raw.cacheWrite),
|
|
1094
|
+
cost: {
|
|
1095
|
+
input: numberValue(cost.input),
|
|
1096
|
+
output: numberValue(cost.output),
|
|
1097
|
+
cacheRead: numberValue(cost.cacheRead),
|
|
1098
|
+
cacheWrite: numberValue(cost.cacheWrite),
|
|
1099
|
+
total: numberValue(cost.total),
|
|
1100
|
+
},
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function toolCallRecords(message: Record<string, unknown>): Array<{ name: string; args: Record<string, unknown> }> {
|
|
1105
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
1106
|
+
return content
|
|
1107
|
+
.filter((part): part is Record<string, unknown> => isRecord(part) && part.type === "toolCall" && typeof part.name === "string")
|
|
1108
|
+
.map((part) => ({ name: part.name as string, args: parseToolArgs(part) }));
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function parseToolArgs(part: Record<string, unknown>): Record<string, unknown> {
|
|
1112
|
+
for (const key of ["args", "input", "parameters"]) {
|
|
1113
|
+
const value = part[key];
|
|
1114
|
+
if (isRecord(value)) return value;
|
|
1115
|
+
if (typeof value === "string") {
|
|
1116
|
+
try {
|
|
1117
|
+
const parsed = JSON.parse(value) as unknown;
|
|
1118
|
+
if (isRecord(parsed)) return parsed;
|
|
1119
|
+
} catch {
|
|
1120
|
+
// ignore malformed tool args
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
return {};
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function policyViolationsForCall(name: string, args: Record<string, unknown>): string[] {
|
|
1128
|
+
const violations: string[] = [];
|
|
1129
|
+
const command = typeof args.command === "string" ? args.command : "";
|
|
1130
|
+
if (name === "bash" && /\b(?:python|python3|node|ruby|perl|php|deno|tsx|ts-node|sh|bash|zsh)\b|[<>]|tee|sed\s+-i|cat\s+>/i.test(command)) {
|
|
1131
|
+
violations.push(`bash_policy:${command.slice(0, 140)}`);
|
|
1132
|
+
}
|
|
1133
|
+
return violations;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function emptyUsage(): TokenUsageSummary {
|
|
1137
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } };
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
function addUsage(target: TokenUsageSummary, source: TokenUsageSummary): void {
|
|
1141
|
+
target.input += source.input;
|
|
1142
|
+
target.output += source.output;
|
|
1143
|
+
target.cacheRead += source.cacheRead;
|
|
1144
|
+
target.cacheWrite += source.cacheWrite;
|
|
1145
|
+
target.totalTokens += source.totalTokens;
|
|
1146
|
+
target.cost.input += source.cost.input;
|
|
1147
|
+
target.cost.output += source.cost.output;
|
|
1148
|
+
target.cost.cacheRead += source.cost.cacheRead;
|
|
1149
|
+
target.cost.cacheWrite += source.cost.cacheWrite;
|
|
1150
|
+
target.cost.total += source.cost.total;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1154
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function numberValue(value: unknown): number {
|
|
1158
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function durationMs(startedAt: string, endedAt?: string): number {
|
|
1162
|
+
const end = endedAt ? Date.parse(endedAt) : Date.now();
|
|
1163
|
+
const start = Date.parse(startedAt);
|
|
1164
|
+
return Number.isFinite(end) && Number.isFinite(start) ? Math.max(0, end - start) : 0;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function truncateText(text: string, max: number): string {
|
|
1168
|
+
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function handoffBudgetChars(agent?: string): number {
|
|
1172
|
+
const parsed = Number(process.env.PI_CHALIN_HANDOFF_BUDGET_CHARS);
|
|
1173
|
+
if (Number.isFinite(parsed) && parsed > 200) return parsed;
|
|
1174
|
+
return agent === "scout" || agent === "context-builder" ? 2200 : 1200;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function rawOutputBudgetChars(): number {
|
|
1178
|
+
const parsed = Number(process.env.PI_CHALIN_RAW_OUTPUT_BUDGET_CHARS);
|
|
1179
|
+
return Number.isFinite(parsed) && parsed > 500 ? parsed : 6000;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function memoryCandidateBudget(): number {
|
|
1183
|
+
const parsed = Number(process.env.PI_CHALIN_MEMORY_CANDIDATE_BUDGET);
|
|
1184
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 3;
|
|
1185
|
+
}
|