pi-advisor-flow 0.5.5 → 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.
- package/CHANGELOG.md +28 -1
- package/README.md +1 -1
- package/dist/index.js +5786 -0
- package/extensions/index.ts +9 -9
- package/package.json +16 -10
- package/src/{untracked.ts → attachments.ts} +2 -5
- package/src/commands/activation-preparation.ts +6 -6
- package/src/commands/activation.ts +7 -7
- package/src/commands/lifecycle.ts +5 -5
- package/src/commands/manual-command.ts +13 -9
- package/src/commands/manual-consultation.ts +8 -8
- package/src/commands/manual-progress.ts +3 -3
- package/src/commands/model-commands.ts +6 -6
- package/src/commands/model-options.ts +4 -4
- package/src/commands/model-picker.ts +2 -2
- package/src/commands/registration.ts +8 -8
- package/src/commands/renderers.ts +5 -5
- package/src/commands/runtime.ts +8 -8
- package/src/commands/settings-commands.ts +8 -8
- package/src/commands/settings-persistence.ts +4 -4
- package/src/commands/types.ts +5 -13
- package/src/commands.ts +2 -2
- package/src/config/args.ts +3 -3
- package/src/config/defaults.ts +3 -3
- package/src/config/schema.ts +322 -0
- package/src/config/state.ts +2 -2
- package/src/config/storage.ts +113 -103
- package/src/config/types.ts +1 -1
- package/src/config/validation.ts +61 -181
- package/src/config.ts +7 -7
- package/src/content-utils.ts +30 -0
- package/src/conversation.ts +13 -168
- package/src/git.ts +4 -18
- package/src/herdr.ts +6 -8
- package/src/model-stream.ts +5 -8
- package/src/outcomes.ts +2 -5
- package/src/pi-settings.ts +59 -0
- package/src/preferences.ts +2 -2
- package/src/redaction.ts +50 -0
- package/src/scout-context.ts +121 -485
- package/src/scout-curation.ts +84 -0
- package/src/scout-groups.ts +485 -0
- package/src/scout-protocol.ts +122 -0
- package/src/scout-reconstruct.ts +44 -0
- package/src/scout-types.ts +66 -0
- package/src/scout.ts +53 -63
- package/src/session-state.ts +119 -76
- package/src/tool-result-cap.ts +88 -0
- package/src/tools/consult-context.ts +155 -0
- package/src/tools/consultation.ts +71 -220
- package/src/tools/gate-policy.ts +5 -5
- package/src/tools/gate-protocol.ts +2 -2
- package/src/tools/loop-gate.ts +70 -61
- package/src/tools/prompts.ts +4 -7
- package/src/tools/register-ask-advisor.ts +9 -14
- package/src/tools/register-lifecycle.ts +7 -7
- package/src/tools/register-outcome.ts +5 -5
- package/src/tools/register-renderers.ts +5 -5
- package/src/tools/registration.ts +10 -10
- package/src/tools/render-advisor-result.ts +11 -8
- package/src/tools/render-common.ts +25 -13
- package/src/tools/scout-status.ts +5 -5
- package/src/tools/session.ts +1 -1
- package/src/tools/types.ts +7 -7
- package/src/tools.ts +9 -9
- package/src/ui/manual-dialog-render.ts +10 -3
- package/src/ui/manual-dialog.ts +4 -9
- package/src/ui/model-selector.ts +7 -6
- package/src/ui/settings-formatting.ts +2 -2
- package/src/ui/settings-items.ts +4 -4
- package/src/ui/settings-mutations.ts +2 -2
- package/src/ui/settings-selector.ts +5 -5
- package/src/ui/text-setting-submenu.ts +6 -1
- package/src/ui/types.ts +2 -2
- package/src/ui.ts +4 -4
- 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
|
+
};
|