neoagent 3.2.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.
- package/docs/agent-run-lifecycle.md +10 -0
- package/flutter_app/lib/main_operations.dart +553 -19
- 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/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +2 -2
- package/server/public/main.dart.js +90015 -87169
- package/server/routes/agents.js +35 -2
- package/server/services/ai/loop/agent_engine_core.js +111 -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 +31 -14
- package/server/services/ai/loop/tool_dispatch.js +9 -0
- package/server/services/ai/providers/anthropic.js +2 -2
- package/server/services/ai/providers/google.js +2 -2
- package/server/services/ai/providers/grok.js +2 -2
- package/server/services/ai/providers/nvidia.js +2 -2
- package/server/services/ai/providers/ollama.js +7 -6
- 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
- package/server/services/messaging/formatting_guides.js +1 -0
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,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
|
-
|
|
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
|
-
|
|
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: {
|
|
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,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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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,
|