opencode-auto-resume 1.0.5 → 1.0.7
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 +2 -0
- package/dist/index.js +130 -37
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
LLM sessions fail in predictable ways. This plugin monitors all sessions and automatically recovers without user intervention.
|
|
8
8
|
|
|
9
9
|
**Stall recovery** — the stream goes silent but the session stays "busy". The UI shows a blinking cursor with no progress. If no events arrive for 48 seconds, the plugin sends `"continue"` with exponential backoff. After 3 failed attempts it gives up.
|
|
10
|
+
|
|
11
|
+
The plugin extracts the **agent, model, and provider** from the last session message, so it resumes with the exact same configuration the user was using (build, sisyphus, prometheus, etc.).
|
|
10
12
|
_( [#55](https://github.com/opencode-ai/opencode/issues/55), [#199](https://github.com/opencode-ai/opencode/issues/199), [#283](https://github.com/opencode-ai/opencode/issues/283) )_
|
|
11
13
|
|
|
12
14
|
**Tool calls as raw text** — the model prints tool invocations as raw XML (`<function=edit>...`) instead of executing them. The session goes idle normally but the tool was never run. On idle, the plugin fetches the last messages and scans for XML tool-call patterns (including truncated and alternative formats). If found, it sends a specific recovery prompt.
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,7 @@ var MAX_IDLE_SESSIONS = 50;
|
|
|
15
15
|
var IDLE_CLEANUP_MS = 10 * 60000;
|
|
16
16
|
var SESSION_DISCOVERY_INTERVAL_MS = 60000;
|
|
17
17
|
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.";
|
|
18
|
+
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.";
|
|
18
19
|
var TOOL_TEXT_PATTERNS = [
|
|
19
20
|
/<function\s*=/i,
|
|
20
21
|
/<function>/i,
|
|
@@ -210,12 +211,53 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
210
211
|
}
|
|
211
212
|
w.continuing = true;
|
|
212
213
|
try {
|
|
214
|
+
let agent2;
|
|
215
|
+
let model2;
|
|
216
|
+
const msgResp = await ctx.client.session.messages({ path: { id: sid } });
|
|
217
|
+
const msgs = extractMessages(msgResp);
|
|
218
|
+
for (let i = msgs.length - 1;i >= 0; i--) {
|
|
219
|
+
const msg = msgs[i];
|
|
220
|
+
const info = msg.info;
|
|
221
|
+
if (info?.role === "user") {
|
|
222
|
+
const rawAgent = info.agent;
|
|
223
|
+
if (typeof rawAgent === "string")
|
|
224
|
+
agent2 = rawAgent;
|
|
225
|
+
const rawModel = info.model;
|
|
226
|
+
if (rawModel && typeof rawModel.providerID === "string" && typeof rawModel.modelID === "string") {
|
|
227
|
+
model2 = {
|
|
228
|
+
providerID: rawModel.providerID,
|
|
229
|
+
modelID: rawModel.modelID
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
213
235
|
await ctx.client.session.prompt({
|
|
214
236
|
path: { id: sid },
|
|
215
|
-
body: {
|
|
237
|
+
body: {
|
|
238
|
+
parts: [{ type: "text", text }],
|
|
239
|
+
agent: agent2,
|
|
240
|
+
model: model2
|
|
241
|
+
}
|
|
216
242
|
});
|
|
243
|
+
await log("debug", `${short(sid)} - prompt sent with agent: ${agent2 ?? "(default)"}, model: ${model2 ? `${model2.providerID}/${model2.modelID}` : "(default)"}`);
|
|
217
244
|
recordContinue(sid);
|
|
218
245
|
w.lastRetryAt = Date.now();
|
|
246
|
+
} catch (err) {
|
|
247
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
248
|
+
await log("warn", `${short(sid)} - prompt failed: ${errMsg}`);
|
|
249
|
+
try {
|
|
250
|
+
await ctx.client.session.prompt({
|
|
251
|
+
path: { id: sid },
|
|
252
|
+
body: { parts: [{ type: "text", text }], agent, model }
|
|
253
|
+
});
|
|
254
|
+
recordContinue(sid);
|
|
255
|
+
w.lastRetryAt = Date.now();
|
|
256
|
+
} catch (retryErr) {
|
|
257
|
+
const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
|
|
258
|
+
await log("error", `${short(sid)} - prompt retry also failed: ${retryMsg}`);
|
|
259
|
+
throw retryErr;
|
|
260
|
+
}
|
|
219
261
|
} finally {
|
|
220
262
|
w.continuing = false;
|
|
221
263
|
}
|
|
@@ -229,6 +271,38 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
229
271
|
return response.messages;
|
|
230
272
|
return [];
|
|
231
273
|
}
|
|
274
|
+
async function checkSubagentStatus(parentSid) {
|
|
275
|
+
try {
|
|
276
|
+
const response = await ctx.client.session.list();
|
|
277
|
+
const allSessions = extractMessages(response);
|
|
278
|
+
let hasBusySubagent = false;
|
|
279
|
+
for (const s of allSessions) {
|
|
280
|
+
const sId = s.id;
|
|
281
|
+
if (!sId || sId === parentSid)
|
|
282
|
+
continue;
|
|
283
|
+
const status = s.status;
|
|
284
|
+
if (status === "busy") {
|
|
285
|
+
hasBusySubagent = true;
|
|
286
|
+
const msgResponse = await ctx.client.session.messages({ path: { id: sId } });
|
|
287
|
+
const messages = extractMessages(msgResponse);
|
|
288
|
+
const lastMsg = messages[messages.length - 1];
|
|
289
|
+
const rawRole = lastMsg?.role ?? lastMsg?.info?.role;
|
|
290
|
+
if (lastMsg && rawRole === "assistant" && (("error" in lastMsg) || lastMsg.info && ("error" in lastMsg.info))) {
|
|
291
|
+
await log("debug", `Subagent ${short(sId)} appears crashed`);
|
|
292
|
+
return "crashed";
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (!hasBusySubagent) {
|
|
297
|
+
return "idle";
|
|
298
|
+
}
|
|
299
|
+
return "busy";
|
|
300
|
+
} catch (err) {
|
|
301
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
302
|
+
await log("debug", `checkSubagentStatus failed: ${errMsg}`);
|
|
303
|
+
return "unknown";
|
|
304
|
+
}
|
|
305
|
+
}
|
|
232
306
|
function resetSessionFlags(w) {
|
|
233
307
|
w.userCancelled = false;
|
|
234
308
|
w.resumeAttempts = 0;
|
|
@@ -267,55 +341,65 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
267
341
|
});
|
|
268
342
|
const messages = extractMessages(response);
|
|
269
343
|
const recent = messages.slice(-3);
|
|
344
|
+
let bestCandidate = null;
|
|
270
345
|
for (const msg of recent) {
|
|
271
|
-
const
|
|
272
|
-
if (
|
|
346
|
+
const rawRole = msg.role ?? msg.info?.role;
|
|
347
|
+
if (rawRole !== "assistant")
|
|
273
348
|
continue;
|
|
274
349
|
const parts = msg.parts;
|
|
275
350
|
if (!parts)
|
|
276
351
|
continue;
|
|
277
352
|
for (const part of parts) {
|
|
278
|
-
|
|
353
|
+
const partType = part.type;
|
|
354
|
+
let text = "";
|
|
355
|
+
let isReasoning = false;
|
|
356
|
+
if (partType === "text") {
|
|
357
|
+
text = part.text ?? "";
|
|
358
|
+
} else if (partType === "reasoning") {
|
|
359
|
+
text = part.text ?? "";
|
|
360
|
+
isReasoning = true;
|
|
361
|
+
} else {
|
|
279
362
|
continue;
|
|
280
|
-
|
|
363
|
+
}
|
|
281
364
|
if (containsToolCallAsText(text)) {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
try {
|
|
290
|
-
await sendContinuePrompt(sid, TOOL_TEXT_RECOVERY_PROMPT, w);
|
|
291
|
-
await log("info", `${short(sid)} - tool-call-as-text recovery sent (attempt ${w.toolTextAttempts})`);
|
|
292
|
-
} catch (err) {
|
|
293
|
-
const errMsg = err instanceof Error ? err.message : String(err);
|
|
294
|
-
await log("warn", `${short(sid)} - tool-call-as-text recovery failed: ${errMsg}`);
|
|
295
|
-
}
|
|
365
|
+
const candidate = {
|
|
366
|
+
prompt: isReasoning ? THINKING_TOOL_RECOVERY_PROMPT : TOOL_TEXT_RECOVERY_PROMPT,
|
|
367
|
+
source: isReasoning ? "reasoning" : "text",
|
|
368
|
+
priority: 0
|
|
369
|
+
};
|
|
370
|
+
if (!bestCandidate || candidate.priority < bestCandidate.priority) {
|
|
371
|
+
bestCandidate = candidate;
|
|
296
372
|
}
|
|
297
|
-
return;
|
|
298
373
|
}
|
|
299
374
|
if (containsReadyToContinuePattern(text)) {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
try {
|
|
308
|
-
await sendContinuePrompt(sid, "continue", w);
|
|
309
|
-
await log("info", `${short(sid)} - ready-to-continue recovery sent (attempt ${w.toolTextAttempts})`);
|
|
310
|
-
} catch (err) {
|
|
311
|
-
const errMsg = err instanceof Error ? err.message : String(err);
|
|
312
|
-
await log("warn", `${short(sid)} - ready-to-continue recovery failed: ${errMsg}`);
|
|
313
|
-
}
|
|
375
|
+
const candidate = {
|
|
376
|
+
prompt: "continue",
|
|
377
|
+
source: "ready-to-continue",
|
|
378
|
+
priority: 1
|
|
379
|
+
};
|
|
380
|
+
if (!bestCandidate || candidate.priority < bestCandidate.priority) {
|
|
381
|
+
bestCandidate = candidate;
|
|
314
382
|
}
|
|
315
|
-
return;
|
|
316
383
|
}
|
|
317
384
|
}
|
|
318
385
|
}
|
|
386
|
+
if (!bestCandidate)
|
|
387
|
+
return;
|
|
388
|
+
w.toolTextRecovered = true;
|
|
389
|
+
w.toolTextAttempts++;
|
|
390
|
+
await log("info", `${bestCandidate.source} detected on ${short(sid)}! ` + `Attempt ${w.toolTextAttempts}/${maxRetries}. Sending recovery prompt...`);
|
|
391
|
+
if (isHallucinationLoop(sid)) {
|
|
392
|
+
await log("warn", `Hallucination loop detected on ${short(sid)} \u2014 aborting instead`);
|
|
393
|
+
await tryAbortAndResume(sid, w);
|
|
394
|
+
} else {
|
|
395
|
+
try {
|
|
396
|
+
await sendContinuePrompt(sid, bestCandidate.prompt, w);
|
|
397
|
+
await log("info", `${short(sid)} - ${bestCandidate.source} recovery sent (attempt ${w.toolTextAttempts})`);
|
|
398
|
+
} catch (err) {
|
|
399
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
400
|
+
await log("warn", `${short(sid)} - ${bestCandidate.source} recovery failed: ${errMsg}`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
319
403
|
} catch (err) {
|
|
320
404
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
321
405
|
log("debug", `${short(sid)} - could not fetch messages: ${errMsg}`);
|
|
@@ -412,7 +496,7 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
412
496
|
function startTimer() {
|
|
413
497
|
if (timer)
|
|
414
498
|
return;
|
|
415
|
-
timer = setInterval(() => {
|
|
499
|
+
timer = setInterval(async () => {
|
|
416
500
|
const now = Date.now();
|
|
417
501
|
const numBusy = busyCount();
|
|
418
502
|
for (const [sid, w] of sessions) {
|
|
@@ -426,7 +510,16 @@ var AutoResumePlugin = async (ctx, options) => {
|
|
|
426
510
|
const orphanIdle = now - w.orphanWatchStartAt;
|
|
427
511
|
if (orphanIdle >= subagentWaitMs + gracePeriodMs) {
|
|
428
512
|
if (w.resumeAttempts < maxRetries) {
|
|
429
|
-
|
|
513
|
+
const subStatus = await checkSubagentStatus(sid);
|
|
514
|
+
if (subStatus === "crashed") {
|
|
515
|
+
await log("info", `Subagent crashed, triggering abort+resume on ${short(sid)}`);
|
|
516
|
+
tryAbortAndResume(sid, w);
|
|
517
|
+
} else if (subStatus === "idle") {
|
|
518
|
+
await log("info", `All subagents idle, parent ${short(sid)} stuck. Triggering abort+resume.`);
|
|
519
|
+
tryAbortAndResume(sid, w);
|
|
520
|
+
} else {
|
|
521
|
+
await log("debug", `Subagent still running, waiting...`);
|
|
522
|
+
}
|
|
430
523
|
} else if (!w.gaveUp) {
|
|
431
524
|
w.gaveUp = true;
|
|
432
525
|
w.orphanWatchStartAt = null;
|
package/package.json
CHANGED