pi-crew 0.9.9 → 0.9.10
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 +278 -0
- package/docs/fixes/v0.9.10/locks-fix-verify.md +3 -0
- package/docs/fixes/v0.9.10/smoke-test.md +12 -0
- package/package.json +1 -1
- package/src/extension/team-tool/doctor.ts +41 -18
- package/src/runtime/child-pi.ts +122 -22
- package/src/runtime/compact-pipeline.ts +56 -0
- package/src/runtime/compact-stages/ansi-strip-stage.ts +25 -0
- package/src/runtime/compact-stages/blank-collapse-stage.ts +31 -0
- package/src/runtime/compact-stages/deduplicate-stage.ts +34 -0
- package/src/runtime/compact-stages/head-snap-stage.ts +57 -0
- package/src/runtime/compact-stages/index.ts +13 -0
- package/src/runtime/compact-stages/tail-capture-stage.ts +72 -0
- package/src/runtime/compact-stages/truncation-stage.ts +71 -0
- package/src/runtime/handoff-manager.ts +10 -0
- package/src/runtime/important-line-classifier.ts +130 -0
- package/src/runtime/iteration-hooks.ts +7 -19
- package/src/runtime/live-session-runtime.ts +50 -1
- package/src/runtime/model-fallback.ts +29 -1
- package/src/runtime/role-permission.ts +2 -2
- package/src/runtime/stream-preview.ts +9 -2
- package/src/runtime/task-output-context.ts +161 -27
- package/src/runtime/task-runner.ts +76 -15
- package/src/state/locks.ts +16 -0
- package/src/state/state-store.ts +8 -2
- package/src/ui/live-run-sidebar.ts +6 -1
- package/src/ui/loaders.ts +24 -4
- package/src/ui/run-dashboard.ts +6 -1
- package/src/ui/run-event-bus.ts +1 -1
- package/src/ui/run-snapshot-cache.ts +50 -16
- package/src/ui/widget/index.ts +27 -5
- package/src/ui/widget/widget-renderer.ts +43 -13
- package/src/utils/redaction.ts +17 -1
- package/src/utils/visual.ts +6 -0
- package/src/ui/crew-widget.ts +0 -544
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Important-Line Classifier (P0-B) — scan middle slice of a truncated value
|
|
3
|
+
* for diagnostic lines worth preserving between head and tail.
|
|
4
|
+
*
|
|
5
|
+
* Ported from Hypa's `ImportantLineClassifier.cs` (5 regexes) and the
|
|
6
|
+
* middle-scanning portion of `Stages/TruncationStage.cs:24-46`, adapted to TS
|
|
7
|
+
* (no `[GeneratedRegex]` AOT) and to pi-crew's head(75%)/tail(25%) split.
|
|
8
|
+
*
|
|
9
|
+
* Design rationale:
|
|
10
|
+
* - Patterns are intentionally OVER-INCLUSIVE. False positives preserve
|
|
11
|
+
* harmless lines; false negatives drop critical diagnostics, which is
|
|
12
|
+
* unacceptable (the whole point of this module). Hypa uses the same
|
|
13
|
+
* over-inclusive design.
|
|
14
|
+
* - Patterns are evaluated against a WHOLE line, not against the raw
|
|
15
|
+
* truncated slice, so a match at a line boundary is reliable.
|
|
16
|
+
* - The `splitWithImportantLines` helper performs the head/tail split AND
|
|
17
|
+
* greedily picks whole important lines from the middle that fit inside
|
|
18
|
+
* `slackFactor * maxChars` (default 15% slack). Callers compose their own
|
|
19
|
+
* marker using the returned parts — keeping `compactString` (marker
|
|
20
|
+
* "compacted ... chars, head+tail preserved") and `readIfSmall` (marker
|
|
21
|
+
* "truncated ... bytes, head+tail preserved") backward-compatible when no
|
|
22
|
+
* important lines are present.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Diagnostic patterns. Anchored where safe to avoid matching noise. */
|
|
26
|
+
export const IMPORTANT_LINE_PATTERNS: readonly RegExp[] = [
|
|
27
|
+
// error keywords — NOTE: "warning" is intentionally excluded here; it has
|
|
28
|
+
// its own case-sensitive pattern below so that the common prose word
|
|
29
|
+
// "warning" does not over-match. (Hypa does the same split.)
|
|
30
|
+
/\b(error|failed|exception|fatal|panic)\b/i,
|
|
31
|
+
// file:line diagnostic — `child-pi.ts:383:`, `App.tsx:42:`
|
|
32
|
+
/\w+\.\w+:\d+:/,
|
|
33
|
+
// HTTP 4xx / 5xx — bounded so it does not match phone numbers etc.
|
|
34
|
+
/\b[45]\d{2}\b/,
|
|
35
|
+
// k8s / linter "Warning" event (case-sensitive so prose is not matched)
|
|
36
|
+
/\bWarning\b/,
|
|
37
|
+
// compiler / linter diagnostic id — `TS2304`, `CS0246`, `ES1234`
|
|
38
|
+
/\b[A-Z]{2,4}\d{3,5}\b/,
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/** True iff `line` matches at least one important-line pattern. */
|
|
42
|
+
export function isImportantLine(line: string): boolean {
|
|
43
|
+
if (!line) return false;
|
|
44
|
+
for (const pattern of IMPORTANT_LINE_PATTERNS) {
|
|
45
|
+
if (pattern.test(line)) return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Extract up to `maxLines` important lines from `text`. Lines are split on
|
|
52
|
+
* `\n` (also handles `\r\n`). Order preserved; duplicates kept (callers may
|
|
53
|
+
* want to see the same diagnostic twice if it appears twice — that often
|
|
54
|
+
* signals a recurring failure).
|
|
55
|
+
*/
|
|
56
|
+
export function extractImportantLines(text: string, maxLines = 30): string[] {
|
|
57
|
+
if (!text || maxLines <= 0) return [];
|
|
58
|
+
const out: string[] = [];
|
|
59
|
+
for (const line of text.split(/\r?\n/)) {
|
|
60
|
+
if (out.length >= maxLines) break;
|
|
61
|
+
if (isImportantLine(line)) out.push(line);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface TruncationSplit {
|
|
67
|
+
/** The first 75% of the value (by char count), verbatim. */
|
|
68
|
+
head: string;
|
|
69
|
+
/** The last 25% of the value (by char count), verbatim. */
|
|
70
|
+
tail: string;
|
|
71
|
+
/**
|
|
72
|
+
* Important lines from the middle slice, greedily picked (whole lines) so
|
|
73
|
+
* the joined length fits inside `slackFactor * maxChars`. Empty when
|
|
74
|
+
* `preserveImportant` is false OR no important lines are present OR none
|
|
75
|
+
* fit the slack budget.
|
|
76
|
+
*/
|
|
77
|
+
importantLines: string[];
|
|
78
|
+
/** `value.length - maxChars` — chars dropped if no important lines preserved. */
|
|
79
|
+
baseDropped: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface SplitOptions {
|
|
83
|
+
/** When false, important-line scanning is skipped (assistant-text mode). */
|
|
84
|
+
preserveImportant?: boolean;
|
|
85
|
+
/** Hard cap on candidate lines before slack-budget selection. Default 30. */
|
|
86
|
+
maxImportantLines?: number;
|
|
87
|
+
/** Fraction of `maxChars` available for important-line content. Default 0.15. */
|
|
88
|
+
slackFactor?: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Split `value` into head + important-middle + tail, returning the parts.
|
|
93
|
+
* The caller is responsible for composing the final result (marker + glue)
|
|
94
|
+
* because the marker wording differs between `compactString` and
|
|
95
|
+
* `readIfSmall`.
|
|
96
|
+
*
|
|
97
|
+
* When no important lines are picked, the returned `importantLines` is `[]`
|
|
98
|
+
* and the marker wording stays bit-identical to the pre-P0-B format.
|
|
99
|
+
*/
|
|
100
|
+
export function splitWithImportantLines(value: string, maxChars: number, opts: SplitOptions = {}): TruncationSplit {
|
|
101
|
+
if (value.length <= maxChars) {
|
|
102
|
+
return { head: value, tail: "", importantLines: [], baseDropped: 0 };
|
|
103
|
+
}
|
|
104
|
+
const headLen = Math.floor(maxChars * 0.75);
|
|
105
|
+
const tailLen = maxChars - headLen;
|
|
106
|
+
const head = value.slice(0, headLen);
|
|
107
|
+
const tail = value.slice(value.length - tailLen);
|
|
108
|
+
|
|
109
|
+
if (opts.preserveImportant === false) {
|
|
110
|
+
return { head, tail, importantLines: [], baseDropped: value.length - maxChars };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const slackFactor = opts.slackFactor ?? 0.15;
|
|
114
|
+
const slackChars = Math.max(0, Math.floor(maxChars * slackFactor));
|
|
115
|
+
const maxCandidates = opts.maxImportantLines ?? 30;
|
|
116
|
+
const middle = value.slice(headLen, value.length - tailLen);
|
|
117
|
+
const candidates = extractImportantLines(middle, maxCandidates);
|
|
118
|
+
|
|
119
|
+
// Greedily pick whole lines that fit in the slack budget.
|
|
120
|
+
const chosen: string[] = [];
|
|
121
|
+
let used = 0;
|
|
122
|
+
for (const line of candidates) {
|
|
123
|
+
const addLen = (chosen.length > 0 ? 1 : 0) + line.length; // '\n' separator
|
|
124
|
+
if (used + addLen > slackChars) break;
|
|
125
|
+
chosen.push(line);
|
|
126
|
+
used += addLen;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { head, tail, importantLines: chosen, baseDropped: value.length - maxChars };
|
|
130
|
+
}
|
|
@@ -8,6 +8,7 @@ import { spawn } from "node:child_process";
|
|
|
8
8
|
import * as fs from "node:fs";
|
|
9
9
|
import * as path from "node:path";
|
|
10
10
|
import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
|
|
11
|
+
import { HeadSnapStage } from "./compact-stages/index.ts";
|
|
11
12
|
import { resolveShellForScript } from "../utils/resolve-shell.ts";
|
|
12
13
|
import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
|
|
13
14
|
import { DENIED_METRIC_NAMES } from "./metric-parser.ts";
|
|
@@ -98,23 +99,6 @@ function notFiredResult(): HookResult {
|
|
|
98
99
|
};
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
/**
|
|
102
|
-
* Truncate a buffer to the given byte limit, snapping to the last newline
|
|
103
|
-
* boundary for UTF-8 safety.
|
|
104
|
-
*/
|
|
105
|
-
function truncateToLimit(buf: Buffer, limit: number): Buffer {
|
|
106
|
-
if (buf.byteLength <= limit) return buf;
|
|
107
|
-
|
|
108
|
-
const slice = buf.subarray(0, limit);
|
|
109
|
-
// Find the last newline within the truncated region
|
|
110
|
-
const lastNewline = slice.lastIndexOf("\n");
|
|
111
|
-
if (lastNewline >= 0) {
|
|
112
|
-
return slice.subarray(0, lastNewline);
|
|
113
|
-
}
|
|
114
|
-
// No newline found — return the full slice
|
|
115
|
-
return slice;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
102
|
/**
|
|
119
103
|
* Check if a script path exists and is executable.
|
|
120
104
|
*/
|
|
@@ -196,13 +180,17 @@ export async function runIterationHook(
|
|
|
196
180
|
const durationMs = Date.now() - startTime;
|
|
197
181
|
|
|
198
182
|
const rawStdout = Buffer.concat(stdoutChunks);
|
|
199
|
-
|
|
183
|
+
// Sprint 5: refactored onto HeadSnapStage. Convert to UTF-8 string once,
|
|
184
|
+
// then apply the byte-cap stage with newline-snap so partial lines
|
|
185
|
+
// never appear in the captured preview. HeadSnapStage is byte-cap-safe
|
|
186
|
+
// (walks back partial UTF-8 sequences at the cut boundary).
|
|
187
|
+
const stdoutText = new HeadSnapStage({ maxBytes: MAX_STDOUT_BYTES }).apply(rawStdout.toString("utf-8"));
|
|
200
188
|
|
|
201
189
|
const rawStderr = Buffer.concat(stderrChunks);
|
|
202
190
|
|
|
203
191
|
resolve({
|
|
204
192
|
fired: true,
|
|
205
|
-
stdout:
|
|
193
|
+
stdout: stdoutText,
|
|
206
194
|
stderr: rawStderr.toString("utf-8"),
|
|
207
195
|
exitCode: code,
|
|
208
196
|
timedOut: killed,
|
|
@@ -241,6 +241,54 @@ function modelFromRegistry(modelRegistry: unknown, modelId: string | undefined):
|
|
|
241
241
|
}
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Round 18: when agent declares `model: false`, the inherited `parentModel`
|
|
246
|
+
* (= `ctx.model` from Pi runtime, set via `team-tool.ts:541/655`) is the
|
|
247
|
+
* session's SAVED model. That saved model can be stale (e.g. a previous
|
|
248
|
+
* session used claude-sonnet-4-5 and saved it as session.model; the new
|
|
249
|
+
* session actually runs on minimax-M3 displayed in the footer). If the
|
|
250
|
+
* saved model has no auth in `modelRegistry`, the worker fails immediately
|
|
251
|
+
* with "No API key found" before reaching any fallback candidate.
|
|
252
|
+
*
|
|
253
|
+
* This helper prefers the saved model when it is in the auth-available
|
|
254
|
+
* registry; otherwise falls back to the first auth-available registry
|
|
255
|
+
* model (e.g. minimax/MiniMax-M3, zai/glm-5.2); otherwise returns the
|
|
256
|
+
* raw `parentModel` unchanged so the caller surfaces E008.
|
|
257
|
+
*/
|
|
258
|
+
export function resolveParentModelFromRegistry(
|
|
259
|
+
modelRegistry: unknown,
|
|
260
|
+
rawParentModel: unknown,
|
|
261
|
+
): string | undefined {
|
|
262
|
+
const raw = typeof rawParentModel === "string" ? rawParentModel.trim() : undefined;
|
|
263
|
+
if (raw) {
|
|
264
|
+
const candidate = raw.includes("/")
|
|
265
|
+
? raw
|
|
266
|
+
: (() => {
|
|
267
|
+
const m = modelFromRegistry(modelRegistry, raw);
|
|
268
|
+
if (m && typeof m === "object" && "fullId" in m) {
|
|
269
|
+
return String((m as { fullId?: unknown }).fullId ?? raw);
|
|
270
|
+
}
|
|
271
|
+
return undefined;
|
|
272
|
+
})();
|
|
273
|
+
if (candidate && modelFromRegistry(modelRegistry, candidate)) return candidate;
|
|
274
|
+
}
|
|
275
|
+
const registry = modelRegistry as { getAvailable?: () => unknown[] } | undefined;
|
|
276
|
+
if (registry && typeof registry.getAvailable === "function") {
|
|
277
|
+
try {
|
|
278
|
+
const available = registry.getAvailable();
|
|
279
|
+
if (Array.isArray(available) && available.length > 0) {
|
|
280
|
+
const first = available[0] as { provider?: unknown; id?: unknown } | undefined;
|
|
281
|
+
if (first && typeof first.provider === "string" && typeof first.id === "string") {
|
|
282
|
+
return `${first.provider}/${first.id}`;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
} catch {
|
|
286
|
+
// ignore — fall through to raw
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return raw;
|
|
290
|
+
}
|
|
291
|
+
|
|
244
292
|
/** Communication intensity by role (caveman-inspired token optimization) */
|
|
245
293
|
const ROLE_INTENSITY: Record<string, "lite" | "full" | "ultra"> = {
|
|
246
294
|
explorer: "ultra",
|
|
@@ -473,7 +521,8 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
473
521
|
});
|
|
474
522
|
await (resourceLoader as { reload?: () => Promise<void> }).reload?.();
|
|
475
523
|
}
|
|
476
|
-
const
|
|
524
|
+
const effectiveParentModel = resolveParentModelFromRegistry(input.modelRegistry, input.parentModel);
|
|
525
|
+
const modelRouting = buildConfiguredModelRouting({ overrideModel: input.modelOverride, stepModel: input.step.model, teamRoleModel: input.teamRoleModel, agentModel: input.agent.model, fallbackModels: input.agent.fallbackModels, parentModel: effectiveParentModel, modelRegistry: input.modelRegistry, cwd: input.manifest.cwd, scopeModelsPatterns: await resolveScopeModelsPatterns(input.manifest.cwd) });
|
|
477
526
|
const resolvedModel = modelFromRegistry(input.modelRegistry, modelRouting.candidates[0] ?? modelRouting.requested) ?? input.parentModel;
|
|
478
527
|
// Phase 4: MCP proxy — will be determined after session creation
|
|
479
528
|
// (we check parent's MCP tools and share connections when available)
|
|
@@ -209,6 +209,25 @@ const RETRYABLE_MODEL_FAILURE_PATTERNS = [
|
|
|
209
209
|
/internal(?:_server)?[ _]error/i,
|
|
210
210
|
/server error/i,
|
|
211
211
|
/bad gateway/i,
|
|
212
|
+
//
|
|
213
|
+
// Broader retryable patterns (added 2026-06-25, FIX 2):
|
|
214
|
+
// - `/provider[_ ]?error/i`: OpenAI-compatible "Provider error" generic fault.
|
|
215
|
+
// - `/context[_ ]?length[_ ]?exceeded/i`: "context_length_exceeded" from
|
|
216
|
+
// OpenAI/Anthropic — when the configured model is the bottleneck, a
|
|
217
|
+
// different model in the fallback chain may have a larger window.
|
|
218
|
+
// - `/safety/i`: Anthropic safety blocks — typically retryable on a
|
|
219
|
+
// different model in the fallback chain.
|
|
220
|
+
// - `/is[_ ]?overloaded/i`: alias to the existing `/overloaded/i` pattern
|
|
221
|
+
// to catch phrasings like "upstream is overloaded".
|
|
222
|
+
// - `/\b408\b/`: HTTP 408 Request Timeout — transient, provider-side.
|
|
223
|
+
//
|
|
224
|
+
// Intentionally NOT added: `/bad_request/` — can mean bad input (e.g.
|
|
225
|
+
// invalid schema), which is non-retryable.
|
|
226
|
+
/provider[_ ]?error/i,
|
|
227
|
+
/context[_ ]?length[_ ]?exceeded/i,
|
|
228
|
+
/safety/i,
|
|
229
|
+
/is[_ ]?overloaded/i,
|
|
230
|
+
/\b408\b/,
|
|
212
231
|
];
|
|
213
232
|
|
|
214
233
|
// These patterns indicate auth/key/billing issues that will never succeed on retry.
|
|
@@ -313,9 +332,18 @@ export function buildConfiguredModelRouting(input: {
|
|
|
313
332
|
const rawModels = availableModels
|
|
314
333
|
? [input.overrideModel, input.stepModel, input.teamRoleModel, effectiveAgentModel, ...(input.fallbackModels ?? []), ...availableModels.map((model) => model.fullId)]
|
|
315
334
|
: [input.overrideModel, input.stepModel, input.teamRoleModel, effectiveAgentModel, ...(input.fallbackModels ?? []), parentModel];
|
|
335
|
+
// Fix (Round 18): when an agent has `model: false` (frontmatter) the
|
|
336
|
+
// inherited `parentModel` (= session chính's model, e.g. minimax-M3) IS the
|
|
337
|
+
// desired primary. It must NOT be filtered out by isAvailableModel — which
|
|
338
|
+
// only knows about models from models.json / registry, NOT builtin Pi models.
|
|
339
|
+
// Pin the inherited parentModel at index 0 regardless of availability.
|
|
340
|
+
const parentModelRaw = effectiveAgentModel?.trim() || undefined;
|
|
316
341
|
const configuredModels = rawModels
|
|
317
342
|
.filter((model): model is string => Boolean(model?.trim()))
|
|
318
|
-
.filter((model) =>
|
|
343
|
+
.filter((model, idx) => {
|
|
344
|
+
if (parentModelRaw && idx === 0 && model.trim() === parentModelRaw) return true;
|
|
345
|
+
return isAvailableModel(model.trim(), availableModels);
|
|
346
|
+
});
|
|
319
347
|
const candidates = buildModelCandidates(configuredModels[0], configuredModels.slice(1), availableModels, preferredProvider);
|
|
320
348
|
const reason = requested && candidates[0] && resolveModelCandidate(requested, availableModels, preferredProvider) !== candidates[0]
|
|
321
349
|
? "requested model unavailable; selected configured Pi fallback"
|
|
@@ -2,8 +2,8 @@ import { isSensitivePath } from "./sensitive-paths.ts";
|
|
|
2
2
|
|
|
3
3
|
export type RolePermissionMode = "read_only" | "workspace_write" | "danger_full_access" | "explicit_confirm";
|
|
4
4
|
|
|
5
|
-
const READ_ONLY_ROLES = new Set(["explorer", "reviewer", "security-reviewer", "verifier", "analyst", "critic", "planner"
|
|
6
|
-
const WRITE_ROLES = new Set(["executor", "test-engineer"]);
|
|
5
|
+
const READ_ONLY_ROLES = new Set(["explorer", "reviewer", "security-reviewer", "verifier", "analyst", "critic", "planner"]);
|
|
6
|
+
const WRITE_ROLES = new Set(["executor", "test-engineer", "writer"]);
|
|
7
7
|
const READ_ONLY_COMMANDS = new Set(["cat", "head", "tail", "less", "more", "wc", "ls", "find", "grep", "rg", "awk", "sed", "echo", "printf", "which", "where", "whoami", "pwd", "env", "printenv", "date", "df", "du", "uname", "file", "stat", "diff", "sort", "uniq", "tr", "cut", "paste", "test", "true", "false", "type", "readlink", "realpath", "basename", "dirname", "sha256sum", "md5sum", "xxd", "hexdump", "od", "strings", "tree", "jq", "git", "gh"]);
|
|
8
8
|
|
|
9
9
|
export interface PermissionCheckResult {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Used by the UI layer to show partial results before task completion.
|
|
4
4
|
|
|
5
5
|
import type { ParsedPiUsage } from "./pi-json-output.ts";
|
|
6
|
+
import { TAIL_CAPTURE_STREAM_STAGE } from "./compact-stages/index.ts";
|
|
6
7
|
|
|
7
8
|
export interface ToolCallPreview {
|
|
8
9
|
toolName: string;
|
|
@@ -111,7 +112,13 @@ export function feedJsonEvent(preview: StreamPreview, event: unknown): boolean {
|
|
|
111
112
|
const text = extractTextFromContent(message?.content ?? obj.content);
|
|
112
113
|
if (text) {
|
|
113
114
|
const appended = preview.textBuffer.length > 0 ? preview.textBuffer + "\n" + text : text;
|
|
114
|
-
|
|
115
|
+
// Sprint 5: refactored onto the stage-chain. TAIL_CAPTURE_STREAM_STAGE
|
|
116
|
+
// is a 16_384-char tail-capture stage with no marker (the UI shows
|
|
117
|
+
// raw text without a prefix). It is bit-equivalent to the inline
|
|
118
|
+
// `appended.slice(appended.length - MAX_TEXT_BUFFER)` for inputs at
|
|
119
|
+
// or below the cap (returns verbatim) and equivalent for over-cap
|
|
120
|
+
// inputs (returns last MAX_TEXT_BUFFER chars).
|
|
121
|
+
preview.textBuffer = TAIL_CAPTURE_STREAM_STAGE.apply(appended);
|
|
115
122
|
}
|
|
116
123
|
modified = true;
|
|
117
124
|
}
|
|
@@ -119,7 +126,7 @@ export function feedJsonEvent(preview: StreamPreview, event: unknown): boolean {
|
|
|
119
126
|
// Detect direct text/final output
|
|
120
127
|
if (typeof obj.text === "string" && obj.text.trim()) {
|
|
121
128
|
const appended = preview.textBuffer.length > 0 ? preview.textBuffer + "\n" + obj.text : obj.text;
|
|
122
|
-
preview.textBuffer =
|
|
129
|
+
preview.textBuffer = TAIL_CAPTURE_STREAM_STAGE.apply(appended);
|
|
123
130
|
modified = true;
|
|
124
131
|
}
|
|
125
132
|
|
|
@@ -5,6 +5,8 @@ import { writeArtifact } from "../state/artifact-store.ts";
|
|
|
5
5
|
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
6
6
|
import type { WorkflowStep } from "../workflows/workflow-config.ts";
|
|
7
7
|
import { pruneToolOutputs, type ToolResultEntry, type FileEditEvent, DEFAULT_PRUNE_CONFIG } from "./tool-output-pruner.ts";
|
|
8
|
+
import { applyCompactPipeline } from "./compact-pipeline.ts";
|
|
9
|
+
import { ANSI_STRIP_STAGE, BLANK_COLLAPSE_STAGE, TruncationStage } from "./compact-stages/index.ts";
|
|
8
10
|
|
|
9
11
|
export interface DependencyContextEntry {
|
|
10
12
|
taskId: string;
|
|
@@ -19,7 +21,14 @@ export interface DependencyContextEntry {
|
|
|
19
21
|
|
|
20
22
|
export interface DependencyOutputContext {
|
|
21
23
|
dependencies: DependencyContextEntry[];
|
|
22
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Each shared artifact read, truncated for inline injection. When truncation
|
|
26
|
+
* is materially lossy (file size > 2× MAX_RESULT_INLINE_BYTES) the FULL
|
|
27
|
+
* content is also teed to `${artifactsRoot}/tee/${taskId}-${name}.full.txt`
|
|
28
|
+
* and the path is exposed via `fullOutputPath` so the downstream worker
|
|
29
|
+
* can `read` it back if it needs the dropped middle.
|
|
30
|
+
*/
|
|
31
|
+
sharedReads: Array<{ name: string; path: string; content: string; fullOutputPath?: string }>;
|
|
23
32
|
}
|
|
24
33
|
|
|
25
34
|
function containedExists(filePath: string, baseDir?: string): boolean {
|
|
@@ -39,35 +48,127 @@ function containedExists(filePath: string, baseDir?: string): boolean {
|
|
|
39
48
|
* (24K/40K/80K) which truncated the same artifact differently depending on
|
|
40
49
|
* which code path read it.
|
|
41
50
|
*/
|
|
42
|
-
const MAX_RESULT_INLINE_BYTES = 32_000;
|
|
51
|
+
export const MAX_RESULT_INLINE_BYTES = 32_000;
|
|
43
52
|
|
|
44
|
-
|
|
45
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Read a file and return its content, truncating to a head+tail slice if it
|
|
55
|
+
* exceeds {@link MAX_RESULT_INLINE_BYTES} characters. Multi-byte UTF-8
|
|
56
|
+
* sequences are preserved by reading the full file as a UTF-8 string and
|
|
57
|
+
* slicing by character count (not raw bytes).
|
|
58
|
+
*/
|
|
59
|
+
export interface TeeRecoveryOptions {
|
|
60
|
+
/** Absolute path to write the full (non-truncated) content to. */
|
|
61
|
+
fullOutputPath: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ReadIfSmallTeeResult {
|
|
65
|
+
/** Truncated content (or full content when no truncation). */
|
|
66
|
+
content: string;
|
|
67
|
+
/** Set only when tee was actually written (file size > 2× threshold + write succeeded). */
|
|
68
|
+
fullOutputPath?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Sanitize a taskId / artifactName into a flat tee filename. Any character
|
|
73
|
+
* outside [A-Za-z0-9._-] is replaced with underscore so the resulting path
|
|
74
|
+
* is always single-segment and cannot escape the tee directory.
|
|
75
|
+
*/
|
|
76
|
+
function safeTeeName(taskId: string, artifactName: string): string {
|
|
77
|
+
const safe = (s: string): string => s.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
78
|
+
return `${safe(taskId)}-${safe(artifactName)}.full.txt`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Canonical tee path for a shared artifact read.
|
|
83
|
+
*
|
|
84
|
+
* Format: `${artifactsRoot}/tee/${taskId}-${artifactName}.full.txt`
|
|
85
|
+
*
|
|
86
|
+
* The downstream worker prompt includes this path so the worker can `read`
|
|
87
|
+
* the full content when it needs the dropped middle.
|
|
88
|
+
*/
|
|
89
|
+
export function teePathForArtifact(artifactsRoot: string, taskId: string, artifactName: string): string {
|
|
90
|
+
return path.join(artifactsRoot, "tee", safeTeeName(taskId, artifactName));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Best-effort tee write. Returns true on success, false on any error (write
|
|
95
|
+
* failures are silent — tee is enhancement, never a hard dependency). The
|
|
96
|
+
* truncated inline content is still returned by the caller either way.
|
|
97
|
+
*/
|
|
98
|
+
function writeTeeFile(fullOutputPath: string, content: string): boolean {
|
|
46
99
|
try {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
100
|
+
fs.mkdirSync(path.dirname(fullOutputPath), { recursive: true });
|
|
101
|
+
fs.writeFileSync(fullOutputPath, content, "utf-8");
|
|
102
|
+
return true;
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Read a file with optional tee-recovery (P1-A). Returns the truncated
|
|
110
|
+
* content AND (when tee was actually written) the absolute path to the full
|
|
111
|
+
* file. Returns undefined if the file cannot be read at all.
|
|
112
|
+
*
|
|
113
|
+
* Tee threshold: only when content.length > 2 * MAX_RESULT_INLINE_BYTES
|
|
114
|
+
* (the head+tail is materially lossy — small over-threshold files are not
|
|
115
|
+
* teed because the inline content is mostly intact and the worker can live
|
|
116
|
+
* with the 75/25 split). File content is read once and reused for both the
|
|
117
|
+
* pipeline (truncation) and the tee write (full file).
|
|
118
|
+
*
|
|
119
|
+
* Truncation behavior is unchanged from the P0-A pipeline: ANSI strip +
|
|
120
|
+
* blank collapse BEFORE truncation, important-line preservation (P0-B)
|
|
121
|
+
* inside TruncationStage, marker wording matches the pre-P1-A `readIfSmall`
|
|
122
|
+
* output exactly (L4 backward-compat).
|
|
123
|
+
*/
|
|
124
|
+
export function readIfSmallWithTee(
|
|
125
|
+
filePath: string,
|
|
126
|
+
opts: { baseDir?: string; tee?: TeeRecoveryOptions } = {},
|
|
127
|
+
): ReadIfSmallTeeResult | undefined {
|
|
128
|
+
const maxChars = MAX_RESULT_INLINE_BYTES;
|
|
129
|
+
try {
|
|
130
|
+
const safePath = opts.baseDir ? resolveRealContainedPath(opts.baseDir, filePath) : filePath;
|
|
131
|
+
const content = fs.readFileSync(safePath, "utf-8");
|
|
132
|
+
if (content.length > maxChars) {
|
|
133
|
+
let fullOutputPath: string | undefined;
|
|
134
|
+
// Tee only when truncation is materially lossy (>2× threshold).
|
|
135
|
+
if (opts.tee && content.length > maxChars * 2) {
|
|
136
|
+
if (writeTeeFile(opts.tee.fullOutputPath, content)) {
|
|
137
|
+
fullOutputPath = opts.tee.fullOutputPath;
|
|
138
|
+
}
|
|
62
139
|
}
|
|
63
|
-
|
|
140
|
+
const result = applyCompactPipeline(content, [
|
|
141
|
+
ANSI_STRIP_STAGE,
|
|
142
|
+
BLANK_COLLAPSE_STAGE,
|
|
143
|
+
new TruncationStage(maxChars, {
|
|
144
|
+
preserveImportant: true,
|
|
145
|
+
marker: { verb: "truncated", unit: "chars", headSeparator: "\n\n", tailSeparator: "\n" },
|
|
146
|
+
}),
|
|
147
|
+
]);
|
|
148
|
+
return fullOutputPath ? { content: result.text, fullOutputPath } : { content: result.text };
|
|
64
149
|
}
|
|
65
|
-
return
|
|
150
|
+
return { content };
|
|
66
151
|
} catch {
|
|
67
152
|
return undefined;
|
|
68
153
|
}
|
|
69
154
|
}
|
|
70
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Read a file and return its content, truncating to a head+tail slice if it
|
|
158
|
+
* exceeds {@link MAX_RESULT_INLINE_BYTES} characters. Multi-byte UTF-8
|
|
159
|
+
* sequences are preserved by reading the full file as a UTF-8 string and
|
|
160
|
+
* slicing by character count (not raw bytes).
|
|
161
|
+
*
|
|
162
|
+
* Thin wrapper around {@link readIfSmallWithTee} for backward compatibility
|
|
163
|
+
* — callers that do not need tee-recovery metadata get just the content
|
|
164
|
+
* string. New tee-recovery call sites should use {@link readIfSmallWithTee}
|
|
165
|
+
* directly so they can include the full output path in the worker prompt.
|
|
166
|
+
*/
|
|
167
|
+
export function readIfSmall(filePath: string, baseDir?: string): string | undefined {
|
|
168
|
+
const result = readIfSmallWithTee(filePath, { baseDir });
|
|
169
|
+
return result?.content;
|
|
170
|
+
}
|
|
171
|
+
|
|
71
172
|
function safeSharedName(name: string): string {
|
|
72
173
|
const normalized = name.replaceAll("\\", "/").replace(/^\.\/+/, "");
|
|
73
174
|
if (!normalized || normalized.split("/").some((segment) => segment === "..") || path.isAbsolute(normalized)) throw new Error(`Invalid shared artifact name: ${name}`);
|
|
@@ -127,6 +228,7 @@ function aggregateUsage(task: TeamTaskState): DependencyContextEntry["usage"] {
|
|
|
127
228
|
function pruneSharedReads(
|
|
128
229
|
reads: Array<{ name: string; path: string; content: string }>,
|
|
129
230
|
dependencies: DependencyContextEntry[],
|
|
231
|
+
artifactsRoot: string,
|
|
130
232
|
): Array<{ name: string; path: string; content: string }> {
|
|
131
233
|
if (reads.length === 0) return reads;
|
|
132
234
|
// Convert shared reads to tool result entries (ordered oldest → newest
|
|
@@ -140,15 +242,19 @@ function pruneSharedReads(
|
|
|
140
242
|
// Collect file edit events from dependency artifacts produced to shared/.
|
|
141
243
|
// A dependency that wrote a shared file after an earlier read invalidates
|
|
142
244
|
// that read (the content is now stale relative to the latest version).
|
|
143
|
-
|
|
245
|
+
// Artifact entries from listTaskArtifacts() are already relative to
|
|
246
|
+
// artifactsRoot (e.g. "shared/foo.md"), so resolve directly against
|
|
247
|
+
// artifactsRoot — NOT against a "shared" subdirectory (which would
|
|
248
|
+
// double-prefix to <artifactsRoot>/shared/shared/foo.md).
|
|
144
249
|
const fileEdits: FileEditEvent[] = [];
|
|
145
250
|
for (let depIndex = 0; depIndex < dependencies.length; depIndex++) {
|
|
146
251
|
const dep = dependencies[depIndex]!;
|
|
147
252
|
const produced = dep.artifactsProduced ?? [];
|
|
148
253
|
for (const artifact of produced) {
|
|
149
254
|
if (typeof artifact !== "string") continue;
|
|
150
|
-
// Map artifact path
|
|
151
|
-
|
|
255
|
+
// Map artifact path (relative to artifactsRoot) to absolute and
|
|
256
|
+
// check against read targets.
|
|
257
|
+
fileEdits.push({ target: path.resolve(artifactsRoot, artifact), index: reads.length + depIndex });
|
|
152
258
|
}
|
|
153
259
|
}
|
|
154
260
|
const pruned = pruneToolOutputs(entries, DEFAULT_PRUNE_CONFIG);
|
|
@@ -175,13 +281,33 @@ export function collectDependencyOutputContext(manifest: TeamRunManifest, tasks:
|
|
|
175
281
|
});
|
|
176
282
|
const rawSharedReads = (step.reads === false ? [] : step.reads ?? []).map((name) => {
|
|
177
283
|
const filePath = sharedPath(manifest, name);
|
|
178
|
-
|
|
284
|
+
// P1-A tee-recovery: when the shared artifact is large enough that the
|
|
285
|
+
// 75/25 head+tail split is materially lossy (>2× MAX_RESULT_INLINE_BYTES),
|
|
286
|
+
// tee the full content to ${artifactsRoot}/tee/${taskId}-${name}.full.txt
|
|
287
|
+
// and expose the path so the downstream worker can `read` the full file
|
|
288
|
+
// if it needs the dropped middle. The truncated content is still
|
|
289
|
+
// included inline; tee is an enhancement, not a hard dependency. Tee
|
|
290
|
+
// write is best-effort (writeTeeFile swallows I/O errors and the result
|
|
291
|
+
// simply omits fullOutputPath in that case).
|
|
292
|
+
const teePath = teePathForArtifact(manifest.artifactsRoot, task.id, name);
|
|
293
|
+
const teeResult = readIfSmallWithTee(filePath, {
|
|
294
|
+
baseDir: path.resolve(manifest.artifactsRoot, "shared"),
|
|
295
|
+
tee: { fullOutputPath: teePath },
|
|
296
|
+
});
|
|
297
|
+
if (teeResult === undefined) return { name, path: filePath, content: "" };
|
|
298
|
+
const entry: { name: string; path: string; content: string; fullOutputPath?: string } = {
|
|
299
|
+
name,
|
|
300
|
+
path: filePath,
|
|
301
|
+
content: teeResult.content,
|
|
302
|
+
};
|
|
303
|
+
if (teeResult.fullOutputPath) entry.fullOutputPath = teeResult.fullOutputPath;
|
|
304
|
+
return entry;
|
|
179
305
|
}).filter((item) => item.content.trim().length > 0);
|
|
180
306
|
// Apply staleness-aware pruning to shared reads: drops superseded reads
|
|
181
307
|
// (same file re-read with different selectors) and replaces stale large
|
|
182
308
|
// outputs with compact digest notices before injecting into the worker
|
|
183
309
|
// prompt. OPT-IN: default config protects recent results.
|
|
184
|
-
const sharedReads = pruneSharedReads(rawSharedReads, dependencies);
|
|
310
|
+
const sharedReads = pruneSharedReads(rawSharedReads, dependencies, manifest.artifactsRoot);
|
|
185
311
|
return { dependencies, sharedReads };
|
|
186
312
|
}
|
|
187
313
|
|
|
@@ -198,7 +324,15 @@ export function renderDependencyOutputContext(context: DependencyOutputContext):
|
|
|
198
324
|
}
|
|
199
325
|
if (context.sharedReads.length) {
|
|
200
326
|
parts.push("# Shared Run Context Reads", "");
|
|
201
|
-
for (const read of context.sharedReads)
|
|
327
|
+
for (const read of context.sharedReads) {
|
|
328
|
+
parts.push(`## shared/${read.name}`, `Path: ${read.path}`);
|
|
329
|
+
// P1-A tee-recovery hint: when the file was materially truncated
|
|
330
|
+
// (>2× threshold) the full content was teed to fullOutputPath so the
|
|
331
|
+
// worker can read the dropped middle if needed. The path is inside
|
|
332
|
+
// artifactsRoot/tee/ and goes through the normal permission gate.
|
|
333
|
+
if (read.fullOutputPath) parts.push(`Full output (if you need the missing middle): ${read.fullOutputPath}`);
|
|
334
|
+
parts.push("", read.content.trim(), "");
|
|
335
|
+
}
|
|
202
336
|
}
|
|
203
337
|
return parts.join("\n").trim();
|
|
204
338
|
}
|