@ikarmic/forge-core 5.2.0 → 5.3.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 +195 -55
- package/dist/agent/enhance-prompt.js +2 -1
- package/dist/agent/prompts.d.ts +15 -0
- package/dist/agent/prompts.js +34 -0
- package/dist/agent/retry.d.ts +50 -0
- package/dist/agent/retry.js +192 -0
- package/dist/agent/shell-command.d.ts +23 -0
- package/dist/agent/shell-command.js +93 -0
- package/dist/catalog/models-dev-snapshot.d.ts +1 -0
- package/dist/catalog/models-dev-snapshot.js +3 -0
- package/dist/catalog/models-dev.d.ts +35 -0
- package/dist/catalog/models-dev.js +143 -0
- package/dist/context/compaction.js +8 -2
- package/dist/git/auto-commit.d.ts +4 -2
- package/dist/git/auto-commit.js +16 -5
- package/dist/index.d.ts +6 -1
- package/dist/index.js +4 -0
- package/dist/memory/session-digest.js +2 -1
- package/dist/pricing.js +12 -0
- package/dist/tools/command-sandbox.d.ts +24 -0
- package/dist/tools/command-sandbox.js +73 -0
- package/dist/tools/edit-file.d.ts +8 -0
- package/dist/tools/edit-file.js +64 -16
- package/dist/tools/run-command.js +12 -2
- package/dist/tools/search-files.js +8 -49
- package/dist/tools/search-worker.d.ts +23 -0
- package/dist/tools/search-worker.js +77 -0
- package/dist/tools/sub-agent.d.ts +1 -1
- package/dist/tools/sub-agent.js +1 -0
- package/dist/types.d.ts +58 -1
- package/package.json +1 -1
package/dist/agent/agent.js
CHANGED
|
@@ -32,12 +32,19 @@ import { composeSkillsPrompt, selectSkills } from '../skills/activator.js';
|
|
|
32
32
|
import { MAX_TOOL_INJECTIONS, selectAtStart, selectOnToolTouch, toWorkspaceRelative, wrapSteering } from '../steering/select.js';
|
|
33
33
|
import { applyCacheHints } from './cache-hints.js';
|
|
34
34
|
import { raceAbort } from './race-abort.js';
|
|
35
|
+
import { DEFAULT_PROVIDER_RETRIES, DEFAULT_RETRY_BASE_MS, defaultNetworkProbe, errorSummary, isContextOverflowError, isDisconnected, retryDecision, waitForNetwork, waitOrAbort, } from './retry.js';
|
|
35
36
|
import { attachmentParts, formatBytes, mediaPartTokens } from '../attachments.js';
|
|
36
37
|
import { renderAnswersBlock } from '../journey/answers-block.js';
|
|
37
|
-
import { CONTINUE_PROMPT, DEFAULT_PLAN_MODE_SUFFIX, DEFAULT_SPEC_MODE_SUFFIX, DEFAULT_SYSTEM_PROMPT, environmentBlock } from './prompts.js';
|
|
38
|
+
import { CONTINUE_PROMPT, DEFAULT_PLAN_MODE_SUFFIX, DEFAULT_SPEC_MODE_SUFFIX, DEFAULT_SYSTEM_PROMPT, LAST_STEP_PROMPT, environmentBlock, modelGuidanceBlock, todoNudgePrompt } from './prompts.js';
|
|
38
39
|
import { detectStackFacts, stackBlock } from './stack-facts.js';
|
|
39
40
|
/** Read-only tool names whose output flows through the redactor. */
|
|
40
41
|
const DEFAULT_MAX_CONTINUATIONS = 3;
|
|
42
|
+
/** Nudges per run when a turn ends with open todos; never two without a tool call between them. */
|
|
43
|
+
const MAX_TODO_NUDGES = 3;
|
|
44
|
+
/** Times an empty or reasoning-only reply is asked for again before it is accepted. */
|
|
45
|
+
const MAX_EMPTY_REPLAYS = 2;
|
|
46
|
+
/** Consecutive tool calls with invalid arguments before the run stops (Kilo: REPEATED_TOOL_FAILURE_LIMIT). */
|
|
47
|
+
const MAX_INVALID_CALLS = 3;
|
|
41
48
|
export const DEFAULT_MAX_DURATION_MS = 10 * 60 * 1000;
|
|
42
49
|
const REDACTABLE_TOOLS = new Set(['read_file', 'search_files', 'list_files']);
|
|
43
50
|
/** Tools whose successful `path` argument can trigger mid-run fileMatch steering. */
|
|
@@ -259,6 +266,26 @@ export class Agent {
|
|
|
259
266
|
let partialStepText = '';
|
|
260
267
|
let continuations = 0;
|
|
261
268
|
const maxContinuations = this.config.maxContinuations ?? DEFAULT_MAX_CONTINUATIONS;
|
|
269
|
+
// Open-todo nudges: the one sent last (so a run that ends before answering it drops it) and whether
|
|
270
|
+
// the step just answered was a nudge (a second text-only reply in a row is the model's answer).
|
|
271
|
+
// Only a list this run wrote counts: the chat carries the last turn's list over, and an unrelated
|
|
272
|
+
// question answered while it is open must not be pushed back onto the old task.
|
|
273
|
+
let todoNudges = 0;
|
|
274
|
+
let todoNudgeMessage;
|
|
275
|
+
let answeringNudge = false;
|
|
276
|
+
let todosWrittenThisRun = false;
|
|
277
|
+
// Provider failures and empty replies retried for the step now running; both reset when a step succeeds.
|
|
278
|
+
const maxProviderRetries = this.config.providerRetry?.maxRetries ?? DEFAULT_PROVIDER_RETRIES;
|
|
279
|
+
const retryBaseMs = this.config.providerRetry?.baseDelayMs ?? DEFAULT_RETRY_BASE_MS;
|
|
280
|
+
let providerFailures = 0;
|
|
281
|
+
let emptyReplays = 0;
|
|
282
|
+
// Time spent waiting for the network in this run, across every offline spell.
|
|
283
|
+
const offlinePollMs = this.config.providerRetry?.offlinePollMs ?? 3000;
|
|
284
|
+
const maxOfflineMs = this.config.providerRetry?.maxOfflineMs ?? 15 * 60_000;
|
|
285
|
+
let offlineWaitedMs = 0;
|
|
286
|
+
let offlineWaits = 0;
|
|
287
|
+
// Consecutive tool calls whose arguments failed the schema, across steps; any valid call resets it.
|
|
288
|
+
let invalidStreak = { tool: '', count: 0 };
|
|
262
289
|
const ctx = {
|
|
263
290
|
cwd: this.config.cwd ?? process.cwd(),
|
|
264
291
|
auto: this.config.auto ?? false,
|
|
@@ -276,13 +303,49 @@ export class Agent {
|
|
|
276
303
|
get: () => this.todoList,
|
|
277
304
|
set: (todos) => {
|
|
278
305
|
this.todoList = todos;
|
|
306
|
+
todosWrittenThisRun = true;
|
|
279
307
|
emit({ type: 'todo_updated', todos });
|
|
280
308
|
},
|
|
281
309
|
},
|
|
282
310
|
...(this.config.commandRunner ? { commandRunner: this.config.commandRunner } : {}),
|
|
311
|
+
...(this.config.commandSandbox ? { commandSandbox: this.config.commandSandbox } : {}),
|
|
283
312
|
...(abortSignal ? { abortSignal } : {}),
|
|
284
313
|
...(this.config.memoryMaxBodyChars !== undefined ? { memoryMaxBodyChars: this.config.memoryMaxBodyChars } : {}),
|
|
285
314
|
};
|
|
315
|
+
// Summarize older turns in place; returns how many messages were dropped (0: nothing could be compacted).
|
|
316
|
+
const compactNow = async (tokens) => {
|
|
317
|
+
if (this.hooks) {
|
|
318
|
+
await this.hooks.dispatch({ type: 'PreCompact', messagesCount: messages.length, tokens, cwd });
|
|
319
|
+
}
|
|
320
|
+
const summarizer = this.config.compactionSummarizer ?? this.buildDefaultSummarizer();
|
|
321
|
+
const result = await compactMessages(messages, summarizer, {
|
|
322
|
+
keepRecent: this.config.compactionKeepRecent,
|
|
323
|
+
});
|
|
324
|
+
if (result.droppedCount === 0)
|
|
325
|
+
return 0;
|
|
326
|
+
messages.length = 0;
|
|
327
|
+
for (const m of result.messages)
|
|
328
|
+
messages.push(m);
|
|
329
|
+
masker?.reset(); // the history was replaced, so the frozen boundary no longer applies
|
|
330
|
+
// A mid-run steering doc summarized away may be injected again on the next touch.
|
|
331
|
+
for (const name of [...steeringInMessages]) {
|
|
332
|
+
const marker = `<steering name="${name}">`;
|
|
333
|
+
if (messages.some((m) => typeof m.content === 'string' && m.content.includes(marker)))
|
|
334
|
+
continue;
|
|
335
|
+
steeringInMessages.delete(name);
|
|
336
|
+
steeringInjected.delete(name);
|
|
337
|
+
steeringInjections--;
|
|
338
|
+
}
|
|
339
|
+
emit({
|
|
340
|
+
type: 'compaction',
|
|
341
|
+
droppedCount: result.droppedCount,
|
|
342
|
+
savedTokens: result.savedTokens,
|
|
343
|
+
summary: result.summary,
|
|
344
|
+
});
|
|
345
|
+
return result.droppedCount;
|
|
346
|
+
};
|
|
347
|
+
// The provider refused the prompt as too long: compact once and ask again (Kilo does the same).
|
|
348
|
+
let overflowCompactions = 0;
|
|
286
349
|
// Emit initial cache stats so the UI knows the cache is wired up.
|
|
287
350
|
emit({ type: 'cache_stats', ...this.cache.getStats() });
|
|
288
351
|
try {
|
|
@@ -327,6 +390,7 @@ export class Agent {
|
|
|
327
390
|
// Track per-turn token deltas so we can compute per-turn cost.
|
|
328
391
|
const turnStart = { input: inputTokens, output: outputTokens, cacheRead: cacheReadTokens, cacheWrite: cacheWriteTokens };
|
|
329
392
|
let reasoningText = '';
|
|
393
|
+
let stepFailure;
|
|
330
394
|
partialStepText = '';
|
|
331
395
|
// Inline <think> blocks (MiniMax over OpenAI-compatible, some open models) become reasoning events.
|
|
332
396
|
const thinkSplitter = new ThinkSplitter();
|
|
@@ -355,6 +419,10 @@ export class Agent {
|
|
|
355
419
|
if (view.stepped)
|
|
356
420
|
emit({ type: 'context_masked', maskedCount: view.maskedCount, savedTokens: Math.ceil(view.savedChars / 4) });
|
|
357
421
|
}
|
|
422
|
+
// The last allowed step: no tools, and a note asking for a summary instead of stopping mid-task.
|
|
423
|
+
const lastStep = !planMode && !specMode && effectiveMaxIter > 1 && iterations >= effectiveMaxIter;
|
|
424
|
+
if (lastStep)
|
|
425
|
+
promptMessages = [...promptMessages, { role: 'user', content: LAST_STEP_PROMPT }];
|
|
358
426
|
const result = streamText({
|
|
359
427
|
model,
|
|
360
428
|
messages: cachePrompts ? applyCacheHints(promptMessages, currentModelConfig) : promptMessages,
|
|
@@ -368,7 +436,10 @@ export class Agent {
|
|
|
368
436
|
// message) around a separate `system` option.
|
|
369
437
|
allowSystemInMessages: true,
|
|
370
438
|
tools: toAiSdkTools(this.tools),
|
|
439
|
+
...(lastStep ? { toolChoice: 'none' } : {}),
|
|
371
440
|
abortSignal,
|
|
441
|
+
// Retries are decided below (retryDecision), for failures before and during the stream alike.
|
|
442
|
+
maxRetries: 0,
|
|
372
443
|
...(this.config.model.reasoningEffort ? { reasoning: this.config.model.reasoningEffort } : {}),
|
|
373
444
|
...(this.config.model.temperature !== undefined ? { temperature: this.config.model.temperature } : {}),
|
|
374
445
|
onChunk: ({ chunk }) => {
|
|
@@ -388,8 +459,9 @@ export class Agent {
|
|
|
388
459
|
// Pressing Stop aborts the request; that is not a failure to report.
|
|
389
460
|
if (abortSignal?.aborted)
|
|
390
461
|
return;
|
|
462
|
+
// Reported only if it is not retried (below); the SDK then throws a generic "No output generated".
|
|
463
|
+
stepFailure = error;
|
|
391
464
|
reportedStreamError = errorMessageOf(error);
|
|
392
|
-
emit({ type: 'error', error: reportedStreamError });
|
|
393
465
|
},
|
|
394
466
|
onFinish: ({ text: rawText, usage }) => {
|
|
395
467
|
emitSegments(thinkSplitter.flush());
|
|
@@ -439,12 +511,88 @@ export class Agent {
|
|
|
439
511
|
}
|
|
440
512
|
},
|
|
441
513
|
});
|
|
442
|
-
|
|
443
|
-
|
|
514
|
+
let stepText;
|
|
515
|
+
let toolCalls;
|
|
516
|
+
let response;
|
|
517
|
+
try {
|
|
518
|
+
await result.consumeStream();
|
|
519
|
+
stepText = stripThinkTags(await result.text);
|
|
520
|
+
toolCalls = await result.toolCalls;
|
|
521
|
+
response = await result.response;
|
|
522
|
+
if (stepFailure !== undefined)
|
|
523
|
+
throw stepFailure;
|
|
524
|
+
}
|
|
525
|
+
catch (err) {
|
|
526
|
+
if (abortSignal?.aborted)
|
|
527
|
+
throw err;
|
|
528
|
+
const failure = stepFailure ?? err;
|
|
529
|
+
stepFailure = undefined;
|
|
530
|
+
if (isContextOverflowError(failure) && this.config.enableCompaction && !planMode && !specMode && overflowCompactions < 2) {
|
|
531
|
+
overflowCompactions++;
|
|
532
|
+
if ((await compactNow(Math.max(lastTurnInputTokens, estimateNextPromptTokens(messages)))) > 0) {
|
|
533
|
+
partialStepText = '';
|
|
534
|
+
iterations--;
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
// No network at all: wait for it to come back instead of spending retries (Kilo's offline guard).
|
|
539
|
+
// Only when a probe confirms it: a mistyped provider host fails DNS too while the network is fine.
|
|
540
|
+
const probe = this.config.providerRetry?.networkProbe ?? defaultNetworkProbe;
|
|
541
|
+
if (isDisconnected(failure) && offlineWaitedMs < maxOfflineMs && !(await probe().catch(() => false))) {
|
|
542
|
+
offlineWaits++;
|
|
543
|
+
emit({ type: 'retry', kind: 'offline', attempt: offlineWaits, maxRetries: 0, delayMs: offlinePollMs, reason: errorSummary(failure) });
|
|
544
|
+
const waited = await waitForNetwork(probe, {
|
|
545
|
+
pollMs: offlinePollMs,
|
|
546
|
+
budgetMs: maxOfflineMs - offlineWaitedMs,
|
|
547
|
+
...(abortSignal ? { abortSignal } : {}),
|
|
548
|
+
});
|
|
549
|
+
offlineWaitedMs += waited.waitedMs;
|
|
550
|
+
if (waited.outcome === 'aborted') {
|
|
551
|
+
reason = 'aborted';
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
if (waited.outcome === 'online') {
|
|
555
|
+
partialStepText = '';
|
|
556
|
+
iterations--;
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
throw failure;
|
|
560
|
+
}
|
|
561
|
+
const decision = retryDecision(failure, providerFailures + 1, retryBaseMs);
|
|
562
|
+
if (!decision.retry || providerFailures >= maxProviderRetries)
|
|
563
|
+
throw failure;
|
|
564
|
+
providerFailures++;
|
|
565
|
+
// The step is thrown away whole: nothing it streamed is kept, and it does not count as a step.
|
|
566
|
+
partialStepText = '';
|
|
567
|
+
emit({ type: 'retry', kind: 'provider_error', attempt: providerFailures, maxRetries: maxProviderRetries, delayMs: decision.delayMs, reason: decision.message });
|
|
568
|
+
iterations--;
|
|
569
|
+
if ((await waitOrAbort(decision.delayMs, abortSignal)) === 'aborted') {
|
|
570
|
+
reason = 'aborted';
|
|
571
|
+
break;
|
|
572
|
+
}
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
// A reply with neither text nor a tool call (empty, or reasoning only) is an unfinished answer, not the end.
|
|
576
|
+
if ((!toolCalls || toolCalls.length === 0) &&
|
|
577
|
+
!stepText.trim() &&
|
|
578
|
+
!continuedText &&
|
|
579
|
+
(await result.finishReason) !== 'length' &&
|
|
580
|
+
emptyReplays < MAX_EMPTY_REPLAYS) {
|
|
581
|
+
emptyReplays++;
|
|
582
|
+
const delayMs = retryBaseMs * emptyReplays;
|
|
583
|
+
emit({ type: 'retry', kind: 'empty_reply', attempt: emptyReplays, maxRetries: MAX_EMPTY_REPLAYS, delayMs, reason: 'The model sent an empty reply.' });
|
|
584
|
+
iterations--;
|
|
585
|
+
if ((await waitOrAbort(delayMs, abortSignal)) === 'aborted') {
|
|
586
|
+
reason = 'aborted';
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
providerFailures = 0;
|
|
592
|
+
emptyReplays = 0;
|
|
593
|
+
finalText = continuedText + stepText;
|
|
444
594
|
continuedText = '';
|
|
445
|
-
const toolCalls = await result.toolCalls;
|
|
446
595
|
lastToolNames = (toolCalls ?? []).map((c) => c.toolName);
|
|
447
|
-
const response = await result.response;
|
|
448
596
|
for (const m of response.messages) {
|
|
449
597
|
messages.push(m);
|
|
450
598
|
}
|
|
@@ -479,6 +627,22 @@ export class Agent {
|
|
|
479
627
|
reason = 'spec';
|
|
480
628
|
break;
|
|
481
629
|
}
|
|
630
|
+
// A reply that ends the turn while the model's own todo list has open items ("Now I'll implement X…")
|
|
631
|
+
// is usually a stall, not an answer. Nudge once, if the model can still update the list and has a step left.
|
|
632
|
+
const openTodos = this.todoList.filter((t) => t.status !== 'completed');
|
|
633
|
+
if (openTodos.length > 0 &&
|
|
634
|
+
todosWrittenThisRun &&
|
|
635
|
+
!answeringNudge &&
|
|
636
|
+
todoNudges < MAX_TODO_NUDGES &&
|
|
637
|
+
iterations < effectiveMaxIter &&
|
|
638
|
+
this.tools.some((t) => t.name === 'todo_write')) {
|
|
639
|
+
todoNudges++;
|
|
640
|
+
answeringNudge = true;
|
|
641
|
+
todoNudgeMessage = { role: 'user', content: todoNudgePrompt(openTodos) };
|
|
642
|
+
messages.push(todoNudgeMessage);
|
|
643
|
+
emit({ type: 'todo_nudge', open: openTodos.length });
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
482
646
|
if (this.config.verify && (this.config.verify.commands.length > 0 || this.config.verify.lsp) && editedSinceVerify) {
|
|
483
647
|
if (verifyApproved === undefined) {
|
|
484
648
|
// Like a tool approval, the question must not outlive Stop: race it against the abort signal.
|
|
@@ -525,6 +689,7 @@ export class Agent {
|
|
|
525
689
|
reason = 'completed';
|
|
526
690
|
break;
|
|
527
691
|
}
|
|
692
|
+
answeringNudge = false;
|
|
528
693
|
// Resolve which tools the model called (only those without `execute`
|
|
529
694
|
// reach this code path; tools with `execute` are handled by the SDK).
|
|
530
695
|
const toolResultMessages = [];
|
|
@@ -540,12 +705,14 @@ export class Agent {
|
|
|
540
705
|
}
|
|
541
706
|
const argsParsed = def.parameters.safeParse(call.input);
|
|
542
707
|
if (!argsParsed.success) {
|
|
708
|
+
invalidStreak = { tool: call.toolName, count: invalidStreak.count + 1 };
|
|
543
709
|
const message = `Invalid arguments for ${call.toolName}: ${argsParsed.error.message}`;
|
|
544
710
|
trackLoop(call.toolName, call.input, message);
|
|
545
711
|
currentToolResults.push(toolResultError(call.toolCallId, call.toolName, message));
|
|
546
712
|
emit({ type: 'tool_result', name: call.toolName, id: call.toolCallId, result: message, isError: true });
|
|
547
713
|
continue;
|
|
548
714
|
}
|
|
715
|
+
invalidStreak = { tool: '', count: 0 };
|
|
549
716
|
const approval = await this.requestApproval(def, call.toolCallId, argsParsed.data, abortSignal, ctx);
|
|
550
717
|
this.audit?.record({ event: 'tool_call', tool: call.toolName, args: approval.args ?? argsParsed.data, approved: approval.approved, auto: this.config.auto === true }, auditCtx);
|
|
551
718
|
if (!approval.approved) {
|
|
@@ -569,7 +736,7 @@ export class Agent {
|
|
|
569
736
|
}
|
|
570
737
|
toolArgs = edited.data;
|
|
571
738
|
}
|
|
572
|
-
emit({ type: 'tool_call', name: call.toolName, id: call.toolCallId, args: toolArgs });
|
|
739
|
+
emit({ type: 'tool_call', name: call.toolName, id: call.toolCallId, args: toolArgs, ...(approval.source ? { approval: approval.source } : {}) });
|
|
573
740
|
if (this.hooks) {
|
|
574
741
|
await this.hooks.dispatch({
|
|
575
742
|
type: 'PreToolUse',
|
|
@@ -700,6 +867,12 @@ export class Agent {
|
|
|
700
867
|
reason = 'loop_detected';
|
|
701
868
|
break;
|
|
702
869
|
}
|
|
870
|
+
if (invalidStreak.count >= MAX_INVALID_CALLS) {
|
|
871
|
+
errorMessage = `Stopped: the model sent invalid arguments for ${invalidStreak.tool} ${invalidStreak.count} times in a row.`;
|
|
872
|
+
emit({ type: 'error', error: errorMessage });
|
|
873
|
+
reason = 'error';
|
|
874
|
+
break;
|
|
875
|
+
}
|
|
703
876
|
// Mid-run fileMatch steering: one extra user message per round, each doc once per run.
|
|
704
877
|
if (steeringDocs.length > 0 && touchedPaths.length > 0) {
|
|
705
878
|
const injected = [];
|
|
@@ -739,39 +912,7 @@ export class Agent {
|
|
|
739
912
|
// components of the same total, they're two guesses at it).
|
|
740
913
|
const totalForNextTurn = Math.max(lastTurnInputTokens, estimateNextPromptTokens(messages));
|
|
741
914
|
if (shouldCompactForBudget(totalForNextTurn, this.config.model, threshold, this.config.contextBudgetTokens)) {
|
|
742
|
-
|
|
743
|
-
await this.hooks.dispatch({
|
|
744
|
-
type: 'PreCompact',
|
|
745
|
-
messagesCount: messages.length,
|
|
746
|
-
tokens: totalForNextTurn,
|
|
747
|
-
cwd,
|
|
748
|
-
});
|
|
749
|
-
}
|
|
750
|
-
const summarizer = this.config.compactionSummarizer ?? this.buildDefaultSummarizer();
|
|
751
|
-
const result = await compactMessages(messages, summarizer, {
|
|
752
|
-
keepRecent: this.config.compactionKeepRecent,
|
|
753
|
-
});
|
|
754
|
-
if (result.droppedCount > 0) {
|
|
755
|
-
messages.length = 0;
|
|
756
|
-
for (const m of result.messages)
|
|
757
|
-
messages.push(m);
|
|
758
|
-
masker?.reset(); // the history was replaced, so the frozen boundary no longer applies
|
|
759
|
-
// A mid-run steering doc summarized away may be injected again on the next touch.
|
|
760
|
-
for (const name of [...steeringInMessages]) {
|
|
761
|
-
const marker = `<steering name="${name}">`;
|
|
762
|
-
if (messages.some((m) => typeof m.content === 'string' && m.content.includes(marker)))
|
|
763
|
-
continue;
|
|
764
|
-
steeringInMessages.delete(name);
|
|
765
|
-
steeringInjected.delete(name);
|
|
766
|
-
steeringInjections--;
|
|
767
|
-
}
|
|
768
|
-
emit({
|
|
769
|
-
type: 'compaction',
|
|
770
|
-
droppedCount: result.droppedCount,
|
|
771
|
-
savedTokens: result.savedTokens,
|
|
772
|
-
summary: result.summary,
|
|
773
|
-
});
|
|
774
|
-
}
|
|
915
|
+
await compactNow(totalForNextTurn);
|
|
775
916
|
}
|
|
776
917
|
}
|
|
777
918
|
}
|
|
@@ -809,14 +950,9 @@ export class Agent {
|
|
|
809
950
|
else {
|
|
810
951
|
reason = 'error';
|
|
811
952
|
const message = errorMessageOf(err);
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
}
|
|
816
|
-
else {
|
|
817
|
-
errorMessage = message;
|
|
818
|
-
emit({ type: 'error', error: errorMessage });
|
|
819
|
-
}
|
|
953
|
+
// The SDK follows a failed call with this generic error; the first one is the useful one.
|
|
954
|
+
errorMessage = reportedStreamError && /^No output generated/i.test(message) ? reportedStreamError : message;
|
|
955
|
+
emit({ type: 'error', error: errorMessage });
|
|
820
956
|
}
|
|
821
957
|
}
|
|
822
958
|
// A run that ends between pushing the continue message and sending it (stop, timeout)
|
|
@@ -824,6 +960,8 @@ export class Agent {
|
|
|
824
960
|
const lastMessage = messages[messages.length - 1];
|
|
825
961
|
if (continuedText !== '' && lastMessage?.role === 'user' && lastMessage.content === CONTINUE_PROMPT)
|
|
826
962
|
messages.pop();
|
|
963
|
+
else if (todoNudgeMessage && lastMessage === todoNudgeMessage)
|
|
964
|
+
messages.pop();
|
|
827
965
|
// Stopped mid-answer: keep the part the user already saw, marked as cut short, so "continue" can pick it up
|
|
828
966
|
// instead of starting over. Only when the model is the next speaker (after the user or a tool result).
|
|
829
967
|
if (reason === 'aborted' && partialStepText.trim()) {
|
|
@@ -981,23 +1119,23 @@ export class Agent {
|
|
|
981
1119
|
if (rule?.action === 'deny')
|
|
982
1120
|
return { approved: false, note: rule.reason };
|
|
983
1121
|
if (rule?.action === 'allow')
|
|
984
|
-
return { approved: true };
|
|
1122
|
+
return { approved: true, source: 'rule' };
|
|
985
1123
|
const forceAsk = rule?.action === 'ask';
|
|
986
1124
|
// Read-only tools the user chose not to be asked about (never a destructive one).
|
|
987
1125
|
if (!forceAsk && !def.destructive && this.config.autoApproveTools?.includes(def.name)) {
|
|
988
|
-
return { approved: true };
|
|
1126
|
+
return { approved: true, source: 'list' };
|
|
989
1127
|
}
|
|
990
1128
|
// Memory writes the user chose not to be asked about. memory_forget is deleted data: always asked.
|
|
991
1129
|
if (!forceAsk && def.name === 'memory_write' && this.config.memoryAutoApproveWrites) {
|
|
992
|
-
return { approved: true };
|
|
1130
|
+
return { approved: true, source: 'memory' };
|
|
993
1131
|
}
|
|
994
1132
|
// A plain command the user already said "always allow" for: exact match only (see command-allow.ts).
|
|
995
1133
|
if (!forceAsk && def.name === 'run_command' && this.config.autoApproveCommands?.length && isListedCommand(args, this.config.autoApproveCommands)) {
|
|
996
|
-
return { approved: true };
|
|
1134
|
+
return { approved: true, source: 'always' };
|
|
997
1135
|
}
|
|
998
1136
|
const auto = this.config.auto ?? false;
|
|
999
1137
|
if (!forceAsk && auto && !(def.destructive && !this.config.allowDestructive)) {
|
|
1000
|
-
return { approved: true };
|
|
1138
|
+
return { approved: true, source: 'autonomous' };
|
|
1001
1139
|
}
|
|
1002
1140
|
let preview;
|
|
1003
1141
|
if (def.previewApproval && ctx) {
|
|
@@ -1025,7 +1163,8 @@ export class Agent {
|
|
|
1025
1163
|
const decision = await raceAbort(answered, abortSignal, 'aborted');
|
|
1026
1164
|
if (decision === 'aborted')
|
|
1027
1165
|
return { approved: false };
|
|
1028
|
-
|
|
1166
|
+
const answer = typeof decision === 'boolean' ? { approved: decision } : decision;
|
|
1167
|
+
return answer.approved ? { ...answer, source: 'person' } : answer;
|
|
1029
1168
|
}
|
|
1030
1169
|
return { approved: false };
|
|
1031
1170
|
}
|
|
@@ -1240,7 +1379,8 @@ async function buildInitialMessages(config, task, skills, steeringSystem = [], u
|
|
|
1240
1379
|
// stable order so cache hits survive across runs that share state. The
|
|
1241
1380
|
// environment block goes last: it is the part that varies per session.
|
|
1242
1381
|
const steeringBlock = steeringSystem.map(wrapSteering).join('\n\n');
|
|
1243
|
-
const
|
|
1382
|
+
const guidanceBlock = config.model ? modelGuidanceBlock(config.model) : '';
|
|
1383
|
+
const blocks = [steeringBlock, instructionsBlock, extraInstructionsBlock, config.mcpInstructions ?? '', config.projectCommands ?? '', memoryBlock, skillsBlock, answersBlock, mapBlock, stackFactsBlock, guidanceBlock, envBlock].filter((b) => b.length > 0);
|
|
1244
1384
|
const systemContent = blocks.length > 0 ? `${basePrompt}\n\n${blocks.join('\n\n')}` : basePrompt;
|
|
1245
1385
|
return [
|
|
1246
1386
|
{ role: 'system', content: systemContent },
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { generateText } from 'ai';
|
|
2
2
|
import { createLanguageModel } from '../providers.js';
|
|
3
|
+
import { withModelRetry } from './retry.js';
|
|
3
4
|
import { stripThinkTags } from './think-tags.js';
|
|
4
5
|
const ENHANCE_INSTRUCTIONS = 'Rewrite the request below so a coding agent can act on it: say what to change and where, name the expected result, and keep every file, symbol and constraint the user mentioned. ' +
|
|
5
6
|
'Do not invent requirements or add steps the user did not ask for. Reply with the rewritten request only, no quotes, no code fences, no preface.';
|
|
@@ -15,7 +16,7 @@ export async function enhancePrompt(input, model, modelFactory = createLanguageM
|
|
|
15
16
|
if (!model || !input.trim())
|
|
16
17
|
return input;
|
|
17
18
|
try {
|
|
18
|
-
const result = await generateText({ model: modelFactory(model), prompt: enhanceRequestPrompt(input, instruction) });
|
|
19
|
+
const result = await withModelRetry(() => generateText({ model: modelFactory(model), prompt: enhanceRequestPrompt(input, instruction), maxRetries: 0 }), { maxRetries: 2 });
|
|
19
20
|
return clean(result.text) || input;
|
|
20
21
|
}
|
|
21
22
|
catch {
|
package/dist/agent/prompts.d.ts
CHANGED
|
@@ -29,6 +29,14 @@ export interface EnvironmentFacts {
|
|
|
29
29
|
* uses in `run_command`: `cmd.exe` on Windows, `/bin/sh` elsewhere.
|
|
30
30
|
*/
|
|
31
31
|
export declare function environmentBlock(facts: EnvironmentFacts): string;
|
|
32
|
+
/**
|
|
33
|
+
* Extra instructions for model families that need them (Kilo chooses a whole prompt per family; Forge appends a
|
|
34
|
+
* block so the base prompt stays the same for prompt caching and mode switching, RB-27). Empty for the rest.
|
|
35
|
+
*/
|
|
36
|
+
export declare function modelGuidanceBlock(model: {
|
|
37
|
+
provider: string;
|
|
38
|
+
model: string;
|
|
39
|
+
}): string;
|
|
32
40
|
/**
|
|
33
41
|
* System-prompt suffix appended when the agent runs in plan mode. The
|
|
34
42
|
* model is told to emit a numbered plan as plain text and stop without
|
|
@@ -44,5 +52,12 @@ export declare const DEFAULT_PLAN_MODE_SUFFIX = "\n\nPLAN MODE \u2014 IMPORTANT:
|
|
|
44
52
|
export declare const DEFAULT_SPEC_MODE_SUFFIX = "\n\nSPEC MODE \u2014 IMPORTANT:\n- You may use read-only tools (read_file, search_files, list_files, find_symbol, list_symbols, find_references) to explore the codebase as needed. Do NOT call write_file, edit_file, run_command, or any other tool.\n- When you have enough context, stop calling tools and produce your final answer as exactly three markdown sections, in this order, each starting with its own heading line:\n\n## requirements.md\n<user stories with EARS-syntax acceptance criteria, e.g. \"WHEN <trigger> THE SYSTEM SHALL <response>\". Number each requirement (1, 2, 3, ...) so tasks.md can reference them.>\n\n## design.md\n<technical design grounded in what you actually found in the codebase: data flow, schemas, interfaces, API endpoints, existing patterns to follow.>\n\n## tasks.md\n<sequenced implementation tasks as a markdown checklist. Write each task as \"- [ ] 1. Title\", numbering them 1, 2, 3, ...; indent subtasks as \" - [ ] 1.1 Title\". Directly under each task add a line like \"_Requirements: 2.1, 3.2_\" referencing the requirement numbers it satisfies. Put every task and every subtask on its own line; never write a second checkbox on the same line as another.>\n\n- Do not proceed to implement anything. The user will review these three documents and decide whether to approve, edit, or cancel.\n";
|
|
45
53
|
/** User message sent after a reply was cut off by the output-token limit. */
|
|
46
54
|
export declare const CONTINUE_PROMPT = "Continue exactly where you stopped. Do not repeat anything you already wrote.";
|
|
55
|
+
/** Added to the last step a run is allowed (tools are off for it); never kept in the conversation. */
|
|
56
|
+
export declare const LAST_STEP_PROMPT: string;
|
|
57
|
+
/** User message sent when a reply ends the turn while the model's own todo list still has open items. */
|
|
58
|
+
export declare function todoNudgePrompt(open: ReadonlyArray<{
|
|
59
|
+
content: string;
|
|
60
|
+
status: string;
|
|
61
|
+
}>): string;
|
|
47
62
|
/** Spec-mode prompt override for architect phase 1 (see agent/architect.ts). */
|
|
48
63
|
export declare const ARCHITECT_PROMPT = "\n\nYou are the ARCHITECT. Explore the codebase with your read-only tools, then\nreply with a precise change plan for a separate editor model to apply. Do not\nwrite code outside the plan. Your final message must contain only:\n\n1. Files to change \u2014 for each file, the exact edits (what to find, what to\n replace it with, or the new code to add and where).\n2. New files \u2014 path and full contents.\n3. Verification \u2014 the commands to run to check the change.\n\nBe exact: the editor applies your plan literally and cannot ask questions.\n";
|
package/dist/agent/prompts.js
CHANGED
|
@@ -69,6 +69,26 @@ export function environmentBlock(facts) {
|
|
|
69
69
|
];
|
|
70
70
|
return `<environment>\n${lines.join('\n')}\n</environment>`;
|
|
71
71
|
}
|
|
72
|
+
/** Model ids of families that tend to end a turn early or announce a tool call without making it. */
|
|
73
|
+
const PERSISTENCE_FAMILIES = /minimax|kimi|moonshot|deepseek|qwen|qwq|glm|llama|mistral|mixtral|codestral|devstral|gpt-oss|grok|nemotron|phi-|gemma|ling-|trinity/i;
|
|
74
|
+
/** Providers that serve local open-weight models, whatever their name. */
|
|
75
|
+
const LOCAL_PROVIDERS = new Set(['ollama', 'lmstudio']);
|
|
76
|
+
/**
|
|
77
|
+
* Extra instructions for model families that need them (Kilo chooses a whole prompt per family; Forge appends a
|
|
78
|
+
* block so the base prompt stays the same for prompt caching and mode switching, RB-27). Empty for the rest.
|
|
79
|
+
*/
|
|
80
|
+
export function modelGuidanceBlock(model) {
|
|
81
|
+
if (!LOCAL_PROVIDERS.has(model.provider) && !PERSISTENCE_FAMILIES.test(model.model))
|
|
82
|
+
return '';
|
|
83
|
+
return [
|
|
84
|
+
'<model-guidance>',
|
|
85
|
+
'- Keep working until the task is fully done before you end your turn; the user expects you to finish it, not to describe what you would do next.',
|
|
86
|
+
'- When you say you are about to call a tool, call it in the same reply. Never end a reply with "Now I will…" and no tool call.',
|
|
87
|
+
'- For a task with several steps, keep the list in `todo_write`: mark one item in_progress, and mark each item completed as soon as it is done.',
|
|
88
|
+
'- End your turn only when every item is done, when you need the user to decide something, or when the user asked you to stop.',
|
|
89
|
+
'</model-guidance>',
|
|
90
|
+
].join('\n');
|
|
91
|
+
}
|
|
72
92
|
/**
|
|
73
93
|
* System-prompt suffix appended when the agent runs in plan mode. The
|
|
74
94
|
* model is told to emit a numbered plan as plain text and stop without
|
|
@@ -108,6 +128,20 @@ SPEC MODE — IMPORTANT:
|
|
|
108
128
|
`;
|
|
109
129
|
/** User message sent after a reply was cut off by the output-token limit. */
|
|
110
130
|
export const CONTINUE_PROMPT = 'Continue exactly where you stopped. Do not repeat anything you already wrote.';
|
|
131
|
+
/** Added to the last step a run is allowed (tools are off for it); never kept in the conversation. */
|
|
132
|
+
export const LAST_STEP_PROMPT = '[Automatic note by Forge, not a message from the user.]\n' +
|
|
133
|
+
'This is the last step this run is allowed, and tools are turned off for it. Reply with text only, to the user: ' +
|
|
134
|
+
'say briefly that the step limit was reached, what is done, and what is left to do, so they can press Continue.';
|
|
135
|
+
/** User message sent when a reply ends the turn while the model's own todo list still has open items. */
|
|
136
|
+
export function todoNudgePrompt(open) {
|
|
137
|
+
const items = open.map((t) => `- [${t.status}] ${t.content}`).join('\n');
|
|
138
|
+
return ('[Automatic check by Forge, not a message from the user.]\n' +
|
|
139
|
+
`Your todo list still has open items:\n${items}\n\n` +
|
|
140
|
+
"The user's own instructions come first. If the user asked you to stop or wait, or you need their answer, " +
|
|
141
|
+
'do not continue: leave the open items as they are and end with one short sentence to the user, addressed as "you" (do not mention this check). ' +
|
|
142
|
+
'If an item no longer applies, update the list with `todo_write`. ' +
|
|
143
|
+
'Otherwise you stopped before finishing: carry on with the next item now, using your tools.');
|
|
144
|
+
}
|
|
111
145
|
/** Spec-mode prompt override for architect phase 1 (see agent/architect.ts). */
|
|
112
146
|
export const ARCHITECT_PROMPT = `
|
|
113
147
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When a failed model call is worth repeating, and how long to wait first. Patterns follow Kilo's
|
|
3
|
+
* session/retry.ts: status codes, the provider's own retry-after headers, and the wordings providers and
|
|
4
|
+
* Node use for overloads and dropped connections.
|
|
5
|
+
*/
|
|
6
|
+
export declare const DEFAULT_PROVIDER_RETRIES = 5;
|
|
7
|
+
export declare const DEFAULT_RETRY_BASE_MS = 2000;
|
|
8
|
+
export type RetryDecision = {
|
|
9
|
+
retry: true;
|
|
10
|
+
delayMs: number;
|
|
11
|
+
message: string;
|
|
12
|
+
} | {
|
|
13
|
+
retry: false;
|
|
14
|
+
};
|
|
15
|
+
/** The first line of the error a person should see. */
|
|
16
|
+
export declare function errorSummary(err: unknown): string;
|
|
17
|
+
export declare function isContextOverflowError(err: unknown): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Whether to retry after failed attempt number `attempt` (1-based) and how long to wait.
|
|
20
|
+
* `random` in [0, 1) adds up to 25% jitter to the back-off.
|
|
21
|
+
*/
|
|
22
|
+
export declare function retryDecision(err: unknown, attempt: number, baseDelayMs?: number, random?: number): RetryDecision;
|
|
23
|
+
/** True when the failure looks like no network (DNS or route gone), not a busy or resetting server. */
|
|
24
|
+
export declare function isDisconnected(err: unknown): boolean;
|
|
25
|
+
/** Opens a TCP connection to a few well-known hosts by address (no DNS needed); true when any of them answers. */
|
|
26
|
+
export declare function defaultNetworkProbe(): Promise<boolean>;
|
|
27
|
+
/**
|
|
28
|
+
* Poll `probe` every `pollMs` until it reports a network, the signal fires, or `budgetMs` runs out.
|
|
29
|
+
* Returns how long it waited and why it stopped.
|
|
30
|
+
*/
|
|
31
|
+
export declare function waitForNetwork(probe: () => Promise<boolean>, opts: {
|
|
32
|
+
pollMs: number;
|
|
33
|
+
budgetMs: number;
|
|
34
|
+
abortSignal?: AbortSignal;
|
|
35
|
+
}): Promise<{
|
|
36
|
+
outcome: 'online' | 'aborted' | 'gave_up';
|
|
37
|
+
waitedMs: number;
|
|
38
|
+
}>;
|
|
39
|
+
/**
|
|
40
|
+
* Run a one-shot model call (generateText with `maxRetries: 0`) under the agent loop's retry policy.
|
|
41
|
+
* Throws the failure itself when it is not worth retrying, when the retries are used up, or on abort.
|
|
42
|
+
*/
|
|
43
|
+
export declare function withModelRetry<T>(call: () => Promise<T>, opts?: {
|
|
44
|
+
maxRetries?: number;
|
|
45
|
+
baseDelayMs?: number;
|
|
46
|
+
abortSignal?: AbortSignal;
|
|
47
|
+
onRetry?: (attempt: number, delayMs: number, reason: string) => void;
|
|
48
|
+
}): Promise<T>;
|
|
49
|
+
/** Resolves after `ms`, or early (to 'aborted') when the signal fires; leaves no listener behind (RB-36). */
|
|
50
|
+
export declare function waitOrAbort(ms: number, signal: AbortSignal | undefined): Promise<'waited' | 'aborted'>;
|