neoagent 3.2.1-beta.0 → 3.2.1-beta.2
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/docs/agent-run-lifecycle.md +10 -0
- package/lib/schema_migrations.js +30 -0
- package/package.json +1 -1
- package/server/db/database.js +2 -2
- package/server/public/.last_build_id +1 -1
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +4 -4
- package/server/routes/agents.js +35 -2
- package/server/services/ai/loop/agent_engine_core.js +122 -9
- package/server/services/ai/loop/conversation_loop.js +123 -21
- package/server/services/ai/loop/lifecycle.js +108 -0
- package/server/services/ai/loop/model_io.js +41 -16
- package/server/services/ai/loop/tool_dispatch.js +9 -0
- package/server/services/ai/providers/anthropic.js +2 -2
- package/server/services/ai/providers/claudeCode.js +6 -5
- package/server/services/ai/providers/githubCopilot.js +6 -5
- package/server/services/ai/providers/google.js +2 -2
- package/server/services/ai/providers/grok.js +2 -2
- package/server/services/ai/providers/grokOauth.js +6 -5
- package/server/services/ai/providers/nvidia.js +2 -2
- package/server/services/ai/providers/ollama.js +25 -17
- package/server/services/ai/providers/openai.js +2 -2
- package/server/services/ai/providers/openaiCodex.js +4 -2
- package/server/services/ai/providers/openrouter.js +2 -2
|
@@ -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
|
package/lib/schema_migrations.js
CHANGED
|
@@ -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
package/server/db/database.js
CHANGED
|
@@ -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
|
|
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
|
|
1032
|
+
WHERE status IN ('running', 'pausing', 'paused', 'resuming')`
|
|
1033
1033
|
).run(normalizedReason);
|
|
1034
1034
|
|
|
1035
1035
|
db.prepare(
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
1c5d94e505331e4ad70cf059dc621b70
|
|
@@ -37,6 +37,6 @@ _flutter.buildConfig = {"engineRevision":"69c8c61792f04cc809dfef0c910414fb9afc06
|
|
|
37
37
|
|
|
38
38
|
_flutter.loader.load({
|
|
39
39
|
serviceWorkerSettings: {
|
|
40
|
-
serviceWorkerVersion: "
|
|
40
|
+
serviceWorkerVersion: "1866288644" /* 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("
|
|
143692
|
+
r=B.b.t("mrwc4gsw-5a86a62").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("
|
|
149559
|
+
if(B.b.t("mrwc4gsw-5a86a62").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,"
|
|
149577
|
+
if(J.br(j)===0||J.e(j,"mrwc4gsw-5a86a62")){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("
|
|
149594
|
+
s=p}for(;;)switch(s){case 0:if(B.b.t("mrwc4gsw-5a86a62").length===0||n.c){s=1
|
|
149595
149595
|
break}n.c=!0
|
|
149596
149596
|
n.H()
|
|
149597
149597
|
p=4
|
package/server/routes/agents.js
CHANGED
|
@@ -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
|
-
|
|
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,68 @@ 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
|
+
if (runMeta.status === 'stopped' || runMeta.status === 'interrupted') {
|
|
1123
|
+
return { action: runMeta.status === 'interrupted' ? 'interrupt' : 'stop' };
|
|
1124
|
+
}
|
|
1125
|
+
const persistedStatus = db.prepare('SELECT status FROM agent_runs WHERE id = ?').get(runId)?.status;
|
|
1126
|
+
if (persistedStatus !== 'running') {
|
|
1127
|
+
const action = persistedStatus === 'interrupted' ? 'interrupt' : 'stop';
|
|
1128
|
+
runMeta.status = persistedStatus || 'stopped';
|
|
1129
|
+
return { action };
|
|
1130
|
+
}
|
|
1131
|
+
runMeta.abortController = new AbortController();
|
|
1132
|
+
runMeta.status = 'running';
|
|
1133
|
+
this.startMessagingProgressSupervisor(runId);
|
|
1134
|
+
return null;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
completeRun(runId, fields = {}) {
|
|
1138
|
+
return closeRun(runId, 'completed', fields, ['running']);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
failRun(runId, fields = {}) {
|
|
1142
|
+
return closeRun(runId, 'failed', fields, ['running']);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1076
1145
|
attachProcessToRun(runId, pid) {
|
|
1077
1146
|
return attachProcessToRunImpl(this, runId, pid);
|
|
1078
1147
|
}
|
|
@@ -1593,6 +1662,10 @@ class AgentEngine {
|
|
|
1593
1662
|
|
|
1594
1663
|
interruptRun(runId, reason = 'Server shutting down while run was in progress.') {
|
|
1595
1664
|
const runMeta = this.activeRuns.get(runId);
|
|
1665
|
+
const persistedRun = runMeta || db.prepare('SELECT user_id AS userId FROM agent_runs WHERE id = ?').get(runId);
|
|
1666
|
+
if (persistedRun?.userId != null) {
|
|
1667
|
+
requestRunControl(runId, persistedRun.userId, 'interrupt', reason);
|
|
1668
|
+
}
|
|
1596
1669
|
const delegatedChildren = db.prepare(
|
|
1597
1670
|
"SELECT child_run_id FROM agent_delegations WHERE parent_run_id = ? AND status = 'running'"
|
|
1598
1671
|
).all(runId);
|
|
@@ -1600,6 +1673,8 @@ class AgentEngine {
|
|
|
1600
1673
|
runMeta.status = 'interrupted';
|
|
1601
1674
|
runMeta.stopReason = reason;
|
|
1602
1675
|
runMeta.aborted = true;
|
|
1676
|
+
runMeta.abortController?.abort(reason);
|
|
1677
|
+
runMeta.resumeRun?.();
|
|
1603
1678
|
this.emit(runMeta.userId, 'run:stopping', { runId });
|
|
1604
1679
|
for (const pid of runMeta.toolPids) {
|
|
1605
1680
|
if (this.runtimeManager && typeof this.runtimeManager.killCommand === 'function') {
|
|
@@ -1621,14 +1696,7 @@ class AgentEngine {
|
|
|
1621
1696
|
completed_at = datetime('now')
|
|
1622
1697
|
WHERE parent_run_id = ? AND status = 'running'`
|
|
1623
1698
|
).run(reason, runId);
|
|
1624
|
-
|
|
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);
|
|
1699
|
+
closeRun(runId, 'interrupted', { error: reason }, ['running', 'pausing', 'paused', 'resuming']);
|
|
1632
1700
|
}
|
|
1633
1701
|
|
|
1634
1702
|
interruptAllActiveRuns(reason = 'Server shutting down while run was in progress.') {
|
|
@@ -1639,12 +1707,18 @@ class AgentEngine {
|
|
|
1639
1707
|
|
|
1640
1708
|
stopRun(runId) {
|
|
1641
1709
|
const runMeta = this.activeRuns.get(runId);
|
|
1710
|
+
const persistedRun = runMeta || db.prepare('SELECT user_id AS userId FROM agent_runs WHERE id = ?').get(runId);
|
|
1711
|
+
if (persistedRun?.userId != null) {
|
|
1712
|
+
requestRunControl(runId, persistedRun.userId, 'stop', 'Stopped by request.');
|
|
1713
|
+
}
|
|
1642
1714
|
const delegatedChildren = db.prepare(
|
|
1643
1715
|
"SELECT child_run_id FROM agent_delegations WHERE parent_run_id = ? AND status = 'running'"
|
|
1644
1716
|
).all(runId);
|
|
1645
1717
|
if (runMeta) {
|
|
1646
1718
|
runMeta.status = 'stopped';
|
|
1647
1719
|
runMeta.aborted = true;
|
|
1720
|
+
runMeta.abortController?.abort('Run stopped.');
|
|
1721
|
+
runMeta.resumeRun?.();
|
|
1648
1722
|
this.emit(runMeta.userId, 'run:stopping', { runId });
|
|
1649
1723
|
for (const pid of runMeta.toolPids) {
|
|
1650
1724
|
if (this.runtimeManager && typeof this.runtimeManager.killCommand === 'function') {
|
|
@@ -1661,7 +1735,46 @@ class AgentEngine {
|
|
|
1661
1735
|
db.prepare(
|
|
1662
1736
|
"UPDATE agent_delegations SET status = 'stopped', updated_at = datetime('now'), completed_at = datetime('now') WHERE parent_run_id = ? AND status = 'running'"
|
|
1663
1737
|
).run(runId);
|
|
1664
|
-
|
|
1738
|
+
closeRun(runId, 'stopped', {}, ['running', 'pausing', 'paused', 'resuming']);
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
pauseRun(runId, { userId, reason = '' } = {}) {
|
|
1742
|
+
if (!runId || userId == null) return false;
|
|
1743
|
+
const runMeta = this.activeRuns.get(runId);
|
|
1744
|
+
if (
|
|
1745
|
+
!runMeta
|
|
1746
|
+
|| Number(runMeta.userId) !== Number(userId)
|
|
1747
|
+
|| runMeta.status !== 'running'
|
|
1748
|
+
|| runMeta.pauseAvailable !== true
|
|
1749
|
+
) return false;
|
|
1750
|
+
const result = requestRunControl(runId, userId, 'pause', reason);
|
|
1751
|
+
if (!result.accepted) return false;
|
|
1752
|
+
transitionRun(runId, 'pausing', {}, ['running']);
|
|
1753
|
+
runMeta.status = 'pausing';
|
|
1754
|
+
runMeta.abortController?.abort('Run paused.');
|
|
1755
|
+
for (const pid of runMeta.toolPids) {
|
|
1756
|
+
if (this.runtimeManager && typeof this.runtimeManager.killCommand === 'function') {
|
|
1757
|
+
void this.runtimeManager.killCommand(runMeta.userId, pid, 'paused');
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
this.emit(runMeta.userId, 'run:pausing', { runId, reason: reason || null });
|
|
1761
|
+
return true;
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
resumeRun(runId, { userId } = {}) {
|
|
1765
|
+
if (!runId || userId == null) return false;
|
|
1766
|
+
const runMeta = this.activeRuns.get(runId);
|
|
1767
|
+
if (!runMeta || Number(runMeta.userId) !== Number(userId) || runMeta.status !== 'paused') return false;
|
|
1768
|
+
if (!transitionRun(runId, 'resuming', {}, ['paused'])) return false;
|
|
1769
|
+
db.prepare(
|
|
1770
|
+
`UPDATE agent_run_controls SET consumed_at = datetime('now')
|
|
1771
|
+
WHERE run_id = ? AND action = 'pause' AND consumed_at IS NULL`,
|
|
1772
|
+
).run(runId);
|
|
1773
|
+
transitionRun(runId, 'running', {}, ['resuming']);
|
|
1774
|
+
this.emit(runMeta.userId, 'run:resumed', { runId });
|
|
1775
|
+
this.recordRunEvent(runMeta.userId, runId, 'run_resumed', {}, { agentId: runMeta.agentId });
|
|
1776
|
+
runMeta.resumeRun?.();
|
|
1777
|
+
return true;
|
|
1665
1778
|
}
|
|
1666
1779
|
|
|
1667
1780
|
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: {
|
|
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
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
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',
|
|
1982
|
-
engine.emit(userId, 'run:tool_end', {
|
|
1983
|
-
|
|
2032
|
+
.run(toolInterruptedForPause ? 'paused' : 'failed', toolErrorMessage, stepId);
|
|
2033
|
+
engine.emit(userId, 'run:tool_end', {
|
|
2034
|
+
runId,
|
|
2035
|
+
stepId,
|
|
1984
2036
|
toolName,
|
|
1985
|
-
|
|
1986
|
-
|
|
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 (
|
|
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
|
-
|
|
2532
|
-
|
|
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
|
-
|
|
2709
|
-
.
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
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,11 @@ async function requestStructuredJson(engine, {
|
|
|
61
62
|
}) {
|
|
62
63
|
const startedAt = Date.now();
|
|
63
64
|
const structuredStep = `model:${phase}`;
|
|
65
|
+
const modelAbortController = new AbortController();
|
|
66
|
+
const parentSignal = telemetry?.signal;
|
|
67
|
+
const abortFromParent = () => modelAbortController.abort(parentSignal?.reason);
|
|
68
|
+
if (parentSignal?.aborted) abortFromParent();
|
|
69
|
+
else parentSignal?.addEventListener('abort', abortFromParent, { once: true });
|
|
64
70
|
if (telemetry?.runId) {
|
|
65
71
|
engine.updateRunProgress(telemetry.runId, {
|
|
66
72
|
currentPhase: 'model',
|
|
@@ -84,12 +90,16 @@ async function requestStructuredJson(engine, {
|
|
|
84
90
|
model,
|
|
85
91
|
maxTokens,
|
|
86
92
|
reasoningEffort: reasoningEffort || engine.getReasoningEffort(providerName, {}),
|
|
93
|
+
signal: modelAbortController.signal,
|
|
87
94
|
}
|
|
88
95
|
),
|
|
89
|
-
telemetry || {},
|
|
96
|
+
{ ...(telemetry || {}), modelAbortController },
|
|
90
97
|
`${phase} model call`,
|
|
91
98
|
),
|
|
92
|
-
{
|
|
99
|
+
{
|
|
100
|
+
label: `Engine ${model} (structured)`,
|
|
101
|
+
isRetryable: (err) => !modelAbortController.signal.aborted && isTransientError(err),
|
|
102
|
+
}
|
|
93
103
|
);
|
|
94
104
|
completed = true;
|
|
95
105
|
if (telemetry?.runId && telemetry?.userId) {
|
|
@@ -114,6 +124,7 @@ async function requestStructuredJson(engine, {
|
|
|
114
124
|
usage: normalizedUsage?.totalTokens || 0,
|
|
115
125
|
};
|
|
116
126
|
} finally {
|
|
127
|
+
parentSignal?.removeEventListener('abort', abortFromParent);
|
|
117
128
|
const runMeta = telemetry?.runId ? engine.getRunMeta(telemetry.runId) : null;
|
|
118
129
|
if (runMeta?.progressLedger?.currentStep === structuredStep) {
|
|
119
130
|
engine.updateRunProgress(telemetry.runId, {
|
|
@@ -140,9 +151,15 @@ async function requestModelResponse(engine, {
|
|
|
140
151
|
}) {
|
|
141
152
|
const startedAt = Date.now();
|
|
142
153
|
const requestMessages = sanitizeConversationMessages(messages);
|
|
154
|
+
const modelAbortController = new AbortController();
|
|
155
|
+
const parentSignal = options.signal;
|
|
156
|
+
const abortFromParent = () => modelAbortController.abort(parentSignal?.reason);
|
|
157
|
+
if (parentSignal?.aborted) abortFromParent();
|
|
158
|
+
else parentSignal?.addEventListener('abort', abortFromParent, { once: true });
|
|
143
159
|
const callOptions = {
|
|
144
160
|
model,
|
|
145
161
|
reasoningEffort: engine.getReasoningEffort(providerName, options),
|
|
162
|
+
signal: modelAbortController.signal,
|
|
146
163
|
};
|
|
147
164
|
|
|
148
165
|
const attemptModelCall = async () => {
|
|
@@ -157,7 +174,7 @@ async function requestModelResponse(engine, {
|
|
|
157
174
|
while (true) {
|
|
158
175
|
const next = await withModelCallTimeout(
|
|
159
176
|
iterator.next(),
|
|
160
|
-
options,
|
|
177
|
+
{ ...options, modelAbortController },
|
|
161
178
|
`Model stream iteration ${iteration}`,
|
|
162
179
|
);
|
|
163
180
|
if (next.done) break;
|
|
@@ -194,7 +211,7 @@ async function requestModelResponse(engine, {
|
|
|
194
211
|
} else {
|
|
195
212
|
response = await withModelCallTimeout(
|
|
196
213
|
provider.chat(requestMessages, tools, callOptions),
|
|
197
|
-
options,
|
|
214
|
+
{ ...options, modelAbortController },
|
|
198
215
|
`Model iteration ${iteration}`,
|
|
199
216
|
);
|
|
200
217
|
}
|
|
@@ -202,18 +219,26 @@ async function requestModelResponse(engine, {
|
|
|
202
219
|
return { response, streamContent };
|
|
203
220
|
};
|
|
204
221
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
})
|
|
215
|
-
|
|
216
|
-
|
|
222
|
+
let response;
|
|
223
|
+
let streamContent;
|
|
224
|
+
try {
|
|
225
|
+
({ response, streamContent } = await withProviderRetry(attemptModelCall, {
|
|
226
|
+
...(options.retry || {}),
|
|
227
|
+
label: `Engine ${model}`,
|
|
228
|
+
isRetryable: (err) => !modelAbortController.signal.aborted
|
|
229
|
+
&& !err?.__providerRetryUnsafe
|
|
230
|
+
&& isTransientError(err),
|
|
231
|
+
onRetry: ({ attempt, delayMs }) => {
|
|
232
|
+
engine.emit(options.userId, 'run:interim', {
|
|
233
|
+
runId,
|
|
234
|
+
message: `Model service busy; retrying (attempt ${attempt}) in ${Math.max(1, Math.round(delayMs / 1000))}s.`,
|
|
235
|
+
phase: 'recovering',
|
|
236
|
+
});
|
|
237
|
+
},
|
|
238
|
+
}));
|
|
239
|
+
} finally {
|
|
240
|
+
parentSignal?.removeEventListener('abort', abortFromParent);
|
|
241
|
+
}
|
|
217
242
|
|
|
218
243
|
const resolvedResponse = response || {
|
|
219
244
|
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 = [];
|
|
@@ -102,7 +102,7 @@ function persistEnvValue(key, value) {
|
|
|
102
102
|
} catch { }
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
async function refreshClaudeCodeAccessToken(refreshToken, fetchImpl = fetch) {
|
|
105
|
+
async function refreshClaudeCodeAccessToken(refreshToken, fetchImpl = fetch, signal = null) {
|
|
106
106
|
if (!refreshToken) return null;
|
|
107
107
|
const response = await fetchImpl(CLAUDE_CODE_TOKEN_URL, {
|
|
108
108
|
method: 'POST',
|
|
@@ -116,6 +116,7 @@ async function refreshClaudeCodeAccessToken(refreshToken, fetchImpl = fetch) {
|
|
|
116
116
|
refresh_token: refreshToken,
|
|
117
117
|
client_id: CLAUDE_CODE_CLIENT_ID,
|
|
118
118
|
}),
|
|
119
|
+
signal,
|
|
119
120
|
});
|
|
120
121
|
|
|
121
122
|
const text = await response.text();
|
|
@@ -209,8 +210,8 @@ class ClaudeCodeProvider extends AnthropicProvider {
|
|
|
209
210
|
});
|
|
210
211
|
}
|
|
211
212
|
|
|
212
|
-
async refreshClient() {
|
|
213
|
-
const refreshed = await refreshClaudeCodeAccessToken(this.refreshToken, this.fetchImpl);
|
|
213
|
+
async refreshClient(signal = null) {
|
|
214
|
+
const refreshed = await refreshClaudeCodeAccessToken(this.refreshToken, this.fetchImpl, signal);
|
|
214
215
|
if (!refreshed?.access) return false;
|
|
215
216
|
this.authToken = refreshed.access;
|
|
216
217
|
this.refreshToken = refreshed.refresh || this.refreshToken;
|
|
@@ -244,7 +245,7 @@ class ClaudeCodeProvider extends AnthropicProvider {
|
|
|
244
245
|
if ((!isAuthenticationError(err) && !isInferenceScopeError(err)) || !this.refreshToken) {
|
|
245
246
|
throw formatClaudeCodeCredentialError(err);
|
|
246
247
|
}
|
|
247
|
-
await this.refreshClient();
|
|
248
|
+
await this.refreshClient(options.signal);
|
|
248
249
|
try {
|
|
249
250
|
return await super.chat(messages, tools, options);
|
|
250
251
|
} catch (retryErr) {
|
|
@@ -260,7 +261,7 @@ class ClaudeCodeProvider extends AnthropicProvider {
|
|
|
260
261
|
if ((!isAuthenticationError(err) && !isInferenceScopeError(err)) || !this.refreshToken) {
|
|
261
262
|
throw formatClaudeCodeCredentialError(err);
|
|
262
263
|
}
|
|
263
|
-
await this.refreshClient();
|
|
264
|
+
await this.refreshClient(options.signal);
|
|
264
265
|
try {
|
|
265
266
|
yield* super.stream(messages, tools, options);
|
|
266
267
|
} catch (retryErr) {
|
|
@@ -26,7 +26,7 @@ class GithubCopilotProvider extends OpenAIProvider {
|
|
|
26
26
|
this._refreshPromise = null;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
async _refreshCopilotToken() {
|
|
29
|
+
async _refreshCopilotToken(signal = null) {
|
|
30
30
|
if (this._refreshPromise) return this._refreshPromise;
|
|
31
31
|
|
|
32
32
|
const now = Math.floor(Date.now() / 1000);
|
|
@@ -46,7 +46,8 @@ class GithubCopilotProvider extends OpenAIProvider {
|
|
|
46
46
|
'Authorization': `token ${this.githubToken}`,
|
|
47
47
|
'Accept': 'application/json',
|
|
48
48
|
'User-Agent': 'NeoAgent/1.0.0'
|
|
49
|
-
}
|
|
49
|
+
},
|
|
50
|
+
signal,
|
|
50
51
|
});
|
|
51
52
|
|
|
52
53
|
if (!res.ok) {
|
|
@@ -79,17 +80,17 @@ class GithubCopilotProvider extends OpenAIProvider {
|
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
async chat(messages, tools = [], options = {}) {
|
|
82
|
-
await this._refreshCopilotToken();
|
|
83
|
+
await this._refreshCopilotToken(options.signal);
|
|
83
84
|
return super.chat(messages, tools, options);
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
async *stream(messages, tools = [], options = {}) {
|
|
87
|
-
await this._refreshCopilotToken();
|
|
88
|
+
await this._refreshCopilotToken(options.signal);
|
|
88
89
|
yield* super.stream(messages, tools, options);
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
async analyzeImage(options = {}) {
|
|
92
|
-
await this._refreshCopilotToken();
|
|
93
|
+
await this._refreshCopilotToken(options.signal);
|
|
93
94
|
return super.analyzeImage(options);
|
|
94
95
|
}
|
|
95
96
|
}
|
|
@@ -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 = '';
|
|
@@ -41,7 +41,7 @@ function persistEnvValue(key, value) {
|
|
|
41
41
|
} catch { }
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
async function refreshGrokOAuthAccessToken(refreshToken, fetchImpl = fetch) {
|
|
44
|
+
async function refreshGrokOAuthAccessToken(refreshToken, fetchImpl = fetch, signal = null) {
|
|
45
45
|
if (!refreshToken) return null;
|
|
46
46
|
const response = await fetchImpl(GROK_OAUTH_TOKEN_URL, {
|
|
47
47
|
method: 'POST',
|
|
@@ -54,6 +54,7 @@ async function refreshGrokOAuthAccessToken(refreshToken, fetchImpl = fetch) {
|
|
|
54
54
|
refresh_token: refreshToken,
|
|
55
55
|
client_id: GROK_OAUTH_CLIENT_ID,
|
|
56
56
|
}),
|
|
57
|
+
signal,
|
|
57
58
|
});
|
|
58
59
|
|
|
59
60
|
const text = await response.text();
|
|
@@ -102,8 +103,8 @@ class GrokOAuthProvider extends GrokProvider {
|
|
|
102
103
|
this.fetchImpl = config.fetch || fetch;
|
|
103
104
|
}
|
|
104
105
|
|
|
105
|
-
async refreshClient() {
|
|
106
|
-
const refreshed = await refreshGrokOAuthAccessToken(this.refreshToken, this.fetchImpl);
|
|
106
|
+
async refreshClient(signal = null) {
|
|
107
|
+
const refreshed = await refreshGrokOAuthAccessToken(this.refreshToken, this.fetchImpl, signal);
|
|
107
108
|
if (!refreshed?.access) return false;
|
|
108
109
|
this.authToken = refreshed.access;
|
|
109
110
|
this.refreshToken = refreshed.refresh || this.refreshToken;
|
|
@@ -122,7 +123,7 @@ class GrokOAuthProvider extends GrokProvider {
|
|
|
122
123
|
return await super.chat(messages, tools, options);
|
|
123
124
|
} catch (err) {
|
|
124
125
|
if (err?.status !== 401 || !this.refreshToken) throw err;
|
|
125
|
-
await this.refreshClient();
|
|
126
|
+
await this.refreshClient(options.signal);
|
|
126
127
|
return await super.chat(messages, tools, options);
|
|
127
128
|
}
|
|
128
129
|
}
|
|
@@ -132,7 +133,7 @@ class GrokOAuthProvider extends GrokProvider {
|
|
|
132
133
|
yield* super.stream(messages, tools, options);
|
|
133
134
|
} catch (err) {
|
|
134
135
|
if (err?.status !== 401 || !this.refreshToken) throw err;
|
|
135
|
-
await this.refreshClient();
|
|
136
|
+
await this.refreshClient(options.signal);
|
|
136
137
|
yield* super.stream(messages, tools, options);
|
|
137
138
|
}
|
|
138
139
|
}
|
|
@@ -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
|
}
|
|
@@ -8,22 +8,28 @@ class OllamaProvider extends BaseProvider {
|
|
|
8
8
|
this.models = [];
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
async listModels() {
|
|
11
|
+
async listModels(signal = null) {
|
|
12
|
+
const controller = new AbortController();
|
|
13
|
+
const abortFromParent = () => controller.abort(signal?.reason);
|
|
14
|
+
if (signal?.aborted) abortFromParent();
|
|
15
|
+
else signal?.addEventListener('abort', abortFromParent, { once: true });
|
|
16
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
12
17
|
try {
|
|
13
|
-
const controller = new AbortController();
|
|
14
|
-
const timer = setTimeout(() => controller.abort(), 5000);
|
|
15
18
|
const res = await fetch(`${this.baseUrl}/api/tags`, { signal: controller.signal });
|
|
16
|
-
clearTimeout(timer);
|
|
17
19
|
const data = await res.json();
|
|
18
20
|
this.models = (data.models || []).map(m => m.name);
|
|
19
21
|
return this.models;
|
|
20
|
-
} catch {
|
|
22
|
+
} catch (err) {
|
|
23
|
+
if (signal?.aborted) throw err;
|
|
21
24
|
return [];
|
|
25
|
+
} finally {
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
signal?.removeEventListener('abort', abortFromParent);
|
|
22
28
|
}
|
|
23
29
|
}
|
|
24
30
|
|
|
25
|
-
async ensureModel(model) {
|
|
26
|
-
const models = await this.listModels();
|
|
31
|
+
async ensureModel(model, signal = null) {
|
|
32
|
+
const models = await this.listModels(signal);
|
|
27
33
|
// Normalization: Ollama often adds :latest if no tag is specified
|
|
28
34
|
const normalizedModel = model.includes(':') ? model : `${model}:latest`;
|
|
29
35
|
const found = models.some(m => m === model || m === normalizedModel);
|
|
@@ -42,7 +48,8 @@ class OllamaProvider extends BaseProvider {
|
|
|
42
48
|
const res = await fetch(`${this.baseUrl}/api/pull`, {
|
|
43
49
|
method: 'POST',
|
|
44
50
|
headers: { 'Content-Type': 'application/json' },
|
|
45
|
-
body: JSON.stringify({ name: model, stream: false })
|
|
51
|
+
body: JSON.stringify({ name: model, stream: false }),
|
|
52
|
+
signal,
|
|
46
53
|
});
|
|
47
54
|
if (!res.ok) throw new Error(`Pull failed: ${res.statusText}`);
|
|
48
55
|
console.log(`[Ollama] Model '${model}' pulled successfully.`);
|
|
@@ -54,7 +61,7 @@ class OllamaProvider extends BaseProvider {
|
|
|
54
61
|
message: `Local Ollama model '${model}' is ready.`
|
|
55
62
|
});
|
|
56
63
|
// Refresh local model list
|
|
57
|
-
await this.listModels();
|
|
64
|
+
await this.listModels(signal);
|
|
58
65
|
return true;
|
|
59
66
|
} catch (e) {
|
|
60
67
|
this.onStatus?.({
|
|
@@ -122,11 +129,12 @@ class OllamaProvider extends BaseProvider {
|
|
|
122
129
|
// status for others; surface both as real errors instead of letting callers
|
|
123
130
|
// see a silently empty response. Tags models that reject tools so the caller
|
|
124
131
|
// can transparently retry without them.
|
|
125
|
-
async postChat(body) {
|
|
132
|
+
async postChat(body, signal = null) {
|
|
126
133
|
const res = await fetch(`${this.baseUrl}/api/chat`, {
|
|
127
134
|
method: 'POST',
|
|
128
135
|
headers: { 'Content-Type': 'application/json' },
|
|
129
|
-
body: JSON.stringify(body)
|
|
136
|
+
body: JSON.stringify(body),
|
|
137
|
+
signal,
|
|
130
138
|
});
|
|
131
139
|
if (!res.ok) {
|
|
132
140
|
const detail = await res.text().catch(() => '');
|
|
@@ -143,15 +151,15 @@ class OllamaProvider extends BaseProvider {
|
|
|
143
151
|
|
|
144
152
|
async chat(messages, tools = [], options = {}) {
|
|
145
153
|
const model = options.model || this.config.model || 'llama3.1';
|
|
146
|
-
await this.ensureModel(model);
|
|
154
|
+
await this.ensureModel(model, options.signal);
|
|
147
155
|
|
|
148
156
|
let res;
|
|
149
157
|
try {
|
|
150
|
-
res = await this.postChat(this.buildChatBody(messages, tools, { ...options, model }, false));
|
|
158
|
+
res = await this.postChat(this.buildChatBody(messages, tools, { ...options, model }, false), options.signal);
|
|
151
159
|
} catch (err) {
|
|
152
160
|
if (err.code === 'OLLAMA_TOOLS_UNSUPPORTED' && tools.length > 0) {
|
|
153
161
|
console.warn(`[Ollama] Model '${model}' does not support tools; retrying without them.`);
|
|
154
|
-
res = await this.postChat(this.buildChatBody(messages, [], { ...options, model }, false));
|
|
162
|
+
res = await this.postChat(this.buildChatBody(messages, [], { ...options, model }, false), options.signal);
|
|
155
163
|
} else {
|
|
156
164
|
throw err;
|
|
157
165
|
}
|
|
@@ -182,15 +190,15 @@ class OllamaProvider extends BaseProvider {
|
|
|
182
190
|
|
|
183
191
|
async *stream(messages, tools = [], options = {}) {
|
|
184
192
|
const model = options.model || this.config.model || 'llama3.1';
|
|
185
|
-
await this.ensureModel(model);
|
|
193
|
+
await this.ensureModel(model, options.signal);
|
|
186
194
|
|
|
187
195
|
let res;
|
|
188
196
|
try {
|
|
189
|
-
res = await this.postChat(this.buildChatBody(messages, tools, { ...options, model }, true));
|
|
197
|
+
res = await this.postChat(this.buildChatBody(messages, tools, { ...options, model }, true), options.signal);
|
|
190
198
|
} catch (err) {
|
|
191
199
|
if (err.code === 'OLLAMA_TOOLS_UNSUPPORTED' && tools.length > 0) {
|
|
192
200
|
console.warn(`[Ollama] Model '${model}' does not support tools; retrying stream without them.`);
|
|
193
|
-
res = await this.postChat(this.buildChatBody(messages, [], { ...options, model }, true));
|
|
201
|
+
res = await this.postChat(this.buildChatBody(messages, [], { ...options, model }, true), options.signal);
|
|
194
202
|
} else {
|
|
195
203
|
throw err;
|
|
196
204
|
}
|
|
@@ -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
|
}
|