@martian-engineering/lossless-claw 0.8.0 → 0.8.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/README.md +8 -0
- package/dist/index.js +19240 -0
- package/docs/configuration.md +15 -5
- package/openclaw.plugin.json +27 -3
- package/package.json +7 -6
- package/skills/lossless-claw/references/config.md +37 -0
- package/index.ts +0 -2
- package/src/assembler.ts +0 -1196
- package/src/compaction.ts +0 -1753
- package/src/db/config.ts +0 -345
- package/src/db/connection.ts +0 -151
- package/src/db/features.ts +0 -61
- package/src/db/migration.ts +0 -868
- package/src/engine.ts +0 -4486
- package/src/estimate-tokens.ts +0 -80
- package/src/expansion-auth.ts +0 -365
- package/src/expansion-policy.ts +0 -303
- package/src/expansion.ts +0 -383
- package/src/integrity.ts +0 -600
- package/src/large-files.ts +0 -546
- package/src/lcm-log.ts +0 -37
- package/src/openclaw-bridge.ts +0 -22
- package/src/plugin/index.ts +0 -2037
- package/src/plugin/lcm-command.ts +0 -1040
- package/src/plugin/lcm-doctor-apply.ts +0 -540
- package/src/plugin/lcm-doctor-cleaners.ts +0 -655
- package/src/plugin/lcm-doctor-shared.ts +0 -210
- package/src/plugin/shared-init.ts +0 -59
- package/src/prune.ts +0 -391
- package/src/retrieval.ts +0 -360
- package/src/session-patterns.ts +0 -23
- package/src/startup-banner-log.ts +0 -49
- package/src/store/compaction-telemetry-store.ts +0 -156
- package/src/store/conversation-store.ts +0 -929
- package/src/store/fts5-sanitize.ts +0 -50
- package/src/store/full-text-fallback.ts +0 -83
- package/src/store/full-text-sort.ts +0 -21
- package/src/store/index.ts +0 -39
- package/src/store/parse-utc-timestamp.ts +0 -25
- package/src/store/summary-store.ts +0 -1519
- package/src/summarize.ts +0 -1508
- package/src/tools/common.ts +0 -53
- package/src/tools/lcm-conversation-scope.ts +0 -127
- package/src/tools/lcm-describe-tool.ts +0 -245
- package/src/tools/lcm-expand-query-tool.ts +0 -1235
- package/src/tools/lcm-expand-tool.delegation.ts +0 -580
- package/src/tools/lcm-expand-tool.ts +0 -453
- package/src/tools/lcm-expansion-recursion-guard.ts +0 -373
- package/src/tools/lcm-grep-tool.ts +0 -228
- package/src/transaction-mutex.ts +0 -136
- package/src/transcript-repair.ts +0 -301
- package/src/types.ts +0 -165
package/src/assembler.ts
DELETED
|
@@ -1,1196 +0,0 @@
|
|
|
1
|
-
import type { ContextEngine } from "openclaw/plugin-sdk";
|
|
2
|
-
import { sanitizeToolUseResultPairing } from "./transcript-repair.js";
|
|
3
|
-
import type {
|
|
4
|
-
ConversationStore,
|
|
5
|
-
MessagePartRecord,
|
|
6
|
-
MessageRole,
|
|
7
|
-
} from "./store/conversation-store.js";
|
|
8
|
-
import type { SummaryStore, ContextItemRecord, SummaryRecord } from "./store/summary-store.js";
|
|
9
|
-
import { estimateTokens } from "./estimate-tokens.js";
|
|
10
|
-
|
|
11
|
-
type AgentMessage = Parameters<ContextEngine["ingest"]>[0]["message"];
|
|
12
|
-
|
|
13
|
-
const TOOL_CALL_TYPES = new Set([
|
|
14
|
-
"toolCall",
|
|
15
|
-
"toolUse",
|
|
16
|
-
"tool_use",
|
|
17
|
-
"tool-use",
|
|
18
|
-
"functionCall",
|
|
19
|
-
"function_call",
|
|
20
|
-
]);
|
|
21
|
-
|
|
22
|
-
// ── Public types ─────────────────────────────────────────────────────────────
|
|
23
|
-
|
|
24
|
-
export interface AssembleContextInput {
|
|
25
|
-
conversationId: number;
|
|
26
|
-
tokenBudget: number;
|
|
27
|
-
/** Number of most recent raw turns to always include (default: 8) */
|
|
28
|
-
freshTailCount?: number;
|
|
29
|
-
/** Optional user query for relevance-based eviction scoring (BM25-lite). When absent or unsearchable, falls back to chronological eviction. */
|
|
30
|
-
prompt?: string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface AssembleContextResult {
|
|
34
|
-
/** Ordered messages ready for the model */
|
|
35
|
-
messages: AgentMessage[];
|
|
36
|
-
/** Total estimated tokens */
|
|
37
|
-
estimatedTokens: number;
|
|
38
|
-
/** Optional dynamic system prompt guidance derived from DAG state */
|
|
39
|
-
systemPromptAddition?: string;
|
|
40
|
-
/** Stats about what was assembled */
|
|
41
|
-
stats: {
|
|
42
|
-
rawMessageCount: number;
|
|
43
|
-
summaryCount: number;
|
|
44
|
-
totalContextItems: number;
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
type SummaryPromptSignal = Pick<SummaryRecord, "kind" | "depth" | "descendantCount">;
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Build dynamic prompt guidance for compacted session context.
|
|
55
|
-
*
|
|
56
|
-
* Guidance is emitted only when summaries are present in assembled context.
|
|
57
|
-
* Static recall policy lives in the plugin prompt hook so this addition
|
|
58
|
-
* remains session-specific and reflects only the current compaction state.
|
|
59
|
-
*/
|
|
60
|
-
function buildSystemPromptAddition(summarySignals: SummaryPromptSignal[]): string | undefined {
|
|
61
|
-
if (summarySignals.length === 0) {
|
|
62
|
-
return undefined;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const maxDepth = summarySignals.reduce((deepest, signal) => Math.max(deepest, signal.depth), 0);
|
|
66
|
-
const condensedCount = summarySignals.filter((signal) => signal.kind === "condensed").length;
|
|
67
|
-
const heavilyCompacted = maxDepth >= 2 || condensedCount >= 2;
|
|
68
|
-
|
|
69
|
-
const sections: string[] = [];
|
|
70
|
-
|
|
71
|
-
// Dynamic compaction reminder — always present when summaries exist.
|
|
72
|
-
sections.push(
|
|
73
|
-
"## Compacted Conversation Context",
|
|
74
|
-
"",
|
|
75
|
-
"Summaries above are compressed context, not full detail.",
|
|
76
|
-
"",
|
|
77
|
-
"Treat summaries as compressed recall cues rather than proof of exact wording or exact values.",
|
|
78
|
-
"",
|
|
79
|
-
"If a summary includes an \"Expand for details about:\" footer, use it as a cue to expand before asserting specifics.",
|
|
80
|
-
);
|
|
81
|
-
|
|
82
|
-
// Precision/evidence rules — always present but stronger when heavily compacted.
|
|
83
|
-
if (heavilyCompacted) {
|
|
84
|
-
sections.push(
|
|
85
|
-
"",
|
|
86
|
-
"**Deeply compacted context: expand before asserting specifics.**",
|
|
87
|
-
"",
|
|
88
|
-
"Before answering with exact commands, SHAs, paths, timestamps, config values, or causal chains, expand for the missing detail.",
|
|
89
|
-
"",
|
|
90
|
-
"Default recall flow for precision work:",
|
|
91
|
-
"1) `lcm_grep` to locate relevant summary/message IDs",
|
|
92
|
-
"2) `lcm_expand_query` with a focused prompt",
|
|
93
|
-
"3) Answer directly from the retrieved evidence",
|
|
94
|
-
"",
|
|
95
|
-
"Keep raw summary IDs in tool context for follow-up; do not include them in the user-facing answer unless the user asks for sources or IDs.",
|
|
96
|
-
"",
|
|
97
|
-
"`lcm_grep` tips: prefer `mode: \"full_text\"` for keyword/topic lookup, quote exact multi-word phrases, use `sort: \"relevance\"` for older-topic retrieval, and use `sort: \"hybrid\"` when recency should still influence ranking.",
|
|
98
|
-
"`lcm_expand_query(query: ...)` uses the same FTS5 full-text search rules as `lcm_grep`: terms are ANDed by default, so extra query words narrow results. Keep `query` to 1-3 distinctive terms or a quoted phrase, and put the natural-language question in `prompt`.",
|
|
99
|
-
"",
|
|
100
|
-
"**Uncertainty checklist (run before answering):**",
|
|
101
|
-
"- Am I making an exact factual claim from a compressed or condensed summary?",
|
|
102
|
-
"- Could compaction have omitted a crucial detail?",
|
|
103
|
-
"- Would I need an expansion step if the user asks for proof or the exact text?",
|
|
104
|
-
"- Should I state uncertainty instead of asserting specifics until I expand?",
|
|
105
|
-
"",
|
|
106
|
-
"If yes to any item, expand first or explicitly say that you need to expand.",
|
|
107
|
-
"",
|
|
108
|
-
"Do not guess exact commands, SHAs, file paths, timestamps, config values, or causal claims from condensed summaries. Expand first or explicitly say that you need to expand.",
|
|
109
|
-
);
|
|
110
|
-
} else {
|
|
111
|
-
sections.push(
|
|
112
|
-
"",
|
|
113
|
-
"For exact commands, SHAs, paths, timestamps, config values, or causal chains, expand for details before answering.",
|
|
114
|
-
"State uncertainty instead of guessing from compressed summaries.",
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
return sections.join("\n");
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Map a DB message role to an AgentMessage role.
|
|
123
|
-
*
|
|
124
|
-
* user -> user
|
|
125
|
-
* assistant -> assistant
|
|
126
|
-
* system -> user (system prompts presented as user messages)
|
|
127
|
-
* tool -> assistant (tool results are part of assistant turns)
|
|
128
|
-
*/
|
|
129
|
-
function parseJson(value: string | null): unknown {
|
|
130
|
-
if (typeof value !== "string" || !value.trim()) {
|
|
131
|
-
return undefined;
|
|
132
|
-
}
|
|
133
|
-
try {
|
|
134
|
-
return JSON.parse(value);
|
|
135
|
-
} catch {
|
|
136
|
-
return undefined;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function getOriginalRole(parts: MessagePartRecord[]): string | null {
|
|
141
|
-
for (const part of parts) {
|
|
142
|
-
const decoded = parseJson(part.metadata);
|
|
143
|
-
if (!decoded || typeof decoded !== "object") {
|
|
144
|
-
continue;
|
|
145
|
-
}
|
|
146
|
-
const role = (decoded as { originalRole?: unknown }).originalRole;
|
|
147
|
-
if (typeof role === "string" && role.length > 0) {
|
|
148
|
-
return role;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
return null;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function getPartMetadata(part: MessagePartRecord): {
|
|
155
|
-
originalRole?: string;
|
|
156
|
-
rawType?: string;
|
|
157
|
-
raw?: unknown;
|
|
158
|
-
} {
|
|
159
|
-
const decoded = parseJson(part.metadata);
|
|
160
|
-
if (!decoded || typeof decoded !== "object") {
|
|
161
|
-
return {};
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const record = decoded as {
|
|
165
|
-
originalRole?: unknown;
|
|
166
|
-
rawType?: unknown;
|
|
167
|
-
raw?: unknown;
|
|
168
|
-
};
|
|
169
|
-
return {
|
|
170
|
-
originalRole:
|
|
171
|
-
typeof record.originalRole === "string" && record.originalRole.length > 0
|
|
172
|
-
? record.originalRole
|
|
173
|
-
: undefined,
|
|
174
|
-
rawType:
|
|
175
|
-
typeof record.rawType === "string" && record.rawType.length > 0
|
|
176
|
-
? record.rawType
|
|
177
|
-
: undefined,
|
|
178
|
-
raw: record.raw,
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function parseStoredValue(value: string | null): unknown {
|
|
183
|
-
if (typeof value !== "string" || value.length === 0) {
|
|
184
|
-
return undefined;
|
|
185
|
-
}
|
|
186
|
-
const parsed = parseJson(value);
|
|
187
|
-
return parsed !== undefined ? parsed : value;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function reasoningBlockFromPart(part: MessagePartRecord, rawType?: string): unknown {
|
|
191
|
-
const type = rawType === "thinking" ? "thinking" : "reasoning";
|
|
192
|
-
if (typeof part.textContent === "string" && part.textContent.length > 0) {
|
|
193
|
-
return type === "thinking"
|
|
194
|
-
? { type, thinking: part.textContent }
|
|
195
|
-
: { type, text: part.textContent };
|
|
196
|
-
}
|
|
197
|
-
return { type };
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Detect if a raw block is an OpenClaw-normalised OpenAI reasoning item.
|
|
202
|
-
* OpenClaw converts OpenAI `{type:"reasoning", id:"rs_…", encrypted_content:"…"}`
|
|
203
|
-
* into `{type:"thinking", thinking:"", thinkingSignature:"{…}"}`.
|
|
204
|
-
* When we reassemble for the OpenAI provider we need the original back.
|
|
205
|
-
*/
|
|
206
|
-
function tryRestoreOpenAIReasoning(raw: Record<string, unknown>): Record<string, unknown> | null {
|
|
207
|
-
if (raw.type !== "thinking") return null;
|
|
208
|
-
const sig = raw.thinkingSignature;
|
|
209
|
-
if (typeof sig !== "string" || !sig.startsWith("{")) return null;
|
|
210
|
-
try {
|
|
211
|
-
const parsed = JSON.parse(sig) as Record<string, unknown>;
|
|
212
|
-
if (parsed.type === "reasoning" && typeof parsed.id === "string") {
|
|
213
|
-
return parsed;
|
|
214
|
-
}
|
|
215
|
-
} catch {
|
|
216
|
-
// not valid JSON — leave as-is
|
|
217
|
-
}
|
|
218
|
-
return null;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** @internal Exported for testing only. */
|
|
222
|
-
export function toolCallBlockFromPart(part: MessagePartRecord, rawType?: string): unknown {
|
|
223
|
-
const type =
|
|
224
|
-
rawType === "function_call" ||
|
|
225
|
-
rawType === "functionCall" ||
|
|
226
|
-
rawType === "tool_use" ||
|
|
227
|
-
rawType === "tool-use" ||
|
|
228
|
-
rawType === "toolUse" ||
|
|
229
|
-
rawType === "toolCall"
|
|
230
|
-
? rawType
|
|
231
|
-
: "toolCall";
|
|
232
|
-
const input = parseStoredValue(part.toolInput);
|
|
233
|
-
const block: Record<string, unknown> = { type };
|
|
234
|
-
|
|
235
|
-
if (type === "function_call") {
|
|
236
|
-
if (typeof part.toolCallId === "string" && part.toolCallId.length > 0) {
|
|
237
|
-
block.call_id = part.toolCallId;
|
|
238
|
-
}
|
|
239
|
-
if (typeof part.toolName === "string" && part.toolName.length > 0) {
|
|
240
|
-
block.name = part.toolName;
|
|
241
|
-
}
|
|
242
|
-
if (input !== undefined) {
|
|
243
|
-
block.arguments = input;
|
|
244
|
-
}
|
|
245
|
-
return block;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Always set id — downstream providers (e.g. Anthropic) call
|
|
249
|
-
// normalizeToolCallId(block.id) which crashes on undefined.
|
|
250
|
-
block.id =
|
|
251
|
-
typeof part.toolCallId === "string" && part.toolCallId.length > 0
|
|
252
|
-
? part.toolCallId
|
|
253
|
-
: `toolu_lcm_${part.partId ?? "unknown"}`;
|
|
254
|
-
if (typeof part.toolName === "string" && part.toolName.length > 0) {
|
|
255
|
-
block.name = part.toolName;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
if (input !== undefined) {
|
|
259
|
-
// toolCall and functionCall use "arguments" (consumed by OpenAI/xAI Chat
|
|
260
|
-
// Completions extractToolCalls and Responses API paths in OpenClaw).
|
|
261
|
-
// tool_use and variants use "input" (Anthropic native format).
|
|
262
|
-
if (type === "functionCall" || type === "toolCall") {
|
|
263
|
-
block.arguments = input;
|
|
264
|
-
} else {
|
|
265
|
-
block.input = input;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
return block;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/** @internal Exported for testing only. */
|
|
272
|
-
export function toolResultBlockFromPart(
|
|
273
|
-
part: MessagePartRecord,
|
|
274
|
-
rawType?: string,
|
|
275
|
-
raw?: Record<string, unknown>,
|
|
276
|
-
): unknown {
|
|
277
|
-
if (
|
|
278
|
-
raw &&
|
|
279
|
-
typeof raw.text === "string" &&
|
|
280
|
-
raw.output === undefined &&
|
|
281
|
-
raw.content === undefined &&
|
|
282
|
-
(part.toolOutput == null || part.toolOutput === "") &&
|
|
283
|
-
(part.textContent == null || part.textContent === raw.text)
|
|
284
|
-
) {
|
|
285
|
-
return {
|
|
286
|
-
type: "text",
|
|
287
|
-
text: raw.text,
|
|
288
|
-
};
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
const type =
|
|
292
|
-
rawType === "function_call_output" || rawType === "toolResult" || rawType === "tool_result"
|
|
293
|
-
? rawType
|
|
294
|
-
: "tool_result";
|
|
295
|
-
const output = parseStoredValue(part.toolOutput);
|
|
296
|
-
const block: Record<string, unknown> = { type };
|
|
297
|
-
|
|
298
|
-
if (typeof part.toolName === "string" && part.toolName.length > 0) {
|
|
299
|
-
block.name = part.toolName;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
if (output !== undefined) {
|
|
303
|
-
block.output = output;
|
|
304
|
-
} else if (typeof part.textContent === "string") {
|
|
305
|
-
block.output = part.textContent;
|
|
306
|
-
} else if (raw && raw.output !== undefined) {
|
|
307
|
-
block.output = raw.output;
|
|
308
|
-
} else if (raw && raw.content !== undefined) {
|
|
309
|
-
block.content = raw.content;
|
|
310
|
-
} else {
|
|
311
|
-
block.output = "";
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
if (raw && typeof raw.is_error === "boolean") {
|
|
315
|
-
block.is_error = raw.is_error;
|
|
316
|
-
} else if (raw && typeof raw.isError === "boolean") {
|
|
317
|
-
block.isError = raw.isError;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
if (type === "function_call_output") {
|
|
321
|
-
if (typeof part.toolCallId === "string" && part.toolCallId.length > 0) {
|
|
322
|
-
block.call_id = part.toolCallId;
|
|
323
|
-
}
|
|
324
|
-
return block;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
if (typeof part.toolCallId === "string" && part.toolCallId.length > 0) {
|
|
328
|
-
block.tool_use_id = part.toolCallId;
|
|
329
|
-
}
|
|
330
|
-
return block;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
function toRuntimeRole(
|
|
334
|
-
dbRole: MessageRole,
|
|
335
|
-
parts: MessagePartRecord[],
|
|
336
|
-
): "user" | "assistant" | "toolResult" {
|
|
337
|
-
const originalRole = getOriginalRole(parts);
|
|
338
|
-
if (originalRole === "toolResult") {
|
|
339
|
-
return "toolResult";
|
|
340
|
-
}
|
|
341
|
-
if (originalRole === "assistant") {
|
|
342
|
-
return "assistant";
|
|
343
|
-
}
|
|
344
|
-
if (originalRole === "user") {
|
|
345
|
-
return "user";
|
|
346
|
-
}
|
|
347
|
-
if (originalRole === "system") {
|
|
348
|
-
// Runtime system prompts are managed via setSystemPrompt(), not message history.
|
|
349
|
-
return "user";
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
if (dbRole === "tool") {
|
|
353
|
-
return "toolResult";
|
|
354
|
-
}
|
|
355
|
-
if (dbRole === "assistant") {
|
|
356
|
-
return "assistant";
|
|
357
|
-
}
|
|
358
|
-
return "user"; // user | system
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
/** @internal Exported for testing only. */
|
|
362
|
-
export function blockFromPart(part: MessagePartRecord): unknown {
|
|
363
|
-
const metadata = getPartMetadata(part);
|
|
364
|
-
if (metadata.raw && typeof metadata.raw === "object") {
|
|
365
|
-
// If this is an OpenClaw-normalised OpenAI reasoning block, restore the original
|
|
366
|
-
// OpenAI format so the Responses API gets the {type:"reasoning", id:"rs_…"} it expects.
|
|
367
|
-
const restored = tryRestoreOpenAIReasoning(metadata.raw as Record<string, unknown>);
|
|
368
|
-
if (restored) return restored;
|
|
369
|
-
|
|
370
|
-
// Don't return raw for tool call/result blocks — they need to go through
|
|
371
|
-
// toolCallBlockFromPart/toolResultBlockFromPart which properly normalize
|
|
372
|
-
// arguments (stringify if object) and format for the target provider.
|
|
373
|
-
// Returning raw here causes arguments to be passed as a JS object instead
|
|
374
|
-
// of a JSON string, which breaks xAI/OpenAI Chat Completions API (422).
|
|
375
|
-
const rawType = (metadata.raw as Record<string, unknown>).type as string | undefined;
|
|
376
|
-
const isToolBlock =
|
|
377
|
-
rawType === "toolCall" ||
|
|
378
|
-
rawType === "tool_use" ||
|
|
379
|
-
rawType === "tool-use" ||
|
|
380
|
-
rawType === "toolUse" ||
|
|
381
|
-
rawType === "functionCall" ||
|
|
382
|
-
rawType === "function_call" ||
|
|
383
|
-
rawType === "function_call_output" ||
|
|
384
|
-
rawType === "toolResult" ||
|
|
385
|
-
rawType === "tool_result";
|
|
386
|
-
if (!isToolBlock) {
|
|
387
|
-
return metadata.raw;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
// When tool blocks are routed through toolCallBlockFromPart (below) instead
|
|
391
|
-
// of returning raw directly, the function reads part.toolCallId / part.toolName
|
|
392
|
-
// from the DB columns. For rows stored as part_type='text' those columns are
|
|
393
|
-
// often NULL — the values only live inside metadata.raw. Backfill them here
|
|
394
|
-
// so the reconstructed block keeps the original id/name.
|
|
395
|
-
const rawRecord = metadata.raw as Record<string, unknown>;
|
|
396
|
-
const rawToolCallId =
|
|
397
|
-
typeof rawRecord.id === "string" && rawRecord.id.length > 0
|
|
398
|
-
? rawRecord.id
|
|
399
|
-
: typeof rawRecord.call_id === "string" && rawRecord.call_id.length > 0
|
|
400
|
-
? rawRecord.call_id
|
|
401
|
-
: undefined;
|
|
402
|
-
if (rawToolCallId) {
|
|
403
|
-
if (typeof part.toolCallId !== "string" || part.toolCallId.length === 0) {
|
|
404
|
-
part.toolCallId = rawToolCallId;
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
if (typeof rawRecord.name === "string" && rawRecord.name.length > 0) {
|
|
408
|
-
if (typeof part.toolName !== "string" || part.toolName.length === 0) {
|
|
409
|
-
part.toolName = rawRecord.name;
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
// Backfill toolInput from raw arguments/input so toolCallBlockFromPart
|
|
413
|
-
// can reconstruct the full block.
|
|
414
|
-
if (part.toolInput == null || part.toolInput === "") {
|
|
415
|
-
const rawArgs = rawRecord.arguments ?? rawRecord.input;
|
|
416
|
-
if (rawArgs !== undefined) {
|
|
417
|
-
part.toolInput = typeof rawArgs === "string" ? rawArgs : JSON.stringify(rawArgs);
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
if (part.partType === "reasoning") {
|
|
423
|
-
return reasoningBlockFromPart(part, metadata.rawType);
|
|
424
|
-
}
|
|
425
|
-
if (part.partType === "tool") {
|
|
426
|
-
if (metadata.originalRole === "toolResult" || metadata.rawType === "function_call_output") {
|
|
427
|
-
return toolResultBlockFromPart(
|
|
428
|
-
part,
|
|
429
|
-
metadata.rawType,
|
|
430
|
-
metadata.raw && typeof metadata.raw === "object"
|
|
431
|
-
? (metadata.raw as Record<string, unknown>)
|
|
432
|
-
: undefined,
|
|
433
|
-
);
|
|
434
|
-
}
|
|
435
|
-
return toolCallBlockFromPart(part, metadata.rawType);
|
|
436
|
-
}
|
|
437
|
-
if (
|
|
438
|
-
metadata.rawType === "function_call" ||
|
|
439
|
-
metadata.rawType === "functionCall" ||
|
|
440
|
-
metadata.rawType === "tool_use" ||
|
|
441
|
-
metadata.rawType === "tool-use" ||
|
|
442
|
-
metadata.rawType === "toolUse" ||
|
|
443
|
-
metadata.rawType === "toolCall"
|
|
444
|
-
) {
|
|
445
|
-
return toolCallBlockFromPart(part, metadata.rawType);
|
|
446
|
-
}
|
|
447
|
-
if (
|
|
448
|
-
metadata.rawType === "function_call_output" ||
|
|
449
|
-
metadata.rawType === "tool_result" ||
|
|
450
|
-
metadata.rawType === "toolResult"
|
|
451
|
-
) {
|
|
452
|
-
return toolResultBlockFromPart(
|
|
453
|
-
part,
|
|
454
|
-
metadata.rawType,
|
|
455
|
-
metadata.raw && typeof metadata.raw === "object"
|
|
456
|
-
? (metadata.raw as Record<string, unknown>)
|
|
457
|
-
: undefined,
|
|
458
|
-
);
|
|
459
|
-
}
|
|
460
|
-
if (part.partType === "text") {
|
|
461
|
-
return { type: "text", text: part.textContent ?? "" };
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
if (typeof part.textContent === "string" && part.textContent.length > 0) {
|
|
465
|
-
return { type: "text", text: part.textContent };
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
const decodedFallback = parseJson(part.metadata);
|
|
469
|
-
if (decodedFallback && typeof decodedFallback === "object") {
|
|
470
|
-
return {
|
|
471
|
-
type: "text",
|
|
472
|
-
text: JSON.stringify(decodedFallback),
|
|
473
|
-
};
|
|
474
|
-
}
|
|
475
|
-
return { type: "text", text: "" };
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
/** @internal Exported for transcript-maintenance reconstruction. */
|
|
479
|
-
export function contentFromParts(
|
|
480
|
-
parts: MessagePartRecord[],
|
|
481
|
-
role: "user" | "assistant" | "toolResult",
|
|
482
|
-
fallbackContent: string,
|
|
483
|
-
): unknown {
|
|
484
|
-
if (parts.length === 0) {
|
|
485
|
-
if (role === "assistant") {
|
|
486
|
-
return fallbackContent ? [{ type: "text", text: fallbackContent }] : [];
|
|
487
|
-
}
|
|
488
|
-
if (role === "toolResult") {
|
|
489
|
-
return [{ type: "text", text: fallbackContent }];
|
|
490
|
-
}
|
|
491
|
-
return fallbackContent;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
const blocks = parts.map(blockFromPart);
|
|
495
|
-
if (
|
|
496
|
-
role === "user" &&
|
|
497
|
-
blocks.length === 1 &&
|
|
498
|
-
blocks[0] &&
|
|
499
|
-
typeof blocks[0] === "object" &&
|
|
500
|
-
(blocks[0] as { type?: unknown }).type === "text" &&
|
|
501
|
-
typeof (blocks[0] as { text?: unknown }).text === "string"
|
|
502
|
-
) {
|
|
503
|
-
return (blocks[0] as { text: string }).text;
|
|
504
|
-
}
|
|
505
|
-
return blocks;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
/** @internal Exported for transcript-maintenance reconstruction. */
|
|
509
|
-
export function pickToolCallId(parts: MessagePartRecord[]): string | undefined {
|
|
510
|
-
for (const part of parts) {
|
|
511
|
-
if (typeof part.toolCallId === "string" && part.toolCallId.length > 0) {
|
|
512
|
-
return part.toolCallId;
|
|
513
|
-
}
|
|
514
|
-
const decoded = parseJson(part.metadata);
|
|
515
|
-
if (!decoded || typeof decoded !== "object") {
|
|
516
|
-
continue;
|
|
517
|
-
}
|
|
518
|
-
const metadataToolCallId = (decoded as { toolCallId?: unknown }).toolCallId;
|
|
519
|
-
if (typeof metadataToolCallId === "string" && metadataToolCallId.length > 0) {
|
|
520
|
-
return metadataToolCallId;
|
|
521
|
-
}
|
|
522
|
-
const raw = (decoded as { raw?: unknown }).raw;
|
|
523
|
-
if (!raw || typeof raw !== "object") {
|
|
524
|
-
continue;
|
|
525
|
-
}
|
|
526
|
-
const maybe = (raw as { toolCallId?: unknown; tool_call_id?: unknown }).toolCallId;
|
|
527
|
-
if (typeof maybe === "string" && maybe.length > 0) {
|
|
528
|
-
return maybe;
|
|
529
|
-
}
|
|
530
|
-
const maybeSnake = (raw as { tool_call_id?: unknown }).tool_call_id;
|
|
531
|
-
if (typeof maybeSnake === "string" && maybeSnake.length > 0) {
|
|
532
|
-
return maybeSnake;
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
return undefined;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
/** @internal Exported for transcript-maintenance reconstruction. */
|
|
539
|
-
export function pickToolName(parts: MessagePartRecord[]): string | undefined {
|
|
540
|
-
for (const part of parts) {
|
|
541
|
-
if (typeof part.toolName === "string" && part.toolName.length > 0) {
|
|
542
|
-
return part.toolName;
|
|
543
|
-
}
|
|
544
|
-
const decoded = parseJson(part.metadata);
|
|
545
|
-
if (!decoded || typeof decoded !== "object") {
|
|
546
|
-
continue;
|
|
547
|
-
}
|
|
548
|
-
const metadataToolName = (decoded as { toolName?: unknown }).toolName;
|
|
549
|
-
if (typeof metadataToolName === "string" && metadataToolName.length > 0) {
|
|
550
|
-
return metadataToolName;
|
|
551
|
-
}
|
|
552
|
-
const raw = (decoded as { raw?: unknown }).raw;
|
|
553
|
-
if (!raw || typeof raw !== "object") {
|
|
554
|
-
continue;
|
|
555
|
-
}
|
|
556
|
-
const maybe = (raw as { name?: unknown }).name;
|
|
557
|
-
if (typeof maybe === "string" && maybe.length > 0) {
|
|
558
|
-
return maybe;
|
|
559
|
-
}
|
|
560
|
-
const maybeCamel = (raw as { toolName?: unknown }).toolName;
|
|
561
|
-
if (typeof maybeCamel === "string" && maybeCamel.length > 0) {
|
|
562
|
-
return maybeCamel;
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
return undefined;
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
/** @internal Exported for transcript-maintenance reconstruction. */
|
|
569
|
-
export function pickToolIsError(parts: MessagePartRecord[]): boolean | undefined {
|
|
570
|
-
for (const part of parts) {
|
|
571
|
-
const decoded = parseJson(part.metadata);
|
|
572
|
-
if (!decoded || typeof decoded !== "object") {
|
|
573
|
-
continue;
|
|
574
|
-
}
|
|
575
|
-
const metadataIsError = (decoded as { isError?: unknown }).isError;
|
|
576
|
-
if (typeof metadataIsError === "boolean") {
|
|
577
|
-
return metadataIsError;
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
return undefined;
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
function extractToolCallId(block: { id?: unknown; call_id?: unknown }): string | null {
|
|
584
|
-
if (typeof block.id === "string" && block.id.length > 0) {
|
|
585
|
-
return block.id;
|
|
586
|
-
}
|
|
587
|
-
if (typeof block.call_id === "string" && block.call_id.length > 0) {
|
|
588
|
-
return block.call_id;
|
|
589
|
-
}
|
|
590
|
-
return null;
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
function extractToolCallIdsFromAssistant(message: AgentMessage): string[] {
|
|
594
|
-
if (message?.role !== "assistant" || !Array.isArray(message.content)) {
|
|
595
|
-
return [];
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
const ids: string[] = [];
|
|
599
|
-
for (const block of message.content) {
|
|
600
|
-
if (!block || typeof block !== "object") {
|
|
601
|
-
continue;
|
|
602
|
-
}
|
|
603
|
-
const record = block as { type?: unknown; id?: unknown; call_id?: unknown };
|
|
604
|
-
if (typeof record.type !== "string" || !TOOL_CALL_TYPES.has(record.type)) {
|
|
605
|
-
continue;
|
|
606
|
-
}
|
|
607
|
-
const id = extractToolCallId(record);
|
|
608
|
-
if (id) {
|
|
609
|
-
ids.push(id);
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
return ids;
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
function extractToolResultIdFromMessage(message: AgentMessage): string | null {
|
|
616
|
-
if (!message || typeof message !== "object") {
|
|
617
|
-
return null;
|
|
618
|
-
}
|
|
619
|
-
if (typeof message.toolCallId === "string" && message.toolCallId.length > 0) {
|
|
620
|
-
return message.toolCallId;
|
|
621
|
-
}
|
|
622
|
-
if (typeof message.toolUseId === "string" && message.toolUseId.length > 0) {
|
|
623
|
-
return message.toolUseId;
|
|
624
|
-
}
|
|
625
|
-
return null;
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
function collectAssistantToolCallIds(items: ResolvedItem[]): Set<string> {
|
|
629
|
-
const ids = new Set<string>();
|
|
630
|
-
for (const item of items) {
|
|
631
|
-
for (const id of extractToolCallIdsFromAssistant(item.message)) {
|
|
632
|
-
ids.add(id);
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
return ids;
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
function mergeFreshTailWithMatchingToolResults(
|
|
639
|
-
freshTail: ResolvedItem[],
|
|
640
|
-
matchingToolResults: ResolvedItem[],
|
|
641
|
-
): ResolvedItem[] {
|
|
642
|
-
if (matchingToolResults.length === 0) {
|
|
643
|
-
return freshTail;
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
const resultsById = new Map<string, ResolvedItem[]>();
|
|
647
|
-
for (const item of matchingToolResults) {
|
|
648
|
-
const toolResultId = extractToolResultIdFromMessage(item.message);
|
|
649
|
-
if (!toolResultId) {
|
|
650
|
-
continue;
|
|
651
|
-
}
|
|
652
|
-
const existing = resultsById.get(toolResultId);
|
|
653
|
-
if (existing) {
|
|
654
|
-
existing.push(item);
|
|
655
|
-
} else {
|
|
656
|
-
resultsById.set(toolResultId, [item]);
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
const merged: ResolvedItem[] = [];
|
|
661
|
-
const usedOrdinals = new Set<number>();
|
|
662
|
-
|
|
663
|
-
for (const item of freshTail) {
|
|
664
|
-
merged.push(item);
|
|
665
|
-
|
|
666
|
-
const toolCallIds = extractToolCallIdsFromAssistant(item.message);
|
|
667
|
-
if (toolCallIds.length === 0) {
|
|
668
|
-
continue;
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
for (const toolCallId of toolCallIds) {
|
|
672
|
-
const matches = resultsById.get(toolCallId);
|
|
673
|
-
if (!matches) {
|
|
674
|
-
continue;
|
|
675
|
-
}
|
|
676
|
-
for (const match of matches) {
|
|
677
|
-
if (usedOrdinals.has(match.ordinal)) {
|
|
678
|
-
continue;
|
|
679
|
-
}
|
|
680
|
-
merged.push(match);
|
|
681
|
-
usedOrdinals.add(match.ordinal);
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
for (const item of matchingToolResults) {
|
|
687
|
-
if (!usedOrdinals.has(item.ordinal)) {
|
|
688
|
-
merged.push(item);
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
return merged;
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
function filterNonFreshAssistantToolCalls(
|
|
696
|
-
items: ResolvedItem[],
|
|
697
|
-
freshTailOrdinals: Set<number>,
|
|
698
|
-
): AgentMessage[] {
|
|
699
|
-
const availableToolResultIds = new Set<string>();
|
|
700
|
-
for (const item of items) {
|
|
701
|
-
const toolResultId = extractToolResultIdFromMessage(item.message);
|
|
702
|
-
if (toolResultId) {
|
|
703
|
-
availableToolResultIds.add(toolResultId);
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
const filteredMessages: AgentMessage[] = [];
|
|
708
|
-
for (const item of items) {
|
|
709
|
-
if (item.message?.role !== "assistant" || freshTailOrdinals.has(item.ordinal)) {
|
|
710
|
-
filteredMessages.push(item.message);
|
|
711
|
-
continue;
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
if (!Array.isArray(item.message.content)) {
|
|
715
|
-
filteredMessages.push(item.message);
|
|
716
|
-
continue;
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
let removedAny = false;
|
|
720
|
-
const content = item.message.content.filter((block) => {
|
|
721
|
-
if (!block || typeof block !== "object") {
|
|
722
|
-
return true;
|
|
723
|
-
}
|
|
724
|
-
const record = block as { type?: unknown; id?: unknown; call_id?: unknown };
|
|
725
|
-
if (typeof record.type !== "string" || !TOOL_CALL_TYPES.has(record.type)) {
|
|
726
|
-
return true;
|
|
727
|
-
}
|
|
728
|
-
const toolCallId = extractToolCallId(record);
|
|
729
|
-
if (!toolCallId || availableToolResultIds.has(toolCallId)) {
|
|
730
|
-
return true;
|
|
731
|
-
}
|
|
732
|
-
removedAny = true;
|
|
733
|
-
return false;
|
|
734
|
-
});
|
|
735
|
-
|
|
736
|
-
if (content.length === 0) {
|
|
737
|
-
continue;
|
|
738
|
-
}
|
|
739
|
-
if (!removedAny) {
|
|
740
|
-
filteredMessages.push(item.message);
|
|
741
|
-
continue;
|
|
742
|
-
}
|
|
743
|
-
filteredMessages.push({
|
|
744
|
-
...item.message,
|
|
745
|
-
content: content as typeof item.message.content,
|
|
746
|
-
} as AgentMessage);
|
|
747
|
-
}
|
|
748
|
-
return filteredMessages;
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
/** Format a Date for XML attributes in the agent's timezone. */
|
|
752
|
-
function formatDateForAttribute(date: Date, timezone?: string): string {
|
|
753
|
-
const tz = timezone ?? "UTC";
|
|
754
|
-
try {
|
|
755
|
-
const fmt = new Intl.DateTimeFormat("en-CA", {
|
|
756
|
-
timeZone: tz,
|
|
757
|
-
year: "numeric",
|
|
758
|
-
month: "2-digit",
|
|
759
|
-
day: "2-digit",
|
|
760
|
-
hour: "2-digit",
|
|
761
|
-
minute: "2-digit",
|
|
762
|
-
second: "2-digit",
|
|
763
|
-
hour12: false,
|
|
764
|
-
});
|
|
765
|
-
const p = Object.fromEntries(
|
|
766
|
-
fmt.formatToParts(date).map((part) => [part.type, part.value]),
|
|
767
|
-
);
|
|
768
|
-
return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}`;
|
|
769
|
-
} catch {
|
|
770
|
-
return date.toISOString();
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
/**
|
|
775
|
-
* Format a summary record into the XML payload string the model sees.
|
|
776
|
-
*/
|
|
777
|
-
async function formatSummaryContent(
|
|
778
|
-
summary: SummaryRecord,
|
|
779
|
-
summaryStore: SummaryStore,
|
|
780
|
-
timezone?: string,
|
|
781
|
-
): Promise<string> {
|
|
782
|
-
const attributes = [
|
|
783
|
-
`id="${summary.summaryId}"`,
|
|
784
|
-
`kind="${summary.kind}"`,
|
|
785
|
-
`depth="${summary.depth}"`,
|
|
786
|
-
`descendant_count="${summary.descendantCount}"`,
|
|
787
|
-
];
|
|
788
|
-
if (summary.earliestAt) {
|
|
789
|
-
attributes.push(`earliest_at="${formatDateForAttribute(summary.earliestAt, timezone)}"`);
|
|
790
|
-
}
|
|
791
|
-
if (summary.latestAt) {
|
|
792
|
-
attributes.push(`latest_at="${formatDateForAttribute(summary.latestAt, timezone)}"`);
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
const lines: string[] = [];
|
|
796
|
-
lines.push(`<summary ${attributes.join(" ")}>`);
|
|
797
|
-
|
|
798
|
-
// For condensed summaries, include parent references.
|
|
799
|
-
if (summary.kind === "condensed") {
|
|
800
|
-
const parents = await summaryStore.getSummaryParents(summary.summaryId);
|
|
801
|
-
if (parents.length > 0) {
|
|
802
|
-
lines.push(" <parents>");
|
|
803
|
-
for (const parent of parents) {
|
|
804
|
-
lines.push(` <summary_ref id="${parent.summaryId}" />`);
|
|
805
|
-
}
|
|
806
|
-
lines.push(" </parents>");
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
lines.push(" <content>");
|
|
811
|
-
lines.push(summary.content);
|
|
812
|
-
lines.push(" </content>");
|
|
813
|
-
lines.push("</summary>");
|
|
814
|
-
return lines.join("\n");
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
// ── Resolved context item (after fetching underlying message/summary) ────────
|
|
818
|
-
|
|
819
|
-
interface ResolvedItem {
|
|
820
|
-
/** Original ordinal from context_items table */
|
|
821
|
-
ordinal: number;
|
|
822
|
-
/** The AgentMessage ready for the model */
|
|
823
|
-
message: AgentMessage;
|
|
824
|
-
/** Estimated token count for this item */
|
|
825
|
-
tokens: number;
|
|
826
|
-
/** Whether this came from a raw message (vs. a summary) */
|
|
827
|
-
isMessage: boolean;
|
|
828
|
-
/** Pre-extracted plain text used for relevance scoring */
|
|
829
|
-
text: string;
|
|
830
|
-
/** Summary metadata used for dynamic system prompt guidance */
|
|
831
|
-
summarySignal?: SummaryPromptSignal;
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
// ── BM25-lite relevance scorer ────────────────────────────────────────────────
|
|
835
|
-
|
|
836
|
-
/** @internal Exported for testing only. Tokenize text into lowercase alphanumeric terms. */
|
|
837
|
-
export function tokenizeText(text: string): string[] {
|
|
838
|
-
return text
|
|
839
|
-
.toLowerCase()
|
|
840
|
-
.split(/[^a-z0-9]+/)
|
|
841
|
-
.filter((t) => t.length > 1);
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
/**
|
|
845
|
-
* @internal Exported for testing only.
|
|
846
|
-
* Score an item's text against a prompt using BM25-lite (term-frequency overlap).
|
|
847
|
-
* Higher scores indicate stronger keyword overlap. Returns 0 when either input is empty.
|
|
848
|
-
*/
|
|
849
|
-
export function scoreRelevance(itemText: string, prompt: string): number {
|
|
850
|
-
const promptTerms = tokenizeText(prompt);
|
|
851
|
-
if (promptTerms.length === 0) return 0;
|
|
852
|
-
|
|
853
|
-
const itemTerms = tokenizeText(itemText);
|
|
854
|
-
if (itemTerms.length === 0) return 0;
|
|
855
|
-
|
|
856
|
-
// Build term-frequency map for the item
|
|
857
|
-
const freq = new Map<string, number>();
|
|
858
|
-
for (const term of itemTerms) {
|
|
859
|
-
freq.set(term, (freq.get(term) ?? 0) + 1);
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
// Sum TF contribution for each unique prompt term
|
|
863
|
-
const seen = new Set<string>();
|
|
864
|
-
let score = 0;
|
|
865
|
-
for (const term of promptTerms) {
|
|
866
|
-
if (seen.has(term)) continue;
|
|
867
|
-
seen.add(term);
|
|
868
|
-
const tf = freq.get(term) ?? 0;
|
|
869
|
-
if (tf > 0) {
|
|
870
|
-
// Normalised TF: tf / itemLength (BM25-lite saturation skipped for simplicity)
|
|
871
|
-
score += tf / itemTerms.length;
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
return score;
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
/** Return true when a prompt contains at least one searchable term. */
|
|
878
|
-
function hasSearchablePrompt(prompt?: string): prompt is string {
|
|
879
|
-
return typeof prompt === "string" && tokenizeText(prompt).length > 0;
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
// ── ContextAssembler ─────────────────────────────────────────────────────────
|
|
883
|
-
|
|
884
|
-
export class ContextAssembler {
|
|
885
|
-
constructor(
|
|
886
|
-
private conversationStore: ConversationStore,
|
|
887
|
-
private summaryStore: SummaryStore,
|
|
888
|
-
private timezone?: string,
|
|
889
|
-
) {}
|
|
890
|
-
|
|
891
|
-
/**
|
|
892
|
-
* Build model context under a token budget.
|
|
893
|
-
*
|
|
894
|
-
* 1. Fetch all context items for the conversation (ordered by ordinal).
|
|
895
|
-
* 2. Resolve each item into an AgentMessage (fetching the underlying
|
|
896
|
-
* message or summary record).
|
|
897
|
-
* 3. Protect the "fresh tail" (last N items) from truncation.
|
|
898
|
-
* 4. If over budget, drop oldest non-fresh items until we fit.
|
|
899
|
-
* 5. Return the final ordered messages in chronological order.
|
|
900
|
-
*/
|
|
901
|
-
async assemble(input: AssembleContextInput): Promise<AssembleContextResult> {
|
|
902
|
-
const { conversationId, tokenBudget } = input;
|
|
903
|
-
const freshTailCount = input.freshTailCount ?? 8;
|
|
904
|
-
|
|
905
|
-
// Step 1: Get all context items ordered by ordinal
|
|
906
|
-
const contextItems = await this.summaryStore.getContextItems(conversationId);
|
|
907
|
-
|
|
908
|
-
if (contextItems.length === 0) {
|
|
909
|
-
return {
|
|
910
|
-
messages: [],
|
|
911
|
-
estimatedTokens: 0,
|
|
912
|
-
stats: { rawMessageCount: 0, summaryCount: 0, totalContextItems: 0 },
|
|
913
|
-
};
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
// Step 2: Resolve each context item into a ResolvedItem
|
|
917
|
-
const resolved = await this.resolveItems(contextItems);
|
|
918
|
-
|
|
919
|
-
// Count stats from the full (pre-truncation) set
|
|
920
|
-
let rawMessageCount = 0;
|
|
921
|
-
let summaryCount = 0;
|
|
922
|
-
const summarySignals: SummaryPromptSignal[] = [];
|
|
923
|
-
for (const item of resolved) {
|
|
924
|
-
if (item.isMessage) {
|
|
925
|
-
rawMessageCount++;
|
|
926
|
-
} else {
|
|
927
|
-
summaryCount++;
|
|
928
|
-
if (item.summarySignal) {
|
|
929
|
-
summarySignals.push(item.summarySignal);
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
const systemPromptAddition = buildSystemPromptAddition(summarySignals);
|
|
935
|
-
|
|
936
|
-
// Step 3: Split into evictable prefix and protected fresh tail
|
|
937
|
-
const tailStart = Math.max(0, resolved.length - freshTailCount);
|
|
938
|
-
const baseFreshTail = resolved.slice(tailStart);
|
|
939
|
-
const initialEvictable = resolved.slice(0, tailStart);
|
|
940
|
-
const freshTailOrdinals = new Set(baseFreshTail.map((item) => item.ordinal));
|
|
941
|
-
const tailToolCallIds = collectAssistantToolCallIds(baseFreshTail);
|
|
942
|
-
const tailPairToolResults = initialEvictable.filter((item) => {
|
|
943
|
-
const toolResultId = extractToolResultIdFromMessage(item.message);
|
|
944
|
-
return toolResultId !== null && tailToolCallIds.has(toolResultId);
|
|
945
|
-
});
|
|
946
|
-
const protectedEvictableOrdinals = new Set(tailPairToolResults.map((item) => item.ordinal));
|
|
947
|
-
const evictable = initialEvictable.filter((item) => !protectedEvictableOrdinals.has(item.ordinal));
|
|
948
|
-
const freshTail = mergeFreshTailWithMatchingToolResults(baseFreshTail, tailPairToolResults);
|
|
949
|
-
|
|
950
|
-
// Step 4: Budget-aware selection
|
|
951
|
-
// First, compute the token cost of the fresh tail (always included).
|
|
952
|
-
let tailTokens = 0;
|
|
953
|
-
for (const item of freshTail) {
|
|
954
|
-
tailTokens += item.tokens;
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
// Fill remaining budget from evictable items, oldest first.
|
|
958
|
-
// If the fresh tail alone exceeds the budget we still include it
|
|
959
|
-
// (we never drop fresh items), but we skip all evictable items.
|
|
960
|
-
const remainingBudget = Math.max(0, tokenBudget - tailTokens);
|
|
961
|
-
const selected: ResolvedItem[] = [];
|
|
962
|
-
let evictableTokens = 0;
|
|
963
|
-
|
|
964
|
-
// Walk evictable items from oldest to newest. We want to keep as many
|
|
965
|
-
// older items as the budget allows; once we exceed the budget we start
|
|
966
|
-
// dropping the *oldest* items. To achieve this we first compute the
|
|
967
|
-
// total, then trim from the front.
|
|
968
|
-
const evictableTotalTokens = evictable.reduce((sum, it) => sum + it.tokens, 0);
|
|
969
|
-
|
|
970
|
-
if (evictableTotalTokens <= remainingBudget) {
|
|
971
|
-
// Everything fits
|
|
972
|
-
selected.push(...evictable);
|
|
973
|
-
evictableTokens = evictableTotalTokens;
|
|
974
|
-
} else if (hasSearchablePrompt(input.prompt)) {
|
|
975
|
-
// Prompt-aware eviction: score each evictable item by relevance to the
|
|
976
|
-
// prompt, then greedily fill budget from highest-scoring items down.
|
|
977
|
-
// Re-sort selected items by ordinal to restore chronological order.
|
|
978
|
-
const scored = evictable.map((item, idx) => ({
|
|
979
|
-
item,
|
|
980
|
-
score: scoreRelevance(item.text, input.prompt),
|
|
981
|
-
idx, // original index — higher = more recent, used as tiebreaker
|
|
982
|
-
}));
|
|
983
|
-
// Sort: highest relevance first; most recent (higher idx) breaks ties
|
|
984
|
-
scored.sort((a, b) => b.score - a.score || b.idx - a.idx);
|
|
985
|
-
|
|
986
|
-
const kept: ResolvedItem[] = [];
|
|
987
|
-
let accum = 0;
|
|
988
|
-
for (const { item } of scored) {
|
|
989
|
-
if (accum + item.tokens <= remainingBudget) {
|
|
990
|
-
kept.push(item);
|
|
991
|
-
accum += item.tokens;
|
|
992
|
-
}
|
|
993
|
-
}
|
|
994
|
-
// Restore chronological order by ordinal before appending freshTail
|
|
995
|
-
kept.sort((a, b) => a.ordinal - b.ordinal);
|
|
996
|
-
selected.push(...kept);
|
|
997
|
-
evictableTokens = accum;
|
|
998
|
-
} else {
|
|
999
|
-
// Chronological eviction (default): drop oldest items until we fit.
|
|
1000
|
-
// Walk from the END of evictable (newest first) accumulating tokens,
|
|
1001
|
-
// then reverse to restore chronological order.
|
|
1002
|
-
const kept: ResolvedItem[] = [];
|
|
1003
|
-
let accum = 0;
|
|
1004
|
-
for (let i = evictable.length - 1; i >= 0; i--) {
|
|
1005
|
-
const item = evictable[i];
|
|
1006
|
-
if (accum + item.tokens <= remainingBudget) {
|
|
1007
|
-
kept.push(item);
|
|
1008
|
-
accum += item.tokens;
|
|
1009
|
-
} else {
|
|
1010
|
-
// Once an item doesn't fit we stop — all older items are also dropped
|
|
1011
|
-
break;
|
|
1012
|
-
}
|
|
1013
|
-
}
|
|
1014
|
-
kept.reverse();
|
|
1015
|
-
selected.push(...kept);
|
|
1016
|
-
evictableTokens = accum;
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
// Append fresh tail after the evictable prefix
|
|
1020
|
-
selected.push(...freshTail);
|
|
1021
|
-
|
|
1022
|
-
const estimatedTokens = evictableTokens + tailTokens;
|
|
1023
|
-
|
|
1024
|
-
// Normalize assistant string content to array blocks (some providers return
|
|
1025
|
-
// content as a plain string; Anthropic expects content block arrays).
|
|
1026
|
-
const rawMessages = filterNonFreshAssistantToolCalls(selected, freshTailOrdinals);
|
|
1027
|
-
for (let i = 0; i < rawMessages.length; i++) {
|
|
1028
|
-
const msg = rawMessages[i];
|
|
1029
|
-
if (msg?.role === "assistant" && typeof msg.content === "string") {
|
|
1030
|
-
rawMessages[i] = {
|
|
1031
|
-
...msg,
|
|
1032
|
-
content: [{ type: "text", text: msg.content }] as unknown as typeof msg.content,
|
|
1033
|
-
} as typeof msg;
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
// Filter out assistant messages with empty content — these can occur when
|
|
1038
|
-
// tool-use-only turns are stored with content="" and zero message_parts,
|
|
1039
|
-
// or when filterNonFreshAssistantToolCalls strips all tool_use blocks.
|
|
1040
|
-
// Anthropic (and other providers) reject empty content arrays/strings.
|
|
1041
|
-
const cleaned = rawMessages.filter(
|
|
1042
|
-
(m) =>
|
|
1043
|
-
!(
|
|
1044
|
-
m?.role === "assistant" &&
|
|
1045
|
-
(Array.isArray(m.content) ? m.content.length === 0 : !m.content)
|
|
1046
|
-
),
|
|
1047
|
-
);
|
|
1048
|
-
return {
|
|
1049
|
-
messages: sanitizeToolUseResultPairing(cleaned) as AgentMessage[],
|
|
1050
|
-
estimatedTokens,
|
|
1051
|
-
systemPromptAddition,
|
|
1052
|
-
stats: {
|
|
1053
|
-
rawMessageCount,
|
|
1054
|
-
summaryCount,
|
|
1055
|
-
totalContextItems: resolved.length,
|
|
1056
|
-
},
|
|
1057
|
-
};
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
// ── Private helpers ──────────────────────────────────────────────────────
|
|
1061
|
-
|
|
1062
|
-
/**
|
|
1063
|
-
* Resolve a list of context items into ResolvedItems by fetching the
|
|
1064
|
-
* underlying message or summary record for each.
|
|
1065
|
-
*
|
|
1066
|
-
* Items that cannot be resolved (e.g. deleted message) are silently skipped.
|
|
1067
|
-
*/
|
|
1068
|
-
private async resolveItems(contextItems: ContextItemRecord[]): Promise<ResolvedItem[]> {
|
|
1069
|
-
const resolved: ResolvedItem[] = [];
|
|
1070
|
-
|
|
1071
|
-
for (const item of contextItems) {
|
|
1072
|
-
const result = await this.resolveItem(item);
|
|
1073
|
-
if (result) {
|
|
1074
|
-
resolved.push(result);
|
|
1075
|
-
}
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
return resolved;
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
/**
|
|
1082
|
-
* Resolve a single context item.
|
|
1083
|
-
*/
|
|
1084
|
-
private async resolveItem(item: ContextItemRecord): Promise<ResolvedItem | null> {
|
|
1085
|
-
if (item.itemType === "message" && item.messageId != null) {
|
|
1086
|
-
return this.resolveMessageItem(item);
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
if (item.itemType === "summary" && item.summaryId != null) {
|
|
1090
|
-
return this.resolveSummaryItem(item);
|
|
1091
|
-
}
|
|
1092
|
-
|
|
1093
|
-
// Malformed item — skip
|
|
1094
|
-
return null;
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
/**
|
|
1098
|
-
* Resolve a context item that references a raw message.
|
|
1099
|
-
*/
|
|
1100
|
-
private async resolveMessageItem(item: ContextItemRecord): Promise<ResolvedItem | null> {
|
|
1101
|
-
const msg = await this.conversationStore.getMessageById(item.messageId!);
|
|
1102
|
-
if (!msg) {
|
|
1103
|
-
return null;
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
const parts = await this.conversationStore.getMessageParts(msg.messageId);
|
|
1107
|
-
|
|
1108
|
-
// Skip empty assistant messages left by error/aborted responses.
|
|
1109
|
-
// These waste context tokens and can confuse models that reject
|
|
1110
|
-
// consecutive empty assistant turns. Only skip when both the stored
|
|
1111
|
-
// content text AND the message_parts table are empty — assistant
|
|
1112
|
-
// messages that contain tool calls have empty text content but
|
|
1113
|
-
// non-empty parts and must be preserved.
|
|
1114
|
-
if (msg.role === "assistant" && !msg.content.trim() && parts.length === 0) {
|
|
1115
|
-
return null;
|
|
1116
|
-
}
|
|
1117
|
-
const roleFromStore = toRuntimeRole(msg.role, parts);
|
|
1118
|
-
const isToolResult = roleFromStore === "toolResult";
|
|
1119
|
-
const toolCallId = isToolResult ? pickToolCallId(parts) : undefined;
|
|
1120
|
-
const toolName = isToolResult ? (pickToolName(parts) ?? "unknown") : undefined;
|
|
1121
|
-
const toolIsError = isToolResult ? pickToolIsError(parts) : undefined;
|
|
1122
|
-
// Tool results without a call id cannot be serialized for Anthropic-compatible APIs.
|
|
1123
|
-
// This happens for legacy/bootstrap rows that have role=tool but no message_parts.
|
|
1124
|
-
// Preserve the text by degrading to assistant content instead of emitting invalid toolResult.
|
|
1125
|
-
const role: "user" | "assistant" | "toolResult" =
|
|
1126
|
-
isToolResult && !toolCallId ? "assistant" : roleFromStore;
|
|
1127
|
-
const content = contentFromParts(parts, role, msg.content);
|
|
1128
|
-
const contentText =
|
|
1129
|
-
typeof content === "string" ? content : (JSON.stringify(content) ?? msg.content);
|
|
1130
|
-
const tokenCount = estimateTokens(contentText);
|
|
1131
|
-
|
|
1132
|
-
// Cast: these are reconstructed from DB storage, not live agent messages,
|
|
1133
|
-
// so they won't carry the full AgentMessage metadata (timestamp, usage, etc.)
|
|
1134
|
-
return {
|
|
1135
|
-
ordinal: item.ordinal,
|
|
1136
|
-
message:
|
|
1137
|
-
role === "assistant"
|
|
1138
|
-
? ({
|
|
1139
|
-
role,
|
|
1140
|
-
content,
|
|
1141
|
-
usage: {
|
|
1142
|
-
input: 0,
|
|
1143
|
-
output: tokenCount,
|
|
1144
|
-
cacheRead: 0,
|
|
1145
|
-
cacheWrite: 0,
|
|
1146
|
-
totalTokens: tokenCount,
|
|
1147
|
-
cost: {
|
|
1148
|
-
input: 0,
|
|
1149
|
-
output: 0,
|
|
1150
|
-
cacheRead: 0,
|
|
1151
|
-
cacheWrite: 0,
|
|
1152
|
-
total: 0,
|
|
1153
|
-
},
|
|
1154
|
-
},
|
|
1155
|
-
} as AgentMessage)
|
|
1156
|
-
: ({
|
|
1157
|
-
role,
|
|
1158
|
-
content,
|
|
1159
|
-
...(toolCallId ? { toolCallId } : {}),
|
|
1160
|
-
...(toolName ? { toolName } : {}),
|
|
1161
|
-
...(role === "toolResult" && toolIsError !== undefined ? { isError: toolIsError } : {}),
|
|
1162
|
-
} as AgentMessage),
|
|
1163
|
-
tokens: tokenCount,
|
|
1164
|
-
isMessage: true,
|
|
1165
|
-
text: contentText,
|
|
1166
|
-
};
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
/**
|
|
1170
|
-
* Resolve a context item that references a summary.
|
|
1171
|
-
* Summaries are presented as user messages with a structured XML wrapper.
|
|
1172
|
-
*/
|
|
1173
|
-
private async resolveSummaryItem(item: ContextItemRecord): Promise<ResolvedItem | null> {
|
|
1174
|
-
const summary = await this.summaryStore.getSummary(item.summaryId!);
|
|
1175
|
-
if (!summary) {
|
|
1176
|
-
return null;
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
const content = await formatSummaryContent(summary, this.summaryStore, this.timezone);
|
|
1180
|
-
const tokens = estimateTokens(content);
|
|
1181
|
-
|
|
1182
|
-
// Cast: summaries are synthetic user messages without full AgentMessage metadata
|
|
1183
|
-
return {
|
|
1184
|
-
ordinal: item.ordinal,
|
|
1185
|
-
message: { role: "user" as const, content } as AgentMessage,
|
|
1186
|
-
tokens,
|
|
1187
|
-
isMessage: false,
|
|
1188
|
-
text: summary.content,
|
|
1189
|
-
summarySignal: {
|
|
1190
|
-
kind: summary.kind,
|
|
1191
|
-
depth: summary.depth,
|
|
1192
|
-
descendantCount: summary.descendantCount,
|
|
1193
|
-
},
|
|
1194
|
-
};
|
|
1195
|
-
}
|
|
1196
|
-
}
|