opencode-auto-resume 1.1.17 → 1.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -0
- package/dist/index.js +161 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -46,6 +46,20 @@ A separate **tool-call loop detector** catches the model calling the same tool 3
|
|
|
46
46
|
|
|
47
47
|
Loop detection runs in two places. At idle, tool names are scanned from recent assistant messages. **Live**, every `tool.execute.before` hook fingerprints the call as `tool name + arguments` — so a subagent stuck re-reading the same file/range (even alternating between two near-identical argument sets, which never produces 3 consecutive identical calls) is caught after 6+ calls in the repeating cycle. On live detection the plugin aborts the running turn immediately (this is the sanctioned exception to the never-abort-busy rule: 6+ identical name+args fingerprints prove a hallucinated loop, and the current call has not started yet) and then sends `TOOL_LOOP_RECOVERY_PROMPT`. Esc-cancelled sessions are never touched.
|
|
48
48
|
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
### Unknown tool suggestion
|
|
52
|
+
|
|
53
|
+
When the model calls a tool that does not exist (a typo, a hallucinated name, or a tool from a different plugin that isn't loaded), OpenCode returns a `tool` part with `state.status = "error"`. If the same wrong tool name appears **2 times** in the session's message history, the plugin:
|
|
54
|
+
|
|
55
|
+
1. Fetches the list of available tools via `ctx.client.tool.ids()` (cached for 5 minutes).
|
|
56
|
+
2. Computes the closest match by Levenshtein distance (case-insensitive, threshold = half the wrong name's length).
|
|
57
|
+
3. Sends a continue prompt that names the wrong tool, states it does not exist, suggests the closest match (if one was found), and lists the first 20 available tools for reference.
|
|
58
|
+
|
|
59
|
+
The suggestion fires once per busy cycle. A new user message resets the counter so the suggestion can fire again on a fresh cycle. Tool parts that already succeeded (`state.status = "completed"`) and parts for tools that do exist are skipped.
|
|
60
|
+
|
|
61
|
+
Threshold and cache are compile-time constants (`UNKNOWN_TOOL_THRESHOLD = 2`, `TOOL_IDS_CACHE_MS = 5 min`). The detection runs at idle, alongside the tool-text recovery check.
|
|
62
|
+
|
|
49
63
|
_Motivated by:_
|
|
50
64
|
- [#22142](https://github.com/anomalyco/opencode/issues/22142) — Repetitive tool-call loops with alibaba-coding-plan-cn/qwen3.6-plus
|
|
51
65
|
- [#16218](https://github.com/anomalyco/opencode/issues/16218) — Model repeats the same response in a loop after generating an answer
|
|
@@ -203,6 +217,8 @@ The agent can call the built-in `task_complete` tool to signal that all work is
|
|
|
203
217
|
|
|
204
218
|
If `task_complete` is called while open todos remain, the call is rejected (up to `maxRetries` times) with a message asking the agent to finish the remaining work first.
|
|
205
219
|
|
|
220
|
+
Repeat calls with no new user message in between are guarded: the first acknowledgement carries an explicit stop instruction, the second returns a repeat warning, and the third and subsequent calls are rejected as errors. This breaks the ack self-loop where the acknowledgement tool-result is fed back into the turn and a stuck model re-emits `task_complete` instead of ending with text. The counter resets on a genuine user message, on the block path above, or when any other tool runs in between.
|
|
221
|
+
|
|
206
222
|
---
|
|
207
223
|
|
|
208
224
|
### 🎉 emoji completion
|
|
@@ -312,6 +328,10 @@ session.status events:
|
|
|
312
328
|
|
|
313
329
|
todo.updated events:
|
|
314
330
|
└─ track real todo state (not regex on message text)
|
|
331
|
+
└─ if missing (no event received): idle path + task_complete
|
|
332
|
+
fetch on-demand via session.todo() API so the
|
|
333
|
+
open-todos reminder and the 🎉 completion latch see
|
|
334
|
+
real state, not a stale empty array.
|
|
315
335
|
|
|
316
336
|
Timer loop (every 5s):
|
|
317
337
|
for each busy session:
|
package/dist/index.js
CHANGED
|
@@ -12363,7 +12363,8 @@ var DEFAULT_STREAMING_FAILURE_MESSAGE_PATTERNS = [
|
|
|
12363
12363
|
"streaming response failed",
|
|
12364
12364
|
"stream.*fail",
|
|
12365
12365
|
"connection.*reset",
|
|
12366
|
-
"connection.*closed"
|
|
12366
|
+
"connection.*closed",
|
|
12367
|
+
"aborted due to timeout"
|
|
12367
12368
|
];
|
|
12368
12369
|
var MAX_IDLE_SESSIONS = 50;
|
|
12369
12370
|
var IDLE_CLEANUP_MS = 10 * 60000;
|
|
@@ -12371,7 +12372,12 @@ var SESSION_DISCOVERY_INTERVAL_MS = 60000;
|
|
|
12371
12372
|
var TOOL_TEXT_RECOVERY_PROMPT = "Your last message contained a raw tool call printed as text instead of being executed. " + "Please use the proper tool calling mechanism to execute it.";
|
|
12372
12373
|
var THINKING_TOOL_RECOVERY_PROMPT = "I noticed you have a tool call generated in your thinking/reasoning. " + "Please execute it using the proper tool calling mechanism instead of keeping it in reasoning.";
|
|
12373
12374
|
var TOOL_LOOP_RECOVERY_PROMPT = "I notice you've been calling the same tool multiple times in a row without making progress. " + "Please step back and reassess your approach. Consider: " + "1) Are you stuck in a loop? 2) Do you need different information first? " + "3) Should you try a different tool or break the task into smaller steps? " + "Take a moment to think about what's blocking you and propose a different strategy.";
|
|
12375
|
+
var TASK_COMPLETE_ACK = "Task completion acknowledged. No further continuation will be sent. " + "End your turn now with a brief text reply \u2014 do not call task_complete or any other tool again " + "unless the user sends a new message.";
|
|
12376
|
+
var TASK_COMPLETE_REPEAT_WARNING = "Task completion acknowledged already on your previous call \u2014 completion is recorded. " + "End your turn now with a brief text reply. Do not call task_complete again; " + "further repeat calls are rejected as errors.";
|
|
12377
|
+
var TASK_COMPLETE_REPEAT_ERROR = "task_complete already acknowledged twice with no new user message or tool work since \u2014 " + "completion is recorded. End your turn with text and make no further tool calls.";
|
|
12374
12378
|
var CTX_WRAPUP_TRIGGER = "ctx-wrapup";
|
|
12379
|
+
var UNKNOWN_TOOL_THRESHOLD = 2;
|
|
12380
|
+
var TOOL_IDS_CACHE_MS = 5 * 60000;
|
|
12375
12381
|
var TOOL_TEXT_PATTERNS = [
|
|
12376
12382
|
/<function\s*=/i,
|
|
12377
12383
|
/<function>/i,
|
|
@@ -12466,6 +12472,42 @@ function containsWorkDescription(text) {
|
|
|
12466
12472
|
return true;
|
|
12467
12473
|
return false;
|
|
12468
12474
|
}
|
|
12475
|
+
function levenshtein(a, b) {
|
|
12476
|
+
const m = a.length, n = b.length;
|
|
12477
|
+
if (m === 0)
|
|
12478
|
+
return n;
|
|
12479
|
+
if (n === 0)
|
|
12480
|
+
return m;
|
|
12481
|
+
let prev = new Array(n + 1);
|
|
12482
|
+
let curr = new Array(n + 1);
|
|
12483
|
+
for (let j = 0;j <= n; j++)
|
|
12484
|
+
prev[j] = j;
|
|
12485
|
+
for (let i = 1;i <= m; i++) {
|
|
12486
|
+
curr[0] = i;
|
|
12487
|
+
for (let j = 1;j <= n; j++) {
|
|
12488
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
12489
|
+
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
|
|
12490
|
+
}
|
|
12491
|
+
const tmp = prev;
|
|
12492
|
+
prev = curr;
|
|
12493
|
+
curr = tmp;
|
|
12494
|
+
}
|
|
12495
|
+
return prev[n];
|
|
12496
|
+
}
|
|
12497
|
+
function suggestClosestTool(wrongName, available) {
|
|
12498
|
+
const lower = wrongName.toLowerCase();
|
|
12499
|
+
let best = null;
|
|
12500
|
+
let bestDist = Infinity;
|
|
12501
|
+
for (const id of available) {
|
|
12502
|
+
const dist = levenshtein(lower, id.toLowerCase());
|
|
12503
|
+
const threshold = Math.max(2, Math.floor(lower.length / 2));
|
|
12504
|
+
if (dist < bestDist && dist <= threshold) {
|
|
12505
|
+
bestDist = dist;
|
|
12506
|
+
best = id;
|
|
12507
|
+
}
|
|
12508
|
+
}
|
|
12509
|
+
return best;
|
|
12510
|
+
}
|
|
12469
12511
|
function isStreamingFailure(errorName, errorMessage, errorNames = DEFAULT_STREAMING_FAILURE_ERROR_NAMES, messagePatterns = DEFAULT_STREAMING_FAILURE_MESSAGE_PATTERNS) {
|
|
12470
12512
|
if (!errorName && !errorMessage)
|
|
12471
12513
|
return false;
|
|
@@ -12727,6 +12769,7 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
12727
12769
|
completionSignaled: false,
|
|
12728
12770
|
todoNudgeAttempts: 0,
|
|
12729
12771
|
taskCompleteOverrides: 0,
|
|
12772
|
+
taskCompleteSignals: 0,
|
|
12730
12773
|
doneClaimNoTodosAttempts: 0,
|
|
12731
12774
|
lastUserMessageAt: 0,
|
|
12732
12775
|
pendingTools: 0,
|
|
@@ -12735,7 +12778,10 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
12735
12778
|
pendingRecoveryReason: null,
|
|
12736
12779
|
pendingRecoveryAt: 0,
|
|
12737
12780
|
recoveryAttempts: 0,
|
|
12738
|
-
watchdogRetryGuard: false
|
|
12781
|
+
watchdogRetryGuard: false,
|
|
12782
|
+
unknownToolErrors: new Map,
|
|
12783
|
+
unknownToolSuggestionSent: false,
|
|
12784
|
+
checkedToolPartIDs: new Set
|
|
12739
12785
|
};
|
|
12740
12786
|
sessions.set(sid, w);
|
|
12741
12787
|
}
|
|
@@ -13054,6 +13100,73 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
13054
13100
|
messagesInflight.set(sid, p);
|
|
13055
13101
|
return p;
|
|
13056
13102
|
}
|
|
13103
|
+
let cachedToolIds = null;
|
|
13104
|
+
let cachedToolIdsAt = 0;
|
|
13105
|
+
async function getAvailableToolIds() {
|
|
13106
|
+
if (cachedToolIds && Date.now() - cachedToolIdsAt < TOOL_IDS_CACHE_MS) {
|
|
13107
|
+
return cachedToolIds;
|
|
13108
|
+
}
|
|
13109
|
+
try {
|
|
13110
|
+
const result = await ctx.client.tool.ids();
|
|
13111
|
+
const ids = result.data ?? [];
|
|
13112
|
+
cachedToolIds = ids;
|
|
13113
|
+
cachedToolIdsAt = Date.now();
|
|
13114
|
+
return ids;
|
|
13115
|
+
} catch (e) {
|
|
13116
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
13117
|
+
await log("warn", `Failed to fetch tool IDs: ${msg}`);
|
|
13118
|
+
return cachedToolIds ?? [];
|
|
13119
|
+
}
|
|
13120
|
+
}
|
|
13121
|
+
async function checkForUnknownToolCalls(sid, w) {
|
|
13122
|
+
if (w.unknownToolSuggestionSent)
|
|
13123
|
+
return false;
|
|
13124
|
+
if (w.userCancelled || w.completionSignaled)
|
|
13125
|
+
return false;
|
|
13126
|
+
try {
|
|
13127
|
+
const available = await getAvailableToolIds();
|
|
13128
|
+
if (available.length === 0)
|
|
13129
|
+
return false;
|
|
13130
|
+
const messages = await getSessionMessages(sid);
|
|
13131
|
+
for (const msg of messages) {
|
|
13132
|
+
const parts = msg.parts;
|
|
13133
|
+
if (!parts)
|
|
13134
|
+
continue;
|
|
13135
|
+
for (const part of parts) {
|
|
13136
|
+
const partType = part.type;
|
|
13137
|
+
if (partType !== "tool")
|
|
13138
|
+
continue;
|
|
13139
|
+
const partId = part.id ?? part.callID ?? "";
|
|
13140
|
+
if (!partId || w.checkedToolPartIDs.has(partId))
|
|
13141
|
+
continue;
|
|
13142
|
+
w.checkedToolPartIDs.add(partId);
|
|
13143
|
+
const state = part.state;
|
|
13144
|
+
if (!state || state.status !== "error")
|
|
13145
|
+
continue;
|
|
13146
|
+
const toolName = part.tool ?? "";
|
|
13147
|
+
if (!toolName || available.includes(toolName))
|
|
13148
|
+
continue;
|
|
13149
|
+
const count = (w.unknownToolErrors.get(toolName) ?? 0) + 1;
|
|
13150
|
+
w.unknownToolErrors.set(toolName, count);
|
|
13151
|
+
if (count < UNKNOWN_TOOL_THRESHOLD)
|
|
13152
|
+
continue;
|
|
13153
|
+
const suggestion = suggestClosestTool(toolName, available);
|
|
13154
|
+
const toolList = available.slice(0, 20).join(", ");
|
|
13155
|
+
const prompt = suggestion ? `You tried to use the tool "${toolName}" ${count} times, but it does not exist. ` + `The closest matching tool is "${suggestion}". ` + `Please use "${suggestion}" instead and adjust your arguments accordingly. ` + `Available tools include: ${toolList}.` : `You tried to use the tool "${toolName}" ${count} times, but it does not exist. ` + `Please check the available tools and use the correct one. ` + `Available tools include: ${toolList}.`;
|
|
13156
|
+
w.unknownToolSuggestionSent = true;
|
|
13157
|
+
w.toolTextRecovered = true;
|
|
13158
|
+
await log("warn", `${short(sid)} - unknown tool "${toolName}" called ${count}x, suggesting "${suggestion ?? "(none)"}"`);
|
|
13159
|
+
await sendContinuePrompt(sid, prompt, w);
|
|
13160
|
+
return true;
|
|
13161
|
+
}
|
|
13162
|
+
}
|
|
13163
|
+
return false;
|
|
13164
|
+
} catch (e) {
|
|
13165
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
13166
|
+
await log("warn", `${short(sid)} - checkForUnknownToolCalls error: ${msg}`);
|
|
13167
|
+
return false;
|
|
13168
|
+
}
|
|
13169
|
+
}
|
|
13057
13170
|
function roleOf(msg) {
|
|
13058
13171
|
if (!msg)
|
|
13059
13172
|
return;
|
|
@@ -13441,6 +13554,15 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
13441
13554
|
await log("info", `${short(sid)} - user recently active, skipping tool-text check`);
|
|
13442
13555
|
return;
|
|
13443
13556
|
}
|
|
13557
|
+
if ((w.todos || []).length === 0) {
|
|
13558
|
+
try {
|
|
13559
|
+
const fetched = await fetchSessionTodos(sid);
|
|
13560
|
+
if (fetched.length > 0)
|
|
13561
|
+
w.todos = fetched;
|
|
13562
|
+
} catch (e) {
|
|
13563
|
+
dbg(`checkForToolCallAsText sid=${short(sid)}: lazy todo fetch error: ${e}`);
|
|
13564
|
+
}
|
|
13565
|
+
}
|
|
13444
13566
|
const recent = messages.slice(-3);
|
|
13445
13567
|
let bestCandidate = null;
|
|
13446
13568
|
let allAssistantText = "";
|
|
@@ -14283,6 +14405,9 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
14283
14405
|
w.status = "idle";
|
|
14284
14406
|
resetIdleFlags(w);
|
|
14285
14407
|
dbg(`session.idle sid=${short(sid)}: resetIdleFlags done, toolTextRecovered=${w.toolTextRecovered}, toolTextAttempts=${w.toolTextAttempts}, maxRetries=${maxRetries}`);
|
|
14408
|
+
if (!w.unknownToolSuggestionSent && !w.completionSignaled && !w.userCancelled) {
|
|
14409
|
+
checkForUnknownToolCalls(sid, w);
|
|
14410
|
+
}
|
|
14286
14411
|
if (resumeOnActionIntent && !w.toolTextRecovered && !w.completionSignaled) {
|
|
14287
14412
|
setTimeout(async () => {
|
|
14288
14413
|
try {
|
|
@@ -14360,6 +14485,9 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
14360
14485
|
if (w) {
|
|
14361
14486
|
w.doneClaimNoTodosAttempts = 0;
|
|
14362
14487
|
w.lastUserMessageAt = Date.now();
|
|
14488
|
+
w.unknownToolErrors.clear();
|
|
14489
|
+
w.unknownToolSuggestionSent = false;
|
|
14490
|
+
w.checkedToolPartIDs.clear();
|
|
14363
14491
|
}
|
|
14364
14492
|
break;
|
|
14365
14493
|
}
|
|
@@ -14452,20 +14580,40 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
14452
14580
|
}
|
|
14453
14581
|
}
|
|
14454
14582
|
const taskCompleteTool = tool({
|
|
14455
|
-
description: "Signal that all work is complete and stop automatic continuation prompts. Call this ONLY after finishing everything requested.",
|
|
14583
|
+
description: "Signal that all work is complete and stop automatic continuation prompts. Call this ONLY after finishing everything requested. Call exactly once per completed round of work \u2014 repeat calls with no new user message in between are rejected.",
|
|
14456
14584
|
args: {},
|
|
14457
14585
|
execute: async (_args, ctx) => {
|
|
14458
14586
|
const w = sessions.get(ctx.sessionID);
|
|
14459
14587
|
if (w) {
|
|
14460
14588
|
if (!w.isSubagent) {
|
|
14589
|
+
if ((w.todos || []).length === 0) {
|
|
14590
|
+
try {
|
|
14591
|
+
const fetched = await fetchSessionTodos(ctx.sessionID);
|
|
14592
|
+
if (fetched.length > 0)
|
|
14593
|
+
w.todos = fetched;
|
|
14594
|
+
} catch (e) {
|
|
14595
|
+
dbg(`task_complete sid=${short(ctx.sessionID)}: lazy todo fetch error: ${e}`);
|
|
14596
|
+
}
|
|
14597
|
+
}
|
|
14461
14598
|
const openTodos = (w.todos || []).filter((t) => t.status === "pending" || t.status === "in_progress");
|
|
14462
14599
|
if (openTodos.length > 0 && w.taskCompleteOverrides < maxRetries) {
|
|
14463
14600
|
w.taskCompleteOverrides++;
|
|
14601
|
+
w.taskCompleteSignals = 0;
|
|
14464
14602
|
const reminder = buildOpenTodosReminder(w.todos);
|
|
14465
14603
|
const blockMsg = `Mark any finished todos complete and do not redo completed work.
|
|
14466
14604
|
${reminder}`;
|
|
14467
14605
|
await log("info", `${short(ctx.sessionID)} - task_complete blocked: ${openTodos.length} open todos remain (override ${w.taskCompleteOverrides}/${maxRetries})`);
|
|
14468
|
-
|
|
14606
|
+
let awaitingInput = false;
|
|
14607
|
+
try {
|
|
14608
|
+
awaitingInput = hasPendingUserInput(await getSessionMessages(ctx.sessionID));
|
|
14609
|
+
} catch (e) {
|
|
14610
|
+
dbg(`task_complete block: awaiting-input check error: ${e}`);
|
|
14611
|
+
}
|
|
14612
|
+
if (awaitingInput) {
|
|
14613
|
+
await log("info", `${short(ctx.sessionID)} - task_complete blocked but user input pending, skipping visible nudge`);
|
|
14614
|
+
} else {
|
|
14615
|
+
await sendContinuePrompt(ctx.sessionID, blockMsg, w);
|
|
14616
|
+
}
|
|
14469
14617
|
return blockMsg;
|
|
14470
14618
|
}
|
|
14471
14619
|
}
|
|
@@ -14476,8 +14624,13 @@ ${reminder}`;
|
|
|
14476
14624
|
w.toolTextTimer = null;
|
|
14477
14625
|
}
|
|
14478
14626
|
log("info", `${short(ctx.sessionID)} - task_complete called, ${w.isSubagent ? "subagent" : "agent"} done`);
|
|
14627
|
+
w.taskCompleteSignals++;
|
|
14628
|
+
if (w.taskCompleteSignals === 2)
|
|
14629
|
+
return TASK_COMPLETE_REPEAT_WARNING;
|
|
14630
|
+
if (w.taskCompleteSignals > 2)
|
|
14631
|
+
throw new Error(TASK_COMPLETE_REPEAT_ERROR);
|
|
14479
14632
|
}
|
|
14480
|
-
return
|
|
14633
|
+
return TASK_COMPLETE_ACK;
|
|
14481
14634
|
}
|
|
14482
14635
|
});
|
|
14483
14636
|
return {
|
|
@@ -14509,6 +14662,7 @@ ${reminder}`;
|
|
|
14509
14662
|
if (w.userCancelled || w.completionSignaled) {
|
|
14510
14663
|
w.userCancelled = false;
|
|
14511
14664
|
w.completionSignaled = false;
|
|
14665
|
+
w.taskCompleteSignals = 0;
|
|
14512
14666
|
await log("info", `${short(sid)} - new user message, re-arming auto-resume`);
|
|
14513
14667
|
}
|
|
14514
14668
|
},
|
|
@@ -14519,6 +14673,8 @@ ${reminder}`;
|
|
|
14519
14673
|
w.pendingTools++;
|
|
14520
14674
|
w.lastActivityAt = Date.now();
|
|
14521
14675
|
const toolName = input.tool ?? "unknown";
|
|
14676
|
+
if (toolName !== "task_complete")
|
|
14677
|
+
w.taskCompleteSignals = 0;
|
|
14522
14678
|
const rawArgs = hookArgs?.args ?? input.args;
|
|
14523
14679
|
w.liveToolSigs.push(toolCallSignature(toolName, rawArgs));
|
|
14524
14680
|
if (w.liveToolSigs.length > 15)
|
package/package.json
CHANGED