pi-advisor-flow 0.5.6 → 0.6.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/index.js +1371 -1128
  3. package/extensions/index.ts +9 -9
  4. package/package.json +3 -1
  5. package/src/{untracked.ts → attachments.ts} +2 -5
  6. package/src/commands/activation-preparation.ts +6 -6
  7. package/src/commands/activation.ts +7 -7
  8. package/src/commands/lifecycle.ts +5 -5
  9. package/src/commands/manual-command.ts +13 -9
  10. package/src/commands/manual-consultation.ts +8 -8
  11. package/src/commands/manual-progress.ts +3 -3
  12. package/src/commands/model-commands.ts +6 -6
  13. package/src/commands/model-options.ts +4 -4
  14. package/src/commands/model-picker.ts +2 -2
  15. package/src/commands/registration.ts +8 -8
  16. package/src/commands/renderers.ts +5 -5
  17. package/src/commands/runtime.ts +8 -8
  18. package/src/commands/settings-commands.ts +8 -8
  19. package/src/commands/settings-persistence.ts +4 -4
  20. package/src/commands/types.ts +5 -13
  21. package/src/commands.ts +2 -2
  22. package/src/config/args.ts +3 -3
  23. package/src/config/defaults.ts +3 -3
  24. package/src/config/schema.ts +322 -0
  25. package/src/config/state.ts +2 -2
  26. package/src/config/storage.ts +20 -117
  27. package/src/config/types.ts +1 -1
  28. package/src/config/validation.ts +61 -181
  29. package/src/config.ts +7 -7
  30. package/src/content-utils.ts +30 -0
  31. package/src/conversation.ts +13 -168
  32. package/src/git.ts +4 -18
  33. package/src/herdr.ts +6 -8
  34. package/src/model-stream.ts +5 -8
  35. package/src/outcomes.ts +2 -5
  36. package/src/pi-settings.ts +59 -0
  37. package/src/preferences.ts +2 -2
  38. package/src/redaction.ts +50 -0
  39. package/src/scout-context.ts +121 -485
  40. package/src/scout-curation.ts +84 -0
  41. package/src/scout-groups.ts +485 -0
  42. package/src/scout-protocol.ts +122 -0
  43. package/src/scout-reconstruct.ts +44 -0
  44. package/src/scout-types.ts +66 -0
  45. package/src/scout.ts +53 -63
  46. package/src/session-state.ts +119 -76
  47. package/src/tool-result-cap.ts +88 -0
  48. package/src/tools/consult-context.ts +155 -0
  49. package/src/tools/consultation.ts +71 -220
  50. package/src/tools/gate-policy.ts +5 -5
  51. package/src/tools/gate-protocol.ts +2 -2
  52. package/src/tools/loop-gate.ts +70 -61
  53. package/src/tools/prompts.ts +4 -7
  54. package/src/tools/register-ask-advisor.ts +9 -14
  55. package/src/tools/register-lifecycle.ts +7 -7
  56. package/src/tools/register-outcome.ts +5 -5
  57. package/src/tools/register-renderers.ts +5 -5
  58. package/src/tools/registration.ts +10 -10
  59. package/src/tools/render-advisor-result.ts +11 -8
  60. package/src/tools/render-common.ts +25 -13
  61. package/src/tools/scout-status.ts +5 -5
  62. package/src/tools/session.ts +1 -1
  63. package/src/tools/types.ts +7 -7
  64. package/src/tools.ts +9 -9
  65. package/src/ui/manual-dialog-render.ts +2 -2
  66. package/src/ui/manual-dialog.ts +4 -9
  67. package/src/ui/model-selector.ts +1 -1
  68. package/src/ui/settings-formatting.ts +2 -2
  69. package/src/ui/settings-items.ts +4 -4
  70. package/src/ui/settings-mutations.ts +2 -2
  71. package/src/ui/settings-selector.ts +5 -5
  72. package/src/ui/text-setting-submenu.ts +1 -1
  73. package/src/ui/types.ts +2 -2
  74. package/src/ui.ts +4 -4
  75. package/src/usage.ts +1 -6
@@ -0,0 +1,84 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { advisorScoutEnabledRef, executorRef } from "./config/state.ts";
3
+ import {
4
+ runAdvisorScout,
5
+ type ScoutLifecycleEvent,
6
+ type ScoutOutcome,
7
+ } from "./scout.ts";
8
+ import {
9
+ buildScoutManifest,
10
+ reconstructScoutConversation,
11
+ SCOUT_MANIFEST_MAX_BYTES,
12
+ } from "./scout-context.ts";
13
+
14
+ /** Runs Scout over the manifested branch and reconstructs the curated
15
+ * conversation, falling back to the exact legacy conversation on any
16
+ * non-cancellation Scout failure. */
17
+ export const curateAdvisorConversation = async (
18
+ ctx: ExtensionContext,
19
+ legacyConversation: string,
20
+ signal?: AbortSignal,
21
+ onScout?: (event: ScoutLifecycleEvent) => void,
22
+ enabled = advisorScoutEnabledRef,
23
+ runScout: typeof runAdvisorScout = runAdvisorScout,
24
+ currentInvocationId?: string,
25
+ maxChars?: number
26
+ ): Promise<{
27
+ conversation: string;
28
+ scout?: Exclude<ScoutOutcome, { cancelled: true }>;
29
+ }> => {
30
+ if (!enabled) {
31
+ return { conversation: legacyConversation };
32
+ }
33
+ if (maxChars !== undefined && maxChars <= 0) {
34
+ return { conversation: "" };
35
+ }
36
+ const built = buildScoutManifest(ctx, {
37
+ currentInvocationId,
38
+ maxConversationChars: maxChars,
39
+ maxManifestBytes: SCOUT_MANIFEST_MAX_BYTES,
40
+ });
41
+ if (!built.ok) {
42
+ const scout: Exclude<ScoutOutcome, { cancelled: true }> = {
43
+ category: built.reason,
44
+ message: built.message,
45
+ metrics: {
46
+ availableCount: 0,
47
+ inputBytes: 0,
48
+ latencyMs: 0,
49
+ omittedBeforeScout: 0,
50
+ selectedCount: 0,
51
+ },
52
+ model: executorRef,
53
+ ok: false,
54
+ };
55
+ onScout?.({ outcome: scout, type: "fallback" });
56
+ return { conversation: legacyConversation, scout };
57
+ }
58
+ const outcome = await runScout(
59
+ ctx,
60
+ built.manifest,
61
+ signal,
62
+ onScout,
63
+ undefined,
64
+ undefined
65
+ );
66
+ if (!outcome.ok && outcome.cancelled) {
67
+ throw signal?.reason instanceof Error
68
+ ? signal.reason
69
+ : new Error("Advisor operation cancelled during Scout.");
70
+ }
71
+ let conversation = legacyConversation;
72
+ if (outcome.ok) {
73
+ conversation =
74
+ maxChars === undefined
75
+ ? outcome.conversation
76
+ : reconstructScoutConversation(
77
+ built.manifest,
78
+ outcome.selection.selectedIds,
79
+ outcome.selection.synthesis,
80
+ maxChars
81
+ );
82
+ }
83
+ return { conversation, scout: outcome };
84
+ };
@@ -0,0 +1,485 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { AdvisorToolPolicies } from "./config/types.ts";
3
+ import {
4
+ byteLength,
5
+ contentParts,
6
+ isRecord,
7
+ type RecordValue,
8
+ } from "./content-utils.ts";
9
+ import { conversationEntry } from "./conversation.ts";
10
+ import { invalid, toolCallId, toolCalls } from "./scout-protocol.ts";
11
+ import {
12
+ type InvalidProtocolResult,
13
+ SCOUT_LABEL_MAX_CHARS,
14
+ type ScoutContextGroup,
15
+ type ScoutGroupKind,
16
+ } from "./scout-types.ts";
17
+
18
+ export interface GroupPass {
19
+ groups: ScoutContextGroup[];
20
+ ok: true;
21
+ protocolOmittedBytes: number;
22
+ protocolOmittedCount: number;
23
+ }
24
+
25
+ const SPEAKER_PREFIX = /^(User|Executor):\s*/;
26
+
27
+ const boundedLabel = (value: string) =>
28
+ [...value.replace(/\s+/g, " ").trim()]
29
+ .slice(0, SCOUT_LABEL_MAX_CHARS)
30
+ .join("");
31
+
32
+ const labelFor = (kind: ScoutGroupKind, content: string) => {
33
+ const preview = boundedLabel(content.replace(SPEAKER_PREFIX, ""));
34
+ const prefix: Record<ScoutGroupKind, string> = {
35
+ assistant: "Executor",
36
+ compaction: "Compaction summary",
37
+ "pending-invocation": "Current Advisor invocation",
38
+ "tool-exchange": "Tool exchange",
39
+ user: "User request",
40
+ };
41
+ return boundedLabel(`${prefix[kind]}: ${preview || "(no text)"}`);
42
+ };
43
+
44
+ const stableId = (
45
+ index: number,
46
+ entryIds: string[],
47
+ kind: ScoutGroupKind,
48
+ content: string
49
+ ) =>
50
+ `g_${createHash("sha256")
51
+ .update(JSON.stringify([index, entryIds, kind, content]))
52
+ .digest("hex")
53
+ .slice(0, 16)}`;
54
+
55
+ /** The wire projection shared by manifest transport and size accounting. */
56
+ export const groupWire = (group: ScoutContextGroup) => ({
57
+ bytes: group.bytes,
58
+ content: group.content,
59
+ id: group.id,
60
+ kind: group.kind,
61
+ label: group.label,
62
+ required: group.required,
63
+ });
64
+
65
+ export const groupWireBytes = (group: ScoutContextGroup) =>
66
+ byteLength(JSON.stringify(groupWire(group)));
67
+
68
+ const createGroup = (
69
+ originalIndex: number,
70
+ entryIds: string[],
71
+ kind: ScoutGroupKind,
72
+ content: string,
73
+ required: boolean
74
+ ): ScoutContextGroup => ({
75
+ bytes: byteLength(content),
76
+ content,
77
+ id: stableId(originalIndex, entryIds, kind, content),
78
+ kind,
79
+ label: labelFor(kind, content),
80
+ originalIndex,
81
+ required,
82
+ });
83
+
84
+ const pendingAdvisorArguments = (value: unknown): RecordValue => {
85
+ if (!isRecord(value)) {
86
+ return {};
87
+ }
88
+ const allowed: RecordValue = {};
89
+ for (const key of ["gitContext", "question"]) {
90
+ if (key in value) {
91
+ allowed[key] = value[key];
92
+ }
93
+ }
94
+ return allowed;
95
+ };
96
+
97
+ const pendingInvocationDisclosure = (
98
+ entry: RecordValue,
99
+ invocationId: string,
100
+ toolResultMaxLines: number,
101
+ toolResultMaxBytes: number,
102
+ policies: AdvisorToolPolicies,
103
+ redact: boolean,
104
+ disclosed: string
105
+ ) => {
106
+ if (!isRecord(entry.message)) {
107
+ return disclosed;
108
+ }
109
+ const content = contentParts(entry.message.content).map((part) => {
110
+ if (
111
+ !isRecord(part) ||
112
+ part.type !== "toolCall" ||
113
+ toolCallId(part) !== invocationId ||
114
+ part.name !== "ask_advisor"
115
+ ) {
116
+ return part;
117
+ }
118
+ return { ...part, arguments: pendingAdvisorArguments(part.arguments) };
119
+ });
120
+ return conversationEntry(
121
+ { ...entry, message: { ...entry.message, content } },
122
+ toolResultMaxLines,
123
+ toolResultMaxBytes,
124
+ policies,
125
+ redact
126
+ );
127
+ };
128
+
129
+ export interface DisclosureCaps {
130
+ currentInvocationId?: string;
131
+ policies: AdvisorToolPolicies;
132
+ redact: boolean;
133
+ toolResultMaxBytes: number;
134
+ toolResultMaxLines: number;
135
+ }
136
+
137
+ type ToolExchangeOutcome =
138
+ | { kind: "group"; group: ScoutContextGroup }
139
+ | { kind: "omitted"; bytes: number }
140
+ | { kind: "invalid"; message: string };
141
+
142
+ /** Groups an assistant message with its tool calls and matched results into
143
+ * one indivisible tool-exchange (or pending-invocation) group. */
144
+ const toolExchangeGroup = (
145
+ entry: RecordValue,
146
+ index: number,
147
+ entryId: string,
148
+ disclosed: string,
149
+ immediate: unknown,
150
+ indexed: {
151
+ callOwners: Map<string, { index: number; name: string }>;
152
+ resultsByCall: Map<string, Array<{ entry: RecordValue; index: number }>>;
153
+ },
154
+ consumedResultIndexes: Set<number>,
155
+ caps: DisclosureCaps
156
+ ): ToolExchangeOutcome => {
157
+ const calls = toolCalls(entry.message as RecordValue);
158
+ const callIds = calls.map(toolCallId) as string[];
159
+ const adjacentFailure = adjacentResultMismatch(
160
+ immediate,
161
+ index,
162
+ callIds,
163
+ indexed.callOwners
164
+ );
165
+ if (adjacentFailure) {
166
+ return { kind: "invalid", message: adjacentFailure };
167
+ }
168
+ const missing = new Set<string>();
169
+ const resultParts: string[] = [];
170
+ const resultEntryIds: string[] = [];
171
+ const matchFailure = collectResults(
172
+ calls,
173
+ callIds,
174
+ index,
175
+ indexed.resultsByCall,
176
+ consumedResultIndexes,
177
+ caps,
178
+ missing,
179
+ resultParts,
180
+ resultEntryIds
181
+ );
182
+ if (matchFailure) {
183
+ return { kind: "invalid", message: matchFailure };
184
+ }
185
+ if (missing.size > 0) {
186
+ return missingOutcome(
187
+ entry,
188
+ index,
189
+ entryId,
190
+ disclosed,
191
+ callIds,
192
+ missing,
193
+ resultParts,
194
+ resultEntryIds,
195
+ caps
196
+ );
197
+ }
198
+ return {
199
+ group: createGroup(
200
+ index,
201
+ [entryId, ...resultEntryIds],
202
+ "tool-exchange",
203
+ [disclosed, ...resultParts].join("\n\n"),
204
+ false
205
+ ),
206
+ kind: "group",
207
+ };
208
+ };
209
+
210
+ const adjacentResultMismatch = (
211
+ immediate: unknown,
212
+ index: number,
213
+ callIds: string[],
214
+ callOwners: Map<string, { index: number; name: string }>
215
+ ): string | undefined => {
216
+ const next = immediate as RecordValue | undefined;
217
+ if (
218
+ next?.type === "message" &&
219
+ isRecord(next.message) &&
220
+ next.message.role === "toolResult" &&
221
+ typeof next.message.toolCallId === "string" &&
222
+ !callIds.includes(next.message.toolCallId) &&
223
+ !callOwners.has(next.message.toolCallId)
224
+ ) {
225
+ return `Tool result at context entry ${index + 1} does not match its adjacent assistant group.`;
226
+ }
227
+ return undefined;
228
+ };
229
+
230
+ const collectResults = (
231
+ calls: RecordValue[],
232
+ callIds: string[],
233
+ index: number,
234
+ resultsByCall: Map<string, Array<{ entry: RecordValue; index: number }>>,
235
+ consumedResultIndexes: Set<number>,
236
+ caps: DisclosureCaps,
237
+ missing: Set<string>,
238
+ resultParts: string[],
239
+ resultEntryIds: string[]
240
+ ): string | undefined => {
241
+ for (const [callIndex, callId] of callIds.entries()) {
242
+ const resultMatch = resultsByCall.get(callId)?.[0];
243
+ if (!resultMatch) {
244
+ missing.add(callId);
245
+ continue;
246
+ }
247
+ if (resultMatch.index <= index) {
248
+ return `Tool result at context entry ${resultMatch.index} precedes its assistant call.`;
249
+ }
250
+ const resultMessage = resultMatch.entry.message;
251
+ const expectedName =
252
+ typeof calls[callIndex].name === "string"
253
+ ? calls[callIndex].name
254
+ : "unknown";
255
+ if (!isRecord(resultMessage) || resultMessage.toolName !== expectedName) {
256
+ return `Tool result at context entry ${resultMatch.index} conflicts with call ${callId}.`;
257
+ }
258
+ consumedResultIndexes.add(resultMatch.index);
259
+ const resultText = conversationEntry(
260
+ resultMatch.entry,
261
+ caps.toolResultMaxLines,
262
+ caps.toolResultMaxBytes,
263
+ caps.policies,
264
+ caps.redact
265
+ );
266
+ if (resultText) {
267
+ resultParts.push(resultText);
268
+ }
269
+ resultEntryIds.push(
270
+ typeof resultMatch.entry.id === "string"
271
+ ? resultMatch.entry.id
272
+ : String(resultMatch.index)
273
+ );
274
+ }
275
+ return undefined;
276
+ };
277
+
278
+ const missingOutcome = (
279
+ entry: RecordValue,
280
+ index: number,
281
+ entryId: string,
282
+ disclosed: string,
283
+ callIds: string[],
284
+ missing: Set<string>,
285
+ resultParts: string[],
286
+ resultEntryIds: string[],
287
+ caps: DisclosureCaps
288
+ ): ToolExchangeOutcome => {
289
+ const { currentInvocationId } = caps;
290
+ const pendingCurrentInvocation =
291
+ currentInvocationId !== undefined &&
292
+ missing.size === 1 &&
293
+ missing.has(currentInvocationId) &&
294
+ callIds.includes(currentInvocationId);
295
+ if (!pendingCurrentInvocation) {
296
+ return {
297
+ bytes: byteLength([disclosed, ...resultParts].join("\n\n")),
298
+ kind: "omitted",
299
+ };
300
+ }
301
+ const pendingDisclosed = pendingInvocationDisclosure(
302
+ entry,
303
+ currentInvocationId,
304
+ caps.toolResultMaxLines,
305
+ caps.toolResultMaxBytes,
306
+ caps.policies,
307
+ caps.redact,
308
+ disclosed
309
+ );
310
+ return {
311
+ group: createGroup(
312
+ index,
313
+ [entryId, ...resultEntryIds],
314
+ "pending-invocation",
315
+ [pendingDisclosed ?? disclosed, ...resultParts].join("\n\n"),
316
+ true
317
+ ),
318
+ kind: "group",
319
+ };
320
+ };
321
+
322
+ /** Second entry pass: groups each disclosed entry, delegating
323
+ * assistant-with-calls entries to the tool-exchange builder. */
324
+ export const buildGroups = (
325
+ entries: unknown[],
326
+ indexed: {
327
+ callOwners: Map<string, { index: number; name: string }>;
328
+ latestUserIndex: number;
329
+ resultsByCall: Map<string, Array<{ entry: RecordValue; index: number }>>;
330
+ },
331
+ caps: DisclosureCaps & {
332
+ maxGroupBytes: number;
333
+ }
334
+ ): GroupPass | InvalidProtocolResult => {
335
+ const groups: ScoutContextGroup[] = [];
336
+ const consumedResultIndexes = new Set<number>();
337
+ let protocolOmittedBytes = 0;
338
+ let protocolOmittedCount = 0;
339
+ for (let index = 0; index < entries.length; index += 1) {
340
+ const entry = entries[index] as unknown as RecordValue;
341
+ const disclosed = conversationEntry(
342
+ entry,
343
+ caps.toolResultMaxLines,
344
+ caps.toolResultMaxBytes,
345
+ caps.policies,
346
+ caps.redact
347
+ );
348
+ if (!disclosed) {
349
+ continue;
350
+ }
351
+ const outcome = groupForEntry(
352
+ entry,
353
+ index,
354
+ disclosed,
355
+ entries[index + 1],
356
+ indexed,
357
+ consumedResultIndexes,
358
+ caps
359
+ );
360
+ if (outcome.kind === "invalid") {
361
+ return invalid(outcome.message);
362
+ }
363
+ if (outcome.kind === "omitted") {
364
+ protocolOmittedCount += 1;
365
+ protocolOmittedBytes += outcome.bytes;
366
+ } else if (outcome.kind === "group") {
367
+ groups.push(outcome.group);
368
+ }
369
+ }
370
+ return {
371
+ groups,
372
+ ok: true,
373
+ protocolOmittedBytes,
374
+ protocolOmittedCount,
375
+ };
376
+ };
377
+
378
+ type EntryOutcome =
379
+ | { kind: "group"; group: ScoutContextGroup }
380
+ | { kind: "omitted"; bytes: number }
381
+ | { kind: "invalid"; message: string }
382
+ | { kind: "skipped" };
383
+
384
+ const groupForEntry = (
385
+ entry: RecordValue,
386
+ index: number,
387
+ disclosed: string,
388
+ immediate: unknown,
389
+ indexed: {
390
+ callOwners: Map<string, { index: number; name: string }>;
391
+ latestUserIndex: number;
392
+ resultsByCall: Map<string, Array<{ entry: RecordValue; index: number }>>;
393
+ },
394
+ consumedResultIndexes: Set<number>,
395
+ caps: DisclosureCaps
396
+ ): EntryOutcome => {
397
+ const entryId = typeof entry.id === "string" ? entry.id : String(index);
398
+ if (entry.type !== "message" || !isRecord(entry.message)) {
399
+ return {
400
+ group: createGroup(index, [entryId], "compaction", disclosed, false),
401
+ kind: "group",
402
+ };
403
+ }
404
+ const { message } = entry;
405
+ if (message.role === "user") {
406
+ return {
407
+ group: createGroup(
408
+ index,
409
+ [entryId],
410
+ "user",
411
+ disclosed,
412
+ index === indexed.latestUserIndex
413
+ ),
414
+ kind: "group",
415
+ };
416
+ }
417
+ if (message.role === "toolResult") {
418
+ return toolResultOutcome(
419
+ message,
420
+ index,
421
+ disclosed,
422
+ indexed.callOwners,
423
+ consumedResultIndexes
424
+ );
425
+ }
426
+ if (message.role !== "assistant") {
427
+ return { kind: "skipped" };
428
+ }
429
+ if (!hasCalls(message)) {
430
+ return {
431
+ group: createGroup(index, [entryId], "assistant", disclosed, false),
432
+ kind: "group",
433
+ };
434
+ }
435
+ return toolExchangeGroup(
436
+ entry,
437
+ index,
438
+ entryId,
439
+ disclosed,
440
+ immediate,
441
+ indexed,
442
+ consumedResultIndexes,
443
+ caps
444
+ );
445
+ };
446
+
447
+ const toolResultOutcome = (
448
+ message: RecordValue,
449
+ index: number,
450
+ disclosed: string,
451
+ callOwners: Map<string, { index: number; name: string }>,
452
+ consumedResultIndexes: Set<number>
453
+ ): EntryOutcome => {
454
+ if (consumedResultIndexes.has(index)) {
455
+ return { kind: "skipped" };
456
+ }
457
+ if (ownerOf(message, callOwners)) {
458
+ return {
459
+ kind: "invalid",
460
+ message: `Tool result at context entry ${index} precedes or conflicts with its retained call.`,
461
+ };
462
+ }
463
+ // Retained results without their call are unavailable optional evidence
464
+ // and are never offered to Scout.
465
+ return { bytes: byteLength(disclosed), kind: "omitted" };
466
+ };
467
+
468
+ const ownerOf = (
469
+ message: RecordValue,
470
+ callOwners: Map<string, { index: number; name: string }>
471
+ ) =>
472
+ typeof message.toolCallId === "string"
473
+ ? callOwners.get(message.toolCallId)
474
+ : undefined;
475
+
476
+ const hasCalls = (message: RecordValue) =>
477
+ contentPartsOf(message).some(
478
+ (part) => isRecord(part) && part.type === "toolCall"
479
+ );
480
+
481
+ const contentPartsOf = (message: RecordValue) =>
482
+ Array.isArray(message.content) ? message.content : [];
483
+
484
+ /** Final pass: selects groups within the transport and conversation budgets,
485
+ * keeping every required group. */
@@ -0,0 +1,122 @@
1
+ import { contentParts, isRecord, type RecordValue } from "./content-utils.ts";
2
+ import { textFrom } from "./conversation.ts";
3
+ import type { InvalidProtocolResult } from "./scout-types.ts";
4
+
5
+ /** The single invalid-protocol failure constructor. */
6
+ export const invalid = (message: string): InvalidProtocolResult => ({
7
+ message,
8
+ ok: false,
9
+ reason: "invalid-protocol",
10
+ });
11
+
12
+ export interface ToolCallIndex {
13
+ callOwners: Map<string, { index: number; name: string }>;
14
+ latestUserIndex: number;
15
+ resultsByCall: Map<string, Array<{ entry: RecordValue; index: number }>>;
16
+ }
17
+
18
+ export const toolCalls = (message: RecordValue) =>
19
+ contentParts(message.content).filter(
20
+ (part): part is RecordValue => isRecord(part) && part.type === "toolCall"
21
+ );
22
+
23
+ export const toolCallId = (part: RecordValue) =>
24
+ typeof part.id === "string" ? part.id : undefined;
25
+
26
+ /** First entry pass: indexes assistant tool calls and their results, tracking
27
+ * the latest user message. Returns an invalid result on protocol violations. */
28
+ export const indexToolCalls = (
29
+ entries: unknown[]
30
+ ): { index: ToolCallIndex; ok: true } | InvalidProtocolResult => {
31
+ const callOwners = new Map<string, { index: number; name: string }>();
32
+ const resultsByCall = new Map<
33
+ string,
34
+ Array<{ entry: RecordValue; index: number }>
35
+ >();
36
+ const state = { latestUserIndex: -1 };
37
+ for (let index = 0; index < entries.length; index += 1) {
38
+ const failure = indexEntry(
39
+ entries[index] as unknown as RecordValue,
40
+ index,
41
+ callOwners,
42
+ resultsByCall,
43
+ state
44
+ );
45
+ if (failure) {
46
+ return failure;
47
+ }
48
+ }
49
+ for (const [id, results] of resultsByCall) {
50
+ if (results.length > 1) {
51
+ return invalid(`Tool call ${id} has duplicate result messages.`);
52
+ }
53
+ }
54
+ return {
55
+ index: {
56
+ callOwners,
57
+ latestUserIndex: state.latestUserIndex,
58
+ resultsByCall,
59
+ },
60
+ ok: true,
61
+ };
62
+ };
63
+
64
+ const indexEntry = (
65
+ entry: RecordValue,
66
+ index: number,
67
+ callOwners: Map<string, { index: number; name: string }>,
68
+ resultsByCall: Map<string, Array<{ entry: RecordValue; index: number }>>,
69
+ state: { latestUserIndex: number }
70
+ ): InvalidProtocolResult | undefined => {
71
+ if (entry.type !== "message" || !isRecord(entry.message)) {
72
+ return undefined;
73
+ }
74
+ if (entry.message.role === "user" && textFrom(entry.message.content)) {
75
+ state.latestUserIndex = index;
76
+ }
77
+ if (entry.message.role === "assistant") {
78
+ return indexAssistantCalls(entry.message, index, callOwners);
79
+ }
80
+ if (entry.message.role === "toolResult") {
81
+ return indexToolResult(entry, entry.message, index, resultsByCall);
82
+ }
83
+ return undefined;
84
+ };
85
+
86
+ const indexToolResult = (
87
+ entry: RecordValue,
88
+ message: RecordValue,
89
+ index: number,
90
+ resultsByCall: Map<string, Array<{ entry: RecordValue; index: number }>>
91
+ ): InvalidProtocolResult | undefined => {
92
+ const id = message.toolCallId;
93
+ if (typeof id !== "string") {
94
+ return invalid(
95
+ `Tool result at context entry ${index} has no tool-call ID.`
96
+ );
97
+ }
98
+ const results = resultsByCall.get(id) ?? [];
99
+ results.push({ entry, index });
100
+ resultsByCall.set(id, results);
101
+ return undefined;
102
+ };
103
+
104
+ const indexAssistantCalls = (
105
+ message: RecordValue,
106
+ index: number,
107
+ callOwners: Map<string, { index: number; name: string }>
108
+ ): InvalidProtocolResult | undefined => {
109
+ for (const call of toolCalls(message)) {
110
+ const id = toolCallId(call);
111
+ if (!id || callOwners.has(id)) {
112
+ return invalid(
113
+ `Assistant tool calls at context entry ${index} have missing or duplicate IDs.`
114
+ );
115
+ }
116
+ callOwners.set(id, {
117
+ index,
118
+ name: typeof call.name === "string" ? call.name : "unknown",
119
+ });
120
+ }
121
+ return undefined;
122
+ };