@ikarmic/forge-core 5.3.0 → 5.4.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/dist/agent/agent.js +132 -70
- package/dist/agent/cache-hints.d.ts +2 -0
- package/dist/agent/cache-hints.js +18 -3
- package/dist/agent/history-adapt.d.ts +23 -0
- package/dist/agent/history-adapt.js +114 -0
- package/dist/agent/prompts.d.ts +4 -2
- package/dist/agent/prompts.js +10 -2
- package/dist/agent/tool-repair.d.ts +8 -0
- package/dist/agent/tool-repair.js +16 -0
- package/dist/catalog/models-dev.d.ts +10 -0
- package/dist/catalog/models-dev.js +12 -0
- package/dist/checkpoints/store.js +3 -1
- package/dist/context/compaction.d.ts +21 -6
- package/dist/context/compaction.js +86 -26
- package/dist/context/mask.d.ts +9 -9
- package/dist/context/mask.js +30 -1
- package/dist/format/auto-format.d.ts +22 -0
- package/dist/format/auto-format.js +39 -0
- package/dist/github/github-tools.d.ts +1 -0
- package/dist/github/github-tools.js +9 -9
- package/dist/github/pr.d.ts +34 -0
- package/dist/github/pr.js +70 -0
- package/dist/human-text.d.ts +6 -0
- package/dist/human-text.js +37 -0
- package/dist/index.d.ts +13 -8
- package/dist/index.js +10 -6
- package/dist/journey/answers-block.d.ts +2 -4
- package/dist/lsp/diagnostics.d.ts +4 -0
- package/dist/lsp/diagnostics.js +8 -2
- package/dist/lsp/index.d.ts +1 -1
- package/dist/lsp/index.js +1 -1
- package/dist/mcp/index.d.ts +1 -1
- package/dist/mcp/index.js +1 -1
- package/dist/mcp/json-schema-to-zod.js +2 -4
- package/dist/mcp/mcp-client.d.ts +14 -0
- package/dist/mcp/mcp-client.js +102 -18
- package/dist/orchestrator/specialists-registry.js +14 -1
- package/dist/orchestrator/specialists.js +3 -3
- package/dist/semantic/embedder.d.ts +11 -0
- package/dist/semantic/embedder.js +16 -0
- package/dist/semantic/semantic-index.d.ts +39 -1
- package/dist/semantic/semantic-index.js +43 -13
- package/dist/semantic/vector-store.d.ts +2 -0
- package/dist/semantic/vector-store.js +13 -1
- package/dist/sessions/index.d.ts +2 -2
- package/dist/sessions/index.js +1 -1
- package/dist/sessions/search.js +2 -2
- package/dist/sessions/session-export.d.ts +1 -1
- package/dist/sessions/session-export.js +41 -33
- package/dist/sessions/session-store.d.ts +20 -1
- package/dist/sessions/session-store.js +7 -0
- package/dist/sessions/watch.js +4 -4
- package/dist/skills/activator.d.ts +6 -0
- package/dist/skills/activator.js +17 -0
- package/dist/skills/skill-tool.d.ts +12 -0
- package/dist/skills/skill-tool.js +28 -0
- package/dist/tools/background-process.d.ts +63 -0
- package/dist/tools/background-process.js +150 -0
- package/dist/tools/edit-file.js +2 -27
- package/dist/tools/edit-match.d.ts +16 -0
- package/dist/tools/edit-match.js +115 -0
- package/dist/tools/fetch-url.d.ts +15 -1
- package/dist/tools/fetch-url.js +7 -47
- package/dist/tools/get-diagnostics.d.ts +14 -0
- package/dist/tools/get-diagnostics.js +44 -0
- package/dist/tools/glob-filter.d.ts +7 -0
- package/dist/tools/glob-filter.js +12 -0
- package/dist/tools/html-to-markdown.d.ts +13 -0
- package/dist/tools/html-to-markdown.js +507 -0
- package/dist/tools/index.d.ts +5 -1
- package/dist/tools/index.js +11 -1
- package/dist/tools/list-files.d.ts +1 -0
- package/dist/tools/list-files.js +17 -5
- package/dist/tools/list-symbols.js +2 -2
- package/dist/tools/network-policy.d.ts +5 -0
- package/dist/tools/network-policy.js +77 -0
- package/dist/tools/office-docs.d.ts +6 -0
- package/dist/tools/office-docs.js +393 -0
- package/dist/tools/permission-rules.js +55 -5
- package/dist/tools/read-file.d.ts +2 -0
- package/dist/tools/read-file.js +47 -10
- package/dist/tools/read-image.js +2 -2
- package/dist/tools/read-only.d.ts +1 -1
- package/dist/tools/read-only.js +4 -0
- package/dist/tools/repo-clone.d.ts +40 -0
- package/dist/tools/repo-clone.js +218 -0
- package/dist/tools/result-cap.d.ts +8 -0
- package/dist/tools/result-cap.js +44 -0
- package/dist/tools/run-command.d.ts +18 -1
- package/dist/tools/run-command.js +41 -16
- package/dist/tools/search-files.js +5 -5
- package/dist/tools/search-worker.js +11 -2
- package/dist/tools/shared.d.ts +14 -1
- package/dist/tools/shared.js +76 -4
- package/dist/tools/shell.d.ts +27 -0
- package/dist/tools/shell.js +80 -0
- package/dist/tools/sub-agent.js +3 -1
- package/dist/tools/suggest-plan.d.ts +18 -0
- package/dist/tools/suggest-plan.js +28 -0
- package/dist/types.d.ts +74 -0
- package/dist/wakeup/wakeup-tools.d.ts +22 -0
- package/dist/wakeup/wakeup-tools.js +34 -0
- package/dist/wakeup/wakeup.d.ts +75 -0
- package/dist/wakeup/wakeup.js +151 -0
- package/dist/worktree/health.d.ts +51 -0
- package/dist/worktree/health.js +186 -0
- package/dist/worktree/index.d.ts +4 -0
- package/dist/worktree/index.js +2 -0
- package/dist/worktree/setup.d.ts +51 -0
- package/dist/worktree/setup.js +173 -0
- package/dist/worktree/worktree.d.ts +7 -0
- package/dist/worktree/worktree.js +9 -4
- package/package.json +1 -1
package/dist/agent/agent.js
CHANGED
|
@@ -2,13 +2,19 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { release } from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { streamText } from 'ai';
|
|
5
|
+
import { formatFile, formatMessage } from '../format/auto-format.js';
|
|
6
|
+
import { BackgroundProcesses } from '../tools/background-process.js';
|
|
7
|
+
import { networkDenial } from '../tools/network-policy.js';
|
|
8
|
+
import { runnerShell } from '../tools/shell.js';
|
|
9
|
+
import { cancelWakeupTool, scheduleWakeupTool } from '../wakeup/wakeup-tools.js';
|
|
5
10
|
import { lintFile, lintMessage } from '../lint/auto-lint.js';
|
|
6
11
|
import { buildMemoryBlock } from '../memory/memory-store.js';
|
|
7
12
|
import { filesTouchedBy } from '../tools/write-tools.js';
|
|
8
13
|
import { buildExtraInstructionsBlock } from '../steering/extra-instructions.js';
|
|
9
14
|
import { buildProjectInstructionsBlock } from '../steering/project-instructions.js';
|
|
10
15
|
import { buildRepoMap, repoMapBlock } from '../repomap/repo-map.js';
|
|
11
|
-
import {
|
|
16
|
+
import { adaptHistoryForModel, normalizeForModel } from './history-adapt.js';
|
|
17
|
+
import { buildLlmSummarizer, compactMessages, effectiveLimitTokens, estimateMessageTokens, getModelWindowTokens, shouldCompactForBudget } from '../context/compaction.js';
|
|
12
18
|
import { createStepMasker } from '../context/mask.js';
|
|
13
19
|
import { lookupPricing, computeCostUsd } from '../pricing.js';
|
|
14
20
|
import { BrowserSession } from '../browser/session.js';
|
|
@@ -19,6 +25,7 @@ import { createCodebaseSearchTool } from '../tools/codebase-search.js';
|
|
|
19
25
|
import { createGitHubTools, githubFromEnv } from '../github/github-tools.js';
|
|
20
26
|
import { createWebSearchTool, webSearchFromEnv } from '../tools/web-search.js';
|
|
21
27
|
import { embedderFromEnv } from '../semantic/embedder.js';
|
|
28
|
+
import { capToolResult } from '../tools/result-cap.js';
|
|
22
29
|
import { DEFAULT_VERIFY_RETRIES, failureMessage, runVerification } from './verify.js';
|
|
23
30
|
import { LoopGuard, resultKeyOf } from './loop-guard.js';
|
|
24
31
|
import { isListedCommand } from '../tools/command-allow.js';
|
|
@@ -27,13 +34,16 @@ import { createLanguageModel } from '../providers.js';
|
|
|
27
34
|
import { BUILTIN_TOOLS } from '../tools/index.js';
|
|
28
35
|
import { ToolResultCache, cachedExecute } from '../tools/result-cache.js';
|
|
29
36
|
import { ThinkSplitter, stripThinkTags } from './think-tags.js';
|
|
37
|
+
import { repairToolCall } from './tool-repair.js';
|
|
38
|
+
import { maxOutputTokensFor } from '../catalog/models-dev.js';
|
|
30
39
|
import { redact } from '../tools/redact.js';
|
|
31
|
-
import { composeSkillsPrompt, selectSkills } from '../skills/activator.js';
|
|
40
|
+
import { buildSkillIndex, composeSkillsPrompt, selectSkills } from '../skills/activator.js';
|
|
41
|
+
import { createSkillTool } from '../skills/skill-tool.js';
|
|
32
42
|
import { MAX_TOOL_INJECTIONS, selectAtStart, selectOnToolTouch, toWorkspaceRelative, wrapSteering } from '../steering/select.js';
|
|
33
|
-
import { applyCacheHints } from './cache-hints.js';
|
|
43
|
+
import { applyCacheHints, cacheProviderOptions } from './cache-hints.js';
|
|
34
44
|
import { raceAbort } from './race-abort.js';
|
|
35
45
|
import { DEFAULT_PROVIDER_RETRIES, DEFAULT_RETRY_BASE_MS, defaultNetworkProbe, errorSummary, isContextOverflowError, isDisconnected, retryDecision, waitForNetwork, waitOrAbort, } from './retry.js';
|
|
36
|
-
import { attachmentParts, formatBytes
|
|
46
|
+
import { attachmentParts, formatBytes } from '../attachments.js';
|
|
37
47
|
import { renderAnswersBlock } from '../journey/answers-block.js';
|
|
38
48
|
import { CONTINUE_PROMPT, DEFAULT_PLAN_MODE_SUFFIX, DEFAULT_SPEC_MODE_SUFFIX, DEFAULT_SYSTEM_PROMPT, LAST_STEP_PROMPT, environmentBlock, modelGuidanceBlock, todoNudgePrompt } from './prompts.js';
|
|
39
49
|
import { detectStackFacts, stackBlock } from './stack-facts.js';
|
|
@@ -45,6 +55,8 @@ const MAX_TODO_NUDGES = 3;
|
|
|
45
55
|
const MAX_EMPTY_REPLAYS = 2;
|
|
46
56
|
/** Consecutive tool calls with invalid arguments before the run stops (Kilo: REPEATED_TOOL_FAILURE_LIMIT). */
|
|
47
57
|
const MAX_INVALID_CALLS = 3;
|
|
58
|
+
/** Most recent content kept word for word by compaction, at the top end (RB-56). */
|
|
59
|
+
const KEEP_RECENT_TOKENS = 20_000;
|
|
48
60
|
export const DEFAULT_MAX_DURATION_MS = 10 * 60 * 1000;
|
|
49
61
|
const REDACTABLE_TOOLS = new Set(['read_file', 'search_files', 'list_files']);
|
|
50
62
|
/** Tools whose successful `path` argument can trigger mid-run fileMatch steering. */
|
|
@@ -81,6 +93,12 @@ export class Agent {
|
|
|
81
93
|
const allTools = (config.disableBuiltins
|
|
82
94
|
? [...(config.extraTools ?? [])]
|
|
83
95
|
: [...BUILTIN_TOOLS, ...(config.extraTools ?? [])]);
|
|
96
|
+
// Wakeups need a chat that can deliver them, so the tools exist only when a surface supplied a scheduler.
|
|
97
|
+
// The `skill` tool goes with the skills index in the prompt: both exist only when a registry is supplied and not disabled.
|
|
98
|
+
if (config.skills && !config.skills.disabled)
|
|
99
|
+
allTools.push(createSkillTool(config.skills.registry));
|
|
100
|
+
if (config.wakeups)
|
|
101
|
+
allTools.push(scheduleWakeupTool, cancelWakeupTool);
|
|
84
102
|
if (config.browser) {
|
|
85
103
|
this.browser = new BrowserSession(config.browser === true ? {} : config.browser);
|
|
86
104
|
allTools.push(...createBrowserTools(this.browser));
|
|
@@ -96,6 +114,8 @@ export class Agent {
|
|
|
96
114
|
...(config.semanticSearch.extensions ? { extensions: config.semanticSearch.extensions } : {}),
|
|
97
115
|
...(config.semanticSearch.minScore !== undefined ? { minScore: config.semanticSearch.minScore } : {}),
|
|
98
116
|
...(config.semanticSearch.defaultLimit ? { defaultLimit: config.semanticSearch.defaultLimit } : {}),
|
|
117
|
+
...(config.semanticSearch.consent ? { consent: config.semanticSearch.consent } : {}),
|
|
118
|
+
...(config.semanticSearch.onStatus ? { onStatus: config.semanticSearch.onStatus } : {}),
|
|
99
119
|
}));
|
|
100
120
|
}
|
|
101
121
|
const memoryFiltered = config.disableMemory
|
|
@@ -123,7 +143,7 @@ export class Agent {
|
|
|
123
143
|
const cfg = this.checkpoints?.captureBeforeTools;
|
|
124
144
|
if (!cfg) {
|
|
125
145
|
// Default: file-mutating tools + run_command (which can mutate via shell).
|
|
126
|
-
return new Set(['write_file', 'edit_file', 'edit_notebook', 'apply_patch', 'run_command']);
|
|
146
|
+
return new Set(['write_file', 'edit_file', 'edit_notebook', 'apply_patch', 'run_command', 'bg_start']);
|
|
127
147
|
}
|
|
128
148
|
return new Set(cfg);
|
|
129
149
|
}
|
|
@@ -197,9 +217,15 @@ export class Agent {
|
|
|
197
217
|
/** Docs injected mid-run (not skipped ones), so compaction can tell which it removed. */
|
|
198
218
|
const steeringInMessages = new Set();
|
|
199
219
|
let steeringInjections = 0;
|
|
220
|
+
// The system message is rebuilt on every turn from what is true now (RB-57): files, steering, the mode's prompt
|
|
221
|
+
// and the date can change between turns. Built in a stable order, it is byte-identical when nothing changed.
|
|
222
|
+
const systemSteering = options.history
|
|
223
|
+
? selectAtStart(steeringDocs, { task: options.task, cwd, includeAlways: true }, this.config.steering?.maxBytes).system
|
|
224
|
+
: startSteering.system;
|
|
225
|
+
const system = { role: 'system', content: await buildSystemContent(this.config, options.task, this.skills, systemSteering) };
|
|
200
226
|
const messages = options.history
|
|
201
|
-
? [...options.history, { role: 'user', content: userContent }]
|
|
202
|
-
:
|
|
227
|
+
? [system, ...adaptHistoryForModel(options.history, this.config.model).filter((m, i) => !(i === 0 && m.role === 'system')), { role: 'user', content: userContent }]
|
|
228
|
+
: [system, { role: 'user', content: userContent }];
|
|
203
229
|
if (options.attachments && options.attachments.length > 0) {
|
|
204
230
|
// Attached files ride in this turn's user message as native parts (see attachments.ts).
|
|
205
231
|
messages[messages.length - 1] = {
|
|
@@ -207,6 +233,18 @@ export class Agent {
|
|
|
207
233
|
content: [{ type: 'text', text: userContent }, ...attachmentParts(options.attachments)],
|
|
208
234
|
};
|
|
209
235
|
}
|
|
236
|
+
// This turn's messages on their own (RB-58): compaction replaces `messages`, never these, so a surface can show
|
|
237
|
+
// the whole conversation. Everything the run adds goes through append().
|
|
238
|
+
const turnMessages = [messages[messages.length - 1]];
|
|
239
|
+
const append = (m) => {
|
|
240
|
+
messages.push(m);
|
|
241
|
+
turnMessages.push(m);
|
|
242
|
+
};
|
|
243
|
+
const unappend = () => {
|
|
244
|
+
const last = messages.pop();
|
|
245
|
+
if (last !== undefined && turnMessages[turnMessages.length - 1] === last)
|
|
246
|
+
turnMessages.pop();
|
|
247
|
+
};
|
|
210
248
|
// Plan mode: hard-cap iterations to 1 and add a system prompt suffix
|
|
211
249
|
// telling the model to emit a plan as text and stop. Spec mode: allow
|
|
212
250
|
// up to `specMaxIterations` turns (default 15) so the model can
|
|
@@ -226,6 +264,7 @@ export class Agent {
|
|
|
226
264
|
? createStepMasker({
|
|
227
265
|
keepRecentTurns: (typeof maskConfig === 'object' ? maskConfig.keepRecentTurns : undefined) ?? 6,
|
|
228
266
|
minChars: (typeof maskConfig === 'object' ? maskConfig.minChars : undefined) ?? 600,
|
|
267
|
+
...(typeof maskConfig === 'object' && maskConfig.protectTokens !== undefined ? { protectTokens: maskConfig.protectTokens } : {}),
|
|
229
268
|
softBudgetTokens: 0.5 * effectiveLimitTokens(this.config.model, this.config.compactionThreshold ?? 0.7, this.config.contextBudgetTokens),
|
|
230
269
|
})
|
|
231
270
|
: undefined;
|
|
@@ -241,6 +280,8 @@ export class Agent {
|
|
|
241
280
|
let reportedStreamError;
|
|
242
281
|
const modelFactory = this.config.modelFactory ?? createLanguageModel;
|
|
243
282
|
let currentModelConfig = this.config.model;
|
|
283
|
+
/** The prompt size at which to compact for a given model: its window times the threshold, or the budget. */
|
|
284
|
+
const limitOf = (m) => effectiveLimitTokens(m, this.config.compactionThreshold ?? 0.7, this.config.contextBudgetTokens);
|
|
244
285
|
let model = modelFactory(currentModelConfig);
|
|
245
286
|
let stepPricing = this.pricing;
|
|
246
287
|
let lastToolNames = [];
|
|
@@ -296,7 +337,16 @@ export class Agent {
|
|
|
296
337
|
...(this.config.permissionRules ? { permissionRules: this.config.permissionRules } : {}),
|
|
297
338
|
...(this.config.toolSandbox ? { toolSandbox: this.config.toolSandbox } : {}),
|
|
298
339
|
shownFolderInstructions: new Set(),
|
|
340
|
+
processes: new BackgroundProcesses(),
|
|
341
|
+
...(this.config.shell ? { shell: this.config.shell } : {}),
|
|
342
|
+
// Earlier grants of this chat (G16), then whatever repo_clone and a yes to a question add during the run.
|
|
343
|
+
readRoots: [...(this.config.readRoots ?? [])],
|
|
344
|
+
...(this.config.askReadOutside ? { askReadOutside: this.config.askReadOutside } : {}),
|
|
345
|
+
...(this.config.suggestPlan ? { suggestPlan: true } : {}),
|
|
346
|
+
...(this.config.planDeclined ? { planDeclined: true } : {}),
|
|
347
|
+
...(this.config.wakeups ? { wakeups: this.config.wakeups } : {}),
|
|
299
348
|
...(this.config.maxToolOutputChars ? { maxOutputChars: this.config.maxToolOutputChars } : {}),
|
|
349
|
+
...(typeof this.config.verify?.lsp === 'object' ? { lspConfig: this.config.verify.lsp } : {}),
|
|
300
350
|
...(this.config.askUser ? { askUser: this.config.askUser } : {}),
|
|
301
351
|
...(this.config.askBudget ? { askBudget: this.config.askBudget } : {}),
|
|
302
352
|
todos: {
|
|
@@ -318,11 +368,15 @@ export class Agent {
|
|
|
318
368
|
await this.hooks.dispatch({ type: 'PreCompact', messagesCount: messages.length, tokens, cwd });
|
|
319
369
|
}
|
|
320
370
|
const summarizer = this.config.compactionSummarizer ?? this.buildDefaultSummarizer();
|
|
371
|
+
const limit = limitOf(currentModelConfig);
|
|
321
372
|
const result = await compactMessages(messages, summarizer, {
|
|
322
373
|
keepRecent: this.config.compactionKeepRecent,
|
|
374
|
+
keepRecentTokens: Math.min(KEEP_RECENT_TOKENS, Math.floor(limit / 4)),
|
|
323
375
|
});
|
|
324
376
|
if (result.droppedCount === 0)
|
|
325
377
|
return 0;
|
|
378
|
+
// The last reported size describes the prompt before compaction; until the next call reports, the estimate rules.
|
|
379
|
+
lastTurnInputTokens = 0;
|
|
326
380
|
messages.length = 0;
|
|
327
381
|
for (const m of result.messages)
|
|
328
382
|
messages.push(m);
|
|
@@ -412,10 +466,20 @@ export class Agent {
|
|
|
412
466
|
}
|
|
413
467
|
wasInThink = thinkSplitter.reasoning;
|
|
414
468
|
};
|
|
469
|
+
if (!planMode && !specMode && this.config.enableCompaction) {
|
|
470
|
+
const before = Math.max(lastTurnInputTokens, estimateMessageTokens(messages));
|
|
471
|
+
if (before >= limitOf(currentModelConfig))
|
|
472
|
+
await compactNow(before);
|
|
473
|
+
}
|
|
415
474
|
let promptMessages = messages;
|
|
416
475
|
if (masker) {
|
|
417
|
-
const view = masker.apply(messages, Math.max(lastTurnInputTokens,
|
|
418
|
-
|
|
476
|
+
const view = masker.apply(messages, Math.max(lastTurnInputTokens, estimateMessageTokens(messages)));
|
|
477
|
+
// Stubs are stored in the prompt history (RB-60), so the next turn sends the same prefix and the cache hits.
|
|
478
|
+
// The full results stay in turnMessages, which the transcript keeps (RB-58).
|
|
479
|
+
for (let i = 0; i < messages.length; i++)
|
|
480
|
+
if (view.messages[i] !== messages[i])
|
|
481
|
+
messages[i] = view.messages[i];
|
|
482
|
+
promptMessages = messages;
|
|
419
483
|
if (view.stepped)
|
|
420
484
|
emit({ type: 'context_masked', maskedCount: view.maskedCount, savedTokens: Math.ceil(view.savedChars / 4) });
|
|
421
485
|
}
|
|
@@ -425,17 +489,27 @@ export class Agent {
|
|
|
425
489
|
promptMessages = [...promptMessages, { role: 'user', content: LAST_STEP_PROMPT }];
|
|
426
490
|
const result = streamText({
|
|
427
491
|
model,
|
|
428
|
-
|
|
492
|
+
// Per-provider clean-up on every call (RB-61), then cache breakpoints on the cleaned messages.
|
|
493
|
+
messages: cachePrompts
|
|
494
|
+
? applyCacheHints(normalizeForModel(promptMessages, currentModelConfig), currentModelConfig)
|
|
495
|
+
: normalizeForModel(promptMessages, currentModelConfig),
|
|
496
|
+
...(cachePrompts && cacheProviderOptions(currentModelConfig, this.config.sessionId)
|
|
497
|
+
? { providerOptions: cacheProviderOptions(currentModelConfig, this.config.sessionId) }
|
|
498
|
+
: {}),
|
|
429
499
|
// v7 rejects a system-role message inside `messages` by default
|
|
430
500
|
// ("Invalid prompt: System messages are not allowed in the prompt
|
|
431
501
|
// or messages fields. Use the instructions option instead.").
|
|
432
|
-
// Forge's system prompt lives as messages[0] (
|
|
502
|
+
// Forge's system prompt lives as messages[0] (buildSystemContent)
|
|
433
503
|
//: this is the SDK's own documented escape hatch to keep that
|
|
434
504
|
// architecture rather than restructuring session persistence and
|
|
435
505
|
// compaction (both of which also treat messages[0] as the system
|
|
436
506
|
// message) around a separate `system` option.
|
|
437
507
|
allowSystemInMessages: true,
|
|
438
508
|
tools: toAiSdkTools(this.tools),
|
|
509
|
+
repairToolCall,
|
|
510
|
+
// Ask for the model's own reply length (G8): some hosts default to a few thousand tokens, which cuts a long
|
|
511
|
+
// write_file call in half. A half-written tool call cannot be continued, unlike text.
|
|
512
|
+
...(maxOutputTokensFor(currentModelConfig) ? { maxOutputTokens: maxOutputTokensFor(currentModelConfig) } : {}),
|
|
439
513
|
...(lastStep ? { toolChoice: 'none' } : {}),
|
|
440
514
|
abortSignal,
|
|
441
515
|
// Retries are decided below (retryDecision), for failures before and during the stream alike.
|
|
@@ -529,7 +603,7 @@ export class Agent {
|
|
|
529
603
|
stepFailure = undefined;
|
|
530
604
|
if (isContextOverflowError(failure) && this.config.enableCompaction && !planMode && !specMode && overflowCompactions < 2) {
|
|
531
605
|
overflowCompactions++;
|
|
532
|
-
if ((await compactNow(Math.max(lastTurnInputTokens,
|
|
606
|
+
if ((await compactNow(Math.max(lastTurnInputTokens, estimateMessageTokens(messages)))) > 0) {
|
|
533
607
|
partialStepText = '';
|
|
534
608
|
iterations--;
|
|
535
609
|
continue;
|
|
@@ -594,7 +668,7 @@ export class Agent {
|
|
|
594
668
|
continuedText = '';
|
|
595
669
|
lastToolNames = (toolCalls ?? []).map((c) => c.toolName);
|
|
596
670
|
for (const m of response.messages) {
|
|
597
|
-
|
|
671
|
+
append(m);
|
|
598
672
|
}
|
|
599
673
|
// Infinite output: a reply cut off by the output-token limit (and not
|
|
600
674
|
// calling tools) is continued with a follow-up user message rather
|
|
@@ -606,7 +680,7 @@ export class Agent {
|
|
|
606
680
|
continuations++;
|
|
607
681
|
continuedText = finalText;
|
|
608
682
|
emit({ type: 'continuation', count: continuations });
|
|
609
|
-
|
|
683
|
+
append({ role: 'user', content: CONTINUE_PROMPT });
|
|
610
684
|
continue;
|
|
611
685
|
}
|
|
612
686
|
if (!toolCalls || toolCalls.length === 0) {
|
|
@@ -639,7 +713,7 @@ export class Agent {
|
|
|
639
713
|
todoNudges++;
|
|
640
714
|
answeringNudge = true;
|
|
641
715
|
todoNudgeMessage = { role: 'user', content: todoNudgePrompt(openTodos) };
|
|
642
|
-
|
|
716
|
+
append(todoNudgeMessage);
|
|
643
717
|
emit({ type: 'todo_nudge', open: openTodos.length });
|
|
644
718
|
continue;
|
|
645
719
|
}
|
|
@@ -682,7 +756,7 @@ export class Agent {
|
|
|
682
756
|
verification = { passed: outcome.passed, attempts: verifyAttempts };
|
|
683
757
|
if (!outcome.passed && verifyFixes < (this.config.verify.maxRetries ?? DEFAULT_VERIFY_RETRIES)) {
|
|
684
758
|
verifyFixes++;
|
|
685
|
-
|
|
759
|
+
append({ role: 'user', content: failureMessage(outcome.failures) });
|
|
686
760
|
continue;
|
|
687
761
|
}
|
|
688
762
|
}
|
|
@@ -766,12 +840,17 @@ export class Agent {
|
|
|
766
840
|
}
|
|
767
841
|
const toolStartedAt = Date.now();
|
|
768
842
|
try {
|
|
769
|
-
|
|
843
|
+
// With the sandbox's network off, Forge's own network tools are held to its allowed hosts, from one place (F13).
|
|
844
|
+
const denied = networkDenial(def.name, toolArgs, this.config.commandSandbox);
|
|
845
|
+
if (denied)
|
|
846
|
+
throw new Error(`[sandbox] ${denied}`);
|
|
847
|
+
// One size limit for every tool, not only run_command (G6).
|
|
848
|
+
const rawResult = capToolResult(await def.execute(toolArgs, {
|
|
770
849
|
...ctx,
|
|
771
850
|
policy: this.config.toolSandbox?.policies?.[def.name],
|
|
772
851
|
onProgress: (msg) => emit({ type: 'text_delta', text: `\n[${def.name}] ${msg}\n` }),
|
|
773
852
|
onChildProgress: (event) => emit({ type: 'tool_result', name: def.name, id: call.toolCallId, result: `[child] ${event.event.type}`, isError: false }),
|
|
774
|
-
});
|
|
853
|
+
}), this.config.maxToolOutputChars);
|
|
775
854
|
// Apply redaction to read-style tool output. The model gets the
|
|
776
855
|
// scrubbed version; the user (via the on-event stream) sees the
|
|
777
856
|
// count so they know something was hidden.
|
|
@@ -792,6 +871,15 @@ export class Agent {
|
|
|
792
871
|
if (FILE_WRITE_TOOLS.has(call.toolName))
|
|
793
872
|
for (const p of touchedFiles)
|
|
794
873
|
editedFiles.add(p);
|
|
874
|
+
if (this.config.autoFormat && FILE_WRITE_TOOLS.has(call.toolName)) {
|
|
875
|
+
for (const editedPath of touchedFiles) {
|
|
876
|
+
const note = formatMessage(editedPath, await formatFile(editedPath, this.config.autoFormat, { ...ctx, policy: undefined }));
|
|
877
|
+
if (note)
|
|
878
|
+
finalResult = typeof finalResult === 'string' ? `${finalResult}
|
|
879
|
+
|
|
880
|
+
${note}` : { result: finalResult, autoFormat: note };
|
|
881
|
+
}
|
|
882
|
+
}
|
|
795
883
|
if (this.config.autoLint && FILE_WRITE_TOOLS.has(call.toolName)) {
|
|
796
884
|
for (const editedPath of touchedFiles) {
|
|
797
885
|
const report = await lintFile(editedPath, this.config.autoLint, { ...ctx, policy: undefined }).catch(() => null);
|
|
@@ -861,7 +949,7 @@ export class Agent {
|
|
|
861
949
|
toolResultMessages.push({ role: 'tool', content: currentToolResults });
|
|
862
950
|
}
|
|
863
951
|
for (const m of toolResultMessages)
|
|
864
|
-
|
|
952
|
+
append(m);
|
|
865
953
|
if (loopTripped) {
|
|
866
954
|
emit({ type: 'loop_detected', tool: loopTripped.tool, count: loopTripped.count });
|
|
867
955
|
reason = 'loop_detected';
|
|
@@ -895,7 +983,7 @@ export class Agent {
|
|
|
895
983
|
}
|
|
896
984
|
}
|
|
897
985
|
if (injected.length > 0) {
|
|
898
|
-
|
|
986
|
+
append({ role: 'user', content: injected.map(wrapSteering).join('\n\n') });
|
|
899
987
|
}
|
|
900
988
|
}
|
|
901
989
|
// Emit updated cache stats after the tool round so the UI sees hits.
|
|
@@ -910,8 +998,8 @@ export class Agent {
|
|
|
910
998
|
// Two independent estimates of "how big is the next prompt":
|
|
911
999
|
// take the larger rather than summing them (they're not additive
|
|
912
1000
|
// components of the same total, they're two guesses at it).
|
|
913
|
-
const totalForNextTurn = Math.max(lastTurnInputTokens,
|
|
914
|
-
if (shouldCompactForBudget(totalForNextTurn,
|
|
1001
|
+
const totalForNextTurn = Math.max(lastTurnInputTokens, estimateMessageTokens(messages));
|
|
1002
|
+
if (shouldCompactForBudget(totalForNextTurn, currentModelConfig, threshold, this.config.contextBudgetTokens)) {
|
|
915
1003
|
await compactNow(totalForNextTurn);
|
|
916
1004
|
}
|
|
917
1005
|
}
|
|
@@ -959,17 +1047,22 @@ export class Agent {
|
|
|
959
1047
|
// must not hand that unanswered message back as history.
|
|
960
1048
|
const lastMessage = messages[messages.length - 1];
|
|
961
1049
|
if (continuedText !== '' && lastMessage?.role === 'user' && lastMessage.content === CONTINUE_PROMPT)
|
|
962
|
-
|
|
1050
|
+
unappend();
|
|
963
1051
|
else if (todoNudgeMessage && lastMessage === todoNudgeMessage)
|
|
964
|
-
|
|
1052
|
+
unappend();
|
|
965
1053
|
// Stopped mid-answer: keep the part the user already saw, marked as cut short, so "continue" can pick it up
|
|
966
1054
|
// instead of starting over. Only when the model is the next speaker (after the user or a tool result).
|
|
967
1055
|
if (reason === 'aborted' && partialStepText.trim()) {
|
|
968
1056
|
const last = messages[messages.length - 1];
|
|
969
1057
|
if (last && (last.role === 'user' || last.role === 'tool')) {
|
|
970
|
-
|
|
1058
|
+
append({ role: 'assistant', content: `${partialStepText.trim()}\n\n[Stopped by the user before finishing.]` });
|
|
971
1059
|
}
|
|
972
1060
|
}
|
|
1061
|
+
// Background processes belong to the run: stop what is still going and say so in the answer.
|
|
1062
|
+
const leftRunning = ctx.processes?.stopAll() ?? [];
|
|
1063
|
+
if (leftRunning.length > 0) {
|
|
1064
|
+
finalText += `${finalText ? '\n\n' : ''}[Stopped ${leftRunning.length} background process${leftRunning.length === 1 ? '' : 'es'} when this run ended: ${leftRunning.map((p) => p.command).join(', ')}]`;
|
|
1065
|
+
}
|
|
973
1066
|
// The browser is per-run: never leave a Chrome process behind.
|
|
974
1067
|
await this.browser?.close().catch(() => undefined);
|
|
975
1068
|
emit({ type: 'done', reason });
|
|
@@ -993,6 +1086,7 @@ export class Agent {
|
|
|
993
1086
|
// Snapshot of the final conversation: the REPL passes this back as
|
|
994
1087
|
// the `history` argument on the next turn so context persists.
|
|
995
1088
|
messages: messages.slice(),
|
|
1089
|
+
turnMessages: turnMessages.slice(),
|
|
996
1090
|
...(verification ? { verification } : {}),
|
|
997
1091
|
...(errorMessage ? { error: errorMessage } : {}),
|
|
998
1092
|
};
|
|
@@ -1242,7 +1336,9 @@ function extractMediaAttachment(result) {
|
|
|
1242
1336
|
const bytes = typeof r.bytes === 'number' ? r.bytes : 0;
|
|
1243
1337
|
const path = typeof r.path === 'string' ? r.path : '<unknown>';
|
|
1244
1338
|
const what = mime.startsWith('image/') ? 'Image' : 'Document';
|
|
1245
|
-
|
|
1339
|
+
// A tool that sends words with its media (an MCP result) keeps them next to the attachment line.
|
|
1340
|
+
const words = typeof r.text === 'string' && r.text.trim() ? `\n${r.text}` : '';
|
|
1341
|
+
const summary = `${what} attached: ${mime} (${formatBytes(bytes)}) at ${path}${words}`;
|
|
1246
1342
|
const comma = dataUrl.indexOf(',');
|
|
1247
1343
|
const filename = typeof r.path === 'string' ? r.path.split(/[\\/]/).pop() : undefined;
|
|
1248
1344
|
return { dataUrl, base64: dataUrl.slice(comma + 1), mime, ...(filename ? { filename } : {}), summary };
|
|
@@ -1281,15 +1377,14 @@ export function applySandbox(tools, sandbox) {
|
|
|
1281
1377
|
return result;
|
|
1282
1378
|
}
|
|
1283
1379
|
/**
|
|
1284
|
-
* Build the
|
|
1285
|
-
*
|
|
1286
|
-
*
|
|
1287
|
-
* prompt so the agent picks up project conventions on turn one.
|
|
1380
|
+
* Build the system message for a run, on every turn (RB-57). If memory is enabled and a `.forge/MEMORY.md` exists
|
|
1381
|
+
* somewhere up the cwd tree, a `<project-memory>...</project-memory>` block is appended so the agent picks up project
|
|
1382
|
+
* conventions.
|
|
1288
1383
|
*
|
|
1289
1384
|
* This is async because memory loading hits the disk. When memory is
|
|
1290
1385
|
* disabled or no file exists, this is a fast synchronous-feeling path.
|
|
1291
1386
|
*/
|
|
1292
|
-
async function
|
|
1387
|
+
async function buildSystemContent(config, task, skills, steeringSystem = []) {
|
|
1293
1388
|
let basePrompt = config.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
|
|
1294
1389
|
if (config.planMode) {
|
|
1295
1390
|
basePrompt = basePrompt + (config.planModePrompt ?? DEFAULT_PLAN_MODE_SUFFIX);
|
|
@@ -1315,7 +1410,8 @@ async function buildInitialMessages(config, task, skills, steeringSystem = [], u
|
|
|
1315
1410
|
try {
|
|
1316
1411
|
const all = skills.registry.list();
|
|
1317
1412
|
const selected = selectSkills(all, task, skills.maxSkills ?? 3);
|
|
1318
|
-
|
|
1413
|
+
// The index of every skill (G12) comes first and is the same every turn; the picked bodies follow.
|
|
1414
|
+
skillsBlock = [buildSkillIndex(all), composeSkillsPrompt(selected)].filter(Boolean).join('\n\n');
|
|
1319
1415
|
}
|
|
1320
1416
|
catch {
|
|
1321
1417
|
// Skill selection failure shouldn't kill a run: just skip.
|
|
@@ -1348,12 +1444,7 @@ async function buildInitialMessages(config, task, skills, steeringSystem = [], u
|
|
|
1348
1444
|
// A map failure shouldn't kill a run: just skip.
|
|
1349
1445
|
}
|
|
1350
1446
|
}
|
|
1351
|
-
// Interview answers from a guided journey.
|
|
1352
|
-
// history is passed (see the `options.history` branch in `run`), so every
|
|
1353
|
-
// block here is assembled once, on a session's first turn, and then rides
|
|
1354
|
-
// inside `history[0]`. That is why a journey's interview must finish before
|
|
1355
|
-
// the first run: an answer that arrives on turn 3 can never enter the system
|
|
1356
|
-
// message, and a middle turn would be summarised away by compaction.
|
|
1447
|
+
// Interview answers from a guided journey. Rebuilt with the rest of the system message on every turn (RB-57).
|
|
1357
1448
|
const answersBlock = renderAnswersBlock(config.journeyAnswers ?? []);
|
|
1358
1449
|
// The versions the workspace's files state, so code is written for them and not for what training saw most.
|
|
1359
1450
|
let stackFactsBlock = '';
|
|
@@ -1374,6 +1465,8 @@ async function buildInitialMessages(config, task, skills, steeringSystem = [], u
|
|
|
1374
1465
|
isGitRepo: existsSync(path.join(cwd, '.git')),
|
|
1375
1466
|
today: new Date().toISOString().slice(0, 10),
|
|
1376
1467
|
model: config.model?.model,
|
|
1468
|
+
// A custom runner (the VS Code terminal) says which shell it is; Forge's own runner picks one itself.
|
|
1469
|
+
...(config.runnerShell ? { shell: config.runnerShell } : config.commandRunner ? {} : { shell: runnerShell(config.shell).kind }),
|
|
1377
1470
|
});
|
|
1378
1471
|
// Compose the final system prompt: base + (memory, skills) blocks in a
|
|
1379
1472
|
// stable order so cache hits survive across runs that share state. The
|
|
@@ -1381,36 +1474,5 @@ async function buildInitialMessages(config, task, skills, steeringSystem = [], u
|
|
|
1381
1474
|
const steeringBlock = steeringSystem.map(wrapSteering).join('\n\n');
|
|
1382
1475
|
const guidanceBlock = config.model ? modelGuidanceBlock(config.model) : '';
|
|
1383
1476
|
const blocks = [steeringBlock, instructionsBlock, extraInstructionsBlock, config.mcpInstructions ?? '', config.projectCommands ?? '', memoryBlock, skillsBlock, answersBlock, mapBlock, stackFactsBlock, guidanceBlock, envBlock].filter((b) => b.length > 0);
|
|
1384
|
-
|
|
1385
|
-
return [
|
|
1386
|
-
{ role: 'system', content: systemContent },
|
|
1387
|
-
{ role: 'user', content: userContent },
|
|
1388
|
-
];
|
|
1389
|
-
}
|
|
1390
|
-
/**
|
|
1391
|
-
* Rough estimate of the next-turn prompt size from the message list.
|
|
1392
|
-
* Used to decide whether to compact before the next model call. We use
|
|
1393
|
-
* `Math.ceil(text.length / 4)` (≈ OpenAI's tokenizer behavior): good
|
|
1394
|
-
* enough for "are we close to the window?" decisions.
|
|
1395
|
-
*/
|
|
1396
|
-
function estimateNextPromptTokens(messages) {
|
|
1397
|
-
let total = 0;
|
|
1398
|
-
for (const m of messages) {
|
|
1399
|
-
if (typeof m.content === 'string') {
|
|
1400
|
-
total += Math.ceil(m.content.length / 4);
|
|
1401
|
-
}
|
|
1402
|
-
else if (Array.isArray(m.content)) {
|
|
1403
|
-
for (const part of m.content) {
|
|
1404
|
-
if (part && typeof part === 'object' && 'text' in part) {
|
|
1405
|
-
const text = part.text;
|
|
1406
|
-
if (typeof text === 'string')
|
|
1407
|
-
total += Math.ceil(text.length / 4);
|
|
1408
|
-
}
|
|
1409
|
-
else {
|
|
1410
|
-
total += mediaPartTokens(part);
|
|
1411
|
-
}
|
|
1412
|
-
}
|
|
1413
|
-
}
|
|
1414
|
-
}
|
|
1415
|
-
return total;
|
|
1477
|
+
return blocks.length > 0 ? `${basePrompt}\n\n${blocks.join('\n\n')}` : basePrompt;
|
|
1416
1478
|
}
|
|
@@ -13,3 +13,5 @@ import type { ModelMessage } from 'ai';
|
|
|
13
13
|
import type { ModelConfig } from '../types.js';
|
|
14
14
|
export declare function supportsCacheHints(model: ModelConfig): boolean;
|
|
15
15
|
export declare function applyCacheHints(messages: ModelMessage[], model: ModelConfig): ModelMessage[];
|
|
16
|
+
/** Call-level provider options that help the provider's cache: OpenAI routes one chat's calls together by key. */
|
|
17
|
+
export declare function cacheProviderOptions(model: ModelConfig, sessionId: string | undefined): Record<string, Record<string, string>> | undefined;
|
|
@@ -23,8 +23,23 @@ export function applyCacheHints(messages, model) {
|
|
|
23
23
|
const out = messages.slice();
|
|
24
24
|
if (out[0].role === 'system')
|
|
25
25
|
out[0] = withHint(out[0], hint);
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
// The last two other messages too, as Kilo does: the previous call's breakpoint is then still marked, so a step that
|
|
27
|
+
// adds many content blocks keeps its cache hit (Anthropic looks back at most 20 blocks from a breakpoint). RB-61.
|
|
28
|
+
// Three breakpoints in all; Anthropic allows four.
|
|
29
|
+
const firstOther = out[0].role === 'system' ? 1 : 0;
|
|
30
|
+
for (let i = Math.max(firstOther, out.length - 2); i < out.length; i++)
|
|
31
|
+
out[i] = withHint(out[i], hint);
|
|
29
32
|
return out;
|
|
30
33
|
}
|
|
34
|
+
/** Call-level provider options that help the provider's cache: OpenAI routes one chat's calls together by key. */
|
|
35
|
+
export function cacheProviderOptions(model, sessionId) {
|
|
36
|
+
if (!sessionId)
|
|
37
|
+
return undefined;
|
|
38
|
+
if (model.endpoint)
|
|
39
|
+
return undefined;
|
|
40
|
+
if (model.provider === 'openai')
|
|
41
|
+
return { openai: { promptCacheKey: sessionId } };
|
|
42
|
+
if (model.provider === 'azure')
|
|
43
|
+
return { openai: { promptCacheKey: sessionId }, azure: { promptCacheKey: sessionId } };
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Make a stored conversation safe to replay to a different provider (RB-53).
|
|
3
|
+
*
|
|
4
|
+
* The messages the loop keeps are the AI SDK's own. The assistant parts in them carry the writing provider's
|
|
5
|
+
* bookkeeping under `providerOptions.<provider>`: Anthropic thinking signatures, OpenAI item ids. Another provider
|
|
6
|
+
* rejects those, so a chat that switched model failed on the next turn. Reasoning from an earlier turn is not
|
|
7
|
+
* needed to continue a conversation, so a reasoning part tagged only for other providers is dropped; any other
|
|
8
|
+
* foreign tag is removed and the part kept.
|
|
9
|
+
*
|
|
10
|
+
* Pure: returns new messages and never mutates `history`, which is persisted and reused.
|
|
11
|
+
*/
|
|
12
|
+
import type { ModelMessage } from 'ai';
|
|
13
|
+
import type { ModelConfig } from '../types.js';
|
|
14
|
+
/** The key the AI SDK writes provider data under for this model's client. */
|
|
15
|
+
export declare function providerKeyFor(model: Pick<ModelConfig, 'provider' | 'endpoint'>): string;
|
|
16
|
+
export declare function adaptHistoryForModel(history: readonly ModelMessage[], model: Pick<ModelConfig, 'provider' | 'endpoint'>): ModelMessage[];
|
|
17
|
+
/**
|
|
18
|
+
* Per-provider clean-up of the messages sent on every call (RB-61): tool-call ids the target accepts (Claude: only
|
|
19
|
+
* letters, digits, `_` and `-`; Mistral: exactly nine letters or digits), an assistant turn between a tool result and
|
|
20
|
+
* a user message for Mistral, and no empty text parts (Anthropic refuses an empty text block). Deterministic and
|
|
21
|
+
* identity-preserving, so a prompt prefix that needs nothing stays byte-identical and the cache keeps hitting.
|
|
22
|
+
*/
|
|
23
|
+
export declare function normalizeForModel(messages: readonly ModelMessage[], model: Pick<ModelConfig, 'provider' | 'model'>): ModelMessage[];
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { COMPACTION_SUMMARY_PREFIX } from '../context/compaction.js';
|
|
2
|
+
/** The key the AI SDK writes provider data under for this model's client. */
|
|
3
|
+
export function providerKeyFor(model) {
|
|
4
|
+
if (model.endpoint)
|
|
5
|
+
return model.endpoint.api === 'anthropic' ? 'anthropic' : 'openai';
|
|
6
|
+
switch (model.provider) {
|
|
7
|
+
case 'anthropic':
|
|
8
|
+
case 'google':
|
|
9
|
+
case 'bedrock':
|
|
10
|
+
case 'vertex':
|
|
11
|
+
case 'azure':
|
|
12
|
+
return model.provider;
|
|
13
|
+
default:
|
|
14
|
+
// openai, openrouter, ollama, lmstudio, minimax and openai-compatible all use the OpenAI client.
|
|
15
|
+
return 'openai';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function adaptPart(part, key) {
|
|
19
|
+
if (typeof part !== 'object' || part === null)
|
|
20
|
+
return part;
|
|
21
|
+
const options = part.providerOptions;
|
|
22
|
+
if (!options || typeof options !== 'object')
|
|
23
|
+
return part;
|
|
24
|
+
const keys = Object.keys(options);
|
|
25
|
+
if (keys.length === 0 || keys.every((k) => k === key))
|
|
26
|
+
return part;
|
|
27
|
+
if (part.type === 'reasoning' && !keys.includes(key))
|
|
28
|
+
return undefined;
|
|
29
|
+
const { providerOptions: _dropped, ...rest } = part;
|
|
30
|
+
const kept = Object.fromEntries(Object.entries(options).filter(([k]) => k === key));
|
|
31
|
+
return Object.keys(kept).length > 0 ? { ...rest, providerOptions: kept } : rest;
|
|
32
|
+
}
|
|
33
|
+
function legacySummary(text) {
|
|
34
|
+
const body = text.replace(/^\[Compacted summary of [^\]]*\]\s*/, '');
|
|
35
|
+
return `${COMPACTION_SUMMARY_PREFIX}, written by Forge to save context. It is not a new request.]\n\n${body}`;
|
|
36
|
+
}
|
|
37
|
+
export function adaptHistoryForModel(history, model) {
|
|
38
|
+
const key = providerKeyFor(model);
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const [index, message] of history.entries()) {
|
|
41
|
+
// Before RB-56, compaction stored its summary as a system message mid-conversation; Gemini and Bedrock refuse it.
|
|
42
|
+
if (index > 0 && message.role === 'system') {
|
|
43
|
+
out.push({ role: 'user', content: legacySummary(typeof message.content === 'string' ? message.content : JSON.stringify(message.content)) });
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (message.role !== 'assistant' || !Array.isArray(message.content)) {
|
|
47
|
+
out.push(message);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const parts = message.content.map((p) => adaptPart(p, key)).filter((p) => p !== undefined);
|
|
51
|
+
if (parts.length === 0)
|
|
52
|
+
continue;
|
|
53
|
+
const unchanged = parts.length === message.content.length && parts.every((p, i) => p === message.content[i]);
|
|
54
|
+
out.push(unchanged ? message : { ...message, content: parts });
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
const MISTRAL_FAMILIES = ['mistral', 'devstral', 'codestral', 'pixtral', 'mixtral', 'ministral', 'magistral'];
|
|
59
|
+
function isClaude(model) {
|
|
60
|
+
return model.provider === 'anthropic' || model.model.toLowerCase().includes('claude');
|
|
61
|
+
}
|
|
62
|
+
function isMistral(model) {
|
|
63
|
+
const id = model.model.toLowerCase();
|
|
64
|
+
return MISTRAL_FAMILIES.some((f) => id.includes(f));
|
|
65
|
+
}
|
|
66
|
+
/** Nine alphanumeric characters from any id, the same every time; hashing (not cutting) keeps two ids apart. */
|
|
67
|
+
function mistralToolId(id) {
|
|
68
|
+
if (/^[a-zA-Z0-9]{9}$/.test(id))
|
|
69
|
+
return id;
|
|
70
|
+
let a = 0x811c9dc5;
|
|
71
|
+
let b = 0x01000193;
|
|
72
|
+
for (let i = 0; i < id.length; i++) {
|
|
73
|
+
const c = id.charCodeAt(i);
|
|
74
|
+
a = Math.imul(a ^ c, 0x01000193) >>> 0;
|
|
75
|
+
b = Math.imul(b ^ c, 0x5bd1e995) >>> 0;
|
|
76
|
+
}
|
|
77
|
+
return (a.toString(36) + b.toString(36)).padEnd(9, '0').slice(0, 9);
|
|
78
|
+
}
|
|
79
|
+
function claudeToolId(id) {
|
|
80
|
+
return /^[a-zA-Z0-9_-]+$/.test(id) ? id : id.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Per-provider clean-up of the messages sent on every call (RB-61): tool-call ids the target accepts (Claude: only
|
|
84
|
+
* letters, digits, `_` and `-`; Mistral: exactly nine letters or digits), an assistant turn between a tool result and
|
|
85
|
+
* a user message for Mistral, and no empty text parts (Anthropic refuses an empty text block). Deterministic and
|
|
86
|
+
* identity-preserving, so a prompt prefix that needs nothing stays byte-identical and the cache keeps hitting.
|
|
87
|
+
*/
|
|
88
|
+
export function normalizeForModel(messages, model) {
|
|
89
|
+
const rewriteId = isMistral(model) ? mistralToolId : isClaude(model) ? claudeToolId : undefined;
|
|
90
|
+
const mistral = isMistral(model);
|
|
91
|
+
const out = [];
|
|
92
|
+
for (let i = 0; i < messages.length; i++) {
|
|
93
|
+
let m = messages[i];
|
|
94
|
+
if (Array.isArray(m.content) && (m.role === 'assistant' || m.role === 'tool')) {
|
|
95
|
+
const parts = m.content
|
|
96
|
+
.filter((p) => !(p.type === 'text' && p.text === ''))
|
|
97
|
+
.map((p) => {
|
|
98
|
+
if (!rewriteId || typeof p.toolCallId !== 'string')
|
|
99
|
+
return p;
|
|
100
|
+
const id = rewriteId(p.toolCallId);
|
|
101
|
+
return id === p.toolCallId ? p : { ...p, toolCallId: id };
|
|
102
|
+
});
|
|
103
|
+
if (parts.length === 0 && m.role === 'assistant')
|
|
104
|
+
continue;
|
|
105
|
+
const same = parts.length === m.content.length && parts.every((p, k) => p === m.content[k]);
|
|
106
|
+
if (!same)
|
|
107
|
+
m = { ...m, content: parts };
|
|
108
|
+
}
|
|
109
|
+
out.push(m);
|
|
110
|
+
if (mistral && m.role === 'tool' && messages[i + 1]?.role === 'user')
|
|
111
|
+
out.push({ role: 'assistant', content: 'Done.' });
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|