neoagent 3.2.1-beta.0 → 3.2.1-beta.1

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.
@@ -45,6 +45,12 @@ runs:
45
45
  The engine records steps, run events, model usage, timing, and artifacts.
46
46
  Repetition guards and loop limits prevent unbounded retries.
47
47
 
48
+ Run control is checked at model and tool boundaries. Abort and interruption
49
+ are terminal; pause cancels the active model or command, records a checkpoint,
50
+ and parks the in-process run until the authenticated resume action releases it.
51
+ If a state-changing tool is interrupted after dispatch, its outcome is recorded
52
+ as unknown and must be verified before the model can attempt it again.
53
+
48
54
  ## 4. Complete and deliver
49
55
 
50
56
  The final response is sanitized and stored. Messaging-triggered runs send an
@@ -55,6 +61,10 @@ The engine emits `run:complete`, persists prompt and usage metrics, refreshes
55
61
  conversation summaries and working state, and cancels unfinished subagents.
56
62
  Failures and user stops produce separate terminal run states.
57
63
 
64
+ Terminal transitions are first-writer-wins. A late model, tool, verifier, or
65
+ delivery callback cannot overwrite an earlier stop or interruption, and active
66
+ run, step, and delegation rows are settled together.
67
+
58
68
  ## 5. Post-run processing
59
69
 
60
70
  Completed conversations can run structured memory consolidation. The engine
@@ -633,6 +633,34 @@ function migrateBilling(db) {
633
633
  `);
634
634
  }
635
635
 
636
+ function migrateAgentRunLifecycle(db) {
637
+ db.exec(`
638
+ CREATE TABLE IF NOT EXISTS agent_run_controls (
639
+ run_id TEXT PRIMARY KEY,
640
+ user_id INTEGER NOT NULL,
641
+ action TEXT NOT NULL CHECK(action IN ('pause', 'stop', 'interrupt')),
642
+ reason TEXT DEFAULT '',
643
+ requested_at TEXT DEFAULT (datetime('now')),
644
+ consumed_at TEXT,
645
+ FOREIGN KEY (run_id) REFERENCES agent_runs(id) ON DELETE CASCADE,
646
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
647
+ );
648
+
649
+ CREATE TABLE IF NOT EXISTS agent_run_checkpoints (
650
+ run_id TEXT PRIMARY KEY,
651
+ version INTEGER NOT NULL DEFAULT 1,
652
+ phase TEXT NOT NULL,
653
+ state_json TEXT NOT NULL DEFAULT '{}',
654
+ created_at TEXT DEFAULT (datetime('now')),
655
+ updated_at TEXT DEFAULT (datetime('now')),
656
+ FOREIGN KEY (run_id) REFERENCES agent_runs(id) ON DELETE CASCADE
657
+ );
658
+
659
+ CREATE INDEX IF NOT EXISTS idx_agent_run_controls_pending
660
+ ON agent_run_controls(user_id, consumed_at, requested_at);
661
+ `);
662
+ }
663
+
636
664
  function runSchemaMigrations(db) {
637
665
  removeRetiredCaptureData(db);
638
666
  migrateMemoryEmbeddingIndex(db);
@@ -647,6 +675,7 @@ function runSchemaMigrations(db) {
647
675
  migrateApprovalPersistence(db);
648
676
  migrateToolPoliciesAllowAlways(db);
649
677
  migrateBilling(db);
678
+ migrateAgentRunLifecycle(db);
650
679
  }
651
680
 
652
681
  module.exports = {
@@ -664,5 +693,6 @@ module.exports = {
664
693
  migrateApprovalPersistence,
665
694
  migrateToolPoliciesAllowAlways,
666
695
  migrateBilling,
696
+ migrateAgentRunLifecycle,
667
697
  runSchemaMigrations,
668
698
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neoagent",
3
- "version": "3.2.1-beta.0",
3
+ "version": "3.2.1-beta.1",
4
4
  "description": "Self-hosted AI agent for long-running tasks, automation, messaging, device control, and local memory",
5
5
  "license": "AGPL-3.0-only",
6
6
  "main": "server/index.js",
@@ -1021,7 +1021,7 @@ function interruptStaleAgentRuns(reason = STALE_RUN_INTERRUPTED_ERROR) {
1021
1021
  const staleRunIds = db.prepare(
1022
1022
  `SELECT id
1023
1023
  FROM agent_runs
1024
- WHERE status = 'running'`
1024
+ WHERE status IN ('running', 'pausing', 'paused', 'resuming')`
1025
1025
  ).all().map((row) => row.id);
1026
1026
  const runsResult = db.prepare(
1027
1027
  `UPDATE agent_runs
@@ -1029,7 +1029,7 @@ function interruptStaleAgentRuns(reason = STALE_RUN_INTERRUPTED_ERROR) {
1029
1029
  error = COALESCE(NULLIF(error, ''), ?),
1030
1030
  updated_at = datetime('now'),
1031
1031
  completed_at = COALESCE(completed_at, datetime('now'))
1032
- WHERE status = 'running'`
1032
+ WHERE status IN ('running', 'pausing', 'paused', 'resuming')`
1033
1033
  ).run(normalizedReason);
1034
1034
 
1035
1035
  db.prepare(
@@ -1 +1 @@
1
- aec653c57b1f385952b818425c0e92be
1
+ 93bc60f11ff8c552f052d374db0c2c06
@@ -37,6 +37,6 @@ _flutter.buildConfig = {"engineRevision":"69c8c61792f04cc809dfef0c910414fb9afc06
37
37
 
38
38
  _flutter.loader.load({
39
39
  serviceWorkerSettings: {
40
- serviceWorkerVersion: "972754670" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
40
+ serviceWorkerVersion: "1664863442" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
41
41
  }
42
42
  });
@@ -143689,7 +143689,7 @@ if(r){r=s.d
143689
143689
  r===$&&A.b()
143690
143690
  p.push(A.jt(q,A.jw(!1,new A.Y(B.wO,A.cS(new A.cF(B.kE,new A.acS(r,q),q),q,q),q),!1,B.I,!0),q,q,0,0,0,q))}if(!s.ay){r=s.e
143691
143691
  r===$&&A.b()
143692
- r=B.b.t("mrvxwxuc-014147c").length!==0&&r.b}else r=!1
143692
+ r=B.b.t("mrwbc2ah-7309fe8").length!==0&&r.b}else r=!1
143693
143693
  if(r){r=s.d
143694
143694
  r===$&&A.b()
143695
143695
  r=r.aU&&!r.ag?84:0
@@ -149556,7 +149556,7 @@ $S:0}
149556
149556
  A.a3g.prototype={}
149557
149557
  A.WA.prototype={
149558
149558
  r6(a){var s=this
149559
- if(B.b.t("mrvxwxuc-014147c").length===0||s.a!=null)return
149559
+ if(B.b.t("mrwbc2ah-7309fe8").length===0||s.a!=null)return
149560
149560
  s.Cn()
149561
149561
  s.a=A.mJ(B.Y6,new A.bmI(s))},
149562
149562
  Cn(){var s=0,r=A.l(t.H),q,p=2,o=[],n=this,m,l,k,j,i,h,g,f
@@ -149574,7 +149574,7 @@ if(!t.f.b(k)){s=1
149574
149574
  break}i=J.a3(k,"buildId")
149575
149575
  h=i==null?null:B.b.t(J.q(i))
149576
149576
  j=h==null?"":h
149577
- if(J.br(j)===0||J.e(j,"mrvxwxuc-014147c")){s=1
149577
+ if(J.br(j)===0||J.e(j,"mrwbc2ah-7309fe8")){s=1
149578
149578
  break}n.b=!0
149579
149579
  n.H()
149580
149580
  p=2
@@ -149591,7 +149591,7 @@ case 2:return A.i(o.at(-1),r)}})
149591
149591
  return A.k($async$Cn,r)},
149592
149592
  wV(){var s=0,r=A.l(t.H),q,p=2,o=[],n=this,m,l,k,j,i,h,g,f,e,d,c,b,a,a0,a1
149593
149593
  var $async$wV=A.h(function(a2,a3){if(a2===1){o.push(a3)
149594
- s=p}for(;;)switch(s){case 0:if(B.b.t("mrvxwxuc-014147c").length===0||n.c){s=1
149594
+ s=p}for(;;)switch(s){case 0:if(B.b.t("mrwbc2ah-7309fe8").length===0||n.c){s=1
149595
149595
  break}n.c=!0
149596
149596
  n.H()
149597
149597
  p=4
@@ -362,8 +362,16 @@ router.get('/:id/steps', (req, res) => {
362
362
  || run.final_response
363
363
  || null;
364
364
  const usage = buildRunUsageSummary(run.id);
365
-
366
- res.json({ run, steps, events: listRunEvents(run.id), response, usage });
365
+ const lifecycle = db.prepare(
366
+ `SELECT c.action, c.reason, c.requested_at, c.consumed_at,
367
+ p.phase AS checkpoint_phase, p.updated_at AS checkpoint_updated_at
368
+ FROM agent_runs r
369
+ LEFT JOIN agent_run_controls c ON c.run_id = r.id
370
+ LEFT JOIN agent_run_checkpoints p ON p.run_id = r.id
371
+ WHERE r.id = ?`,
372
+ ).get(run.id) || null;
373
+
374
+ res.json({ run, steps, events: listRunEvents(run.id), response, usage, lifecycle });
367
375
  });
368
376
 
369
377
  // Abort a run
@@ -379,6 +387,31 @@ router.post('/:id/abort', (req, res) => {
379
387
  }
380
388
  });
381
389
 
390
+ router.post('/:id/pause', (req, res) => {
391
+ try {
392
+ const engine = req.app.locals.agentEngine;
393
+ const accepted = engine.pauseRun(req.params.id, {
394
+ userId: req.session.userId,
395
+ reason: req.body?.reason || '',
396
+ });
397
+ if (!accepted) return res.status(409).json({ error: 'Run is not active or cannot be paused.' });
398
+ res.json({ success: true, status: 'pausing' });
399
+ } catch (err) {
400
+ res.status(500).json({ error: sanitizeError(err) });
401
+ }
402
+ });
403
+
404
+ router.post('/:id/resume', (req, res) => {
405
+ try {
406
+ const engine = req.app.locals.agentEngine;
407
+ const accepted = engine.resumeRun(req.params.id, { userId: req.session.userId });
408
+ if (!accepted) return res.status(409).json({ error: 'Run is not paused or cannot be resumed.' });
409
+ res.json({ success: true, status: 'running' });
410
+ } catch (err) {
411
+ res.status(500).json({ error: sanitizeError(err) });
412
+ }
413
+ });
414
+
382
415
  // Delete a run
383
416
  router.delete('/:id', (req, res) => {
384
417
  const run = db.prepare('SELECT id FROM agent_runs WHERE id = ? AND user_id = ?').get(req.params.id, req.session.userId);
@@ -20,6 +20,13 @@ const { summarizeCapabilityHealth } = require('../capabilityHealth');
20
20
  const { shouldAcceptTaskComplete } = require('../completion');
21
21
  const { shortenRunId, summarizeForLog } = require('../logFormat');
22
22
  const { runConversation } = require('./conversation_loop');
23
+ const {
24
+ checkpointRun,
25
+ closeRun,
26
+ getRunControl,
27
+ requestRunControl,
28
+ transitionRun,
29
+ } = require('./lifecycle');
23
30
  const {
24
31
  buildChurnAssessmentPrompt,
25
32
  buildCompletionDecisionPrompt,
@@ -1073,6 +1080,59 @@ class AgentEngine {
1073
1080
  return isRunStoppedImpl(this, runId);
1074
1081
  }
1075
1082
 
1083
+ async checkpointLifecycle(runId, phase, state = {}) {
1084
+ const runMeta = this.activeRuns.get(runId);
1085
+ if (!runMeta) return { action: 'stop' };
1086
+ const control = getRunControl(runId);
1087
+ if (!control) return null;
1088
+ if (control.action !== 'pause') return control;
1089
+
1090
+ checkpointRun(runId, phase, {
1091
+ iteration: Number(state.iteration) || 0,
1092
+ stepIndex: Number(state.stepIndex) || 0,
1093
+ currentPhase: runMeta.progressLedger?.currentPhase || phase,
1094
+ activeTools: (runMeta.activeTools || []).map((tool) => tool.name),
1095
+ goalContract: runMeta.goalContract || null,
1096
+ progressLedger: this.buildProgressLedgerSnapshot(runMeta),
1097
+ });
1098
+ transitionRun(runId, 'paused', {}, ['running', 'pausing']);
1099
+ db.transaction(() => {
1100
+ db.prepare(
1101
+ `UPDATE agent_steps
1102
+ SET status = 'paused', error = COALESCE(NULLIF(error, ''), 'Paused by request.'), completed_at = COALESCE(completed_at, datetime('now'))
1103
+ WHERE run_id = ? AND status = 'running'`,
1104
+ ).run(runId);
1105
+ db.prepare(
1106
+ `UPDATE pending_approvals
1107
+ SET status = 'expired', decided_at = COALESCE(decided_at, datetime('now')), updated_at = datetime('now')
1108
+ WHERE run_id = ? AND status = 'pending'`,
1109
+ ).run(runId);
1110
+ })();
1111
+ runMeta.status = 'paused';
1112
+ runMeta.abortController = null;
1113
+ this.stopMessagingProgressSupervisor(runId);
1114
+ this.emit(runMeta.userId, 'run:paused', { runId, reason: control.reason || null });
1115
+ this.recordRunEvent(runMeta.userId, runId, 'run_paused', {
1116
+ phase,
1117
+ reason: control.reason || null,
1118
+ }, { agentId: runMeta.agentId });
1119
+
1120
+ await new Promise((resolve) => { runMeta.resumeRun = resolve; });
1121
+ runMeta.resumeRun = null;
1122
+ runMeta.abortController = new AbortController();
1123
+ runMeta.status = 'running';
1124
+ this.startMessagingProgressSupervisor(runId);
1125
+ return null;
1126
+ }
1127
+
1128
+ completeRun(runId, fields = {}) {
1129
+ return closeRun(runId, 'completed', fields, ['running']);
1130
+ }
1131
+
1132
+ failRun(runId, fields = {}) {
1133
+ return closeRun(runId, 'failed', fields, ['running']);
1134
+ }
1135
+
1076
1136
  attachProcessToRun(runId, pid) {
1077
1137
  return attachProcessToRunImpl(this, runId, pid);
1078
1138
  }
@@ -1593,6 +1653,10 @@ class AgentEngine {
1593
1653
 
1594
1654
  interruptRun(runId, reason = 'Server shutting down while run was in progress.') {
1595
1655
  const runMeta = this.activeRuns.get(runId);
1656
+ const persistedRun = runMeta || db.prepare('SELECT user_id AS userId FROM agent_runs WHERE id = ?').get(runId);
1657
+ if (persistedRun?.userId != null) {
1658
+ requestRunControl(runId, persistedRun.userId, 'interrupt', reason);
1659
+ }
1596
1660
  const delegatedChildren = db.prepare(
1597
1661
  "SELECT child_run_id FROM agent_delegations WHERE parent_run_id = ? AND status = 'running'"
1598
1662
  ).all(runId);
@@ -1600,6 +1664,7 @@ class AgentEngine {
1600
1664
  runMeta.status = 'interrupted';
1601
1665
  runMeta.stopReason = reason;
1602
1666
  runMeta.aborted = true;
1667
+ runMeta.abortController?.abort(reason);
1603
1668
  this.emit(runMeta.userId, 'run:stopping', { runId });
1604
1669
  for (const pid of runMeta.toolPids) {
1605
1670
  if (this.runtimeManager && typeof this.runtimeManager.killCommand === 'function') {
@@ -1621,14 +1686,7 @@ class AgentEngine {
1621
1686
  completed_at = datetime('now')
1622
1687
  WHERE parent_run_id = ? AND status = 'running'`
1623
1688
  ).run(reason, runId);
1624
- db.prepare(
1625
- `UPDATE agent_runs
1626
- SET status = 'interrupted',
1627
- error = COALESCE(NULLIF(error, ''), ?),
1628
- updated_at = datetime('now'),
1629
- completed_at = datetime('now')
1630
- WHERE id = ?`
1631
- ).run(reason, runId);
1689
+ closeRun(runId, 'interrupted', { error: reason }, ['running', 'pausing', 'paused', 'resuming']);
1632
1690
  }
1633
1691
 
1634
1692
  interruptAllActiveRuns(reason = 'Server shutting down while run was in progress.') {
@@ -1639,12 +1697,17 @@ class AgentEngine {
1639
1697
 
1640
1698
  stopRun(runId) {
1641
1699
  const runMeta = this.activeRuns.get(runId);
1700
+ const persistedRun = runMeta || db.prepare('SELECT user_id AS userId FROM agent_runs WHERE id = ?').get(runId);
1701
+ if (persistedRun?.userId != null) {
1702
+ requestRunControl(runId, persistedRun.userId, 'stop', 'Stopped by request.');
1703
+ }
1642
1704
  const delegatedChildren = db.prepare(
1643
1705
  "SELECT child_run_id FROM agent_delegations WHERE parent_run_id = ? AND status = 'running'"
1644
1706
  ).all(runId);
1645
1707
  if (runMeta) {
1646
1708
  runMeta.status = 'stopped';
1647
1709
  runMeta.aborted = true;
1710
+ runMeta.abortController?.abort('Run stopped.');
1648
1711
  this.emit(runMeta.userId, 'run:stopping', { runId });
1649
1712
  for (const pid of runMeta.toolPids) {
1650
1713
  if (this.runtimeManager && typeof this.runtimeManager.killCommand === 'function') {
@@ -1661,7 +1724,46 @@ class AgentEngine {
1661
1724
  db.prepare(
1662
1725
  "UPDATE agent_delegations SET status = 'stopped', updated_at = datetime('now'), completed_at = datetime('now') WHERE parent_run_id = ? AND status = 'running'"
1663
1726
  ).run(runId);
1664
- db.prepare("UPDATE agent_runs SET status = 'stopped', updated_at = datetime('now') WHERE id = ?").run(runId);
1727
+ closeRun(runId, 'stopped', {}, ['running', 'pausing', 'paused', 'resuming']);
1728
+ }
1729
+
1730
+ pauseRun(runId, { userId, reason = '' } = {}) {
1731
+ if (!runId || userId == null) return false;
1732
+ const runMeta = this.activeRuns.get(runId);
1733
+ if (
1734
+ !runMeta
1735
+ || Number(runMeta.userId) !== Number(userId)
1736
+ || runMeta.status !== 'running'
1737
+ || runMeta.pauseAvailable !== true
1738
+ ) return false;
1739
+ const result = requestRunControl(runId, userId, 'pause', reason);
1740
+ if (!result.accepted) return false;
1741
+ transitionRun(runId, 'pausing', {}, ['running']);
1742
+ runMeta.status = 'pausing';
1743
+ runMeta.abortController?.abort('Run paused.');
1744
+ for (const pid of runMeta.toolPids) {
1745
+ if (this.runtimeManager && typeof this.runtimeManager.killCommand === 'function') {
1746
+ void this.runtimeManager.killCommand(runMeta.userId, pid, 'paused');
1747
+ }
1748
+ }
1749
+ this.emit(runMeta.userId, 'run:pausing', { runId, reason: reason || null });
1750
+ return true;
1751
+ }
1752
+
1753
+ resumeRun(runId, { userId } = {}) {
1754
+ if (!runId || userId == null) return false;
1755
+ const runMeta = this.activeRuns.get(runId);
1756
+ if (!runMeta || Number(runMeta.userId) !== Number(userId) || runMeta.status !== 'paused') return false;
1757
+ if (!transitionRun(runId, 'resuming', {}, ['paused'])) return false;
1758
+ db.prepare(
1759
+ `UPDATE agent_run_controls SET consumed_at = datetime('now')
1760
+ WHERE run_id = ? AND action = 'pause' AND consumed_at IS NULL`,
1761
+ ).run(runId);
1762
+ transitionRun(runId, 'running', {}, ['resuming']);
1763
+ this.emit(runMeta.userId, 'run:resumed', { runId });
1764
+ this.recordRunEvent(runMeta.userId, runId, 'run_resumed', {}, { agentId: runMeta.agentId });
1765
+ runMeta.resumeRun?.();
1766
+ return true;
1665
1767
  }
1666
1768
 
1667
1769
  abort(runId, { userId } = {}) {
@@ -709,6 +709,8 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
709
709
  voiceSessionId: options.voiceSessionId || null,
710
710
  steeringQueue: [],
711
711
  systemSteeringQueue: [],
712
+ abortController: new AbortController(),
713
+ pauseAvailable: false,
712
714
  toolPids: new Set(),
713
715
  subagentDepth: Math.max(0, Number(options.subagentDepth) || 0),
714
716
  repetitionGuard: new ToolRepetitionGuard(),
@@ -1207,8 +1209,15 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1207
1209
  // regardless of which iteration they fall in.
1208
1210
  let consecutiveToolFailures = 0;
1209
1211
  const iterationBudget = new IterationBudget(maxIterations);
1212
+ const activeRunMeta = engine.getRunMeta(runId);
1213
+ if (activeRunMeta) activeRunMeta.pauseAvailable = !directAnswerEligible;
1210
1214
 
1211
1215
  while (!directAnswerEligible && iterationBudget.consume()) {
1216
+ const lifecycleAtStart = await engine.checkpointLifecycle(runId, 'iteration_boundary', {
1217
+ iteration: iterationBudget.used,
1218
+ stepIndex,
1219
+ });
1220
+ if (lifecycleAtStart?.action === 'stop' || lifecycleAtStart?.action === 'interrupt') break;
1212
1221
  if (engine.isRunStopped(runId)) break;
1213
1222
  iteration = iterationBudget.used;
1214
1223
 
@@ -1452,7 +1461,14 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1452
1461
  model,
1453
1462
  messages,
1454
1463
  tools,
1455
- options: { ...options, userId, agentId, runId, phase: 'model_turn' },
1464
+ options: {
1465
+ ...options,
1466
+ userId,
1467
+ agentId,
1468
+ runId,
1469
+ phase: 'model_turn',
1470
+ signal: engine.getRunMeta(runId)?.abortController?.signal,
1471
+ },
1456
1472
  runId,
1457
1473
  iteration,
1458
1474
  });
@@ -1512,6 +1528,21 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1512
1528
  try {
1513
1529
  await tryModelCall();
1514
1530
  } catch (err) {
1531
+ const lifecycleAbort = err?.name === 'AbortError'
1532
+ || err?.code === 'ABORT_ERR'
1533
+ || /abort/i.test(String(err?.name || ''))
1534
+ || engine.getRunMeta(runId)?.abortController?.signal?.aborted === true;
1535
+ const lifecycleControl = await engine.checkpointLifecycle(runId, 'model_boundary', {
1536
+ iteration,
1537
+ stepIndex,
1538
+ });
1539
+ if (engine.isRunStopped(runId)) break;
1540
+ if (lifecycleAbort && !lifecycleControl && engine.getRunMeta(runId)?.status === 'running') {
1541
+ iterationBudget.refund();
1542
+ iteration = iterationBudget.used;
1543
+ continue;
1544
+ }
1545
+ if (lifecycleControl?.action === 'stop' || lifecycleControl?.action === 'interrupt') break;
1515
1546
  const modelError = String(err?.message || 'Model call failed');
1516
1547
 
1517
1548
  if (modelFailureRecoveries < loopPolicy.maxModelFailureRecoveries) {
@@ -1543,6 +1574,12 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1543
1574
  response = { content: streamContent, toolCalls: [], finishReason: 'stop', usage: null };
1544
1575
  }
1545
1576
 
1577
+ const lifecycleAfterModel = await engine.checkpointLifecycle(runId, 'model_boundary', {
1578
+ iteration,
1579
+ stepIndex,
1580
+ });
1581
+ if (lifecycleAfterModel?.action === 'stop' || lifecycleAfterModel?.action === 'interrupt') break;
1582
+
1546
1583
  if (response.usage) {
1547
1584
  totalTokens += response.usage.totalTokens || 0;
1548
1585
  }
@@ -1718,6 +1755,11 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1718
1755
  iteration,
1719
1756
  options,
1720
1757
  });
1758
+ const lifecycleAfterBatch = await engine.checkpointLifecycle(runId, 'tool_boundary', {
1759
+ iteration,
1760
+ stepIndex: batch.endingStepIndex,
1761
+ });
1762
+ if (lifecycleAfterBatch?.action === 'stop' || lifecycleAfterBatch?.action === 'interrupt') break;
1721
1763
  stepIndex = batch.endingStepIndex;
1722
1764
  let batchGatheredNewEvidence = false;
1723
1765
  for (const item of batch.results) {
@@ -1922,6 +1964,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1922
1964
 
1923
1965
  let toolResult;
1924
1966
  let toolErrorMessage = '';
1967
+ let toolInterruptedForPause = false;
1925
1968
  try {
1926
1969
  toolResult = await engine.executeTool(toolName, toolArgs, {
1927
1970
  userId,
@@ -1938,6 +1981,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1938
1981
  deliveryState: options.deliveryState || null,
1939
1982
  allowMultipleProactiveMessages: options.allowMultipleProactiveMessages === true,
1940
1983
  allowExternalSideEffects: options.allowExternalSideEffects === true,
1984
+ signal: engine.getRunMeta(runId)?.abortController?.signal,
1941
1985
  });
1942
1986
  engine.detachProcessFromRun(runId, toolResult?.pid);
1943
1987
  toolErrorMessage = inferToolFailureMessage(toolName, toolResult);
@@ -1973,17 +2017,30 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1973
2017
  );
1974
2018
  }
1975
2019
  } catch (err) {
1976
- toolResult = { error: err.message };
1977
- toolErrorMessage = String(err.message || 'Tool execution failed');
1978
- failedStepCount++;
2020
+ const currentRunMeta = engine.getRunMeta(runId);
2021
+ toolInterruptedForPause = currentRunMeta?.status === 'pausing'
2022
+ && currentRunMeta.abortController?.signal?.aborted === true;
2023
+ toolErrorMessage = toolInterruptedForPause
2024
+ ? 'The tool was interrupted for pause after dispatch; its external outcome is unknown.'
2025
+ : String(err.message || 'Tool execution failed');
2026
+ toolResult = toolInterruptedForPause
2027
+ ? { status: 'outcome_unknown', error: toolErrorMessage }
2028
+ : { error: err.message };
2029
+ if (!toolInterruptedForPause) failedStepCount++;
1979
2030
  engine.detachProcessFromRun(runId, toolResult?.pid);
1980
2031
  db.prepare('UPDATE agent_steps SET status = ?, error = ?, completed_at = datetime(\'now\') WHERE id = ?')
1981
- .run('failed', err.message, stepId);
1982
- engine.emit(userId, 'run:tool_end', { runId, stepId, toolName, error: err.message, status: 'failed' });
1983
- engine.recordRunEvent(userId, runId, 'tool_failed', {
2032
+ .run(toolInterruptedForPause ? 'paused' : 'failed', toolErrorMessage, stepId);
2033
+ engine.emit(userId, 'run:tool_end', {
2034
+ runId,
2035
+ stepId,
1984
2036
  toolName,
1985
- status: 'failed',
1986
- error: err.message,
2037
+ error: toolErrorMessage,
2038
+ status: toolInterruptedForPause ? 'paused' : 'failed',
2039
+ });
2040
+ engine.recordRunEvent(userId, runId, toolInterruptedForPause ? 'tool_paused' : 'tool_failed', {
2041
+ toolName,
2042
+ status: toolInterruptedForPause ? 'paused' : 'failed',
2043
+ error: toolErrorMessage,
1987
2044
  durationMs: Date.now() - stepStartedAt,
1988
2045
  }, { agentId, stepId });
1989
2046
  console.warn(
@@ -1991,6 +2048,12 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1991
2048
  );
1992
2049
  }
1993
2050
 
2051
+ const lifecycleAfterTool = await engine.checkpointLifecycle(runId, 'tool_boundary', {
2052
+ iteration,
2053
+ stepIndex,
2054
+ });
2055
+ if (lifecycleAfterTool?.action === 'stop' || lifecycleAfterTool?.action === 'interrupt') break;
2056
+
1994
2057
  const execution = classifyToolExecution(toolName, toolArgs, toolResult, toolErrorMessage);
1995
2058
  execution.input = toolArgs;
1996
2059
  const repetitionObservation = repetitionGuard?.observe(toolName, toolArgs, toolResult);
@@ -2054,7 +2117,13 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2054
2117
  tools = engine.getActiveTools(runId);
2055
2118
  }
2056
2119
 
2057
- if (toolErrorMessage) {
2120
+ if (toolInterruptedForPause) {
2121
+ consecutiveToolFailures = 0;
2122
+ messages.push({
2123
+ role: 'system',
2124
+ content: `The outcome of "${toolName}" is unknown because pause interrupted it after dispatch. Do not repeat the call. First inspect or query the affected state with a safe read-only tool, then continue based on verified evidence.`,
2125
+ });
2126
+ } else if (toolErrorMessage) {
2058
2127
  consecutiveToolFailures += 1;
2059
2128
  const currentRunMeta = engine.getRunMeta(runId);
2060
2129
  trackErrorPattern(toolErrorMessage, currentRunMeta);
@@ -2182,6 +2251,9 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2182
2251
  if (!engine.activeRuns.has(runId)) break;
2183
2252
  }
2184
2253
 
2254
+ const finalizingRunMeta = engine.getRunMeta(runId);
2255
+ if (finalizingRunMeta) finalizingRunMeta.pauseAvailable = false;
2256
+
2185
2257
  if (engine.isRunStopped(runId)) {
2186
2258
  const stoppedRunMeta = engine.getRunMeta(runId);
2187
2259
  const terminalStatus = stoppedRunMeta?.status === 'interrupted' ? 'interrupted' : 'stopped';
@@ -2336,6 +2408,22 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2336
2408
  }
2337
2409
  }
2338
2410
 
2411
+ const lifecycleBeforeFinalize = await engine.checkpointLifecycle(runId, 'finalization_boundary', {
2412
+ iteration,
2413
+ stepIndex,
2414
+ });
2415
+ if (
2416
+ engine.isRunStopped(runId)
2417
+ || lifecycleBeforeFinalize?.action === 'stop'
2418
+ || lifecycleBeforeFinalize?.action === 'interrupt'
2419
+ ) {
2420
+ const terminal = db.prepare('SELECT status FROM agent_runs WHERE id = ?').get(runId)?.status || 'stopped';
2421
+ engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2422
+ engine.stopMessagingProgressSupervisor(runId);
2423
+ engine.activeRuns.delete(runId);
2424
+ return { runId, content: '', totalTokens, iterations: iteration, status: terminal };
2425
+ }
2426
+
2339
2427
  if (
2340
2428
  !normalizeOutgoingMessage(lastContent, options?.source || null)
2341
2429
  && !messagingSent
@@ -2528,8 +2616,17 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2528
2616
  }
2529
2617
  }
2530
2618
 
2531
- db.prepare('UPDATE agent_runs SET status = ?, total_tokens = ?, final_response = ?, updated_at = datetime(\'now\'), completed_at = datetime(\'now\') WHERE id = ?')
2532
- .run('completed', totalTokens, finalResponseText || null, runId);
2619
+ const completionWon = engine.completeRun(runId, {
2620
+ totalTokens,
2621
+ finalResponse: finalResponseText || null,
2622
+ });
2623
+ if (!completionWon) {
2624
+ const terminal = db.prepare('SELECT status FROM agent_runs WHERE id = ?').get(runId)?.status || 'stopped';
2625
+ engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2626
+ engine.stopMessagingProgressSupervisor(runId);
2627
+ engine.activeRuns.delete(runId);
2628
+ return { runId, content: '', totalTokens, iterations: iteration, status: terminal };
2629
+ }
2533
2630
 
2534
2631
  if (conversationId && options.skipConversationMaintenance !== true) {
2535
2632
  await engine.refreshConversationState({
@@ -2705,15 +2802,20 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2705
2802
  }
2706
2803
  }
2707
2804
 
2708
- db.prepare('UPDATE agent_runs SET status = ?, error = ?, final_response = ?, updated_at = datetime(\'now\') WHERE id = ?')
2709
- .run(
2710
- 'failed',
2711
- err.message,
2712
- sendSucceeded
2713
- ? (messagingFailureContent || null)
2714
- : (deliverableFailureResponse || null),
2715
- runId,
2716
- );
2805
+ const failureWon = engine.failRun(runId, {
2806
+ error: err.message,
2807
+ finalResponse: sendSucceeded
2808
+ ? (messagingFailureContent || null)
2809
+ : (deliverableFailureResponse || null),
2810
+ totalTokens,
2811
+ });
2812
+ if (!failureWon) {
2813
+ engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2814
+ engine.stopMessagingProgressSupervisor(runId);
2815
+ engine.activeRuns.delete(runId);
2816
+ const terminal = db.prepare('SELECT status FROM agent_runs WHERE id = ?').get(runId)?.status || 'stopped';
2817
+ return { runId, content: '', totalTokens, iterations: iteration, status: terminal };
2818
+ }
2717
2819
  console.error(
2718
2820
  `[Run ${shortenRunId(runId)}] failed trigger=${triggerSource} steps=${stepIndex} tokens=${totalTokens} error=${summarizeForLog(err.message, 180)}`
2719
2821
  );
@@ -0,0 +1,108 @@
1
+ 'use strict';
2
+
3
+ const db = require('../../../db/database');
4
+
5
+ const TERMINAL_STATUSES = new Set(['completed', 'failed', 'stopped', 'interrupted']);
6
+ const CONTROL_PRIORITY = Object.freeze({ pause: 1, stop: 2, interrupt: 3 });
7
+
8
+ function getRunControl(runId) {
9
+ return db.prepare(
10
+ `SELECT action, reason, requested_at
11
+ FROM agent_run_controls
12
+ WHERE run_id = ? AND consumed_at IS NULL`,
13
+ ).get(runId) || null;
14
+ }
15
+
16
+ function requestRunControl(runId, userId, action, reason = '') {
17
+ if (!Object.hasOwn(CONTROL_PRIORITY, action)) {
18
+ throw new Error(`Unsupported run control action: ${action}`);
19
+ }
20
+ const run = db.prepare(
21
+ 'SELECT status FROM agent_runs WHERE id = ? AND user_id = ?',
22
+ ).get(runId, userId);
23
+ if (!run) return { accepted: false, reason: 'not_found' };
24
+ if (TERMINAL_STATUSES.has(run.status)) return { accepted: false, reason: 'terminal', status: run.status };
25
+
26
+ const existing = getRunControl(runId);
27
+ if (existing && CONTROL_PRIORITY[existing.action] > CONTROL_PRIORITY[action]) {
28
+ return { accepted: false, reason: 'stronger_signal_pending', action: existing.action };
29
+ }
30
+ db.prepare(
31
+ `INSERT INTO agent_run_controls (run_id, user_id, action, reason)
32
+ VALUES (?, ?, ?, ?)
33
+ ON CONFLICT(run_id) DO UPDATE SET
34
+ action = excluded.action,
35
+ reason = excluded.reason,
36
+ requested_at = datetime('now'),
37
+ consumed_at = NULL`,
38
+ ).run(runId, userId, action, String(reason || '').slice(0, 1000));
39
+ return { accepted: true, action };
40
+ }
41
+
42
+ function checkpointRun(runId, phase, state = {}) {
43
+ db.prepare(
44
+ `INSERT INTO agent_run_checkpoints (run_id, version, phase, state_json)
45
+ VALUES (?, 1, ?, ?)
46
+ ON CONFLICT(run_id) DO UPDATE SET
47
+ version = 1,
48
+ phase = excluded.phase,
49
+ state_json = excluded.state_json,
50
+ updated_at = datetime('now')`,
51
+ ).run(runId, phase, JSON.stringify(state));
52
+ }
53
+
54
+ function transitionRun(runId, status, fields = {}, allowed = ['running']) {
55
+ const assignments = ['status = ?', "updated_at = datetime('now')"];
56
+ const values = [status];
57
+ if (Object.hasOwn(fields, 'error')) {
58
+ assignments.push('error = ?');
59
+ values.push(fields.error || null);
60
+ }
61
+ if (Object.hasOwn(fields, 'finalResponse')) {
62
+ assignments.push('final_response = ?');
63
+ values.push(fields.finalResponse || null);
64
+ }
65
+ if (Object.hasOwn(fields, 'totalTokens')) {
66
+ assignments.push('total_tokens = ?');
67
+ values.push(Number(fields.totalTokens) || 0);
68
+ }
69
+ if (TERMINAL_STATUSES.has(status)) assignments.push("completed_at = COALESCE(completed_at, datetime('now'))");
70
+ const placeholders = allowed.map(() => '?').join(', ');
71
+ values.push(runId, ...allowed);
72
+ const result = db.prepare(
73
+ `UPDATE agent_runs SET ${assignments.join(', ')}
74
+ WHERE id = ? AND status IN (${placeholders})`,
75
+ ).run(...values);
76
+ return result.changes > 0;
77
+ }
78
+
79
+ function closeRun(runId, status, fields = {}, allowed = ['running']) {
80
+ const transaction = db.transaction(() => {
81
+ if (!transitionRun(runId, status, fields, allowed)) return false;
82
+ db.prepare(
83
+ `UPDATE agent_steps
84
+ SET status = ?, error = COALESCE(NULLIF(error, ''), ?), completed_at = COALESCE(completed_at, datetime('now'))
85
+ WHERE run_id = ? AND status = 'running'`,
86
+ ).run(status, fields.error || null, runId);
87
+ db.prepare(
88
+ `UPDATE agent_delegations
89
+ SET status = ?, error = COALESCE(NULLIF(error, ''), ?), updated_at = datetime('now'), completed_at = COALESCE(completed_at, datetime('now'))
90
+ WHERE parent_run_id = ? AND status = 'running'`,
91
+ ).run(status, fields.error || null, runId);
92
+ db.prepare(
93
+ `UPDATE agent_run_controls SET consumed_at = datetime('now')
94
+ WHERE run_id = ? AND consumed_at IS NULL`,
95
+ ).run(runId);
96
+ return true;
97
+ });
98
+ return transaction();
99
+ }
100
+
101
+ module.exports = {
102
+ TERMINAL_STATUSES,
103
+ checkpointRun,
104
+ closeRun,
105
+ getRunControl,
106
+ requestRunControl,
107
+ transitionRun,
108
+ };
@@ -34,6 +34,7 @@ async function withModelCallTimeout(promise, options = {}, label = 'Model call')
34
34
  let timer = null;
35
35
  const timeout = new Promise((_, reject) => {
36
36
  timer = setTimeout(() => {
37
+ options?.modelAbortController?.abort(`${label} timed out.`);
37
38
  const error = new Error(`${label} timed out after ${formatElapsedDuration(timeoutMs)}.`);
38
39
  error.code = 'MODEL_CALL_TIMEOUT';
39
40
  reject(error);
@@ -61,6 +62,7 @@ async function requestStructuredJson(engine, {
61
62
  }) {
62
63
  const startedAt = Date.now();
63
64
  const structuredStep = `model:${phase}`;
65
+ const signal = telemetry?.signal;
64
66
  if (telemetry?.runId) {
65
67
  engine.updateRunProgress(telemetry.runId, {
66
68
  currentPhase: 'model',
@@ -84,6 +86,7 @@ async function requestStructuredJson(engine, {
84
86
  model,
85
87
  maxTokens,
86
88
  reasoningEffort: reasoningEffort || engine.getReasoningEffort(providerName, {}),
89
+ signal,
87
90
  }
88
91
  ),
89
92
  telemetry || {},
@@ -140,9 +143,15 @@ async function requestModelResponse(engine, {
140
143
  }) {
141
144
  const startedAt = Date.now();
142
145
  const requestMessages = sanitizeConversationMessages(messages);
146
+ const modelAbortController = new AbortController();
147
+ const parentSignal = options.signal;
148
+ const abortFromParent = () => modelAbortController.abort(parentSignal?.reason);
149
+ if (parentSignal?.aborted) abortFromParent();
150
+ else parentSignal?.addEventListener('abort', abortFromParent, { once: true });
143
151
  const callOptions = {
144
152
  model,
145
153
  reasoningEffort: engine.getReasoningEffort(providerName, options),
154
+ signal: modelAbortController.signal,
146
155
  };
147
156
 
148
157
  const attemptModelCall = async () => {
@@ -157,7 +166,7 @@ async function requestModelResponse(engine, {
157
166
  while (true) {
158
167
  const next = await withModelCallTimeout(
159
168
  iterator.next(),
160
- options,
169
+ { ...options, modelAbortController },
161
170
  `Model stream iteration ${iteration}`,
162
171
  );
163
172
  if (next.done) break;
@@ -194,7 +203,7 @@ async function requestModelResponse(engine, {
194
203
  } else {
195
204
  response = await withModelCallTimeout(
196
205
  provider.chat(requestMessages, tools, callOptions),
197
- options,
206
+ { ...options, modelAbortController },
198
207
  `Model iteration ${iteration}`,
199
208
  );
200
209
  }
@@ -202,18 +211,26 @@ async function requestModelResponse(engine, {
202
211
  return { response, streamContent };
203
212
  };
204
213
 
205
- const { response, streamContent } = await withProviderRetry(attemptModelCall, {
206
- ...(options.retry || {}),
207
- label: `Engine ${model}`,
208
- isRetryable: (err) => !err?.__providerRetryUnsafe && isTransientError(err),
209
- onRetry: ({ attempt, delayMs }) => {
210
- engine.emit(options.userId, 'run:interim', {
211
- runId,
212
- message: `Model service busy; retrying (attempt ${attempt}) in ${Math.max(1, Math.round(delayMs / 1000))}s.`,
213
- phase: 'recovering',
214
- });
215
- },
216
- });
214
+ let response;
215
+ let streamContent;
216
+ try {
217
+ ({ response, streamContent } = await withProviderRetry(attemptModelCall, {
218
+ ...(options.retry || {}),
219
+ label: `Engine ${model}`,
220
+ isRetryable: (err) => !modelAbortController.signal.aborted
221
+ && !err?.__providerRetryUnsafe
222
+ && isTransientError(err),
223
+ onRetry: ({ attempt, delayMs }) => {
224
+ engine.emit(options.userId, 'run:interim', {
225
+ runId,
226
+ message: `Model service busy; retrying (attempt ${attempt}) in ${Math.max(1, Math.round(delayMs / 1000))}s.`,
227
+ phase: 'recovering',
228
+ });
229
+ },
230
+ }));
231
+ } finally {
232
+ parentSignal?.removeEventListener('abort', abortFromParent);
233
+ }
217
234
 
218
235
  const resolvedResponse = response || {
219
236
  content: streamContent,
@@ -145,6 +145,14 @@ async function executeReadOnlyBatch(engine, toolCalls, context = {}) {
145
145
  }, { agentId });
146
146
  const results = await Promise.all(prepared.map(async (item) => {
147
147
  if (item.blocked) return item;
148
+ const runMeta = engine.getRunMeta(runId);
149
+ if (!runMeta || runMeta.status !== 'running') {
150
+ const result = { status: 'paused', reason: 'Run paused before this read-only call started.' };
151
+ db.prepare(
152
+ `UPDATE agent_steps SET status = 'paused', result = ?, completed_at = datetime('now') WHERE id = ? AND status = 'running'`,
153
+ ).run(JSON.stringify(result), item.stepId);
154
+ return { ...item, result };
155
+ }
148
156
  const startedAt = Date.now();
149
157
  try {
150
158
  const result = await executeTool(engine, item.toolName, item.toolArgs, {
@@ -162,6 +170,7 @@ async function executeReadOnlyBatch(engine, toolCalls, context = {}) {
162
170
  deliveryState: options.deliveryState || null,
163
171
  allowMultipleProactiveMessages: options.allowMultipleProactiveMessages === true,
164
172
  allowExternalSideEffects: false,
173
+ signal: runMeta.abortController?.signal,
165
174
  });
166
175
  const error = inferToolFailureMessage(item.toolName, result);
167
176
  const status = error ? 'failed' : 'completed';
@@ -169,7 +169,7 @@ class AnthropicProvider extends BaseProvider {
169
169
 
170
170
  if (system.length > 0) params.system = system;
171
171
  if (tools.length > 0) params.tools = this.formatTools(tools);
172
- const response = await this.client.messages.create(params);
172
+ const response = await this.client.messages.create(params, { signal: options.signal });
173
173
  const responseBlocks = Array.isArray(response?.content)
174
174
  ? response.content
175
175
  : (response?.content && typeof response.content === 'object' ? [response.content] : []);
@@ -225,7 +225,7 @@ class AnthropicProvider extends BaseProvider {
225
225
 
226
226
  if (system.length > 0) params.system = system;
227
227
  if (tools.length > 0) params.tools = this.formatTools(tools);
228
- const stream = await this.client.messages.stream(params);
228
+ const stream = await this.client.messages.stream(params, { signal: options.signal });
229
229
 
230
230
  let content = '';
231
231
  let currentToolCalls = [];
@@ -151,7 +151,7 @@ class GoogleProvider extends BaseProvider {
151
151
 
152
152
  const lastMessage = history.pop();
153
153
  const chat = genModel.startChat({ history });
154
- const result = await chat.sendMessage(lastMessage.parts);
154
+ const result = await chat.sendMessage(lastMessage.parts, { signal: options.signal });
155
155
  const response = result.response;
156
156
 
157
157
  let content = '';
@@ -205,7 +205,7 @@ class GoogleProvider extends BaseProvider {
205
205
 
206
206
  const lastMessage = history.pop();
207
207
  const chat = genModel.startChat({ history });
208
- const result = await chat.sendMessageStream(lastMessage.parts);
208
+ const result = await chat.sendMessageStream(lastMessage.parts, { signal: options.signal });
209
209
 
210
210
  let content = '';
211
211
  const toolCalls = [];
@@ -61,7 +61,7 @@ class GrokProvider extends OpenAICompatibleProvider {
61
61
  const model = options.model || 'grok-4-1-fast-reasoning';
62
62
  const params = this._buildParams(model, messages, tools, options);
63
63
 
64
- const response = await this.client.chat.completions.create(params);
64
+ const response = await this.client.chat.completions.create(params, { signal: options.signal });
65
65
  return this.normalizeResponse(response);
66
66
  }
67
67
 
@@ -73,7 +73,7 @@ class GrokProvider extends OpenAICompatibleProvider {
73
73
  stream_options: { include_usage: true }
74
74
  };
75
75
 
76
- const stream = await this.client.chat.completions.create(params);
76
+ const stream = await this.client.chat.completions.create(params, { signal: options.signal });
77
77
 
78
78
  let toolCalls = [];
79
79
  let content = '';
@@ -76,7 +76,7 @@ class NvidiaProvider extends OpenAICompatibleProvider {
76
76
  const params = this._buildParams(model, messages, tools, options);
77
77
  let response;
78
78
  try {
79
- response = await this.client.chat.completions.create(params);
79
+ response = await this.client.chat.completions.create(params, { signal: options.signal });
80
80
  } catch (err) {
81
81
  throw new Error(`NVIDIA NIM request failed: ${err?.message || String(err)}`);
82
82
  }
@@ -93,7 +93,7 @@ class NvidiaProvider extends OpenAICompatibleProvider {
93
93
 
94
94
  let stream;
95
95
  try {
96
- stream = await this.client.chat.completions.create(params);
96
+ stream = await this.client.chat.completions.create(params, { signal: options.signal });
97
97
  } catch (err) {
98
98
  throw new Error(`NVIDIA NIM request failed: ${err?.message || String(err)}`);
99
99
  }
@@ -122,11 +122,12 @@ class OllamaProvider extends BaseProvider {
122
122
  // status for others; surface both as real errors instead of letting callers
123
123
  // see a silently empty response. Tags models that reject tools so the caller
124
124
  // can transparently retry without them.
125
- async postChat(body) {
125
+ async postChat(body, signal = null) {
126
126
  const res = await fetch(`${this.baseUrl}/api/chat`, {
127
127
  method: 'POST',
128
128
  headers: { 'Content-Type': 'application/json' },
129
- body: JSON.stringify(body)
129
+ body: JSON.stringify(body),
130
+ signal,
130
131
  });
131
132
  if (!res.ok) {
132
133
  const detail = await res.text().catch(() => '');
@@ -147,11 +148,11 @@ class OllamaProvider extends BaseProvider {
147
148
 
148
149
  let res;
149
150
  try {
150
- res = await this.postChat(this.buildChatBody(messages, tools, { ...options, model }, false));
151
+ res = await this.postChat(this.buildChatBody(messages, tools, { ...options, model }, false), options.signal);
151
152
  } catch (err) {
152
153
  if (err.code === 'OLLAMA_TOOLS_UNSUPPORTED' && tools.length > 0) {
153
154
  console.warn(`[Ollama] Model '${model}' does not support tools; retrying without them.`);
154
- res = await this.postChat(this.buildChatBody(messages, [], { ...options, model }, false));
155
+ res = await this.postChat(this.buildChatBody(messages, [], { ...options, model }, false), options.signal);
155
156
  } else {
156
157
  throw err;
157
158
  }
@@ -186,11 +187,11 @@ class OllamaProvider extends BaseProvider {
186
187
 
187
188
  let res;
188
189
  try {
189
- res = await this.postChat(this.buildChatBody(messages, tools, { ...options, model }, true));
190
+ res = await this.postChat(this.buildChatBody(messages, tools, { ...options, model }, true), options.signal);
190
191
  } catch (err) {
191
192
  if (err.code === 'OLLAMA_TOOLS_UNSUPPORTED' && tools.length > 0) {
192
193
  console.warn(`[Ollama] Model '${model}' does not support tools; retrying stream without them.`);
193
- res = await this.postChat(this.buildChatBody(messages, [], { ...options, model }, true));
194
+ res = await this.postChat(this.buildChatBody(messages, [], { ...options, model }, true), options.signal);
194
195
  } else {
195
196
  throw err;
196
197
  }
@@ -108,7 +108,7 @@ class OpenAIProvider extends OpenAICompatibleProvider {
108
108
  const model = options.model || this.config.model || this.getDefaultModel();
109
109
  const params = this._buildParams(model, messages, tools, options);
110
110
 
111
- const response = await this.client.chat.completions.create(params);
111
+ const response = await this.client.chat.completions.create(params, { signal: options.signal });
112
112
  const choice = response.choices[0];
113
113
 
114
114
  return {
@@ -125,7 +125,7 @@ class OpenAIProvider extends OpenAICompatibleProvider {
125
125
  const params = this._buildParams(model, messages, tools, options);
126
126
  params.stream = true;
127
127
  params.stream_options = { include_usage: true };
128
- const stream = await this.client.chat.completions.create(params);
128
+ const stream = await this.client.chat.completions.create(params, { signal: options.signal });
129
129
 
130
130
  let currentToolCalls = [];
131
131
  let content = '';
@@ -436,9 +436,10 @@ class OpenAICodexProvider extends BaseProvider {
436
436
  try {
437
437
  response = await this.client.responses.create(
438
438
  { model, ...request },
439
- { headers: this._requestHeaders() },
439
+ { headers: this._requestHeaders(), signal: options.signal },
440
440
  );
441
441
  } catch (err) {
442
+ if (options.signal?.aborted) throw err;
442
443
  throw new Error(`OpenAI Codex request failed: ${formatOpenAIError(err)}`);
443
444
  }
444
445
 
@@ -464,9 +465,10 @@ class OpenAICodexProvider extends BaseProvider {
464
465
  try {
465
466
  stream = await this.client.responses.create(
466
467
  { model, ...request, stream: true },
467
- { headers: this._requestHeaders() },
468
+ { headers: this._requestHeaders(), signal: options.signal },
468
469
  );
469
470
  } catch (err) {
471
+ if (options.signal?.aborted) throw err;
470
472
  throw new Error(`OpenAI Codex request failed: ${formatOpenAIError(err)}`);
471
473
  }
472
474
 
@@ -72,7 +72,7 @@ class OpenRouterProvider extends OpenAICompatibleProvider {
72
72
  const params = this._buildParams(model, messages, tools, options);
73
73
  let response;
74
74
  try {
75
- response = await this.client.chat.completions.create(params);
75
+ response = await this.client.chat.completions.create(params, { signal: options.signal });
76
76
  } catch (err) {
77
77
  throw new Error(`OpenRouter request failed: ${err?.message || String(err)}`);
78
78
  }
@@ -95,7 +95,7 @@ class OpenRouterProvider extends OpenAICompatibleProvider {
95
95
 
96
96
  let stream;
97
97
  try {
98
- stream = await this.client.chat.completions.create(params);
98
+ stream = await this.client.chat.completions.create(params, { signal: options.signal });
99
99
  } catch (err) {
100
100
  throw new Error(`OpenRouter request failed: ${err?.message || String(err)}`);
101
101
  }