@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.
Files changed (113) hide show
  1. package/dist/agent/agent.js +132 -70
  2. package/dist/agent/cache-hints.d.ts +2 -0
  3. package/dist/agent/cache-hints.js +18 -3
  4. package/dist/agent/history-adapt.d.ts +23 -0
  5. package/dist/agent/history-adapt.js +114 -0
  6. package/dist/agent/prompts.d.ts +4 -2
  7. package/dist/agent/prompts.js +10 -2
  8. package/dist/agent/tool-repair.d.ts +8 -0
  9. package/dist/agent/tool-repair.js +16 -0
  10. package/dist/catalog/models-dev.d.ts +10 -0
  11. package/dist/catalog/models-dev.js +12 -0
  12. package/dist/checkpoints/store.js +3 -1
  13. package/dist/context/compaction.d.ts +21 -6
  14. package/dist/context/compaction.js +86 -26
  15. package/dist/context/mask.d.ts +9 -9
  16. package/dist/context/mask.js +30 -1
  17. package/dist/format/auto-format.d.ts +22 -0
  18. package/dist/format/auto-format.js +39 -0
  19. package/dist/github/github-tools.d.ts +1 -0
  20. package/dist/github/github-tools.js +9 -9
  21. package/dist/github/pr.d.ts +34 -0
  22. package/dist/github/pr.js +70 -0
  23. package/dist/human-text.d.ts +6 -0
  24. package/dist/human-text.js +37 -0
  25. package/dist/index.d.ts +13 -8
  26. package/dist/index.js +10 -6
  27. package/dist/journey/answers-block.d.ts +2 -4
  28. package/dist/lsp/diagnostics.d.ts +4 -0
  29. package/dist/lsp/diagnostics.js +8 -2
  30. package/dist/lsp/index.d.ts +1 -1
  31. package/dist/lsp/index.js +1 -1
  32. package/dist/mcp/index.d.ts +1 -1
  33. package/dist/mcp/index.js +1 -1
  34. package/dist/mcp/json-schema-to-zod.js +2 -4
  35. package/dist/mcp/mcp-client.d.ts +14 -0
  36. package/dist/mcp/mcp-client.js +102 -18
  37. package/dist/orchestrator/specialists-registry.js +14 -1
  38. package/dist/orchestrator/specialists.js +3 -3
  39. package/dist/semantic/embedder.d.ts +11 -0
  40. package/dist/semantic/embedder.js +16 -0
  41. package/dist/semantic/semantic-index.d.ts +39 -1
  42. package/dist/semantic/semantic-index.js +43 -13
  43. package/dist/semantic/vector-store.d.ts +2 -0
  44. package/dist/semantic/vector-store.js +13 -1
  45. package/dist/sessions/index.d.ts +2 -2
  46. package/dist/sessions/index.js +1 -1
  47. package/dist/sessions/search.js +2 -2
  48. package/dist/sessions/session-export.d.ts +1 -1
  49. package/dist/sessions/session-export.js +41 -33
  50. package/dist/sessions/session-store.d.ts +20 -1
  51. package/dist/sessions/session-store.js +7 -0
  52. package/dist/sessions/watch.js +4 -4
  53. package/dist/skills/activator.d.ts +6 -0
  54. package/dist/skills/activator.js +17 -0
  55. package/dist/skills/skill-tool.d.ts +12 -0
  56. package/dist/skills/skill-tool.js +28 -0
  57. package/dist/tools/background-process.d.ts +63 -0
  58. package/dist/tools/background-process.js +150 -0
  59. package/dist/tools/edit-file.js +2 -27
  60. package/dist/tools/edit-match.d.ts +16 -0
  61. package/dist/tools/edit-match.js +115 -0
  62. package/dist/tools/fetch-url.d.ts +15 -1
  63. package/dist/tools/fetch-url.js +7 -47
  64. package/dist/tools/get-diagnostics.d.ts +14 -0
  65. package/dist/tools/get-diagnostics.js +44 -0
  66. package/dist/tools/glob-filter.d.ts +7 -0
  67. package/dist/tools/glob-filter.js +12 -0
  68. package/dist/tools/html-to-markdown.d.ts +13 -0
  69. package/dist/tools/html-to-markdown.js +507 -0
  70. package/dist/tools/index.d.ts +5 -1
  71. package/dist/tools/index.js +11 -1
  72. package/dist/tools/list-files.d.ts +1 -0
  73. package/dist/tools/list-files.js +17 -5
  74. package/dist/tools/list-symbols.js +2 -2
  75. package/dist/tools/network-policy.d.ts +5 -0
  76. package/dist/tools/network-policy.js +77 -0
  77. package/dist/tools/office-docs.d.ts +6 -0
  78. package/dist/tools/office-docs.js +393 -0
  79. package/dist/tools/permission-rules.js +55 -5
  80. package/dist/tools/read-file.d.ts +2 -0
  81. package/dist/tools/read-file.js +47 -10
  82. package/dist/tools/read-image.js +2 -2
  83. package/dist/tools/read-only.d.ts +1 -1
  84. package/dist/tools/read-only.js +4 -0
  85. package/dist/tools/repo-clone.d.ts +40 -0
  86. package/dist/tools/repo-clone.js +218 -0
  87. package/dist/tools/result-cap.d.ts +8 -0
  88. package/dist/tools/result-cap.js +44 -0
  89. package/dist/tools/run-command.d.ts +18 -1
  90. package/dist/tools/run-command.js +41 -16
  91. package/dist/tools/search-files.js +5 -5
  92. package/dist/tools/search-worker.js +11 -2
  93. package/dist/tools/shared.d.ts +14 -1
  94. package/dist/tools/shared.js +76 -4
  95. package/dist/tools/shell.d.ts +27 -0
  96. package/dist/tools/shell.js +80 -0
  97. package/dist/tools/sub-agent.js +3 -1
  98. package/dist/tools/suggest-plan.d.ts +18 -0
  99. package/dist/tools/suggest-plan.js +28 -0
  100. package/dist/types.d.ts +74 -0
  101. package/dist/wakeup/wakeup-tools.d.ts +22 -0
  102. package/dist/wakeup/wakeup-tools.js +34 -0
  103. package/dist/wakeup/wakeup.d.ts +75 -0
  104. package/dist/wakeup/wakeup.js +151 -0
  105. package/dist/worktree/health.d.ts +51 -0
  106. package/dist/worktree/health.js +186 -0
  107. package/dist/worktree/index.d.ts +4 -0
  108. package/dist/worktree/index.js +2 -0
  109. package/dist/worktree/setup.d.ts +51 -0
  110. package/dist/worktree/setup.js +173 -0
  111. package/dist/worktree/worktree.d.ts +7 -0
  112. package/dist/worktree/worktree.js +9 -4
  113. package/package.json +1 -1
@@ -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 { buildLlmSummarizer, compactMessages, effectiveLimitTokens, getModelWindowTokens, shouldCompactForBudget } from '../context/compaction.js';
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, mediaPartTokens } from '../attachments.js';
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
- : await buildInitialMessages(this.config, options.task, this.skills, startSteering.system, userContent);
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, estimateNextPromptTokens(messages)));
418
- promptMessages = view.messages;
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
- messages: cachePrompts ? applyCacheHints(promptMessages, currentModelConfig) : promptMessages,
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] (buildInitialMessages)
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, estimateNextPromptTokens(messages)))) > 0) {
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
- messages.push(m);
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
- messages.push({ role: 'user', content: CONTINUE_PROMPT });
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
- messages.push(todoNudgeMessage);
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
- messages.push({ role: 'user', content: failureMessage(outcome.failures) });
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
- const rawResult = await def.execute(toolArgs, {
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
- messages.push(m);
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
- messages.push({ role: 'user', content: injected.map(wrapSteering).join('\n\n') });
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, estimateNextPromptTokens(messages));
914
- if (shouldCompactForBudget(totalForNextTurn, this.config.model, threshold, this.config.contextBudgetTokens)) {
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
- messages.pop();
1050
+ unappend();
963
1051
  else if (todoNudgeMessage && lastMessage === todoNudgeMessage)
964
- messages.pop();
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
- messages.push({ role: 'assistant', content: `${partialStepText.trim()}\n\n[Stopped by the user before finishing.]` });
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
- const summary = `${what} attached: ${mime} (${formatBytes(bytes)}) at ${path}`;
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 initial `[system, user]` messages for a fresh run. If memory
1285
- * is enabled and a `.forge/MEMORY.md` exists somewhere up the cwd tree, we
1286
- * append a `<project-memory>...</project-memory>` block to the system
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 buildInitialMessages(config, task, skills, steeringSystem = [], userContent = task) {
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
- skillsBlock = composeSkillsPrompt(selected);
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. This function runs only when no
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
- const systemContent = blocks.length > 0 ? `${basePrompt}\n\n${blocks.join('\n\n')}` : basePrompt;
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
- const last = out.length - 1;
27
- if (last > 0 || out[0].role !== 'system')
28
- out[last] = withHint(out[last], hint);
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
+ }