@dudousxd/nestjs-agent-opencode 0.4.0 → 0.5.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/README.md CHANGED
@@ -94,8 +94,9 @@ the dashboard and `GET /quota` see an OpenCode turn like any other.
94
94
  the turn's last message) is added up from the turn's own calls, not read back from the store: each
95
95
  milestone carries what was spent since the last one, and a durable run journals it with the
96
96
  milestone, so a run resumed in another process (an approval answered after a restart) still reports
97
- the whole run, cache tokens included. A process that dies while OpenCode works loses the calls it
98
- saw from that total (their ledger rows are already written).
97
+ the whole run, cache tokens included. A process that dies while OpenCode works does not lose the
98
+ calls it saw: the `observe` re-run reads each step's usage back from the session's messages (see
99
+ below).
99
100
 
100
101
  ## Durable turns
101
102
 
@@ -109,7 +110,13 @@ AgentModule.forRoot({ engine: openCodeDurable({ host: MyOpenCodeHost, sessions:
109
110
  Every step of a turn is checkpointed (`begin → prompt → observe → [wait for a person → reply →
110
111
  observe]* → finish`) and a person's decision is a durable signal: a turn waiting on an approval
111
112
  survives restarts and is resumed by whichever process gets the decision. If OpenCode restarted
112
- meanwhile, a new session is opened with the conversation and the decision. `openCode()` runs the
113
+ meanwhile, a new session is opened with the conversation and the decision. If the API restarted
114
+ while OpenCode worked (its `observe` step re-runs), the rebuilt turn catches up on what OpenCode
115
+ asked meanwhile and, when the execution ends, on what it said: the text, tool calls and results and
116
+ each step's usage are read back from the session's messages after the turn's prompt (the prompt
117
+ carries the run id in its `metadata`), less what was already taken before the restart (journaled
118
+ with each ask). A session that left no answer at all fails the turn (`LOST_ANSWER_ERROR`) instead
119
+ of settling it as an empty success. It needs the client's `message.list`. `openCode()` runs the
113
120
  same steps in memory (single replica). Several processes need a cross-process sink and a persistent
114
121
  `OpenCodeSessionStore`.
115
122
 
@@ -285,6 +285,9 @@ function subUsage(a, b) {
285
285
  };
286
286
  }
287
287
  __name(subUsage, "subUsage");
288
+ var LOST_ANSWER_ERROR = "The answer was lost while the agent restarted; ask again.";
289
+ var RECOVER_PAGES = 10;
290
+ var RECOVER_PAGE_SIZE = 100;
288
291
  var ASK_TOOL_NAME = "ask";
289
292
  var FAILED_STATUSES = /* @__PURE__ */ new Set([
290
293
  "error",
@@ -373,6 +376,14 @@ var OpenCodeTurn = class {
373
376
  wroteMessage = false;
374
377
  ended = false;
375
378
  stepError;
379
+ /** What this turn object took from the session's assistant messages (see {@link TurnProgress}). */
380
+ took = {
381
+ text: /* @__PURE__ */ new Map(),
382
+ reasoning: /* @__PURE__ */ new Map(),
383
+ called: /* @__PURE__ */ new Set(),
384
+ settled: /* @__PURE__ */ new Set(),
385
+ steps: /* @__PURE__ */ new Set()
386
+ };
376
387
  constructor(a) {
377
388
  this.a = a;
378
389
  this.model = modelOfLabel(a.input.model);
@@ -390,6 +401,30 @@ var OpenCodeTurn = class {
390
401
  tokens: info.tokens
391
402
  };
392
403
  }
404
+ /** What the turn took so far: carried from before this turn object, plus what it took. */
405
+ progress() {
406
+ const before = this.a.progress;
407
+ const merged = /* @__PURE__ */ __name((carried, own) => {
408
+ const out = {
409
+ ...carried ?? {}
410
+ };
411
+ for (const [id, n] of own) out[id] = (out[id] ?? 0) + n;
412
+ return out;
413
+ }, "merged");
414
+ const union = /* @__PURE__ */ __name((carried, own) => [
415
+ .../* @__PURE__ */ new Set([
416
+ ...carried ?? [],
417
+ ...own
418
+ ])
419
+ ], "union");
420
+ return {
421
+ text: merged(before?.text, this.took.text),
422
+ reasoning: merged(before?.reasoning, this.took.reasoning),
423
+ called: union(before?.called, this.took.called),
424
+ settled: union(before?.settled, this.took.settled),
425
+ steps: union(before?.steps, this.took.steps)
426
+ };
427
+ }
393
428
  /** What the run spent so far: carried from before this turn object, plus what it saw. */
394
429
  totalUsage() {
395
430
  return addUsage(this.a.carried, this.spent);
@@ -480,7 +515,11 @@ var OpenCodeTurn = class {
480
515
  this.reported = {
481
516
  ...this.spent
482
517
  };
483
- const milestone = {
518
+ const milestone = reached.kind === "ask" ? {
519
+ ...reached,
520
+ usage,
521
+ progress: this.progress()
522
+ } : {
484
523
  ...reached,
485
524
  usage
486
525
  };
@@ -535,6 +574,7 @@ var OpenCodeTurn = class {
535
574
  innerSettled: /* @__PURE__ */ new Set()
536
575
  };
537
576
  this.tools.set(id, state);
577
+ this.took.called.add(id);
538
578
  this.segment.calls.push({
539
579
  id,
540
580
  name,
@@ -662,6 +702,7 @@ var OpenCodeTurn = class {
662
702
  case "session.text.delta": {
663
703
  const delta = typeof d.delta === "string" ? d.delta : "";
664
704
  if (!delta) return;
705
+ this.count(this.took.text, d.assistantMessageID, delta.length);
665
706
  this.endReasoning();
666
707
  if (!this.sawText && !delta.trim()) return;
667
708
  await this.step();
@@ -683,6 +724,7 @@ ${delta}` : delta;
683
724
  case "session.reasoning.delta": {
684
725
  const delta = typeof d.delta === "string" ? d.delta : "";
685
726
  if (!delta) return;
727
+ this.count(this.took.reasoning, d.assistantMessageID, delta.length);
686
728
  this.reasoningSince ??= Date.now();
687
729
  await this.step();
688
730
  this.segment.reasoning += delta;
@@ -737,6 +779,7 @@ ${delta}` : delta;
737
779
  }
738
780
  case "session.step.ended":
739
781
  this.endReasoning();
782
+ if (typeof d.assistantMessageID === "string") this.took.steps.add(d.assistantMessageID);
740
783
  await this.stepEnded(d);
741
784
  return;
742
785
  case "session.step.failed":
@@ -777,10 +820,16 @@ ${delta}` : delta;
777
820
  return;
778
821
  }
779
822
  }
823
+ /** Characters taken from an assistant message's text or reasoning. */
824
+ count(into, messageId, n) {
825
+ if (typeof messageId !== "string") return;
826
+ into.set(messageId, (into.get(messageId) ?? 0) + n);
827
+ }
780
828
  async settleTool(id, ok, error = "failed") {
781
829
  const state = this.tool(id);
782
830
  if (state.settled) return;
783
831
  state.settled = true;
832
+ this.took.settled.add(id);
784
833
  if (ok) {
785
834
  this.segment.results.push({
786
835
  id,
@@ -1330,9 +1379,171 @@ ${delta}` : delta;
1330
1379
  cost: cost < 1e-9 ? 0 : cost
1331
1380
  }, this.compacted ? "history_summary" : "title");
1332
1381
  }
1333
- async end(outcome) {
1382
+ /**
1383
+ * The session's messages since this turn's prompt, oldest first: everything after its newest user
1384
+ * message. `undefined` when they cannot be read (no `message.list`, no prompt within reach); empty
1385
+ * when the newest prompt is another run's (this turn's never reached the session).
1386
+ */
1387
+ async messagesSincePrompt() {
1388
+ const messages = this.a.client.message;
1389
+ if (messages === void 0) return void 0;
1390
+ const since = [];
1391
+ let cursor;
1392
+ for (let page = 0; page < RECOVER_PAGES; page += 1) {
1393
+ const listed = await messages.list({
1394
+ sessionID: this.a.sessionId,
1395
+ order: "desc",
1396
+ limit: RECOVER_PAGE_SIZE,
1397
+ ...cursor !== void 0 ? {
1398
+ cursor
1399
+ } : {}
1400
+ });
1401
+ for (const message of listed.data) {
1402
+ if (message.type === "user") {
1403
+ const runId = message.metadata?.runId;
1404
+ if (typeof runId === "string" && runId !== this.a.runId) return [];
1405
+ return since.reverse();
1406
+ }
1407
+ since.push(message);
1408
+ }
1409
+ const next = listed.cursor?.next;
1410
+ if (typeof next !== "string" || next.length === 0 || listed.data.length === 0) break;
1411
+ cursor = next;
1412
+ }
1413
+ return void 0;
1414
+ }
1415
+ /**
1416
+ * A turn rebuilt after a restart heard only what the session said once it re-attached: what it
1417
+ * said in between — read back from the session's messages since the prompt, less what an earlier
1418
+ * process already took ({@link TurnProgress}) and what this one heard — is taken now, as its
1419
+ * events would have been: text, reasoning, tool calls and results, each step's usage. A session
1420
+ * that left nothing for the turn fails it ({@link LOST_ANSWER_ERROR}) rather than reading as an
1421
+ * empty answer.
1422
+ */
1423
+ async recover(outcome) {
1424
+ const messages = await this.messagesSincePrompt().catch((error) => {
1425
+ this.a.logger.warn(`run ${this.a.runId}: ${errorText(error, "could not read the session messages")}`);
1426
+ return void 0;
1427
+ });
1428
+ const before = this.a.progress;
1429
+ const text = [];
1430
+ let reasoning = "";
1431
+ let lastError;
1432
+ for (const message of messages ?? []) {
1433
+ if (message.type !== "assistant") continue;
1434
+ const id = message.id;
1435
+ const parts = message.content ?? [];
1436
+ text.push(...missed(parts, "text", before?.text[id] ?? 0, this.took.text.get(id) ?? 0));
1437
+ reasoning += missed(parts, "reasoning", before?.reasoning[id] ?? 0, this.took.reasoning.get(id) ?? 0).map((piece) => piece.text).join("");
1438
+ for (const part of parts) if (part.type === "tool") await this.missedTool(part);
1439
+ if (message.tokens !== void 0 && !before?.steps.includes(id) && !this.took.steps.has(id)) {
1440
+ this.took.steps.add(id);
1441
+ const model = message.model;
1442
+ if (typeof model?.id === "string" && typeof model.providerID === "string") this.model = {
1443
+ providerID: model.providerID,
1444
+ id: model.id
1445
+ };
1446
+ await this.spend({
1447
+ tokens: message.tokens,
1448
+ cost: message.cost
1449
+ }, "chat");
1450
+ }
1451
+ if (message.error?.message) lastError = message.error.message;
1452
+ }
1453
+ const recovered = text.map((piece) => piece.text).join("\n\n").trimStart();
1454
+ if (recovered.trim()) {
1455
+ await this.step();
1456
+ const joint = this.segment.text && !text.at(-1)?.continues ? "\n\n" : "";
1457
+ this.segment.text = `${recovered}${joint}${this.segment.text}`;
1458
+ this.sawText = true;
1459
+ await this.write({
1460
+ kind: "text",
1461
+ text: recovered
1462
+ });
1463
+ }
1464
+ if (reasoning) {
1465
+ await this.step();
1466
+ this.segment.reasoning = `${reasoning}${this.segment.reasoning}`;
1467
+ await this.write({
1468
+ kind: "reasoning",
1469
+ text: reasoning
1470
+ });
1471
+ }
1472
+ if (outcome.status !== "succeeded") return outcome;
1473
+ const idle = (messages ?? []).filter((m) => m.type === "idle").at(-1);
1474
+ if (idle?.outcome === "failed") return {
1475
+ status: "failed",
1476
+ error: lastError ?? this.stepError ?? "the execution failed"
1477
+ };
1478
+ if (idle?.outcome === "interrupted") return {
1479
+ status: "interrupted"
1480
+ };
1481
+ const s = this.segment;
1482
+ const said = s.text || s.reasoning || s.calls.length > 0 || s.ui.length > 0;
1483
+ const progress = this.progress();
1484
+ const tookAny = Object.keys(progress.text).length > 0 || progress.called.length > 0 || progress.steps.length > 0;
1485
+ if (!said && !this.wroteMessage && !tookAny) return {
1486
+ status: "failed",
1487
+ error: LOST_ANSWER_ERROR
1488
+ };
1489
+ return outcome;
1490
+ }
1491
+ /** A tool part of a message the turn did not hear: its call, its result, whichever it lacks. */
1492
+ async missedTool(part) {
1493
+ const id = part.id;
1494
+ const name = part.name ?? "tool";
1495
+ if (id === void 0 || HIDDEN_TOOLS.has(name)) return;
1496
+ const state = part.state ?? {};
1497
+ const before = this.a.progress;
1498
+ if (!before?.called.includes(id) && !this.tools.has(id)) {
1499
+ await this.step();
1500
+ this.setInput(id, state.input, name);
1501
+ await this.makeAvailable(id);
1502
+ }
1503
+ const ok = state.status === "completed";
1504
+ if (!ok && state.status !== "error" || before?.settled.includes(id)) return;
1505
+ const error = errorText(state.error, "tool failed");
1506
+ if (this.tools.has(id)) {
1507
+ await this.settleTool(id, ok, error);
1508
+ return;
1509
+ }
1510
+ this.took.settled.add(id);
1511
+ const result = ok ? {
1512
+ id,
1513
+ name,
1514
+ output: {
1515
+ ok: true
1516
+ }
1517
+ } : {
1518
+ id,
1519
+ name,
1520
+ output: {
1521
+ ok: false
1522
+ },
1523
+ error
1524
+ };
1525
+ const messageId = await this.messageOf(id);
1526
+ if (messageId !== void 0) await this.addResult(messageId, result);
1527
+ else this.segment.results.push(result);
1528
+ await this.write(ok ? {
1529
+ kind: "tool-output",
1530
+ id,
1531
+ output: {
1532
+ ok: true
1533
+ }
1534
+ } : {
1535
+ kind: "tool-output-error",
1536
+ id,
1537
+ error
1538
+ });
1539
+ }
1540
+ async end(ended) {
1334
1541
  if (this.ended) return;
1335
1542
  this.ended = true;
1543
+ const outcome = this.a.resumed === true ? await this.recover(ended).catch((error) => {
1544
+ this.a.logger.warn(`run ${this.a.runId}: ${errorText(error, "could not recover the turn")}`);
1545
+ return ended;
1546
+ }) : ended;
1336
1547
  await this.unreported().catch((error) => this.a.logger.warn(`run ${this.a.runId}: ${errorText(error, "could not read the session usage")}`));
1337
1548
  if (outcome.status !== "failed" || this.segment.text || this.segment.calls.length > 0 || this.segment.ui.length > 0) {
1338
1549
  await this.flush(true);
@@ -1349,6 +1560,26 @@ ${delta}` : delta;
1349
1560
  });
1350
1561
  }
1351
1562
  };
1563
+ function missed(parts, type, head, tail) {
1564
+ const texts = parts.flatMap((p) => p.type === type && typeof p.text === "string" ? [
1565
+ p.text
1566
+ ] : []);
1567
+ const total = texts.reduce((n, t) => n + t.length, 0);
1568
+ const end = total - tail;
1569
+ const out = [];
1570
+ let at = 0;
1571
+ for (const t of texts) {
1572
+ const from = Math.max(head, at);
1573
+ const to = Math.min(end, at + t.length);
1574
+ if (to > from) out.push({
1575
+ text: t.slice(from - at, to - at),
1576
+ continues: to < at + t.length
1577
+ });
1578
+ at += t.length;
1579
+ }
1580
+ return out;
1581
+ }
1582
+ __name(missed, "missed");
1352
1583
  function modelOfLabel(label) {
1353
1584
  const slash = label?.indexOf("/") ?? -1;
1354
1585
  if (label === void 0 || slash <= 0 || slash === label.length - 1) return void 0;
@@ -2494,7 +2725,11 @@ ${body}
2494
2725
  text: prompt.text,
2495
2726
  ...prompt.files?.length ? {
2496
2727
  files: prompt.files
2497
- } : {}
2728
+ } : {},
2729
+ // Marks the prompt as this run's: a turn resumed after a restart reads what came after it.
2730
+ metadata: {
2731
+ runId
2732
+ }
2498
2733
  });
2499
2734
  } catch (error) {
2500
2735
  live.turn.fail(`OpenCode refused the message: ${error.message}`);
@@ -2507,9 +2742,11 @@ ${body}
2507
2742
  *
2508
2743
  * `spent` is what the run spent before (the sum of the `usage` of its earlier milestones): a turn
2509
2744
  * rebuilt here starts from it, so the answer it persists carries the whole run's usage.
2745
+ * `progress` is what the turn took before (its last `ask` milestone's): a turn rebuilt here reads
2746
+ * back from the session's messages, when the execution ends, what nobody heard after that.
2510
2747
  */
2511
- async observe(runId, input, handle, spent) {
2512
- const live = await this.ensureLive(runId, input, handle, true, spent);
2748
+ async observe(runId, input, handle, spent, progress) {
2749
+ const live = await this.ensureLive(runId, input, handle, true, spent, progress);
2513
2750
  const { turn, client } = live;
2514
2751
  if (turn.hasMilestone()) return this.reached(runId, input, handle, await turn.next());
2515
2752
  let done = false;
@@ -2548,12 +2785,12 @@ ${body}
2548
2785
  * request is gone with it: a new session is opened, told what the person decided, and prompted
2549
2786
  * to go on.
2550
2787
  */
2551
- async reply(runId, input, handle, ask, reply, spent) {
2788
+ async reply(runId, input, handle, ask, reply, spent, progress) {
2552
2789
  const server = await this.host.server(input.actor);
2553
2790
  if (server.key !== handle.serverKey || server.bootId !== handle.bootId) {
2554
2791
  return this.continueAfterRestart(runId, input, server, ask, reply, spent);
2555
2792
  }
2556
- const live = await this.ensureLive(runId, input, handle, false, spent);
2793
+ const live = await this.ensureLive(runId, input, handle, false, spent, progress);
2557
2794
  await live.turn.decide(ask, reply);
2558
2795
  return handle;
2559
2796
  }
@@ -2571,7 +2808,10 @@ ${body}
2571
2808
  live.turn.setBaseline(await this.sessionUsage(live.client, handle.sessionId));
2572
2809
  await live.client.session.prompt({
2573
2810
  sessionID: handle.sessionId,
2574
- text: note
2811
+ text: note,
2812
+ metadata: {
2813
+ runId
2814
+ }
2575
2815
  });
2576
2816
  return handle;
2577
2817
  }
@@ -2595,9 +2835,10 @@ ${body}
2595
2835
  sessionID: known.sessionId
2596
2836
  });
2597
2837
  }
2598
- async ensureLive(runId, input, handle, catchUp, spent) {
2838
+ async ensureLive(runId, input, handle, catchUp, spent, progress) {
2599
2839
  const current = this.live.get(runId);
2600
2840
  if (current !== void 0 && current.handle.sessionId === handle.sessionId) return current;
2841
+ const resumed = catchUp && current === void 0;
2601
2842
  current?.stop();
2602
2843
  const server = await this.host.server(input.actor);
2603
2844
  const deps = this.deps.forAgent(input.agentName);
@@ -2628,6 +2869,10 @@ ${body}
2628
2869
  ...spent !== void 0 ? {
2629
2870
  carried: spent
2630
2871
  } : {},
2872
+ ...progress !== void 0 ? {
2873
+ progress
2874
+ } : {},
2875
+ resumed,
2631
2876
  logger: this.logger,
2632
2877
  onGranted: /* @__PURE__ */ __name((action, id) => {
2633
2878
  const grants = this.grants.get(runId) ?? [];
@@ -2647,7 +2892,7 @@ ${body}
2647
2892
  stop
2648
2893
  };
2649
2894
  this.live.set(runId, live);
2650
- if (catchUp && current === void 0) await turn.catchUp().catch(() => void 0);
2895
+ if (resumed) await turn.catchUp().catch(() => void 0);
2651
2896
  return live;
2652
2897
  }
2653
2898
  /** Stop following a run in this process. */
@@ -2761,6 +3006,12 @@ ${body}
2761
3006
  kind: "cancelled"
2762
3007
  }));
2763
3008
  await writer.end();
3009
+ (0, import_nestjs_agent_core4.publishAgentRunCancelled)({
3010
+ runId,
3011
+ threadId: input.threadId,
3012
+ durationMs,
3013
+ context: (0, import_nestjs_agent_core4.agentRunContext)(input)
3014
+ });
2764
3015
  await this.store.recordRunEnd?.({
2765
3016
  runId,
2766
3017
  status: "cancelled",
@@ -2791,12 +3042,16 @@ ${body}
2791
3042
  this.logger.error(`agent run ${runId} failed: ${error}`);
2792
3043
  (0, import_nestjs_agent_core4.publishAgentRunFailed)({
2793
3044
  runId,
3045
+ threadId: input.threadId,
2794
3046
  code: "opencode_failed",
2795
- message: error
3047
+ message: error,
3048
+ durationMs,
3049
+ context: (0, import_nestjs_agent_core4.agentRunContext)(input)
2796
3050
  });
2797
3051
  await this.store.recordRunEnd?.({
2798
3052
  runId,
2799
3053
  status: "failed",
3054
+ durationMs,
2800
3055
  errorCode: "opencode_failed",
2801
3056
  errorMessage: error
2802
3057
  });
@@ -3225,6 +3480,7 @@ var OpenCodeRunWorkflow = class {
3225
3480
  }
3226
3481
  async run(ctx, input) {
3227
3482
  let spent = emptyUsage();
3483
+ let progress;
3228
3484
  try {
3229
3485
  const begun = await ctx.localStep("begin", async () => ({
3230
3486
  handle: await this.turns.begin(ctx.runId, input),
@@ -3237,7 +3493,8 @@ var OpenCodeRunWorkflow = class {
3237
3493
  });
3238
3494
  for (let n = 0; ; n += 1) {
3239
3495
  const before = spent;
3240
- const milestone = await ctx.localStep(`observe:${n}`, () => this.turns.observe(ctx.runId, input, handle, before));
3496
+ const taken = progress;
3497
+ const milestone = await ctx.localStep(`observe:${n}`, () => this.turns.observe(ctx.runId, input, handle, before, taken));
3241
3498
  spent = addUsage(spent, milestone.usage);
3242
3499
  const total = spent;
3243
3500
  if (milestone.kind === "finished") {
@@ -3263,7 +3520,9 @@ var OpenCodeRunWorkflow = class {
3263
3520
  };
3264
3521
  }
3265
3522
  const ask = milestone.ask;
3266
- handle = await ctx.localStep(`reply:${n}`, () => this.turns.reply(ctx.runId, input, handle, ask, reply, total));
3523
+ progress = milestone.progress ?? progress;
3524
+ const tookSoFar = progress;
3525
+ handle = await ctx.localStep(`reply:${n}`, () => this.turns.reply(ctx.runId, input, handle, ask, reply, total, tookSoFar));
3267
3526
  }
3268
3527
  } catch (error) {
3269
3528
  if ((0, import_nestjs_durable_core.isWorkflowControlFlowSignal)(error)) throw error;