@llblab/pi-actors 0.40.1 → 0.41.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3 -3
- package/CHANGELOG.md +32 -0
- package/README.md +2 -1
- package/dist/index.js +80 -58
- package/dist/lib/inspector-overlay.d.ts +95 -0
- package/dist/lib/inspector-overlay.js +873 -0
- package/dist/lib/inspector.d.ts +17 -33
- package/dist/lib/inspector.js +101 -151
- package/dist/lib/limits.d.ts +3 -0
- package/dist/lib/limits.js +3 -0
- package/dist/lib/observability.d.ts +38 -3
- package/dist/lib/observability.js +151 -33
- package/dist/lib/recipes-context.d.ts +5 -0
- package/dist/lib/recipes-context.js +15 -0
- package/dist/lib/session-evidence.d.ts +45 -0
- package/dist/lib/session-evidence.js +204 -0
- package/dist/lib/tools-response.d.ts +3 -0
- package/dist/lib/tools-response.js +29 -0
- package/dist/scripts/async-runner.mjs +40 -5
- package/dist/scripts/coordinator.mjs +55 -9
- package/dist/skills/actors/SKILL.md +5 -11
- package/dist/skills/swarm/SKILL.md +1 -1
- package/docs/README.md +1 -0
- package/docs/actor-inspector.md +77 -0
- package/docs/async-runs.md +1 -1
- package/index.ts +80 -70
- package/lib/inspector-overlay.ts +1071 -0
- package/lib/inspector.ts +135 -204
- package/lib/limits.ts +3 -0
- package/lib/observability.ts +205 -39
- package/lib/recipes-context.ts +25 -0
- package/lib/session-evidence.ts +302 -0
- package/lib/tools-response.ts +32 -0
- package/package.json +1 -1
- package/scripts/async-runner.mjs +40 -5
- package/scripts/coordinator.mjs +55 -9
- package/skills/actors/SKILL.md +5 -11
- package/skills/swarm/SKILL.md +1 -1
package/lib/observability.ts
CHANGED
|
@@ -101,15 +101,19 @@ export function createRunUiObservationState(): RunUiObservationState {
|
|
|
101
101
|
export function readRunUiSnapshot(
|
|
102
102
|
state: RunUiObservationState,
|
|
103
103
|
ownerId: string,
|
|
104
|
+
options: { includeOutbox?: boolean; stateRoot?: string } = {},
|
|
104
105
|
): RunUiSnapshot {
|
|
105
|
-
const summary = summarizeRuns(
|
|
106
|
+
const summary = summarizeRuns(options.stateRoot, ownerId);
|
|
106
107
|
const status = renderRunStatus(summary, state.frame++);
|
|
107
108
|
return {
|
|
108
|
-
outboxEvents:
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
109
|
+
outboxEvents:
|
|
110
|
+
options.includeOutbox === false
|
|
111
|
+
? []
|
|
112
|
+
: detectRunOutboxEvents(
|
|
113
|
+
state.eventLines,
|
|
114
|
+
summary,
|
|
115
|
+
state.outboxEventIds,
|
|
116
|
+
),
|
|
113
117
|
status,
|
|
114
118
|
summary,
|
|
115
119
|
transitions: detectRunTransitions(state.observed, summary),
|
|
@@ -134,27 +138,55 @@ export function pruneRunUiObservationState(
|
|
|
134
138
|
export function deliverRunTransitionNotifications(
|
|
135
139
|
transitions: RunTransition[],
|
|
136
140
|
sink: RunUiNotificationSink,
|
|
141
|
+
inFlight: Set<string> = new Set(),
|
|
137
142
|
): void {
|
|
138
143
|
for (const transition of transitions) {
|
|
139
144
|
if (!shouldNotifyRunTransition(transition)) continue;
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
transition
|
|
152
|
-
|
|
153
|
-
)
|
|
145
|
+
const key = transition.stateDir ?? transition.run;
|
|
146
|
+
if (inFlight.has(key)) continue;
|
|
147
|
+
inFlight.add(key);
|
|
148
|
+
try {
|
|
149
|
+
const text = formatRunTransitionMessage(transition);
|
|
150
|
+
sink.notify(text, getRunTransitionNotificationType(transition));
|
|
151
|
+
if (!shouldSendRunTransitionFollowUp(transition)) continue;
|
|
152
|
+
sink.sendFollowUp({
|
|
153
|
+
customType: "pi-actors-run",
|
|
154
|
+
content: text,
|
|
155
|
+
display: true,
|
|
156
|
+
details: transition,
|
|
157
|
+
});
|
|
158
|
+
if (transition.stateDir) {
|
|
159
|
+
AsyncRuns.markRunTerminalNotificationHandled(
|
|
160
|
+
transition.stateDir,
|
|
161
|
+
transition.to,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
} finally {
|
|
165
|
+
inFlight.delete(key);
|
|
154
166
|
}
|
|
155
167
|
}
|
|
156
168
|
}
|
|
157
169
|
|
|
170
|
+
export function reconcileRunTerminalNotifications(input: {
|
|
171
|
+
inFlight?: Set<string>;
|
|
172
|
+
ownerId: string;
|
|
173
|
+
sink: RunUiNotificationSink;
|
|
174
|
+
state: RunUiObservationState;
|
|
175
|
+
stateRoot?: string;
|
|
176
|
+
}): RunUiSnapshot {
|
|
177
|
+
const snapshot = readRunUiSnapshot(input.state, input.ownerId, {
|
|
178
|
+
includeOutbox: false,
|
|
179
|
+
stateRoot: input.stateRoot,
|
|
180
|
+
});
|
|
181
|
+
deliverRunTransitionNotifications(
|
|
182
|
+
snapshot.transitions,
|
|
183
|
+
input.sink,
|
|
184
|
+
input.inFlight,
|
|
185
|
+
);
|
|
186
|
+
pruneRunUiObservationState(input.state, snapshot);
|
|
187
|
+
return snapshot;
|
|
188
|
+
}
|
|
189
|
+
|
|
158
190
|
export function deliverRunOutboxNotifications(
|
|
159
191
|
events: RunOutboxEvent[],
|
|
160
192
|
sink: RunUiNotificationSink,
|
|
@@ -189,18 +221,94 @@ export interface RunRetirementExecution {
|
|
|
189
221
|
stateDir: string;
|
|
190
222
|
}
|
|
191
223
|
|
|
224
|
+
export type RunStateWatcherDiagnosticCode =
|
|
225
|
+
| "attach_failed"
|
|
226
|
+
| "error"
|
|
227
|
+
| "removed"
|
|
228
|
+
| "rearmed";
|
|
229
|
+
|
|
230
|
+
export interface RunStateWatcherDiagnostic {
|
|
231
|
+
code: RunStateWatcherDiagnosticCode;
|
|
232
|
+
id: number;
|
|
233
|
+
message: string;
|
|
234
|
+
path: string;
|
|
235
|
+
scope: "root" | "run";
|
|
236
|
+
ts: string;
|
|
237
|
+
}
|
|
238
|
+
|
|
192
239
|
export interface RunStateWatcher {
|
|
193
240
|
close(): void;
|
|
241
|
+
getDiagnostics(): RunStateWatcherDiagnostic[];
|
|
194
242
|
refresh(): void;
|
|
195
243
|
}
|
|
196
244
|
|
|
245
|
+
const RUN_WATCHER_DIAGNOSTIC_LIMIT = 32;
|
|
246
|
+
|
|
197
247
|
export function createRunStateWatcher(input: {
|
|
198
|
-
|
|
248
|
+
exists?: (path: string) => boolean;
|
|
249
|
+
listDirectories?: (path: string) => string[];
|
|
199
250
|
onChange: () => void;
|
|
251
|
+
stateRoot?: string;
|
|
252
|
+
watchPath?: (path: string, onChange: () => void) => FSWatcher;
|
|
200
253
|
}): RunStateWatcher {
|
|
201
254
|
const stateRoot = input.stateRoot ?? Paths.getRunStateRoot();
|
|
255
|
+
const pathExists = input.exists ?? existsSync;
|
|
256
|
+
const listDirectories =
|
|
257
|
+
input.listDirectories ??
|
|
258
|
+
((path: string) =>
|
|
259
|
+
readdirSync(path, { withFileTypes: true })
|
|
260
|
+
.filter((entry) => entry.isDirectory())
|
|
261
|
+
.map((entry) => join(path, entry.name)));
|
|
262
|
+
const watchPath = input.watchPath ?? ((path, onChange) => watch(path, onChange));
|
|
263
|
+
let diagnosticId = 0;
|
|
264
|
+
let rootDegraded = false;
|
|
202
265
|
let stateRootWatcher: FSWatcher | undefined;
|
|
266
|
+
const degradedRunDirs = new Set<string>();
|
|
267
|
+
const diagnostics: RunStateWatcherDiagnostic[] = [];
|
|
268
|
+
const lastDiagnosticSignatures = new Map<string, string>();
|
|
203
269
|
const runDirWatchers = new Map<string, FSWatcher>();
|
|
270
|
+
const record = (
|
|
271
|
+
code: RunStateWatcherDiagnosticCode,
|
|
272
|
+
scope: "root" | "run",
|
|
273
|
+
path: string,
|
|
274
|
+
error?: unknown,
|
|
275
|
+
): void => {
|
|
276
|
+
const detail = error instanceof Error ? `: ${error.message}` : "";
|
|
277
|
+
const signature = `${code}${detail}`;
|
|
278
|
+
const signatureKey = `${scope}:${path}`;
|
|
279
|
+
if (lastDiagnosticSignatures.get(signatureKey) === signature) return;
|
|
280
|
+
lastDiagnosticSignatures.set(signatureKey, signature);
|
|
281
|
+
const recovery =
|
|
282
|
+
code === "rearmed"
|
|
283
|
+
? "; terminal watch acceleration restored"
|
|
284
|
+
: "; terminal reconciliation remains active and watcher rearm will retry";
|
|
285
|
+
diagnostics.push({
|
|
286
|
+
code,
|
|
287
|
+
id: ++diagnosticId,
|
|
288
|
+
message: `Run-state ${scope} watcher ${code.replace("_", " ")} for ${path}${detail}${recovery}`,
|
|
289
|
+
path,
|
|
290
|
+
scope,
|
|
291
|
+
ts: new Date().toISOString(),
|
|
292
|
+
});
|
|
293
|
+
if (diagnostics.length > RUN_WATCHER_DIAGNOSTIC_LIMIT) diagnostics.shift();
|
|
294
|
+
};
|
|
295
|
+
const removeRunWatcher = (
|
|
296
|
+
stateDir: string,
|
|
297
|
+
watcher: FSWatcher,
|
|
298
|
+
options: { degraded?: boolean; error?: unknown } = {},
|
|
299
|
+
): void => {
|
|
300
|
+
if (runDirWatchers.get(stateDir) !== watcher) return;
|
|
301
|
+
watcher.close();
|
|
302
|
+
runDirWatchers.delete(stateDir);
|
|
303
|
+
if (options.degraded === false) {
|
|
304
|
+
degradedRunDirs.delete(stateDir);
|
|
305
|
+
lastDiagnosticSignatures.delete(`run:${stateDir}`);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
degradedRunDirs.add(stateDir);
|
|
309
|
+
if (options.error) record("error", "run", stateDir, options.error);
|
|
310
|
+
record("removed", "run", stateDir);
|
|
311
|
+
};
|
|
204
312
|
const close = (): void => {
|
|
205
313
|
stateRootWatcher?.close();
|
|
206
314
|
stateRootWatcher = undefined;
|
|
@@ -208,37 +316,95 @@ export function createRunStateWatcher(input: {
|
|
|
208
316
|
runDirWatchers.clear();
|
|
209
317
|
};
|
|
210
318
|
const watchRunDir = (stateDir: string): void => {
|
|
211
|
-
if (runDirWatchers.has(stateDir) || !
|
|
319
|
+
if (runDirWatchers.has(stateDir) || !pathExists(stateDir)) return;
|
|
212
320
|
try {
|
|
213
|
-
const watcher =
|
|
214
|
-
watcher.on("error", () =>
|
|
215
|
-
watcher
|
|
216
|
-
|
|
217
|
-
});
|
|
321
|
+
const watcher = watchPath(stateDir, input.onChange);
|
|
322
|
+
watcher.on("error", (error) =>
|
|
323
|
+
removeRunWatcher(stateDir, watcher, { error }),
|
|
324
|
+
);
|
|
218
325
|
runDirWatchers.set(stateDir, watcher);
|
|
219
|
-
|
|
220
|
-
|
|
326
|
+
if (degradedRunDirs.delete(stateDir)) record("rearmed", "run", stateDir);
|
|
327
|
+
} catch (error) {
|
|
328
|
+
degradedRunDirs.add(stateDir);
|
|
329
|
+
record("attach_failed", "run", stateDir, error);
|
|
221
330
|
}
|
|
222
331
|
};
|
|
223
332
|
function refresh(): void {
|
|
224
|
-
if (!
|
|
333
|
+
if (!pathExists(stateRoot)) return;
|
|
225
334
|
if (!stateRootWatcher) {
|
|
226
335
|
try {
|
|
227
|
-
|
|
228
|
-
stateRootWatcher
|
|
229
|
-
|
|
336
|
+
const watcher = watchPath(stateRoot, input.onChange);
|
|
337
|
+
stateRootWatcher = watcher;
|
|
338
|
+
watcher.on("error", (error) => {
|
|
339
|
+
if (stateRootWatcher !== watcher) return;
|
|
340
|
+
watcher.close();
|
|
230
341
|
stateRootWatcher = undefined;
|
|
342
|
+
rootDegraded = true;
|
|
343
|
+
record("error", "root", stateRoot, error);
|
|
344
|
+
record("removed", "root", stateRoot);
|
|
231
345
|
});
|
|
232
|
-
|
|
233
|
-
|
|
346
|
+
if (rootDegraded) {
|
|
347
|
+
rootDegraded = false;
|
|
348
|
+
record("rearmed", "root", stateRoot);
|
|
349
|
+
}
|
|
350
|
+
} catch (error) {
|
|
351
|
+
rootDegraded = true;
|
|
352
|
+
record("attach_failed", "root", stateRoot, error);
|
|
234
353
|
}
|
|
235
354
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
355
|
+
let stateDirs: string[];
|
|
356
|
+
try {
|
|
357
|
+
stateDirs = listDirectories(stateRoot);
|
|
358
|
+
} catch (error) {
|
|
359
|
+
record("attach_failed", "root", stateRoot, error);
|
|
360
|
+
return;
|
|
239
361
|
}
|
|
362
|
+
const present = new Set(stateDirs);
|
|
363
|
+
for (const [stateDir, watcher] of runDirWatchers) {
|
|
364
|
+
if (present.has(stateDir) && pathExists(stateDir)) continue;
|
|
365
|
+
removeRunWatcher(stateDir, watcher, { degraded: false });
|
|
366
|
+
}
|
|
367
|
+
for (const stateDir of stateDirs) watchRunDir(stateDir);
|
|
240
368
|
}
|
|
241
|
-
return {
|
|
369
|
+
return {
|
|
370
|
+
close,
|
|
371
|
+
getDiagnostics: () => [...diagnostics],
|
|
372
|
+
refresh,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export interface RunTerminalReconciliationLoop {
|
|
377
|
+
close(): void;
|
|
378
|
+
reconcileNow(): void;
|
|
379
|
+
start(): void;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function createRunTerminalReconciliationLoop(input: {
|
|
383
|
+
intervalMs?: number;
|
|
384
|
+
onError?: (error: unknown) => void;
|
|
385
|
+
reconcile: () => void;
|
|
386
|
+
refreshWatcher: () => void;
|
|
387
|
+
}): RunTerminalReconciliationLoop {
|
|
388
|
+
const intervalMs = input.intervalMs ?? 10_000;
|
|
389
|
+
let interval: NodeJS.Timeout | undefined;
|
|
390
|
+
const reconcileNow = (): void => {
|
|
391
|
+
try {
|
|
392
|
+
input.refreshWatcher();
|
|
393
|
+
input.reconcile();
|
|
394
|
+
} catch (error) {
|
|
395
|
+
input.onError?.(error);
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
const close = (): void => {
|
|
399
|
+
if (interval) clearInterval(interval);
|
|
400
|
+
interval = undefined;
|
|
401
|
+
};
|
|
402
|
+
const start = (): void => {
|
|
403
|
+
close();
|
|
404
|
+
interval = setInterval(reconcileNow, intervalMs);
|
|
405
|
+
interval.unref?.();
|
|
406
|
+
};
|
|
407
|
+
return { close, reconcileNow, start };
|
|
242
408
|
}
|
|
243
409
|
|
|
244
410
|
export interface RunRetirementExecutorOptions {
|
package/lib/recipes-context.ts
CHANGED
|
@@ -167,6 +167,31 @@ export function appendRecipeContextToPiArgs(
|
|
|
167
167
|
return next;
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
export interface ManagedPiSessionArgs {
|
|
171
|
+
args: string[];
|
|
172
|
+
sessionDir?: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const PI_SESSION_POLICY_FLAGS = new Set([
|
|
176
|
+
"--fork",
|
|
177
|
+
"--no-session",
|
|
178
|
+
"--session",
|
|
179
|
+
"--session-dir",
|
|
180
|
+
"--session-id",
|
|
181
|
+
]);
|
|
182
|
+
|
|
183
|
+
export function attachPiSessionDir(
|
|
184
|
+
command: string,
|
|
185
|
+
args: string[],
|
|
186
|
+
sessionDir: string,
|
|
187
|
+
): ManagedPiSessionArgs {
|
|
188
|
+
if (!isPiCommand(command) || findPiPrintPromptIndex(args) === undefined) {
|
|
189
|
+
return { args };
|
|
190
|
+
}
|
|
191
|
+
if (args.some((arg) => PI_SESSION_POLICY_FLAGS.has(arg))) return { args };
|
|
192
|
+
return { args: ["--session-dir", sessionDir, ...args], sessionDir };
|
|
193
|
+
}
|
|
194
|
+
|
|
170
195
|
export interface MaterializedPiPrintPromptArgs {
|
|
171
196
|
args: string[];
|
|
172
197
|
promptBytes?: number;
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persisted Pi session evidence reader.
|
|
3
|
+
* Zones: subagent turns, active session branches, bounded/redacted previews
|
|
4
|
+
* Owns read-only normalization of session JSONL into inspector-ready turns.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as Limits from "./limits.ts";
|
|
8
|
+
import {
|
|
9
|
+
readJsonlFileResilient,
|
|
10
|
+
type StateReadDiagnostic,
|
|
11
|
+
} from "./state-readers.ts";
|
|
12
|
+
|
|
13
|
+
interface SessionEntry {
|
|
14
|
+
id?: unknown;
|
|
15
|
+
message?: unknown;
|
|
16
|
+
parentId?: unknown;
|
|
17
|
+
timestamp?: unknown;
|
|
18
|
+
type?: unknown;
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SessionEvidenceToolCall {
|
|
23
|
+
arguments?: unknown;
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
result?: unknown;
|
|
27
|
+
resultError?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SessionEvidenceTurn {
|
|
31
|
+
assistantEntryId?: string;
|
|
32
|
+
assistantText?: string;
|
|
33
|
+
error?: string;
|
|
34
|
+
index: number;
|
|
35
|
+
model?: string;
|
|
36
|
+
provider?: string;
|
|
37
|
+
stopReason?: string;
|
|
38
|
+
thinking?: string;
|
|
39
|
+
timestamp?: string;
|
|
40
|
+
toolCalls: SessionEvidenceToolCall[];
|
|
41
|
+
unmatchedToolResults: number;
|
|
42
|
+
usage?: unknown;
|
|
43
|
+
userEntryId?: string;
|
|
44
|
+
userText?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SessionEvidence {
|
|
48
|
+
activeLeafId?: string;
|
|
49
|
+
diagnostics: StateReadDiagnostic[];
|
|
50
|
+
path: string;
|
|
51
|
+
session?: Record<string, unknown>;
|
|
52
|
+
totalTurns: number;
|
|
53
|
+
truncated: boolean;
|
|
54
|
+
turns: SessionEvidenceTurn[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SessionEvidenceReadOptions {
|
|
58
|
+
maxTextChars?: number;
|
|
59
|
+
maxToolCalls?: number;
|
|
60
|
+
maxTurns?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const SENSITIVE_KEY = /(?:^|[_-])(?:api[-_]?key|authorization|cookie|credential|password|private[-_]?key|secret|secret[-_]?access[-_]?key|token)$|^(?:access|refresh|auth|api)Token$|^(?:clientSecret|privateKey|secretAccessKey)$/i;
|
|
64
|
+
const SENSITIVE_TEXT = /(bearer\s+)[A-Za-z0-9._~+/=-]+|["']?\b(api[-_]?key|authorization|clientSecret|cookie|password|private[-_]?key|privateKey|secret|secretAccessKey|token)["']?(\s*[:=]\s*)["']?([^\s,;"'}]+)/gi;
|
|
65
|
+
|
|
66
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
67
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
68
|
+
? (value as Record<string, unknown>)
|
|
69
|
+
: {};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function boundedText(value: unknown, maxChars: number): string | undefined {
|
|
73
|
+
if (typeof value !== "string") return undefined;
|
|
74
|
+
if (/^\s*[\[{]/.test(value)) {
|
|
75
|
+
try {
|
|
76
|
+
const structured = JSON.stringify(
|
|
77
|
+
redactSessionEvidenceValue(JSON.parse(value), maxChars),
|
|
78
|
+
);
|
|
79
|
+
return structured.length > maxChars
|
|
80
|
+
? `${structured.slice(0, Math.max(0, maxChars - 1))}…`
|
|
81
|
+
: structured;
|
|
82
|
+
} catch {
|
|
83
|
+
// Fall through to bounded text redaction.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const redacted = value.replaceAll(
|
|
87
|
+
SENSITIVE_TEXT,
|
|
88
|
+
(match, bearer: string | undefined, key: string | undefined, separator: string | undefined) =>
|
|
89
|
+
bearer ? `${bearer}[REDACTED]` : `${key}${separator}[REDACTED]`,
|
|
90
|
+
);
|
|
91
|
+
return redacted.length > maxChars
|
|
92
|
+
? `${redacted.slice(0, Math.max(0, maxChars - 1))}…`
|
|
93
|
+
: redacted;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function redactSessionEvidenceValue(
|
|
97
|
+
value: unknown,
|
|
98
|
+
maxTextChars = Limits.SESSION_EVIDENCE_TEXT_CHARS,
|
|
99
|
+
seen = new WeakSet<object>(),
|
|
100
|
+
): unknown {
|
|
101
|
+
if (typeof value === "string") return boundedText(value, maxTextChars);
|
|
102
|
+
if (!value || typeof value !== "object") return value;
|
|
103
|
+
if (seen.has(value)) return "[CIRCULAR]";
|
|
104
|
+
seen.add(value);
|
|
105
|
+
if (Array.isArray(value)) {
|
|
106
|
+
return value.map((item) => redactSessionEvidenceValue(item, maxTextChars, seen));
|
|
107
|
+
}
|
|
108
|
+
return Object.fromEntries(
|
|
109
|
+
Object.entries(value as Record<string, unknown>).map(([key, item]) => [
|
|
110
|
+
key,
|
|
111
|
+
SENSITIVE_KEY.test(key)
|
|
112
|
+
? "[REDACTED]"
|
|
113
|
+
: redactSessionEvidenceValue(item, maxTextChars, seen),
|
|
114
|
+
]),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function contentBlocks(message: Record<string, unknown>): Record<string, unknown>[] {
|
|
119
|
+
return Array.isArray(message.content)
|
|
120
|
+
? message.content.map(asRecord)
|
|
121
|
+
: typeof message.content === "string"
|
|
122
|
+
? [{ type: "text", text: message.content }]
|
|
123
|
+
: [];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function contentText(
|
|
127
|
+
message: Record<string, unknown>,
|
|
128
|
+
type: "text" | "thinking",
|
|
129
|
+
maxChars: number,
|
|
130
|
+
): string | undefined {
|
|
131
|
+
const text = contentBlocks(message)
|
|
132
|
+
.filter((block) => block.type === type && typeof block[type] === "string")
|
|
133
|
+
.map((block) => String(block[type]))
|
|
134
|
+
.join("\n");
|
|
135
|
+
return boundedText(text, maxChars);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function activeBranch(
|
|
139
|
+
entries: SessionEntry[],
|
|
140
|
+
path: string,
|
|
141
|
+
diagnostics: StateReadDiagnostic[],
|
|
142
|
+
): SessionEntry[] {
|
|
143
|
+
const treeEntries = entries.filter(
|
|
144
|
+
(entry) => entry.type !== "session" && typeof entry.id === "string",
|
|
145
|
+
);
|
|
146
|
+
const leaf = treeEntries.at(-1);
|
|
147
|
+
if (!leaf || typeof leaf.id !== "string") return [];
|
|
148
|
+
const byId = new Map(
|
|
149
|
+
treeEntries.map((entry) => [String(entry.id), entry] as const),
|
|
150
|
+
);
|
|
151
|
+
const branch: SessionEntry[] = [];
|
|
152
|
+
const visited = new Set<string>();
|
|
153
|
+
let current: SessionEntry | undefined = leaf;
|
|
154
|
+
while (current && typeof current.id === "string") {
|
|
155
|
+
if (visited.has(current.id)) {
|
|
156
|
+
diagnostics.push({ message: `session entry cycle at ${current.id}`, path });
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
visited.add(current.id);
|
|
160
|
+
branch.push(current);
|
|
161
|
+
if (current.parentId === null || current.parentId === undefined) break;
|
|
162
|
+
if (typeof current.parentId !== "string" || !byId.has(current.parentId)) {
|
|
163
|
+
diagnostics.push({
|
|
164
|
+
message: `missing parent ${String(current.parentId)} for ${current.id}`,
|
|
165
|
+
path,
|
|
166
|
+
});
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
current = byId.get(current.parentId);
|
|
170
|
+
}
|
|
171
|
+
return branch.reverse();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function toolCalls(
|
|
175
|
+
message: Record<string, unknown>,
|
|
176
|
+
maxTextChars: number,
|
|
177
|
+
maxToolCalls: number,
|
|
178
|
+
): SessionEvidenceToolCall[] {
|
|
179
|
+
return contentBlocks(message)
|
|
180
|
+
.filter(
|
|
181
|
+
(block) =>
|
|
182
|
+
block.type === "toolCall" &&
|
|
183
|
+
typeof block.id === "string" &&
|
|
184
|
+
typeof block.name === "string",
|
|
185
|
+
)
|
|
186
|
+
.slice(0, maxToolCalls)
|
|
187
|
+
.map((block) => ({
|
|
188
|
+
arguments: redactSessionEvidenceValue(block.arguments, maxTextChars),
|
|
189
|
+
id: String(block.id),
|
|
190
|
+
name: String(block.name),
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function readSessionEvidence(
|
|
195
|
+
path: string,
|
|
196
|
+
options: SessionEvidenceReadOptions = {},
|
|
197
|
+
): SessionEvidence {
|
|
198
|
+
const maxTextChars = Math.max(
|
|
199
|
+
1,
|
|
200
|
+
options.maxTextChars ?? Limits.SESSION_EVIDENCE_TEXT_CHARS,
|
|
201
|
+
);
|
|
202
|
+
const maxToolCalls = Math.max(
|
|
203
|
+
1,
|
|
204
|
+
options.maxToolCalls ?? Limits.SESSION_EVIDENCE_MAX_TOOL_CALLS,
|
|
205
|
+
);
|
|
206
|
+
const maxTurns = Math.max(
|
|
207
|
+
1,
|
|
208
|
+
options.maxTurns ?? Limits.SESSION_EVIDENCE_MAX_TURNS,
|
|
209
|
+
);
|
|
210
|
+
const read = readJsonlFileResilient<SessionEntry>(path);
|
|
211
|
+
const diagnostics = [...read.diagnostics];
|
|
212
|
+
const header = read.records.find((entry) => entry.type === "session");
|
|
213
|
+
const branch = activeBranch(read.records, path, diagnostics);
|
|
214
|
+
const turns: SessionEvidenceTurn[] = [];
|
|
215
|
+
let pendingUser: { id: string; text?: string } | undefined;
|
|
216
|
+
let currentTurn: SessionEvidenceTurn | undefined;
|
|
217
|
+
for (const entry of branch) {
|
|
218
|
+
if (entry.type !== "message") continue;
|
|
219
|
+
const message = asRecord(entry.message);
|
|
220
|
+
const role = message.role;
|
|
221
|
+
if (role === "user") {
|
|
222
|
+
pendingUser = {
|
|
223
|
+
id: String(entry.id),
|
|
224
|
+
text: contentText(message, "text", maxTextChars),
|
|
225
|
+
};
|
|
226
|
+
currentTurn = undefined;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (role === "assistant") {
|
|
230
|
+
currentTurn = {
|
|
231
|
+
...(typeof entry.id === "string" ? { assistantEntryId: entry.id } : {}),
|
|
232
|
+
...(contentText(message, "text", maxTextChars)
|
|
233
|
+
? { assistantText: contentText(message, "text", maxTextChars) }
|
|
234
|
+
: {}),
|
|
235
|
+
...(typeof message.errorMessage === "string"
|
|
236
|
+
? { error: boundedText(message.errorMessage, maxTextChars) }
|
|
237
|
+
: {}),
|
|
238
|
+
index: turns.length + 1,
|
|
239
|
+
...(typeof message.model === "string" ? { model: message.model } : {}),
|
|
240
|
+
...(typeof message.provider === "string"
|
|
241
|
+
? { provider: message.provider }
|
|
242
|
+
: {}),
|
|
243
|
+
...(typeof message.stopReason === "string"
|
|
244
|
+
? { stopReason: message.stopReason }
|
|
245
|
+
: {}),
|
|
246
|
+
...(contentText(message, "thinking", maxTextChars)
|
|
247
|
+
? { thinking: contentText(message, "thinking", maxTextChars) }
|
|
248
|
+
: {}),
|
|
249
|
+
...(typeof entry.timestamp === "string"
|
|
250
|
+
? { timestamp: entry.timestamp }
|
|
251
|
+
: {}),
|
|
252
|
+
toolCalls: toolCalls(message, maxTextChars, maxToolCalls),
|
|
253
|
+
unmatchedToolResults: 0,
|
|
254
|
+
...(message.usage !== undefined
|
|
255
|
+
? { usage: redactSessionEvidenceValue(message.usage, maxTextChars) }
|
|
256
|
+
: {}),
|
|
257
|
+
...(pendingUser
|
|
258
|
+
? {
|
|
259
|
+
userEntryId: pendingUser.id,
|
|
260
|
+
...(pendingUser.text ? { userText: pendingUser.text } : {}),
|
|
261
|
+
}
|
|
262
|
+
: {}),
|
|
263
|
+
};
|
|
264
|
+
pendingUser = undefined;
|
|
265
|
+
turns.push(currentTurn);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (role === "toolResult" && currentTurn) {
|
|
269
|
+
const callId = typeof message.toolCallId === "string" ? message.toolCallId : "";
|
|
270
|
+
const call = currentTurn.toolCalls.find((item) => item.id === callId);
|
|
271
|
+
if (!call) {
|
|
272
|
+
currentTurn.unmatchedToolResults += 1;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
call.result = redactSessionEvidenceValue(message.content, maxTextChars);
|
|
276
|
+
call.resultError = message.isError === true;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (pendingUser) {
|
|
280
|
+
turns.push({
|
|
281
|
+
index: turns.length + 1,
|
|
282
|
+
toolCalls: [],
|
|
283
|
+
unmatchedToolResults: 0,
|
|
284
|
+
userEntryId: pendingUser.id,
|
|
285
|
+
...(pendingUser.text ? { userText: pendingUser.text } : {}),
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
const firstVisibleIndex = Math.max(0, turns.length - maxTurns);
|
|
289
|
+
const visibleTurns = turns.slice(firstVisibleIndex).map((turn, index) => ({
|
|
290
|
+
...turn,
|
|
291
|
+
index: firstVisibleIndex + index + 1,
|
|
292
|
+
}));
|
|
293
|
+
return {
|
|
294
|
+
...(branch.at(-1)?.id ? { activeLeafId: String(branch.at(-1)?.id) } : {}),
|
|
295
|
+
diagnostics,
|
|
296
|
+
path,
|
|
297
|
+
...(header ? { session: asRecord(header) } : {}),
|
|
298
|
+
totalTurns: turns.length,
|
|
299
|
+
truncated: turns.length > visibleTurns.length,
|
|
300
|
+
turns: visibleTurns,
|
|
301
|
+
};
|
|
302
|
+
}
|
package/lib/tools-response.ts
CHANGED
|
@@ -7,6 +7,38 @@
|
|
|
7
7
|
import * as Limits from "./limits.ts";
|
|
8
8
|
import * as ToolsMailbox from "./tools-mailbox.ts";
|
|
9
9
|
|
|
10
|
+
export function withLeadingLineBreak(text: string): string {
|
|
11
|
+
return `\n${text.replace(/^\n+/, "")}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function spaceToolResult<T>(result: T): T {
|
|
15
|
+
if (!result || typeof result !== "object") return result;
|
|
16
|
+
const content = (result as { content?: unknown }).content;
|
|
17
|
+
if (!Array.isArray(content)) return result;
|
|
18
|
+
return {
|
|
19
|
+
...result,
|
|
20
|
+
content: content.map((item) =>
|
|
21
|
+
item &&
|
|
22
|
+
typeof item === "object" &&
|
|
23
|
+
(item as { type?: unknown }).type === "text" &&
|
|
24
|
+
typeof (item as { text?: unknown }).text === "string"
|
|
25
|
+
? {
|
|
26
|
+
...item,
|
|
27
|
+
text: withLeadingLineBreak((item as { text: string }).text),
|
|
28
|
+
}
|
|
29
|
+
: item,
|
|
30
|
+
),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function spaceToolError(error: unknown): unknown {
|
|
35
|
+
if (error instanceof Error) {
|
|
36
|
+
error.message = withLeadingLineBreak(error.message);
|
|
37
|
+
return error;
|
|
38
|
+
}
|
|
39
|
+
return new Error(withLeadingLineBreak(String(error)));
|
|
40
|
+
}
|
|
41
|
+
|
|
10
42
|
export function asRecord(value: unknown): Record<string, unknown> {
|
|
11
43
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
12
44
|
? (value as Record<string, unknown>)
|