@cr1ms0n/pi-subagent 0.9.0 → 0.11.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/CHANGELOG.md +23 -2
- package/README.md +40 -672
- package/README.zh-CN.md +42 -0
- package/docs/ARCHITECTURE.md +12 -24
- package/docs/COST-ACCOUNTING.md +6 -7
- package/docs/DEVELOPMENT.md +124 -0
- package/docs/PLAN.md +2 -0
- package/docs/REFERENCE.md +454 -0
- package/docs/RELEASING.md +151 -32
- package/docs/ROADMAP.md +2 -0
- package/docs/SECURITY.md +17 -18
- package/docs/UX.md +8 -12
- package/package.json +9 -1
- package/skills/subagent/SKILL.md +17 -12
- package/src/backend.ts +16 -1
- package/src/config.ts +1 -1
- package/src/extension.ts +33 -15
- package/src/format.ts +98 -3
- package/src/jev-router.ts +63 -27
- package/src/model-failover.ts +445 -0
- package/src/notifications.ts +2 -0
- package/src/orchestrator.ts +522 -303
- package/src/output.ts +9 -4
- package/src/persistence.ts +127 -5
- package/src/policy.ts +26 -3
- package/src/process-lock.ts +16 -0
- package/src/protocol.ts +208 -11
- package/src/registry.ts +33 -7
- package/src/routing-policy.ts +27 -19
- package/src/routing-types.ts +26 -4
- package/src/runner.ts +101 -15
- package/src/schema.ts +3 -3
- package/src/types.ts +88 -1
package/src/output.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
2
|
import type { SubagentConfig } from "./config.js";
|
|
3
|
+
import { earlierAttemptOutputNote } from "./model-failover.js";
|
|
3
4
|
import type { OutputMode, RunSnapshot, TaskResult } from "./types.js";
|
|
4
5
|
|
|
5
6
|
export interface CappedDelivery {
|
|
@@ -9,15 +10,16 @@ export interface CappedDelivery {
|
|
|
9
10
|
totalLines: number;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
function finalAssistantText(messages: any[]): string | undefined {
|
|
13
|
+
function finalAssistantText(messages: any[], latestOnly = false): string | undefined {
|
|
13
14
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
14
15
|
const message = messages[i];
|
|
15
|
-
if (message?.role !== "assistant"
|
|
16
|
+
if (message?.role !== "assistant") continue;
|
|
17
|
+
if (!Array.isArray(message.content)) { if (latestOnly) return undefined; continue; }
|
|
16
18
|
const text = message.content
|
|
17
19
|
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
|
|
18
20
|
.map((part: any) => part.text)
|
|
19
21
|
.join("");
|
|
20
|
-
if (text) return text;
|
|
22
|
+
if (text || latestOnly) return text || undefined;
|
|
21
23
|
}
|
|
22
24
|
return undefined;
|
|
23
25
|
}
|
|
@@ -87,7 +89,10 @@ export class OutputManager {
|
|
|
87
89
|
// the narrative preamble around the fenced block.
|
|
88
90
|
raw = JSON.stringify(result.structuredOutput, null, 2);
|
|
89
91
|
} else {
|
|
90
|
-
|
|
92
|
+
const primary = result.finalOutput || finalAssistantText(result.messages || [], !!result.routing?.rankedModels) || result.liveText;
|
|
93
|
+
raw = primary || result.errorMessage || result.stderr || "(no output)";
|
|
94
|
+
const earlier = !primary ? earlierAttemptOutputNote(result) : undefined;
|
|
95
|
+
if (earlier) raw += `\n\n${earlier}`;
|
|
91
96
|
}
|
|
92
97
|
const artifact = result.outputFile ? ` Full output: ${result.outputFile}` : " Full output is in the child session transcript.";
|
|
93
98
|
const marker = `[Truncated.${artifact}]`;
|
package/src/persistence.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
2
|
import type { SubagentConfig } from "./config.js";
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
MAX_ATTEMPT_PREVIEW_BYTES,
|
|
5
|
+
MAX_MODEL_ATTEMPT_RECORDS,
|
|
6
|
+
trimAttemptPreviews,
|
|
7
|
+
utf8SafePrefix,
|
|
8
|
+
validateModelRanking,
|
|
9
|
+
} from "./model-failover.js";
|
|
10
|
+
import { MAX_ROUTING_MODEL_ID_LENGTH, MAX_ROUTING_TOOL_QUESTIONS, type RankedModelOption, type RoutingReceipt } from "./routing-types.js";
|
|
11
|
+
import type { ChildProcessIdentity, ModelAttemptRecord, ModelFailureCategory, RunMode, RunSnapshot, RunState, TaskProfile, TaskRouting, TaskSpec, TimeoutPhase, ToolActivity, UsageStats } from "./types.js";
|
|
5
12
|
import { emptyUsage } from "./types.js";
|
|
6
13
|
import { isThinkingLevel } from "./thinking.js";
|
|
7
14
|
|
|
@@ -46,6 +53,20 @@ const MAX_ROUTING_VERSION_LENGTH = 128;
|
|
|
46
53
|
const MAX_ROUTING_MODEL_LENGTH = 256;
|
|
47
54
|
const MAX_ROUTING_LIST = 256;
|
|
48
55
|
|
|
56
|
+
const TOOL_ACTIVITY_STATES = ["none", "started", "unknown"] as const;
|
|
57
|
+
const MODEL_FAILURE_CATEGORIES: readonly ModelFailureCategory[] = [
|
|
58
|
+
"model_unavailable",
|
|
59
|
+
"rate_limited",
|
|
60
|
+
"service_overload",
|
|
61
|
+
"transport",
|
|
62
|
+
"auth",
|
|
63
|
+
"quota",
|
|
64
|
+
"invalid_request",
|
|
65
|
+
"context_overflow",
|
|
66
|
+
"refusal",
|
|
67
|
+
"unknown",
|
|
68
|
+
];
|
|
69
|
+
|
|
49
70
|
function routingString(value: unknown, max = MAX_ROUTING_ID_LENGTH): string | undefined {
|
|
50
71
|
if (typeof value !== "string") return undefined;
|
|
51
72
|
const trimmed = value.trim();
|
|
@@ -138,6 +159,27 @@ export function normalizeTaskRouting(value: unknown): TaskRouting | undefined {
|
|
|
138
159
|
const selectorVersions = routingStringArray(r.selectorVersions, MAX_ROUTING_TOOL_QUESTIONS + 1);
|
|
139
160
|
const receiptIds = routingStringArray(r.receiptIds, MAX_ROUTING_TOOL_QUESTIONS + 1);
|
|
140
161
|
const latencyMs = routingNonNegativeNumber(r.latencyMs);
|
|
162
|
+
// Probability ranking is display/history metadata on reload. Any malformed or
|
|
163
|
+
// oversized entry drops the WHOLE field: a partially decoded ranking must
|
|
164
|
+
// never exist downstream, and persisted rankings are never re-finalized into
|
|
165
|
+
// an executable attempt plan regardless.
|
|
166
|
+
let rankedModels: readonly RankedModelOption[] | undefined;
|
|
167
|
+
if (Array.isArray(r.rankedModels) && r.rankedModels.length > 0 && r.rankedModels.length <= MAX_MODEL_ATTEMPT_RECORDS) {
|
|
168
|
+
const entries: RankedModelOption[] = [];
|
|
169
|
+
let valid = true;
|
|
170
|
+
for (const raw of r.rankedModels) {
|
|
171
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) { valid = false; break; }
|
|
172
|
+
const entry = raw as Record<string, unknown>;
|
|
173
|
+
const model = routingString(entry.model, MAX_ROUTING_MODEL_ID_LENGTH);
|
|
174
|
+
const probability = entry.probability;
|
|
175
|
+
if (!model || typeof probability !== "number" || !Number.isFinite(probability) || probability < 0 || probability > 1) { valid = false; break; }
|
|
176
|
+
entries.push(Object.freeze({ model, probability }));
|
|
177
|
+
}
|
|
178
|
+
if (valid && (r.rankedTotal === undefined || r.rankedTotal === entries.length)
|
|
179
|
+
&& validateModelRanking(entries, entries.map((entry) => entry.model), selectedModel) === undefined) {
|
|
180
|
+
rankedModels = Object.freeze(entries);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
141
183
|
|
|
142
184
|
return Object.freeze({
|
|
143
185
|
decisionId,
|
|
@@ -146,6 +188,7 @@ export function normalizeTaskRouting(value: unknown): TaskRouting | undefined {
|
|
|
146
188
|
selectedModel,
|
|
147
189
|
selectedTools,
|
|
148
190
|
...(confidence === undefined ? {} : { confidence }),
|
|
191
|
+
...(rankedModels === undefined ? {} : { rankedModels }),
|
|
149
192
|
selectorModel,
|
|
150
193
|
...(selectorVersion === undefined ? {} : { selectorVersion }),
|
|
151
194
|
selectorVersions: selectorVersions ?? (selectorVersion ? Object.freeze([selectorVersion]) : Object.freeze([])),
|
|
@@ -343,6 +386,10 @@ export interface PersistedResult {
|
|
|
343
386
|
attempts?: number;
|
|
344
387
|
/** Models tried across attempts, in order. */
|
|
345
388
|
attemptedModels?: string[];
|
|
389
|
+
/** Sticky tool-activity boundary state across the task's attempts. */
|
|
390
|
+
toolActivity?: ToolActivity;
|
|
391
|
+
/** Bounded ranked attempt history; previews capped, never executable. */
|
|
392
|
+
modelAttempts?: ModelAttemptRecord[];
|
|
346
393
|
/** Parsed structured result when output_schema validated. */
|
|
347
394
|
structuredOutput?: unknown;
|
|
348
395
|
/** Validation errors when output_schema was requested but failed. */
|
|
@@ -429,6 +476,72 @@ function utf8Prefix(value: string, maxBytes: number): string {
|
|
|
429
476
|
return buffer.subarray(0, end).toString("utf8");
|
|
430
477
|
}
|
|
431
478
|
|
|
479
|
+
/**
|
|
480
|
+
* Bounded decode of the ranked attempt history. Any structurally invalid or
|
|
481
|
+
* over-cap record drops the whole optional field (never a partial ranking),
|
|
482
|
+
* and retained previews beyond the task total lose OLDEST text first while
|
|
483
|
+
* keeping metadata/session pointers. Decoded records are descriptive only:
|
|
484
|
+
* they never authorize execution.
|
|
485
|
+
*/
|
|
486
|
+
export function normalizeModelAttempts(value: unknown): ModelAttemptRecord[] | undefined {
|
|
487
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > MAX_MODEL_ATTEMPT_RECORDS) return undefined;
|
|
488
|
+
const records: ModelAttemptRecord[] = [];
|
|
489
|
+
for (const raw of value) {
|
|
490
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
491
|
+
const entry = raw as Record<string, unknown>;
|
|
492
|
+
const attempt = routingNonNegativeInt(entry.attempt);
|
|
493
|
+
const rank = routingNonNegativeInt(entry.rank);
|
|
494
|
+
const model = routingString(entry.model, MAX_ROUTING_MODEL_ID_LENGTH);
|
|
495
|
+
const probability = entry.probability;
|
|
496
|
+
const outcome = isRunState(entry.outcome) ? entry.outcome : undefined;
|
|
497
|
+
if (attempt === undefined || attempt !== records.length + 1 || attempt > MAX_MODEL_ATTEMPT_RECORDS
|
|
498
|
+
|| rank === undefined || rank >= MAX_MODEL_ATTEMPT_RECORDS || !model
|
|
499
|
+
|| typeof probability !== "number" || !Number.isFinite(probability) || probability < 0 || probability > 1
|
|
500
|
+
|| outcome === undefined) return undefined;
|
|
501
|
+
const stopReason = entry.stopReason === undefined ? undefined : routingString(entry.stopReason, 128);
|
|
502
|
+
if (entry.stopReason !== undefined && stopReason === undefined) return undefined;
|
|
503
|
+
const failureCategory = entry.failureCategory === undefined
|
|
504
|
+
? undefined
|
|
505
|
+
: (MODEL_FAILURE_CATEGORIES as readonly string[]).includes(String(entry.failureCategory))
|
|
506
|
+
? (entry.failureCategory as ModelFailureCategory)
|
|
507
|
+
: undefined;
|
|
508
|
+
const toolActivity = (TOOL_ACTIVITY_STATES as readonly string[]).includes(String(entry.toolActivity))
|
|
509
|
+
? (entry.toolActivity as ToolActivity)
|
|
510
|
+
: undefined;
|
|
511
|
+
const sessionId = entry.sessionId === undefined ? undefined : routingString(entry.sessionId, MAX_ROUTING_ID_LENGTH);
|
|
512
|
+
if (entry.sessionId !== undefined && sessionId === undefined) return undefined;
|
|
513
|
+
const outputPreview = typeof entry.outputPreview === "string"
|
|
514
|
+
? utf8SafePrefix(entry.outputPreview, MAX_ATTEMPT_PREVIEW_BYTES) || undefined
|
|
515
|
+
: undefined;
|
|
516
|
+
records.push({
|
|
517
|
+
attempt,
|
|
518
|
+
rank,
|
|
519
|
+
model,
|
|
520
|
+
probability,
|
|
521
|
+
outcome,
|
|
522
|
+
...(stopReason === undefined ? {} : { stopReason }),
|
|
523
|
+
...(failureCategory === undefined ? {} : { failureCategory }),
|
|
524
|
+
...(toolActivity === undefined ? {} : { toolActivity }),
|
|
525
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
526
|
+
...(outputPreview === undefined ? {} : { outputPreview }),
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
trimAttemptPreviews(records);
|
|
530
|
+
return records;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Bounded compatibility model chain; repeated IDs are valid for infrastructure retries. */
|
|
534
|
+
export function normalizeAttemptedModels(value: unknown): string[] | undefined {
|
|
535
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > MAX_MODEL_ATTEMPT_RECORDS) return undefined;
|
|
536
|
+
const models: string[] = [];
|
|
537
|
+
for (const entry of value) {
|
|
538
|
+
const model = routingString(entry, MAX_ROUTING_MODEL_ID_LENGTH);
|
|
539
|
+
if (!model) return undefined;
|
|
540
|
+
models.push(model);
|
|
541
|
+
}
|
|
542
|
+
return models;
|
|
543
|
+
}
|
|
544
|
+
|
|
432
545
|
function normalizeResult(value: unknown): PersistedResult | undefined {
|
|
433
546
|
if (!value || typeof value !== "object") return undefined;
|
|
434
547
|
const r = value as Partial<PersistedResult>;
|
|
@@ -464,9 +577,11 @@ function normalizeResult(value: unknown): PersistedResult | undefined {
|
|
|
464
577
|
wrappedUp: r.wrappedUp === true ? true : undefined,
|
|
465
578
|
stalledSince: typeof r.stalledSince === "number" && Number.isFinite(r.stalledSince) ? r.stalledSince : undefined,
|
|
466
579
|
attempts: typeof r.attempts === "number" && Number.isInteger(r.attempts) && r.attempts > 1 ? r.attempts : undefined,
|
|
467
|
-
attemptedModels:
|
|
468
|
-
|
|
580
|
+
attemptedModels: normalizeAttemptedModels(r.attemptedModels),
|
|
581
|
+
toolActivity: (TOOL_ACTIVITY_STATES as readonly string[]).includes(String((r as { toolActivity?: unknown }).toolActivity))
|
|
582
|
+
? (r as { toolActivity?: ToolActivity }).toolActivity
|
|
469
583
|
: undefined,
|
|
584
|
+
modelAttempts: normalizeModelAttempts((r as { modelAttempts?: unknown }).modelAttempts),
|
|
470
585
|
structuredOutput: r.structuredOutput !== undefined && Buffer.byteLength(JSON.stringify(r.structuredOutput) ?? "", "utf8") <= 32_768
|
|
471
586
|
? r.structuredOutput
|
|
472
587
|
: undefined,
|
|
@@ -619,8 +734,15 @@ export class PersistenceLayer {
|
|
|
619
734
|
if (typeof event.data.childSessionId === "string") ids.add(event.data.childSessionId);
|
|
620
735
|
if (Array.isArray(event.data.results)) {
|
|
621
736
|
for (const result of event.data.results) {
|
|
622
|
-
const
|
|
737
|
+
const partial = result as Partial<PersistedResult>;
|
|
738
|
+
const sessionId = partial?.sessionId;
|
|
623
739
|
if (typeof sessionId === "string" && sessionId) ids.add(sessionId);
|
|
740
|
+
// Earlier ranked attempts stay referenced while their result record
|
|
741
|
+
// survives on the active branch, so lifecycle distillation cannot
|
|
742
|
+
// delete still-discoverable paid partial output.
|
|
743
|
+
for (const record of normalizeModelAttempts(partial?.modelAttempts) ?? []) {
|
|
744
|
+
if (record.sessionId) ids.add(record.sessionId);
|
|
745
|
+
}
|
|
624
746
|
}
|
|
625
747
|
}
|
|
626
748
|
}
|
package/src/policy.ts
CHANGED
|
@@ -3,10 +3,11 @@ import type { AgentDefinition } from "./agents.js";
|
|
|
3
3
|
import { resolveAgent } from "./agents.js";
|
|
4
4
|
import { isPlausibleSchema, repairDoubleEncodedText } from "./structured.js";
|
|
5
5
|
import { defaultConfig, type TaskDefaults, type TaskDefaultsByProfile } from "./config.js";
|
|
6
|
-
import type { OutputMode, TaskProfile, TaskSpec } from "./types.js";
|
|
6
|
+
import type { ModelAttemptSpec, OutputMode, TaskProfile, TaskSpec } from "./types.js";
|
|
7
7
|
import type { ParallelTaskInput, SubagentParams } from "./schema.js";
|
|
8
8
|
import { BACKEND_NAMES, checkCapabilities, type BackendName } from "./backend.js";
|
|
9
9
|
import { resolveBackend } from "./backends/index.js";
|
|
10
|
+
import { validateModelRanking } from "./model-failover.js";
|
|
10
11
|
import type { JevRoutingConfig, RoutingDecision, RoutingModelCandidate } from "./routing-types.js";
|
|
11
12
|
import { isThinkingLevel } from "./thinking.js";
|
|
12
13
|
|
|
@@ -548,11 +549,33 @@ export function finalizeRoutedTasks(
|
|
|
548
549
|
const tools = [...new Set([...decision.selectedTools, ...item.mandatoryTools])];
|
|
549
550
|
const canWrite = tools.some((tool) => !NON_WRITING_TOOLS.has(tool));
|
|
550
551
|
if (item.profile !== "general" && canWrite) return { ok: false, error: `Task ${index + 1}: writable selector choice violates ${item.profile}.` };
|
|
552
|
+
// The probability ranking is mandatory for every new route: without it there
|
|
553
|
+
// is no failover plan, and a persisted/legacy decision shape must never be
|
|
554
|
+
// silently re-promoted into one.
|
|
555
|
+
const ranked = decision.rankedModels;
|
|
556
|
+
const rankingProblem = validateModelRanking(ranked, models.map((entry) => entry.model), decision.selectedModel);
|
|
557
|
+
if (rankingProblem) return { ok: false, error: `Task ${index + 1}: ${rankingProblem}.` };
|
|
551
558
|
const { candidateTools: _candidates, mandatoryTools, requestedThinking, parentThinking, ...spec } = item;
|
|
552
|
-
|
|
559
|
+
// One frozen attempt plan per ranked candidate: same shared tools and route,
|
|
560
|
+
// per-candidate thinking under explicit > agent > profile > candidate > parent.
|
|
561
|
+
const modelAttemptPlan: ModelAttemptSpec[] = [];
|
|
562
|
+
for (const entry of ranked!) {
|
|
563
|
+
const candidateEntry = models.find((model) => model.model === entry.model)!;
|
|
564
|
+
const thinking = requestedThinking ?? candidateEntry.thinking ?? parentThinking;
|
|
565
|
+
if (thinking !== undefined && !isThinkingLevel(thinking)) {
|
|
566
|
+
return { ok: false, error: `Task ${index + 1}: candidate ${JSON.stringify(entry.model)} has an invalid Pi thinking default.` };
|
|
567
|
+
}
|
|
568
|
+
modelAttemptPlan.push(Object.freeze({
|
|
569
|
+
model: entry.model,
|
|
570
|
+
probability: entry.probability,
|
|
571
|
+
...(thinking === undefined ? {} : { thinking }),
|
|
572
|
+
}));
|
|
573
|
+
}
|
|
574
|
+
const first = modelAttemptPlan[0]!;
|
|
553
575
|
tasks.push({
|
|
554
|
-
...spec, model: candidate.model, thinking, tools, effectiveTools: tools, canWrite,
|
|
576
|
+
...spec, model: candidate.model, thinking: first.thinking, tools, effectiveTools: tools, canWrite,
|
|
555
577
|
fallbackModels: [],
|
|
578
|
+
modelAttemptPlan: Object.freeze(modelAttemptPlan),
|
|
556
579
|
routing: { ...decision, mandatoryTools: [...mandatoryTools], outcome: "success" },
|
|
557
580
|
resolutionNotes: [...item.resolutionNotes.filter((note) => !note.startsWith("routing=")), "routing=jev", `access=${canWrite ? "RW" : "RO"}`],
|
|
558
581
|
});
|
package/src/process-lock.ts
CHANGED
|
@@ -4,6 +4,7 @@ import * as fsp from "node:fs/promises";
|
|
|
4
4
|
import * as os from "node:os";
|
|
5
5
|
import * as path from "node:path";
|
|
6
6
|
import { defaultConfig } from "./config.js";
|
|
7
|
+
import { MAX_ROUTING_MODELS } from "./routing-types.js";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Durable, crash-surviving coordination primitives under `~/.pi/subagent-locks/`.
|
|
@@ -48,6 +49,8 @@ export interface RunProcessRecord {
|
|
|
48
49
|
runId: string;
|
|
49
50
|
parentSessionKey: string;
|
|
50
51
|
childSessionId?: string;
|
|
52
|
+
/** Ranked task's attempt sessions, protected together until final terminalization. */
|
|
53
|
+
childSessionIds?: string[];
|
|
51
54
|
/** Live worktree checkout of this run; shields it from machine-wide GC sweeps. */
|
|
52
55
|
worktreeCwd?: string;
|
|
53
56
|
process: ProcessIdentity;
|
|
@@ -57,6 +60,12 @@ export interface RunProcessRecord {
|
|
|
57
60
|
updatedAt: number;
|
|
58
61
|
}
|
|
59
62
|
|
|
63
|
+
/** Bounded machine-wide session protection; old single-session records still work. */
|
|
64
|
+
export function runRecordSessionIds(record: RunProcessRecord): string[] {
|
|
65
|
+
const values: unknown[] = [record.childSessionId, ...(Array.isArray(record.childSessionIds) ? record.childSessionIds.slice(0, MAX_ROUTING_MODELS) : [])];
|
|
66
|
+
return [...new Set(values.filter((value): value is string => typeof value === "string" && value.trim().length > 0 && value.length <= 256))].slice(0, MAX_ROUTING_MODELS);
|
|
67
|
+
}
|
|
68
|
+
|
|
60
69
|
export interface SlotToken {
|
|
61
70
|
slotId: string;
|
|
62
71
|
path: string;
|
|
@@ -582,6 +591,13 @@ export class ProcessLockManager {
|
|
|
582
591
|
// ---- Run process records (orphan reconcile) ------------------------------
|
|
583
592
|
|
|
584
593
|
writeRunRecord(record: RunProcessRecord): void {
|
|
594
|
+
const previous = this.readRunRecord(record.runId);
|
|
595
|
+
if (record.childSessionIds !== undefined || (previous?.state === "running" && previous.childSessionIds !== undefined)) {
|
|
596
|
+
record = { ...record, childSessionIds: [...new Set([
|
|
597
|
+
...runRecordSessionIds(record),
|
|
598
|
+
...(previous?.state === "running" ? runRecordSessionIds(previous) : []),
|
|
599
|
+
])].slice(0, MAX_ROUTING_MODELS) };
|
|
600
|
+
}
|
|
585
601
|
writeJsonAtomicSync(runRecordPath(this.root, record.runId), record);
|
|
586
602
|
}
|
|
587
603
|
|
package/src/protocol.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { Message } from "@earendil-works/pi-ai";
|
|
2
|
-
import type { TaskResult, UsageStats } from "./types.js";
|
|
2
|
+
import type { TaskResult, ToolActivity, UsageStats } from "./types.js";
|
|
3
3
|
import { emptyUsage } from "./types.js";
|
|
4
4
|
import { addUsage, hasBilledUsage, usageFromMessage, usageFromToolResultMessage } from "./usage.js";
|
|
5
|
+
import { extractProviderError, mergeToolActivity } from "./model-failover.js";
|
|
5
6
|
import { PREFLIGHT_ACK_TYPE } from "./startup-check.js";
|
|
6
7
|
|
|
7
8
|
export type ProtocolUpdate =
|
|
@@ -33,6 +34,7 @@ export class ProtocolParser {
|
|
|
33
34
|
private messages: Message[] = [];
|
|
34
35
|
private usage = emptyUsage();
|
|
35
36
|
private liveText = "";
|
|
37
|
+
private assistantText?: string;
|
|
36
38
|
private parseErrors = 0;
|
|
37
39
|
private validEvents = 0;
|
|
38
40
|
private headerSeen = false;
|
|
@@ -43,6 +45,25 @@ export class ProtocolParser {
|
|
|
43
45
|
private model?: string;
|
|
44
46
|
private stopReason?: string;
|
|
45
47
|
private errorMessage?: string;
|
|
48
|
+
/**
|
|
49
|
+
* Sticky current-invocation tool activity for the pre-tool switch boundary. Latches `started` on
|
|
50
|
+
* tool_execution_start, a completed assistant toolCall or a toolResult; any
|
|
51
|
+
* malformed/dropped protocol evidence raises an otherwise-clean `none` to
|
|
52
|
+
* `unknown`. A later complete event never erases started/unknown and events
|
|
53
|
+
* from resumed/forked history never reach this parser at all (only this
|
|
54
|
+
* child's live event stream does), so historical tool records cannot create
|
|
55
|
+
* false live activity.
|
|
56
|
+
*/
|
|
57
|
+
private toolActivity: ToolActivity = "none";
|
|
58
|
+
/** stopReason of the latest completed assistant message (settle diagnosis). */
|
|
59
|
+
private lastAssistantStopReason?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Bounded errorMessage and primitive diagnostics.error.code from the latest
|
|
62
|
+
* completed assistant error, the only availability-classification input.
|
|
63
|
+
* Replaced on every completed assistant message; never mixed with normal
|
|
64
|
+
* content, arbitrary diagnostics or runner/RPC fallback text.
|
|
65
|
+
*/
|
|
66
|
+
private providerError?: string;
|
|
46
67
|
private transcriptLines: string[] = [];
|
|
47
68
|
private transcriptBytes = 0;
|
|
48
69
|
private transcriptTruncated = false;
|
|
@@ -53,6 +74,49 @@ export class ProtocolParser {
|
|
|
53
74
|
private static readonly MAX_LINE_BYTES = 4 * 1024 * 1024;
|
|
54
75
|
private static readonly MAX_BUFFER_BYTES = 8 * 1024 * 1024;
|
|
55
76
|
|
|
77
|
+
/** Sticky failover-latch merge; never lowers an existing started/unknown mark. */
|
|
78
|
+
private noteToolActivity(observed: ToolActivity): void {
|
|
79
|
+
this.toolActivity = mergeToolActivity(this.toolActivity, observed);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Conservative streamed assistant delta observations: a started tool call is
|
|
84
|
+
* completed-call evidence, its end too; a delta without any start proves the
|
|
85
|
+
* model was emitting a call and the outcome may have been lost — never a
|
|
86
|
+
* conclusive `none`.
|
|
87
|
+
*/
|
|
88
|
+
private observeAssistantDelta(deltaEvent: unknown): void {
|
|
89
|
+
const event = deltaEvent as Record<string, unknown> | undefined;
|
|
90
|
+
if (!event || typeof event !== "object" || typeof event.type !== "string") {
|
|
91
|
+
this.noteMalformedEvidence();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (event.type === "toolcall_start" || event.type === "toolcall_end") {
|
|
95
|
+
// Compact RPC retains contentIndex for both start/end; Pi enriches
|
|
96
|
+
// start with id/toolName, while end carries a nested toolCall. Accept
|
|
97
|
+
// the indexed form as well as identity-bearing legacy events.
|
|
98
|
+
const modern = typeof event.contentIndex === "number" && Number.isInteger(event.contentIndex) && event.contentIndex >= 0;
|
|
99
|
+
const legacy = typeof event.id === "string" && !!event.id && typeof event.toolName === "string" && !!event.toolName;
|
|
100
|
+
if (modern || legacy) this.noteToolActivity("started");
|
|
101
|
+
else this.noteMalformedEvidence();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (event.type === "toolcall_delta") {
|
|
105
|
+
if (typeof event.contentIndex === "number" && Number.isInteger(event.contentIndex) && event.contentIndex >= 0) {
|
|
106
|
+
this.noteToolActivity("unknown");
|
|
107
|
+
} else {
|
|
108
|
+
this.noteMalformedEvidence();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Malformed/dropped evidence: counts a parse error and blocks failover. */
|
|
114
|
+
private noteMalformedEvidence(): void {
|
|
115
|
+
this.parseErrors++;
|
|
116
|
+
// A later complete event cannot erase the uncertainty this creates.
|
|
117
|
+
this.noteToolActivity("unknown");
|
|
118
|
+
}
|
|
119
|
+
|
|
56
120
|
/** Append transcript lines incrementally; never re-flattens the full message list. */
|
|
57
121
|
private appendTranscript(lines: string[]): void {
|
|
58
122
|
for (const line of lines) {
|
|
@@ -92,7 +156,7 @@ export class ProtocolParser {
|
|
|
92
156
|
this.buffer += data.toString();
|
|
93
157
|
// If a single line grows past the hard limit without a newline, drop it as a parse error.
|
|
94
158
|
if (Buffer.byteLength(this.buffer, "utf8") > ProtocolParser.MAX_BUFFER_BYTES) {
|
|
95
|
-
this.
|
|
159
|
+
this.noteMalformedEvidence();
|
|
96
160
|
this.buffer = "";
|
|
97
161
|
return [];
|
|
98
162
|
}
|
|
@@ -113,25 +177,25 @@ export class ProtocolParser {
|
|
|
113
177
|
const trimmed = line.trim();
|
|
114
178
|
if (!trimmed) return [];
|
|
115
179
|
if (Buffer.byteLength(trimmed, "utf8") > ProtocolParser.MAX_LINE_BYTES) {
|
|
116
|
-
this.
|
|
180
|
+
this.noteMalformedEvidence();
|
|
117
181
|
return [];
|
|
118
182
|
}
|
|
119
183
|
let event: any;
|
|
120
184
|
try {
|
|
121
185
|
event = JSON.parse(trimmed);
|
|
122
186
|
} catch {
|
|
123
|
-
this.
|
|
187
|
+
this.noteMalformedEvidence();
|
|
124
188
|
return [];
|
|
125
189
|
}
|
|
126
190
|
if (!event || typeof event !== "object" || typeof event.type !== "string") {
|
|
127
|
-
this.
|
|
191
|
+
this.noteMalformedEvidence();
|
|
128
192
|
return [];
|
|
129
193
|
}
|
|
130
194
|
this.validEvents++;
|
|
131
195
|
|
|
132
196
|
if (event.type === "session") {
|
|
133
197
|
if (typeof event.id !== "string" || !event.id) {
|
|
134
|
-
this.
|
|
198
|
+
this.noteMalformedEvidence();
|
|
135
199
|
return [];
|
|
136
200
|
}
|
|
137
201
|
this.sessionId = event.id;
|
|
@@ -174,10 +238,63 @@ export class ProtocolParser {
|
|
|
174
238
|
return dialog && typeof event.id === "string" ? [{ type: "ui-request", id: event.id }] : [];
|
|
175
239
|
}
|
|
176
240
|
|
|
177
|
-
|
|
241
|
+
// Tool execution begin is the pre-tool switch boundary. Pi emits it before tool
|
|
242
|
+
// preparation (including some rejected/truncated calls); that conservative
|
|
243
|
+
// start is still the no-restart line. Only shape-valid events latch
|
|
244
|
+
// `started`; a malformed start event means a tool may have begun anyway, so
|
|
245
|
+
// it raises `unknown`, which blocks failover just like a proven start.
|
|
246
|
+
if (event.type === "tool_execution_start") {
|
|
247
|
+
if (typeof event.toolCallId === "string" && event.toolCallId
|
|
248
|
+
&& typeof event.toolName === "string" && event.toolName) {
|
|
249
|
+
this.noteToolActivity("started");
|
|
250
|
+
} else {
|
|
251
|
+
this.noteMalformedEvidence();
|
|
252
|
+
}
|
|
253
|
+
return [];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Update/end events prove execution progressed even when the start event
|
|
257
|
+
// was lost or the stream was truncated; neither may leave a `none` latch.
|
|
258
|
+
if (event.type === "tool_execution_update" || event.type === "tool_execution_end") {
|
|
259
|
+
if (typeof event.toolCallId === "string" && event.toolCallId
|
|
260
|
+
&& typeof event.toolName === "string" && event.toolName) {
|
|
261
|
+
this.noteToolActivity("started");
|
|
262
|
+
} else {
|
|
263
|
+
this.noteMalformedEvidence();
|
|
264
|
+
}
|
|
265
|
+
return [];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Modern RPC message_update events are compact: {usage, assistantMessageEvent}
|
|
269
|
+
// without the full message. Every message_update must carry a readable
|
|
270
|
+
// assistant envelope or a typed delta event; one without either is dropped
|
|
271
|
+
// evidence that could hide a tool call or the terminal error.
|
|
272
|
+
if (event.type === "message_update") {
|
|
273
|
+
const hasMessage = !!event.message && typeof event.message === "object" && !Array.isArray(event.message);
|
|
274
|
+
const hasDeltaEvent = !!event.assistantMessageEvent && typeof event.assistantMessageEvent === "object" && !Array.isArray(event.assistantMessageEvent);
|
|
275
|
+
if (!hasMessage && !hasDeltaEvent) {
|
|
276
|
+
this.noteMalformedEvidence();
|
|
277
|
+
return [];
|
|
278
|
+
}
|
|
279
|
+
if (hasDeltaEvent) this.observeAssistantDelta(event.assistantMessageEvent);
|
|
280
|
+
if (!hasMessage || event.message.role !== "assistant") {
|
|
281
|
+
// Compact streamed turn (no full message), or an unexpected envelope:
|
|
282
|
+
// the delta observation above already recorded whatever is provable.
|
|
283
|
+
return [];
|
|
284
|
+
}
|
|
285
|
+
// An assistant stream envelope whose content array carries unreadable
|
|
286
|
+
// parts could hide a tool call: uncertain, never a conclusive none.
|
|
287
|
+
if (event.message.content !== undefined
|
|
288
|
+
&& (!Array.isArray(event.message.content)
|
|
289
|
+
|| (event.message.content as unknown[]).some((part: any) => !part || typeof part !== "object" || Array.isArray(part)))) {
|
|
290
|
+
this.noteMalformedEvidence();
|
|
291
|
+
}
|
|
292
|
+
if (Array.isArray(event.message.content) && event.message.content.some((part: any) => part?.type === "toolCall")) {
|
|
293
|
+
this.noteToolActivity("started");
|
|
294
|
+
}
|
|
178
295
|
const delta =
|
|
179
|
-
typeof event.assistantMessageEvent?.delta === "string"
|
|
180
|
-
? event.assistantMessageEvent.delta
|
|
296
|
+
typeof (event.assistantMessageEvent as any)?.delta === "string"
|
|
297
|
+
? (event.assistantMessageEvent as any).delta
|
|
181
298
|
: this.textParts(event.message).join("");
|
|
182
299
|
if (!delta) return [];
|
|
183
300
|
// Cap live text growth: keep the last 64KB of visible text so long runs stay bounded.
|
|
@@ -185,24 +302,68 @@ export class ProtocolParser {
|
|
|
185
302
|
return [{ type: "live-text", delta, liveText: this.liveText }];
|
|
186
303
|
}
|
|
187
304
|
|
|
188
|
-
if (event.type === "message_end"
|
|
305
|
+
if (event.type === "message_end") {
|
|
306
|
+
// A message_end without a readable message object could have carried the
|
|
307
|
+
// final assistant error or a tool call; that uncertainty must not leave a
|
|
308
|
+
// conclusive `none` for the failover gate.
|
|
309
|
+
if (!event.message || typeof event.message !== "object" || Array.isArray(event.message)) {
|
|
310
|
+
this.noteMalformedEvidence();
|
|
311
|
+
return [];
|
|
312
|
+
}
|
|
189
313
|
const message = event.message as Message & { customType?: unknown };
|
|
190
314
|
// Startup-handshake acknowledgement: report it, but never fold verification
|
|
191
315
|
// traffic into messages, transcript, live text or usage accounting.
|
|
192
316
|
if (message.customType === PREFLIGHT_ACK_TYPE) {
|
|
193
317
|
return [{ type: "preflight-ack", content: typeof (message as any).content === "string" ? (message as any).content : "" }];
|
|
194
318
|
}
|
|
319
|
+
if (typeof message.role !== "string" || !message.role) {
|
|
320
|
+
// Roleless message envelope: nothing about it is trustworthy.
|
|
321
|
+
this.noteMalformedEvidence();
|
|
322
|
+
return [];
|
|
323
|
+
}
|
|
195
324
|
this.messages.push(message);
|
|
196
325
|
this.appendTranscript(this.transcriptFromMessage(message));
|
|
197
326
|
if (message.role === "assistant") {
|
|
327
|
+
// Every new completed message needs a fresh terminal watermark, even
|
|
328
|
+
// when an older host omits agent_start between task and repair turns.
|
|
329
|
+
this.agentEndSeen = false;
|
|
330
|
+
this.agentSettledSeen = false;
|
|
198
331
|
this.assistantEndSeen = true;
|
|
199
332
|
this.usage = addUsage(this.usage, usageFromMessage(message));
|
|
200
333
|
this.model ||= message.model;
|
|
201
334
|
this.stopReason = message.stopReason;
|
|
202
335
|
this.errorMessage = message.errorMessage;
|
|
336
|
+
const partsValid = Array.isArray(message.content) && (message.content as unknown[]).every((part: any) => !!part && typeof part === "object" && !Array.isArray(part));
|
|
337
|
+
const validShape = partsValid && typeof message.stopReason === "string";
|
|
338
|
+
this.lastAssistantStopReason = validShape ? message.stopReason : undefined;
|
|
339
|
+
if (!validShape) {
|
|
340
|
+
// Unreadable assistant content parts, non-array content, or a
|
|
341
|
+
// missing/non-string stopReason: a tool call or terminal error could
|
|
342
|
+
// be hidden in the unreadable shape, so activity becomes uncertain
|
|
343
|
+
// and no error evidence is kept.
|
|
344
|
+
this.noteMalformedEvidence();
|
|
345
|
+
this.providerError = undefined;
|
|
346
|
+
} else {
|
|
347
|
+
const toolCallParts = (message.content as any[]).some((part: any) => part?.type === "toolCall");
|
|
348
|
+
if (toolCallParts) {
|
|
349
|
+
// A completed assistant tool call is conservative execution evidence:
|
|
350
|
+
// a missing tool_execution_start/result must not authorize a restart.
|
|
351
|
+
this.noteToolActivity("started");
|
|
352
|
+
} else if (message.stopReason === "toolUse" || message.stopReason === "refusal") {
|
|
353
|
+
// Claims turn continuation on tools without a readable tool call,
|
|
354
|
+
// or a refusal shape we cannot interpret: uncertain by contract.
|
|
355
|
+
this.noteMalformedEvidence();
|
|
356
|
+
}
|
|
357
|
+
this.providerError = message.stopReason === "error"
|
|
358
|
+
? extractProviderError(message.errorMessage, message.diagnostics)
|
|
359
|
+
: undefined;
|
|
360
|
+
}
|
|
203
361
|
const text = this.textParts(message).join("");
|
|
362
|
+
this.assistantText = text; // Empty latest messages must replace earlier failed text.
|
|
204
363
|
if (text) this.liveText = text;
|
|
205
364
|
} else if (message.role === "toolResult") {
|
|
365
|
+
// Any completed tool result proves execution happened in this invocation.
|
|
366
|
+
this.noteToolActivity("started");
|
|
206
367
|
// Pi ≥ #6671: tool results may carry nested LLM usage (e.g. a
|
|
207
368
|
// grandchild subagent). Fold it into the run's cumulative spend so
|
|
208
369
|
// budgets and parent ledgers see true subtree cost.
|
|
@@ -214,6 +375,13 @@ export class ProtocolParser {
|
|
|
214
375
|
|
|
215
376
|
if (event.type === "agent_end") {
|
|
216
377
|
this.agentEndSeen = true;
|
|
378
|
+
// `willRetry` must be a real boolean when present: a string "true" or any
|
|
379
|
+
// other shape means the settle boundary is unreadable, which is uncertain
|
|
380
|
+
// execution evidence and must block failover, never imply "no retry".
|
|
381
|
+
if (event.willRetry !== undefined && typeof event.willRetry !== "boolean") {
|
|
382
|
+
this.noteMalformedEvidence();
|
|
383
|
+
return [];
|
|
384
|
+
}
|
|
217
385
|
// Pi may retry after agent_end (willRetry: true). That is NOT terminal;
|
|
218
386
|
// only agent_settled marks a fully settled run.
|
|
219
387
|
const willRetry = event.willRetry === true;
|
|
@@ -221,6 +389,18 @@ export class ProtocolParser {
|
|
|
221
389
|
return [{ type: "agent-end", willRetry }];
|
|
222
390
|
}
|
|
223
391
|
|
|
392
|
+
// A new live agent turn invalidates any earlier settle watermark: the run
|
|
393
|
+
// is running again, and only a NEW agent_end-without-retry or
|
|
394
|
+
// agent_settled may complete it. Without this reset, an old settle plus a
|
|
395
|
+
// later provider retry could fake a complete protocol around the newest
|
|
396
|
+
// terminal error.
|
|
397
|
+
if (event.type === "agent_start") {
|
|
398
|
+
this.agentEndSeen = false;
|
|
399
|
+
this.agentSettledSeen = false;
|
|
400
|
+
this.pendingRetry = false;
|
|
401
|
+
return [];
|
|
402
|
+
}
|
|
403
|
+
|
|
224
404
|
if (event.type === "agent_settled") {
|
|
225
405
|
this.agentSettledSeen = true;
|
|
226
406
|
this.pendingRetry = false;
|
|
@@ -251,7 +431,7 @@ export class ProtocolParser {
|
|
|
251
431
|
// Prefer agent_settled as the true terminal watermark. Fall back to
|
|
252
432
|
// agent_end without a pending retry for older Pi builds that never emitted
|
|
253
433
|
// agent_settled (json print historically closed after agent_end).
|
|
254
|
-
const settled = this.
|
|
434
|
+
const settled = !this.pendingRetry && (this.agentSettledSeen || this.agentEndSeen);
|
|
255
435
|
const completeProtocol = this.headerSeen && this.assistantEndSeen && settled;
|
|
256
436
|
const assistantFailed = this.stopReason === "error" || this.stopReason === "aborted";
|
|
257
437
|
const successfulExit = exitCode === 0 && !signal && !assistantFailed;
|
|
@@ -299,6 +479,8 @@ export class ProtocolParser {
|
|
|
299
479
|
: undefined),
|
|
300
480
|
liveText: this.liveText || undefined,
|
|
301
481
|
transcript: this.getTranscript(),
|
|
482
|
+
toolActivity: this.toolActivity,
|
|
483
|
+
providerError: this.providerError,
|
|
302
484
|
protocol,
|
|
303
485
|
sessionId: this.sessionId,
|
|
304
486
|
};
|
|
@@ -317,4 +499,19 @@ export class ProtocolParser {
|
|
|
317
499
|
getMessages(): Message[] {
|
|
318
500
|
return [...this.messages];
|
|
319
501
|
}
|
|
502
|
+
|
|
503
|
+
/** Sticky current-invocation tool activity observed by this parser. */
|
|
504
|
+
getToolActivity(): ToolActivity {
|
|
505
|
+
return this.toolActivity;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** stopReason of the latest completed assistant message, if any. */
|
|
509
|
+
getAssistantStopReason(): string | undefined {
|
|
510
|
+
return this.lastAssistantStopReason;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Latest completed assistant text, including an explicitly empty message. */
|
|
514
|
+
getAssistantText(): string | undefined {
|
|
515
|
+
return this.assistantText;
|
|
516
|
+
}
|
|
320
517
|
}
|