@moxxy/sdk 0.14.2 → 0.14.3
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/dist/mode/collect-stream.d.ts +68 -0
- package/dist/mode/collect-stream.d.ts.map +1 -0
- package/dist/mode/collect-stream.js +181 -0
- package/dist/mode/collect-stream.js.map +1 -0
- package/dist/mode/project-messages.d.ts +84 -0
- package/dist/mode/project-messages.d.ts.map +1 -0
- package/dist/mode/project-messages.js +392 -0
- package/dist/mode/project-messages.js.map +1 -0
- package/dist/mode/single-shot.d.ts +17 -0
- package/dist/mode/single-shot.d.ts.map +1 -0
- package/dist/mode/single-shot.js +53 -0
- package/dist/mode/single-shot.js.map +1 -0
- package/dist/mode/stable-hash.d.ts +7 -0
- package/dist/mode/stable-hash.d.ts.map +1 -0
- package/dist/mode/stable-hash.js +20 -0
- package/dist/mode/stable-hash.js.map +1 -0
- package/dist/mode/stuck-loop.d.ts +39 -0
- package/dist/mode/stuck-loop.d.ts.map +1 -0
- package/dist/mode/stuck-loop.js +51 -0
- package/dist/mode/stuck-loop.js.map +1 -0
- package/dist/mode-helpers.d.ts +15 -175
- package/dist/mode-helpers.d.ts.map +1 -1
- package/dist/mode-helpers.js +14 -666
- package/dist/mode-helpers.js.map +1 -1
- package/package.json +1 -1
- package/src/mode/collect-stream.ts +247 -0
- package/src/mode/project-messages.test.ts +121 -0
- package/src/mode/project-messages.ts +461 -0
- package/src/mode/single-shot.ts +63 -0
- package/src/mode/stable-hash.ts +20 -0
- package/src/mode/stuck-loop.ts +89 -0
- package/src/mode-helpers.ts +32 -850
- package/src/mode.test.ts +20 -0
- package/src/token-accounting.test.ts +90 -0
package/dist/mode-helpers.js
CHANGED
|
@@ -1,670 +1,18 @@
|
|
|
1
|
-
import { computeElisionState, conversationalStub, conversationalStubbed, toolResultBytes, toolResultStub, toolResultStubbed, } from './elision-state.js';
|
|
2
|
-
import { isToolDisplayResult } from './tool-display.js';
|
|
3
|
-
import { applyLazyTools } from './tool-gating.js';
|
|
4
|
-
import { runCompactionIfNeeded } from './compactor-helpers.js';
|
|
5
|
-
import { runElisionIfNeeded } from './elision-helpers.js';
|
|
6
|
-
import { usageEventFields } from './token-accounting.js';
|
|
7
|
-
/** Appended to the system prompt while elision is active (see projection). */
|
|
8
|
-
export const ELISION_SYSTEM_NOTE = 'Context note: to stay within budget, older turns may appear as stubs like ' +
|
|
9
|
-
'`[output elided — recall("id") to view]` or `[elided user turn · recall({ seq: N })]`. ' +
|
|
10
|
-
'These are NOT the real content — call the `recall` tool with the given id/seq to fetch ' +
|
|
11
|
-
'the full text before relying on any detail from an elided turn. Recent turns are always ' +
|
|
12
|
-
'shown verbatim.';
|
|
13
1
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
2
|
+
* Barrel for the shared mode/loop helpers. The implementations now live in
|
|
3
|
+
* focused single-responsibility modules under `./mode/`; this file re-exports
|
|
4
|
+
* them so every existing `from './mode-helpers.js'` import (and the
|
|
5
|
+
* `@moxxy/sdk` index barrel) keeps working byte-identically.
|
|
16
6
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* `
|
|
20
|
-
*
|
|
21
|
-
*
|
|
7
|
+
* - `./mode/project-messages.ts` — event-log → ProviderMessage projection
|
|
8
|
+
* - `./mode/collect-stream.ts` — provider-stream collection
|
|
9
|
+
* - `./mode/single-shot.ts` — single-shot (no-tools) provider turn
|
|
10
|
+
* - `./mode/stuck-loop.ts` — sliding-window stuck-tool-call detector
|
|
11
|
+
* - `./mode/stable-hash.ts` — key-order-canonical input hash util
|
|
22
12
|
*/
|
|
23
|
-
export
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
`When the user's request matches one of these (by name, description, ` +
|
|
29
|
-
`or triggers), call \`load_skill({ name: "<skill-name>" })\` FIRST to ` +
|
|
30
|
-
`fetch the full instructions, then follow them verbatim. Prefer using ` +
|
|
31
|
-
`a skill over re-deriving the workflow with ad-hoc tools.\n`;
|
|
32
|
-
const entries = skills
|
|
33
|
-
.map((s) => {
|
|
34
|
-
const fm = s.frontmatter;
|
|
35
|
-
const triggerHint = fm.triggers?.length
|
|
36
|
-
? ` (triggers: ${fm.triggers.map((t) => `"${t}"`).join(', ')})`
|
|
37
|
-
: '';
|
|
38
|
-
return `- **${fm.name}** — ${fm.description}${triggerHint}`;
|
|
39
|
-
})
|
|
40
|
-
.join('\n');
|
|
41
|
-
const skillBlock = `${header}\n${entries}`;
|
|
42
|
-
return baseSystemPrompt ? `${baseSystemPrompt}\n\n${skillBlock}` : skillBlock;
|
|
43
|
-
}
|
|
44
|
-
function activeCompactionRanges(events) {
|
|
45
|
-
return events
|
|
46
|
-
.filter((event) => event.type === 'compaction' &&
|
|
47
|
-
event.tokensSaved > 0 &&
|
|
48
|
-
event.summary.trim().length > 0 &&
|
|
49
|
-
event.replacedRange[0] <= event.replacedRange[1])
|
|
50
|
-
.map((event) => ({
|
|
51
|
-
from: event.replacedRange[0],
|
|
52
|
-
to: event.replacedRange[1],
|
|
53
|
-
summary: event.summary,
|
|
54
|
-
}));
|
|
55
|
-
}
|
|
56
|
-
function eventInCompactionRange(seq, ranges) {
|
|
57
|
-
for (const range of ranges) {
|
|
58
|
-
if (seq >= range.from && seq <= range.to)
|
|
59
|
-
return range;
|
|
60
|
-
}
|
|
61
|
-
return null;
|
|
62
|
-
}
|
|
63
|
-
/**
|
|
64
|
-
* A compaction lookup that answers "which range (if any) contains `seq`" in
|
|
65
|
-
* O(log ranges) instead of {@link eventInCompactionRange}'s O(ranges) linear
|
|
66
|
-
* scan per event. Compaction ranges are non-overlapping ascending seq prefixes,
|
|
67
|
-
* so a seq belongs to at most one range and binary search over the
|
|
68
|
-
* sorted-by-`from` array returns the SAME range the linear first-match did —
|
|
69
|
-
* byte-identical projection.
|
|
70
|
-
*
|
|
71
|
-
* Defensive fallback: if the ranges are NOT strictly non-overlapping (which the
|
|
72
|
-
* compaction invariant forbids, but a hand-crafted/corrupt log could violate),
|
|
73
|
-
* we keep the exact linear first-match semantics so the projection can never
|
|
74
|
-
* diverge from the old code.
|
|
75
|
-
*/
|
|
76
|
-
function makeCompactionLookup(ranges) {
|
|
77
|
-
if (ranges.length === 0)
|
|
78
|
-
return () => null;
|
|
79
|
-
if (ranges.length === 1) {
|
|
80
|
-
const only = ranges[0];
|
|
81
|
-
return (seq) => (seq >= only.from && seq <= only.to ? only : null);
|
|
82
|
-
}
|
|
83
|
-
// Sort a copy by `from` (stable enough — ranges are non-overlapping). Verify
|
|
84
|
-
// the non-overlap invariant on the sorted copy; only then is binary search
|
|
85
|
-
// provably equivalent to the linear first-match.
|
|
86
|
-
const sorted = [...ranges].sort((a, b) => a.from - b.from);
|
|
87
|
-
let nonOverlapping = true;
|
|
88
|
-
for (let i = 1; i < sorted.length; i++) {
|
|
89
|
-
if (sorted[i].from <= sorted[i - 1].to) {
|
|
90
|
-
nonOverlapping = false;
|
|
91
|
-
break;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
if (!nonOverlapping)
|
|
95
|
-
return (seq) => eventInCompactionRange(seq, ranges);
|
|
96
|
-
return (seq) => {
|
|
97
|
-
// Largest `from <= seq`, then a single containment check.
|
|
98
|
-
let lo = 0;
|
|
99
|
-
let hi = sorted.length - 1;
|
|
100
|
-
let cand = -1;
|
|
101
|
-
while (lo <= hi) {
|
|
102
|
-
const mid = (lo + hi) >> 1;
|
|
103
|
-
if (sorted[mid].from <= seq) {
|
|
104
|
-
cand = mid;
|
|
105
|
-
lo = mid + 1;
|
|
106
|
-
}
|
|
107
|
-
else {
|
|
108
|
-
hi = mid - 1;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
if (cand < 0)
|
|
112
|
-
return null;
|
|
113
|
-
const range = sorted[cand];
|
|
114
|
-
return seq <= range.to ? range : null;
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
export function projectMessagesFromLog(ctx, opts = {}) {
|
|
118
|
-
return projectMessages(ctx, opts).messages;
|
|
119
|
-
}
|
|
120
|
-
/**
|
|
121
|
-
* Same projection as {@link projectMessagesFromLog} but also reports the
|
|
122
|
-
* stable-prefix boundary so the active cache strategy can place a cross-turn
|
|
123
|
-
* breakpoint. Modes that build messages this way should thread the returned
|
|
124
|
-
* `stablePrefixIndex` into {@link collectProviderStream}.
|
|
125
|
-
*/
|
|
126
|
-
export function projectMessages(ctx, opts = {}) {
|
|
127
|
-
const allEvents = ctx.log.slice();
|
|
128
|
-
const compactions = activeCompactionRanges(allEvents);
|
|
129
|
-
const compactionFor = makeCompactionLookup(compactions);
|
|
130
|
-
const emittedCompactions = new Set();
|
|
131
|
-
const el = computeElisionState(allEvents);
|
|
132
|
-
const messages = [];
|
|
133
|
-
// The stable prefix is every message produced from events at/below the
|
|
134
|
-
// elision HWM. Record the latest such message index as we push.
|
|
135
|
-
let stablePrefixIndex = -1;
|
|
136
|
-
const recordStable = (maxSeq) => {
|
|
137
|
-
if (el.hwm >= 0 && maxSeq >= 0 && maxSeq <= el.hwm) {
|
|
138
|
-
stablePrefixIndex = messages.length - 1;
|
|
139
|
-
}
|
|
140
|
-
};
|
|
141
|
-
if (opts.systemPrompt) {
|
|
142
|
-
// When elision is active, tell the model that older turns may be shown as
|
|
143
|
-
// stubs and how to expand them — so it recalls instead of hallucinating.
|
|
144
|
-
// Constant text → busts the system cache once (when elision starts), stable
|
|
145
|
-
// thereafter.
|
|
146
|
-
const sysText = el.hwm >= 0 ? `${opts.systemPrompt}\n\n${ELISION_SYSTEM_NOTE}` : opts.systemPrompt;
|
|
147
|
-
messages.push({ role: 'system', content: [{ type: 'text', text: sysText }] });
|
|
148
|
-
}
|
|
149
|
-
// Pre-scan: build the set of callIds that have a matching tool_result
|
|
150
|
-
// (or tool_call_denied) somewhere in the log. Used to synthesize a
|
|
151
|
-
// fallback `[interrupted]` tool_result for orphan tool_use blocks
|
|
152
|
-
// when the assistant message gets flushed.
|
|
153
|
-
//
|
|
154
|
-
// Without this fallback the provider rejects the whole conversation
|
|
155
|
-
// with "assistant message with 'tool_calls' must be followed by tool
|
|
156
|
-
// messages responding to each 'tool_call_id'". Orphans typically
|
|
157
|
-
// appear after a cancelled turn, an aborted process, or a tool
|
|
158
|
-
// exception that bypassed the loop's tool_result emit path.
|
|
159
|
-
const resolvedCallIds = new Set();
|
|
160
|
-
for (const e of allEvents) {
|
|
161
|
-
if (e.type === 'tool_result' || e.type === 'tool_call_denied') {
|
|
162
|
-
resolvedCallIds.add(e.callId);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
let pendingAssistant = null;
|
|
166
|
-
let pendingAssistantMaxSeq = -1;
|
|
167
|
-
// Reasoning block awaiting attachment to the current assistant turn. Only set
|
|
168
|
-
// for REPLAYABLE reasoning (Anthropic signature / redacted-or-Codex encrypted
|
|
169
|
-
// blob) — render-only reasoning is never sent back. Attached as content[0] of
|
|
170
|
-
// the turn's assistant message (Anthropic requires the signed thinking block
|
|
171
|
-
// first on an interleaved-thinking tool-use continuation; a missing/unsigned
|
|
172
|
-
// one is a hard 400). Dropped at any turn/compaction boundary it doesn't reach.
|
|
173
|
-
let pendingReasoning = null;
|
|
174
|
-
let pendingReasoningSeq = -1;
|
|
175
|
-
const flush = () => {
|
|
176
|
-
if (!pendingAssistant)
|
|
177
|
-
return;
|
|
178
|
-
const flushed = pendingAssistant;
|
|
179
|
-
const groupMaxSeq = pendingAssistantMaxSeq;
|
|
180
|
-
pendingAssistant = null;
|
|
181
|
-
pendingAssistantMaxSeq = -1;
|
|
182
|
-
messages.push(flushed);
|
|
183
|
-
recordStable(groupMaxSeq);
|
|
184
|
-
// Synthesize fallback tool_result messages for any tool_use blocks
|
|
185
|
-
// whose callId never resolved in the event log. Has to land
|
|
186
|
-
// immediately after the assistant message (and before any
|
|
187
|
-
// subsequent user_prompt / assistant_message) so the provider sees
|
|
188
|
-
// a clean assistant→tool-result chain.
|
|
189
|
-
for (const block of flushed.content) {
|
|
190
|
-
if (block.type === 'tool_use' && !resolvedCallIds.has(block.id)) {
|
|
191
|
-
messages.push({
|
|
192
|
-
role: 'tool_result',
|
|
193
|
-
content: [
|
|
194
|
-
{
|
|
195
|
-
type: 'tool_result',
|
|
196
|
-
toolUseId: block.id,
|
|
197
|
-
content: '[tool call did not return a result — possibly interrupted or cancelled]',
|
|
198
|
-
isError: true,
|
|
199
|
-
},
|
|
200
|
-
],
|
|
201
|
-
});
|
|
202
|
-
recordStable(groupMaxSeq);
|
|
203
|
-
// Mark synthesized so we don't double-emit if the same orphan
|
|
204
|
-
// appears in multiple groups (defensive — shouldn't normally
|
|
205
|
-
// happen since each tool_call_requested has a unique callId).
|
|
206
|
-
resolvedCallIds.add(block.id);
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
};
|
|
210
|
-
for (const e of allEvents) {
|
|
211
|
-
const compaction = compactionFor(e.seq);
|
|
212
|
-
if (compaction) {
|
|
213
|
-
if (!emittedCompactions.has(compaction)) {
|
|
214
|
-
emittedCompactions.add(compaction);
|
|
215
|
-
flush();
|
|
216
|
-
pendingReasoning = null;
|
|
217
|
-
messages.push({
|
|
218
|
-
role: 'user',
|
|
219
|
-
content: [{ type: 'text', text: `[summary of earlier turns]\n${compaction.summary}` }],
|
|
220
|
-
});
|
|
221
|
-
recordStable(compaction.to);
|
|
222
|
-
}
|
|
223
|
-
continue;
|
|
224
|
-
}
|
|
225
|
-
switch (e.type) {
|
|
226
|
-
case 'user_prompt': {
|
|
227
|
-
flush();
|
|
228
|
-
pendingReasoning = null;
|
|
229
|
-
// Elided + conversational: collapse to a stub (anchor/tiny kept full).
|
|
230
|
-
if (conversationalStubbed(e, el)) {
|
|
231
|
-
messages.push({
|
|
232
|
-
role: 'user',
|
|
233
|
-
content: [{ type: 'text', text: conversationalStub('user', e.seq) }],
|
|
234
|
-
});
|
|
235
|
-
recordStable(e.seq);
|
|
236
|
-
break;
|
|
237
|
-
}
|
|
238
|
-
const blocks = [{ type: 'text', text: e.text }];
|
|
239
|
-
if (e.attachments) {
|
|
240
|
-
for (const att of e.attachments) {
|
|
241
|
-
if (att.kind === 'image') {
|
|
242
|
-
blocks.push({
|
|
243
|
-
type: 'image',
|
|
244
|
-
mediaType: att.mediaType ?? 'image/png',
|
|
245
|
-
data: att.content,
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
else if (att.kind === 'document') {
|
|
249
|
-
blocks.push({
|
|
250
|
-
type: 'document',
|
|
251
|
-
mediaType: att.mediaType ?? 'application/pdf',
|
|
252
|
-
data: att.content,
|
|
253
|
-
...(att.name ? { name: att.name } : {}),
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
else {
|
|
257
|
-
blocks.push({
|
|
258
|
-
type: 'text',
|
|
259
|
-
text: `[${att.kind}${att.name ? ` ${att.name}` : ''}]\n${att.content}`,
|
|
260
|
-
});
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
messages.push({ role: 'user', content: blocks });
|
|
265
|
-
recordStable(e.seq);
|
|
266
|
-
break;
|
|
267
|
-
}
|
|
268
|
-
case 'reasoning_message': {
|
|
269
|
-
// Render-only reasoning (no signature/encrypted) is never replayed —
|
|
270
|
-
// it exists only for the live/scrollback "Thinking" view. Replayable
|
|
271
|
-
// reasoning is stashed for content[0] of this turn's assistant message.
|
|
272
|
-
if (e.signature || e.encrypted) {
|
|
273
|
-
pendingReasoning = {
|
|
274
|
-
type: 'reasoning',
|
|
275
|
-
text: e.content,
|
|
276
|
-
...(e.signature ? { signature: e.signature } : {}),
|
|
277
|
-
...(e.redacted ? { redacted: true } : {}),
|
|
278
|
-
...(e.encrypted ? { encrypted: e.encrypted } : {}),
|
|
279
|
-
};
|
|
280
|
-
pendingReasoningSeq = e.seq;
|
|
281
|
-
}
|
|
282
|
-
break;
|
|
283
|
-
}
|
|
284
|
-
case 'assistant_message':
|
|
285
|
-
flush();
|
|
286
|
-
if (conversationalStubbed(e, el)) {
|
|
287
|
-
pendingReasoning = null;
|
|
288
|
-
messages.push({
|
|
289
|
-
role: 'assistant',
|
|
290
|
-
content: [{ type: 'text', text: conversationalStub('assistant', e.seq) }],
|
|
291
|
-
});
|
|
292
|
-
recordStable(e.seq);
|
|
293
|
-
break;
|
|
294
|
-
}
|
|
295
|
-
// A tool-only turn can log an assistant_message with empty content
|
|
296
|
-
// (end_turn + tool calls, no prose). Projecting it as an empty text
|
|
297
|
-
// block makes some providers (Anthropic) reject the NEXT request and
|
|
298
|
-
// permanently wedges the session. Skip the block — the turn's
|
|
299
|
-
// tool_use blocks are projected from tool_call_requested events —
|
|
300
|
-
// which also un-wedges historical logs that already contain one.
|
|
301
|
-
if (e.content.trim().length === 0) {
|
|
302
|
-
pendingReasoning = null;
|
|
303
|
-
recordStable(e.seq);
|
|
304
|
-
break;
|
|
305
|
-
}
|
|
306
|
-
{
|
|
307
|
-
const content = [];
|
|
308
|
-
if (pendingReasoning) {
|
|
309
|
-
content.push(pendingReasoning);
|
|
310
|
-
pendingReasoning = null;
|
|
311
|
-
}
|
|
312
|
-
content.push({ type: 'text', text: e.content });
|
|
313
|
-
messages.push({ role: 'assistant', content });
|
|
314
|
-
}
|
|
315
|
-
recordStable(e.seq);
|
|
316
|
-
break;
|
|
317
|
-
case 'tool_call_requested': {
|
|
318
|
-
if (!pendingAssistant) {
|
|
319
|
-
// Seed the assistant turn so the signed reasoning block is content[0],
|
|
320
|
-
// ahead of every tool_use (Anthropic's interleaved-thinking ordering).
|
|
321
|
-
pendingAssistant = { role: 'assistant', content: pendingReasoning ? [pendingReasoning] : [] };
|
|
322
|
-
if (pendingReasoning) {
|
|
323
|
-
pendingAssistantMaxSeq = Math.max(pendingAssistantMaxSeq, pendingReasoningSeq);
|
|
324
|
-
pendingReasoning = null;
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
pendingAssistantMaxSeq = Math.max(pendingAssistantMaxSeq, e.seq);
|
|
328
|
-
pendingAssistant.content.push({
|
|
329
|
-
type: 'tool_use',
|
|
330
|
-
id: e.callId,
|
|
331
|
-
name: e.name,
|
|
332
|
-
input: e.input,
|
|
333
|
-
});
|
|
334
|
-
break;
|
|
335
|
-
}
|
|
336
|
-
case 'tool_result': {
|
|
337
|
-
flush();
|
|
338
|
-
// Stub bulky old tool output to a recall-able marker (decision shared
|
|
339
|
-
// with estimateContextTokens via toolResultStubbed).
|
|
340
|
-
let text;
|
|
341
|
-
if (toolResultStubbed(e, el)) {
|
|
342
|
-
const recalled = el.recalledCallIds.has(e.callId) || el.recalledSeqs.has(e.seq);
|
|
343
|
-
text = toolResultStub(e.callId, toolResultBytes(e.output), recalled);
|
|
344
|
-
}
|
|
345
|
-
else if (e.error) {
|
|
346
|
-
text = `[error:${e.error.kind}] ${e.error.message}`;
|
|
347
|
-
}
|
|
348
|
-
else if (isToolDisplayResult(e.output)) {
|
|
349
|
-
// Rich result (e.g. a file diff): the model only needs the short
|
|
350
|
-
// `forModel` summary — the structured `display` is for channels.
|
|
351
|
-
text = e.output.forModel;
|
|
352
|
-
}
|
|
353
|
-
else {
|
|
354
|
-
text = typeof e.output === 'string' ? e.output : JSON.stringify(e.output ?? '');
|
|
355
|
-
}
|
|
356
|
-
messages.push({
|
|
357
|
-
role: 'tool_result',
|
|
358
|
-
content: [{ type: 'tool_result', toolUseId: e.callId, content: text, isError: !e.ok }],
|
|
359
|
-
});
|
|
360
|
-
recordStable(e.seq);
|
|
361
|
-
break;
|
|
362
|
-
}
|
|
363
|
-
default:
|
|
364
|
-
break;
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
flush();
|
|
368
|
-
if (opts.trailingUserText) {
|
|
369
|
-
// The trailing step nudge is volatile (changes per step), never part of
|
|
370
|
-
// the stable prefix — don't record it.
|
|
371
|
-
messages.push({ role: 'user', content: [{ type: 'text', text: opts.trailingUserText }] });
|
|
372
|
-
}
|
|
373
|
-
return { messages, stablePrefixIndex };
|
|
374
|
-
}
|
|
375
|
-
/**
|
|
376
|
-
* Pulls a provider stream, emits `assistant_chunk` events for text deltas,
|
|
377
|
-
* collects tool_use blocks, and returns the final `{text, toolUses, stopReason}`.
|
|
378
|
-
* Runs `onBeforeProviderCall` lifecycle hooks before the call.
|
|
379
|
-
*/
|
|
380
|
-
export async function collectProviderStream(ctx, messages, opts = {}) {
|
|
381
|
-
// Lazy tool gating (opt-in): send only always-on + loaded tool schemas, and
|
|
382
|
-
// index the rest in the system prompt. Runs BEFORE cache planning since it
|
|
383
|
-
// rewrites the system message and the tool list.
|
|
384
|
-
let effectiveMessages = messages;
|
|
385
|
-
let toolList = opts.includeTools === false ? undefined : ctx.tools.list();
|
|
386
|
-
if (ctx.lazyTools && toolList) {
|
|
387
|
-
const gated = applyLazyTools(messages, toolList, ctx.log);
|
|
388
|
-
effectiveMessages = gated.messages;
|
|
389
|
-
toolList = gated.tools;
|
|
390
|
-
}
|
|
391
|
-
// Ask the active cache strategy where to place prompt-cache breakpoints.
|
|
392
|
-
// The strategy is provider-neutral (returns CacheHints); the provider
|
|
393
|
-
// translates them (Anthropic → cache_control). Falls back to no hints when
|
|
394
|
-
// no strategy is registered. The onBeforeProviderCall hook can still adjust.
|
|
395
|
-
const descriptor = ctx.provider.models.find((m) => m.id === ctx.model);
|
|
396
|
-
const cacheHints = ctx.cacheStrategy
|
|
397
|
-
? ctx.cacheStrategy.plan(effectiveMessages, {
|
|
398
|
-
model: ctx.model,
|
|
399
|
-
contextWindow: descriptor?.contextWindow ?? 0,
|
|
400
|
-
log: ctx.log,
|
|
401
|
-
...(opts.stablePrefixIndex != null && opts.stablePrefixIndex >= 0
|
|
402
|
-
? { stablePrefixMessageIndex: opts.stablePrefixIndex }
|
|
403
|
-
: {}),
|
|
404
|
-
...(opts.volatileTailCount != null && opts.volatileTailCount > 0
|
|
405
|
-
? { volatileTailMessageCount: opts.volatileTailCount }
|
|
406
|
-
: {}),
|
|
407
|
-
})
|
|
408
|
-
: undefined;
|
|
409
|
-
// NOTE: `system` is deliberately NOT prefilled with ctx.systemPrompt — the
|
|
410
|
-
// composed system prompt already rides as the leading system-role message
|
|
411
|
-
// (see projectMessages), and providers deliver `req.system` IN ADDITION to
|
|
412
|
-
// message-derived system text. Prefilling it would duplicate the prompt.
|
|
413
|
-
// It stays as the side channel `onBeforeProviderCall` hooks use to inject
|
|
414
|
-
// per-request system text (e.g. the memory consolidation nudge).
|
|
415
|
-
// Forward the per-provider reasoning preference, but only when THIS model
|
|
416
|
-
// advertises `supportsReasoning` — providers ignore the knob otherwise, but
|
|
417
|
-
// gating here keeps requests clean and avoids unsupported-param errors.
|
|
418
|
-
const reqReasoning = descriptor?.supportsReasoning ? ctx.reasoning : undefined;
|
|
419
|
-
const req = {
|
|
420
|
-
model: ctx.model,
|
|
421
|
-
messages: effectiveMessages,
|
|
422
|
-
...(toolList ? { tools: toolList } : {}),
|
|
423
|
-
...(cacheHints && cacheHints.length > 0 ? { cacheHints } : {}),
|
|
424
|
-
...(opts.maxTokens !== undefined ? { maxTokens: opts.maxTokens } : {}),
|
|
425
|
-
...(reqReasoning ? { reasoning: reqReasoning } : {}),
|
|
426
|
-
signal: ctx.signal,
|
|
427
|
-
};
|
|
428
|
-
const transformed = await ctx.hooks.dispatchBeforeProviderCall(req, {
|
|
429
|
-
sessionId: ctx.sessionId,
|
|
430
|
-
cwd: '',
|
|
431
|
-
log: ctx.log,
|
|
432
|
-
env: {},
|
|
433
|
-
turnId: ctx.turnId,
|
|
434
|
-
iteration: opts.iteration ?? 0,
|
|
435
|
-
});
|
|
436
|
-
let text = '';
|
|
437
|
-
const toolUses = new Map();
|
|
438
|
-
let stopReason = 'end_turn';
|
|
439
|
-
let error = null;
|
|
440
|
-
let usage;
|
|
441
|
-
// Reasoning/thinking accumulation for this single provider call. Emitted as a
|
|
442
|
-
// finalized `reasoning_message` by the mode (turn-iterator / goal-loop) so it
|
|
443
|
-
// persists and round-trips; `signature`/`encrypted` carry Anthropic's signed
|
|
444
|
-
// thinking block / redacted blob for replay.
|
|
445
|
-
let reasoningText = '';
|
|
446
|
-
let reasoningSignature;
|
|
447
|
-
let reasoningRedacted = false;
|
|
448
|
-
let reasoningEncrypted;
|
|
449
|
-
let stream;
|
|
450
|
-
try {
|
|
451
|
-
stream = ctx.provider.stream(transformed);
|
|
452
|
-
}
|
|
453
|
-
catch (err) {
|
|
454
|
-
return {
|
|
455
|
-
text: '',
|
|
456
|
-
toolUses: [],
|
|
457
|
-
stopReason: 'error',
|
|
458
|
-
error: { message: err instanceof Error ? err.message : String(err), retryable: false },
|
|
459
|
-
};
|
|
460
|
-
}
|
|
461
|
-
try {
|
|
462
|
-
for await (const event of stream) {
|
|
463
|
-
switch (event.type) {
|
|
464
|
-
case 'text_delta': {
|
|
465
|
-
text += event.delta;
|
|
466
|
-
await ctx.emit({
|
|
467
|
-
type: 'assistant_chunk',
|
|
468
|
-
sessionId: ctx.sessionId,
|
|
469
|
-
turnId: ctx.turnId,
|
|
470
|
-
source: 'model',
|
|
471
|
-
delta: event.delta,
|
|
472
|
-
});
|
|
473
|
-
break;
|
|
474
|
-
}
|
|
475
|
-
case 'tool_use_start': {
|
|
476
|
-
toolUses.set(event.id, { name: event.name });
|
|
477
|
-
break;
|
|
478
|
-
}
|
|
479
|
-
case 'tool_use_end': {
|
|
480
|
-
const existing = toolUses.get(event.id) ?? {};
|
|
481
|
-
toolUses.set(event.id, { ...existing, input: event.input });
|
|
482
|
-
break;
|
|
483
|
-
}
|
|
484
|
-
case 'message_end': {
|
|
485
|
-
stopReason = event.stopReason;
|
|
486
|
-
if (event.usage)
|
|
487
|
-
usage = event.usage;
|
|
488
|
-
break;
|
|
489
|
-
}
|
|
490
|
-
case 'error': {
|
|
491
|
-
error = { message: event.message, retryable: event.retryable };
|
|
492
|
-
break;
|
|
493
|
-
}
|
|
494
|
-
case 'reasoning_delta': {
|
|
495
|
-
reasoningText += event.delta;
|
|
496
|
-
// Live preview only — parallels `assistant_chunk`; renderers
|
|
497
|
-
// accumulate ephemerally and clear on the finalized reasoning_message.
|
|
498
|
-
await ctx.emit({
|
|
499
|
-
type: 'reasoning_chunk',
|
|
500
|
-
sessionId: ctx.sessionId,
|
|
501
|
-
turnId: ctx.turnId,
|
|
502
|
-
source: 'model',
|
|
503
|
-
delta: event.delta,
|
|
504
|
-
});
|
|
505
|
-
break;
|
|
506
|
-
}
|
|
507
|
-
case 'reasoning_signature': {
|
|
508
|
-
if (event.signature)
|
|
509
|
-
reasoningSignature = event.signature;
|
|
510
|
-
if (event.encrypted)
|
|
511
|
-
reasoningEncrypted = event.encrypted;
|
|
512
|
-
if (event.redacted)
|
|
513
|
-
reasoningRedacted = true;
|
|
514
|
-
break;
|
|
515
|
-
}
|
|
516
|
-
case 'message_start':
|
|
517
|
-
case 'tool_use_delta':
|
|
518
|
-
default:
|
|
519
|
-
break;
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
catch (err) {
|
|
524
|
-
error = {
|
|
525
|
-
message: err instanceof Error ? err.message : String(err),
|
|
526
|
-
retryable: false,
|
|
527
|
-
};
|
|
528
|
-
}
|
|
529
|
-
const finalToolUses = [];
|
|
530
|
-
for (const [id, partial] of toolUses) {
|
|
531
|
-
if (!partial.name)
|
|
532
|
-
continue;
|
|
533
|
-
finalToolUses.push({ id, name: partial.name, input: partial.input ?? {} });
|
|
534
|
-
}
|
|
535
|
-
// Surface reasoning when there's visible text OR an opaque blob to replay
|
|
536
|
-
// (a redacted_thinking block has no text but must still round-trip).
|
|
537
|
-
const reasoning = reasoningText.trim().length > 0 || reasoningEncrypted
|
|
538
|
-
? {
|
|
539
|
-
text: reasoningText,
|
|
540
|
-
...(reasoningSignature ? { signature: reasoningSignature } : {}),
|
|
541
|
-
...(reasoningRedacted ? { redacted: true } : {}),
|
|
542
|
-
...(reasoningEncrypted ? { encrypted: reasoningEncrypted } : {}),
|
|
543
|
-
}
|
|
544
|
-
: undefined;
|
|
545
|
-
return {
|
|
546
|
-
text,
|
|
547
|
-
toolUses: finalToolUses,
|
|
548
|
-
stopReason,
|
|
549
|
-
error,
|
|
550
|
-
...(usage ? { usage } : {}),
|
|
551
|
-
...(reasoning ? { reasoning } : {}),
|
|
552
|
-
};
|
|
553
|
-
}
|
|
554
|
-
/**
|
|
555
|
-
* Run a single-shot (no-tools) provider turn — the shape every planner /
|
|
556
|
-
* synthesis phase shares. Runs context management (compaction + elision),
|
|
557
|
-
* emits the `provider_request` bookend, streams the response with tools
|
|
558
|
-
* disabled, then emits either an `error` event (returning `null`) or the
|
|
559
|
-
* `provider_response` bookend (returning the collected text).
|
|
560
|
-
*
|
|
561
|
-
* Replaces the ~40-line block each mode phase used to inline; centralizing it
|
|
562
|
-
* keeps event emission uniform and means a fix here (e.g. always running
|
|
563
|
-
* elision) lands for every loop strategy at once.
|
|
564
|
-
*/
|
|
565
|
-
export async function runSingleShotTurn(ctx, messages, opts = {}) {
|
|
566
|
-
await runCompactionIfNeeded(ctx);
|
|
567
|
-
await runElisionIfNeeded(ctx);
|
|
568
|
-
await ctx.emit({
|
|
569
|
-
type: 'provider_request',
|
|
570
|
-
sessionId: ctx.sessionId,
|
|
571
|
-
turnId: ctx.turnId,
|
|
572
|
-
source: 'system',
|
|
573
|
-
provider: ctx.provider.name,
|
|
574
|
-
model: ctx.model,
|
|
575
|
-
});
|
|
576
|
-
const { text, usage, error } = await collectProviderStream(ctx, messages, {
|
|
577
|
-
includeTools: false,
|
|
578
|
-
...(opts.maxTokens !== undefined ? { maxTokens: opts.maxTokens } : {}),
|
|
579
|
-
});
|
|
580
|
-
if (error) {
|
|
581
|
-
await ctx.emit({
|
|
582
|
-
type: 'error',
|
|
583
|
-
sessionId: ctx.sessionId,
|
|
584
|
-
turnId: ctx.turnId,
|
|
585
|
-
source: 'system',
|
|
586
|
-
kind: error.retryable ? 'retryable' : 'fatal',
|
|
587
|
-
message: error.message,
|
|
588
|
-
});
|
|
589
|
-
return null;
|
|
590
|
-
}
|
|
591
|
-
await ctx.emit({
|
|
592
|
-
type: 'provider_response',
|
|
593
|
-
sessionId: ctx.sessionId,
|
|
594
|
-
turnId: ctx.turnId,
|
|
595
|
-
source: 'system',
|
|
596
|
-
provider: ctx.provider.name,
|
|
597
|
-
model: ctx.model,
|
|
598
|
-
...usageEventFields(usage),
|
|
599
|
-
});
|
|
600
|
-
return text;
|
|
601
|
-
}
|
|
602
|
-
/** Identity arguments that pin "the same target" across volatile-arg variation,
|
|
603
|
-
* best-first. The first present string field wins. */
|
|
604
|
-
const IDENTITY_ARG_KEYS = ['url', 'file_path', 'path', 'command', 'cmd', 'query', 'pattern'];
|
|
605
|
-
function identityArg(input) {
|
|
606
|
-
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
607
|
-
return null;
|
|
608
|
-
const o = input;
|
|
609
|
-
for (const k of IDENTITY_ARG_KEYS) {
|
|
610
|
-
const v = o[k];
|
|
611
|
-
if (typeof v === 'string' && v.trim().length > 0)
|
|
612
|
-
return `${k}=${v}`;
|
|
613
|
-
}
|
|
614
|
-
return null;
|
|
615
|
-
}
|
|
616
|
-
export function createStuckLoopDetector(opts = {}) {
|
|
617
|
-
const windowSize = opts.windowSize ?? 8;
|
|
618
|
-
const repeatThreshold = opts.repeatThreshold ?? 3;
|
|
619
|
-
// Near-dups need a higher count + a wider window (they're spread out across a
|
|
620
|
-
// burst of other calls), and tolerate a couple of legit "bigger refetch" tries.
|
|
621
|
-
const nearWindowSize = opts.nearWindowSize ?? Math.max(windowSize * 2, 16);
|
|
622
|
-
const nearThreshold = opts.nearThreshold ?? Math.max(repeatThreshold + 2, 5);
|
|
623
|
-
const recent = [];
|
|
624
|
-
const recentNear = [];
|
|
625
|
-
return {
|
|
626
|
-
windowSize,
|
|
627
|
-
repeatThreshold,
|
|
628
|
-
record(toolName, input) {
|
|
629
|
-
const key = `${toolName}|${stableHash(input)}`;
|
|
630
|
-
recent.push(key);
|
|
631
|
-
if (recent.length > windowSize)
|
|
632
|
-
recent.shift();
|
|
633
|
-
const exactCount = recent.filter((k) => k === key).length;
|
|
634
|
-
if (exactCount >= repeatThreshold)
|
|
635
|
-
return { stuck: true, count: exactCount, kind: 'exact' };
|
|
636
|
-
let nearCount = 0;
|
|
637
|
-
const id = identityArg(input);
|
|
638
|
-
if (id !== null) {
|
|
639
|
-
const nearKey = `${toolName}|${id}`;
|
|
640
|
-
recentNear.push(nearKey);
|
|
641
|
-
if (recentNear.length > nearWindowSize)
|
|
642
|
-
recentNear.shift();
|
|
643
|
-
nearCount = recentNear.filter((k) => k === nearKey).length;
|
|
644
|
-
if (nearCount >= nearThreshold)
|
|
645
|
-
return { stuck: true, count: nearCount, kind: 'near' };
|
|
646
|
-
}
|
|
647
|
-
return { stuck: false, count: Math.max(exactCount, nearCount), kind: 'exact' };
|
|
648
|
-
},
|
|
649
|
-
};
|
|
650
|
-
}
|
|
651
|
-
/**
|
|
652
|
-
* Stable, key-order-canonical hash of a tool call's input, so `{a:1,b:2}` and
|
|
653
|
-
* `{b:2,a:1}` produce the same key. Use for any "have I seen this call before"
|
|
654
|
-
* comparison — a raw `JSON.stringify` is NOT order-stable.
|
|
655
|
-
*/
|
|
656
|
-
export function stableHash(input) {
|
|
657
|
-
return canonicalize(input);
|
|
658
|
-
}
|
|
659
|
-
function canonicalize(value) {
|
|
660
|
-
if (value === null || value === undefined)
|
|
661
|
-
return 'null';
|
|
662
|
-
if (typeof value !== 'object')
|
|
663
|
-
return JSON.stringify(value);
|
|
664
|
-
if (Array.isArray(value)) {
|
|
665
|
-
return '[' + value.map(canonicalize).join(',') + ']';
|
|
666
|
-
}
|
|
667
|
-
const entries = Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
668
|
-
return '{' + entries.map(([k, v]) => JSON.stringify(k) + ':' + canonicalize(v)).join(',') + '}';
|
|
669
|
-
}
|
|
13
|
+
export { ELISION_SYSTEM_NOTE, buildSystemPromptWithSkills, projectMessagesFromLog, projectMessages, } from './mode/project-messages.js';
|
|
14
|
+
export { collectProviderStream, } from './mode/collect-stream.js';
|
|
15
|
+
export { runSingleShotTurn } from './mode/single-shot.js';
|
|
16
|
+
export { createStuckLoopDetector, } from './mode/stuck-loop.js';
|
|
17
|
+
export { stableHash } from './mode/stable-hash.js';
|
|
670
18
|
//# sourceMappingURL=mode-helpers.js.map
|