neoagent 3.0.0 → 3.0.1-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -85,6 +85,25 @@ router.post('/approvals/:approvalId', (req, res) => {
85
85
  normalizedScope,
86
86
  );
87
87
  if (!resolved) {
88
+ const storedApproval = approvalGateService.getStoredApproval?.(
89
+ approvalId,
90
+ req.session.userId,
91
+ );
92
+ if (storedApproval?.status === 'expired' || storedApproval?.status === 'timeout') {
93
+ return res.status(410).json({
94
+ error: 'Approval expired because the run was interrupted or the server restarted.',
95
+ });
96
+ }
97
+ if (runId) {
98
+ const run = db.prepare(
99
+ 'SELECT status FROM agent_runs WHERE id = ? AND user_id = ?'
100
+ ).get(runId, req.session.userId);
101
+ if (run?.status === 'interrupted') {
102
+ return res.status(410).json({
103
+ error: 'Approval expired because the run was interrupted or the server restarted.',
104
+ });
105
+ }
106
+ }
88
107
  return res.status(404).json({ error: 'Approval not found or already resolved' });
89
108
  }
90
109
  res.json({ ok: true, approvalId, decision, scope: normalizedScope });
@@ -40,6 +40,7 @@ const AGENT_SETTING_KEYS = new Set([
40
40
  'chat_history_window',
41
41
  'tool_replay_budget_chars',
42
42
  'subagent_max_iterations',
43
+ 'subagent_max_children_per_run',
43
44
  'assistant_behavior_notes',
44
45
  'auto_skill_learning',
45
46
  'auto_recording_insights',
@@ -56,7 +56,7 @@ function parseSkillDocument(content) {
56
56
  router.get('/', async (req, res) => {
57
57
  try {
58
58
  const runner = await getSkillRunner(req.app);
59
- const skills = runner.getAll().map(serializeInstalledSkill);
59
+ const skills = runner.getAll(req.session.userId).map(serializeInstalledSkill);
60
60
  res.json(skills.sort(sortInstalledSkills));
61
61
  } catch (err) {
62
62
  res.status(500).json({ error: sanitizeError(err) });
@@ -107,7 +107,7 @@ router.get('/audit/summary', (req, res) => {
107
107
  router.get('/:name', async (req, res) => {
108
108
  try {
109
109
  const runner = await getSkillRunner(req.app);
110
- const skill = runner.getSkill(req.params.name);
110
+ const skill = runner.getSkill(req.params.name, req.session.userId);
111
111
  if (!skill) return res.status(404).json({ error: 'Skill not found' });
112
112
  const fs = require('fs');
113
113
  const content = fs.readFileSync(skill.filePath, 'utf-8');
@@ -132,6 +132,7 @@ router.post('/', async (req, res) => {
132
132
  }
133
133
 
134
134
  const result = runner.createSkill(
135
+ req.session.userId,
135
136
  req.body.filename || parsed.name,
136
137
  parsed.description,
137
138
  parsed.instructions,
@@ -149,7 +150,8 @@ router.put('/:name', async (req, res) => {
149
150
  try {
150
151
  const runner = await getSkillRunner(req.app);
151
152
  if (typeof req.body.enabled === 'boolean' && !req.body.content) {
152
- const result = runner.setSkillEnabled(req.params.name, req.body.enabled);
153
+ const result = runner.setSkillEnabled(req.session.userId, req.params.name, req.body.enabled);
154
+ if (result.code === 'forbidden') return res.status(403).json(result);
153
155
  if (result.error) return res.status(404).json(result);
154
156
  return res.json(result);
155
157
  }
@@ -159,11 +161,12 @@ router.put('/:name', async (req, res) => {
159
161
  if (parsed.metadata?.command && !isValidCommandTemplate(parsed.metadata.command)) {
160
162
  return res.status(400).json({ error: 'Skill command template contains invalid characters' });
161
163
  }
162
- const result = runner.updateSkill(req.params.name, {
164
+ const result = runner.updateSkill(req.session.userId, req.params.name, {
163
165
  description: parsed.description,
164
166
  instructions: parsed.instructions,
165
167
  metadata: parsed.metadata
166
168
  });
169
+ if (result.code === 'forbidden') return res.status(403).json(result);
167
170
  if (result.error) return res.status(404).json(result);
168
171
  res.json(result);
169
172
  } catch (err) {
@@ -174,7 +177,8 @@ router.put('/:name', async (req, res) => {
174
177
  router.delete('/:name', async (req, res) => {
175
178
  try {
176
179
  const runner = await getSkillRunner(req.app);
177
- const result = runner.deleteSkill(req.params.name);
180
+ const result = runner.deleteSkill(req.session.userId, req.params.name);
181
+ if (result.code === 'forbidden') return res.status(403).json(result);
178
182
  if (result.error) return res.status(404).json(result);
179
183
  res.json(result);
180
184
  } catch (err) {
@@ -81,7 +81,8 @@ class AgentHooks {
81
81
 
82
82
  /**
83
83
  * Run all handlers for an event, merging their return values.
84
- * If any handler returns { block: true }, short-circuits and returns { block: true }.
84
+ * If any handler returns { block: true }, short-circuits and preserves the
85
+ * handler's metadata so the engine can explain why the tool was blocked.
85
86
  *
86
87
  * @param {string} event
87
88
  * @param {object} ctx - Context passed to every handler
@@ -98,7 +99,7 @@ class AgentHooks {
98
99
  console.warn(`[Hooks] Handler "${id}" for "${event}" threw:`, err.message);
99
100
  continue; // don't let a bad hook crash the loop
100
101
  }
101
- if (result?.block === true) return { block: true };
102
+ if (result?.block === true) return { ...merged, ...result };
102
103
  if (result && typeof result === 'object') {
103
104
  merged = { ...merged, ...result };
104
105
  }
@@ -1,5 +1,6 @@
1
1
  const crypto = require('crypto');
2
2
  const db = require('../../db/database');
3
+ const { getAiSettings } = require('./settings');
3
4
 
4
5
  function sanitizeSkillName(input) {
5
6
  const base = String(input || '')
@@ -111,6 +112,8 @@ class LearningManager {
111
112
  if (!userId || !agentId || !runId || !task || !finalContent) return null;
112
113
  if (triggerType && triggerType !== 'user') return null;
113
114
  if (triggerSource && triggerSource !== 'web') return null;
115
+ const aiSettings = getAiSettings(userId, agentId);
116
+ if (!aiSettings.auto_skill_learning) return null;
114
117
 
115
118
  const successfulSteps = Array.isArray(steps)
116
119
  ? steps.filter((step) => step.status === 'completed' && step.tool_name)
@@ -145,12 +148,13 @@ class LearningManager {
145
148
  ).get(userId, agentId, draft.metadata.workflow_signature);
146
149
  if (Number(observation?.observation_count || 0) < 3) return null;
147
150
 
148
- const existing = Array.from(this.skillRunner.skills.values()).find(
149
- (skill) => skill.metadata?.workflow_signature === draft.metadata.workflow_signature,
151
+ const existing = this.skillRunner.findSkillByWorkflowSignature(
152
+ userId,
153
+ draft.metadata.workflow_signature,
150
154
  );
151
155
  if (existing) {
152
156
  if (existing.metadata?.enabled !== false || existing.metadata?.draft !== true) return null;
153
- return this.skillRunner.updateSkill(existing.name, {
157
+ return this.skillRunner.updateSkill(userId, existing.name, {
154
158
  description: draft.description,
155
159
  instructions: draft.instructions,
156
160
  metadata: {
@@ -163,6 +167,7 @@ class LearningManager {
163
167
  }
164
168
 
165
169
  const result = this.skillRunner.createSkill(
170
+ userId,
166
171
  draft.name,
167
172
  draft.description,
168
173
  draft.instructions,
@@ -247,6 +247,7 @@ class AgentEngine {
247
247
  const promptSections = await buildSystemPromptSections(userId, context, memoryManager);
248
248
  const skillRunner = context.skillRunner || this.skillRunner || null;
249
249
  const skillsPrompt = skillRunner?.getSkillsForPrompt?.({
250
+ userId,
250
251
  maxTotalChars: 9000,
251
252
  maxDescriptionChars: 180,
252
253
  maxTriggerChars: 100,
@@ -1180,6 +1181,24 @@ class AgentEngine {
1180
1181
  }
1181
1182
 
1182
1183
  async spawnSubagent(userId, parentRunId, task, options = {}) {
1184
+ const parentRunMeta = this.getRunMeta(parentRunId);
1185
+ const parentDepth = Math.max(0, Number(parentRunMeta?.subagentDepth) || 0);
1186
+ if (parentDepth >= 1) {
1187
+ return {
1188
+ error: 'Sub-agents cannot spawn additional sub-agents. Continue the current child run or return results to the parent run.',
1189
+ };
1190
+ }
1191
+
1192
+ const aiSettings = getAiSettings(userId, options.agentId || null);
1193
+ const maxSubagentsPerRun = Math.max(1, Number(aiSettings.subagent_max_children_per_run) || 10);
1194
+ const existingSubagents = Array.from(this.subagents.values())
1195
+ .filter((record) => record.parentRunId === parentRunId);
1196
+ if (existingSubagents.length >= maxSubagentsPerRun) {
1197
+ return {
1198
+ error: `This run has already spawned ${existingSubagents.length} sub-agents. The limit for one run is ${maxSubagentsPerRun}.`,
1199
+ };
1200
+ }
1201
+
1183
1202
  const handle = uuidv4();
1184
1203
  const childRunId = uuidv4();
1185
1204
  let relevantMemories = [];
@@ -1256,6 +1275,8 @@ class AgentEngine {
1256
1275
  triggerSource: 'agent',
1257
1276
  runId: childRunId,
1258
1277
  agentId: options.agentId || null,
1278
+ subagentDepth: parentDepth + 1,
1279
+ disallowedToolNames: ['spawn_subagent'],
1259
1280
  },
1260
1281
  options.model || null
1261
1282
  );
@@ -1521,6 +1542,52 @@ class AgentEngine {
1521
1542
  return { handle, status: 'cancelled' };
1522
1543
  }
1523
1544
 
1545
+ interruptRun(runId, reason = 'Server shutting down while run was in progress.') {
1546
+ const runMeta = this.activeRuns.get(runId);
1547
+ const delegatedChildren = db.prepare(
1548
+ "SELECT child_run_id FROM agent_delegations WHERE parent_run_id = ? AND status = 'running'"
1549
+ ).all(runId);
1550
+ if (runMeta) {
1551
+ runMeta.status = 'interrupted';
1552
+ runMeta.stopReason = reason;
1553
+ runMeta.aborted = true;
1554
+ this.emit(runMeta.userId, 'run:stopping', { runId });
1555
+ for (const pid of runMeta.toolPids) {
1556
+ if (this.runtimeManager && typeof this.runtimeManager.killCommand === 'function') {
1557
+ void this.runtimeManager.killCommand(runMeta.userId, pid, 'aborted');
1558
+ }
1559
+ }
1560
+ runMeta.toolPids.clear();
1561
+ }
1562
+ for (const child of delegatedChildren) {
1563
+ if (child.child_run_id && child.child_run_id !== runId) {
1564
+ this.interruptRun(child.child_run_id, reason);
1565
+ }
1566
+ }
1567
+ db.prepare(
1568
+ `UPDATE agent_delegations
1569
+ SET status = 'interrupted',
1570
+ error = COALESCE(NULLIF(error, ''), ?),
1571
+ updated_at = datetime('now'),
1572
+ completed_at = datetime('now')
1573
+ WHERE parent_run_id = ? AND status = 'running'`
1574
+ ).run(reason, runId);
1575
+ db.prepare(
1576
+ `UPDATE agent_runs
1577
+ SET status = 'interrupted',
1578
+ error = COALESCE(NULLIF(error, ''), ?),
1579
+ updated_at = datetime('now'),
1580
+ completed_at = datetime('now')
1581
+ WHERE id = ?`
1582
+ ).run(reason, runId);
1583
+ }
1584
+
1585
+ interruptAllActiveRuns(reason = 'Server shutting down while run was in progress.') {
1586
+ for (const runId of Array.from(this.activeRuns.keys())) {
1587
+ this.interruptRun(runId, reason);
1588
+ }
1589
+ }
1590
+
1524
1591
  stopRun(runId) {
1525
1592
  const runMeta = this.activeRuns.get(runId);
1526
1593
  const delegatedChildren = db.prepare(
@@ -141,6 +141,8 @@ const {
141
141
  } = require('../messagingFallback');
142
142
  const {
143
143
  classifyToolExecution,
144
+ isSubstantiveProgressToolName,
145
+ summarizeProgressToolExecutions,
144
146
  summarizeToolExecutions,
145
147
  summarizeAvailableTools,
146
148
  inferToolFailureMessage,
@@ -663,6 +665,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
663
665
  steeringQueue: [],
664
666
  systemSteeringQueue: [],
665
667
  toolPids: new Set(),
668
+ subagentDepth: Math.max(0, Number(options.subagentDepth) || 0),
666
669
  repetitionGuard: new ToolRepetitionGuard(),
667
670
  seenOutputHashes: new Map(),
668
671
  consecutiveReadOnlyIterations: 0,
@@ -715,7 +718,13 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
715
718
  const mcpManager = app?.locals?.mcpManager || app?.locals?.mcpClient || engine.mcpManager;
716
719
  const integrationManager = app?.locals?.integrationManager || null;
717
720
  const mcpTools = mcpManager ? mcpManager.getAllTools(userId, { agentId }) : [];
718
- const allTools = selectToolsForTask(userMessage, builtInTools, mcpTools, options);
721
+ const disallowedToolNames = new Set(
722
+ (Array.isArray(options.disallowedToolNames) ? options.disallowedToolNames : [])
723
+ .map((name) => String(name || '').trim())
724
+ .filter(Boolean),
725
+ );
726
+ const allTools = selectToolsForTask(userMessage, builtInTools, mcpTools, options)
727
+ .filter((tool) => !disallowedToolNames.has(tool?.name));
719
728
  let tools = allTools;
720
729
  const toolNames = allTools.map((tool) => tool.name).filter(Boolean);
721
730
  const coreToolStatus = {
@@ -825,15 +834,21 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
825
834
  // Real, specific evidence (actual commands + their output) so the update is
826
835
  // grounded in what happened, not invented. Bare tool names make a weak model
827
836
  // confabulate generic activity ("running the build", "training").
828
- const recent = summarizeToolExecutions(toolExecutions, 5) || '(no tool activity yet)';
837
+ const recent = summarizeProgressToolExecutions(toolExecutions, 5);
838
+ const currentTool = isSubstantiveProgressToolName(ledger.currentTool)
839
+ ? ledger.currentTool
840
+ : '';
841
+ if (!recent && !currentTool) {
842
+ return '';
843
+ }
829
844
  const priorUpdate = String(rm?.lastInterimMessage || '').trim();
830
845
  const contextBlock = [
831
846
  buildProgressUpdatePrompt(),
832
847
  stalled ? 'This step has been running a while with no new progress; reassure the user you are still on it.' : '',
833
848
  '',
834
849
  `Original request: ${summarizeForLog(userMessage, 320)}`,
835
- `Doing now: ${ledger.currentTool ? `using ${ledger.currentTool}` : (ledger.currentPhase || 'thinking')}`,
836
- `Actual recent tool activity (newest last) — describe ONLY this, do not extrapolate:\n${recent}`,
850
+ currentTool ? `Doing now: using ${currentTool}` : '',
851
+ recent ? `Actual recent tool activity (newest last) — describe ONLY this, do not extrapolate:\n${recent}` : '',
837
852
  priorUpdate ? `Your previous update (say something different): ${summarizeForLog(priorUpdate, 160)}` : '',
838
853
  ].filter(Boolean).join('\n');
839
854
  // Reuse the run's real system prompt so the update follows the same voice and
@@ -1922,20 +1937,41 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1922
1937
  }
1923
1938
 
1924
1939
  if (engine.isRunStopped(runId)) {
1925
- db.prepare('UPDATE agent_runs SET status = ?, updated_at = datetime(\'now\'), completed_at = datetime(\'now\') WHERE id = ?')
1926
- .run('stopped', runId);
1940
+ const stoppedRunMeta = engine.getRunMeta(runId);
1941
+ const terminalStatus = stoppedRunMeta?.status === 'interrupted' ? 'interrupted' : 'stopped';
1942
+ const stopReason = stoppedRunMeta?.stopReason || null;
1943
+ db.prepare(
1944
+ `UPDATE agent_runs
1945
+ SET status = ?,
1946
+ error = COALESCE(?, error),
1947
+ updated_at = datetime('now'),
1948
+ completed_at = datetime('now')
1949
+ WHERE id = ?`
1950
+ ).run(terminalStatus, stopReason, runId);
1927
1951
  console.warn(
1928
- `[Run ${shortenRunId(runId)}] stopped trigger=${triggerSource} steps=${stepIndex} tokens=${totalTokens}`
1952
+ `[Run ${shortenRunId(runId)}] ${terminalStatus} trigger=${triggerSource} steps=${stepIndex} tokens=${totalTokens}`
1929
1953
  );
1954
+ engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
1930
1955
  engine.stopMessagingProgressSupervisor(runId);
1931
1956
  engine.activeRuns.delete(runId);
1932
- engine.emit(userId, 'run:stopped', { runId, triggerSource });
1933
- engine.recordRunEvent(userId, runId, 'run_stopped', {
1957
+ engine.emit(userId, terminalStatus === 'interrupted' ? 'run:interrupted' : 'run:stopped', {
1958
+ runId,
1934
1959
  triggerSource,
1935
- totalTokens,
1936
- iterations: iteration,
1937
- }, { agentId });
1938
- return { runId, content: '', totalTokens, iterations: iteration, status: 'stopped' };
1960
+ reason: stopReason,
1961
+ });
1962
+ engine.recordRunEvent(
1963
+ userId,
1964
+ runId,
1965
+ terminalStatus === 'interrupted' ? 'run_interrupted' : 'run_stopped',
1966
+ {
1967
+ triggerSource,
1968
+ totalTokens,
1969
+ iterations: iteration,
1970
+ reason: stopReason,
1971
+ },
1972
+ { agentId },
1973
+ );
1974
+ return { runId, content: '', totalTokens, iterations: iteration, status: terminalStatus };
1939
1975
  }
1940
1976
 
1941
1977
  const runMeta = engine.activeRuns.get(runId);
@@ -149,6 +149,7 @@ function createDefaultAiSettings() {
149
149
  chat_history_window: 20,
150
150
  tool_replay_budget_chars: 6000,
151
151
  subagent_max_iterations: 6,
152
+ subagent_max_children_per_run: 10,
152
153
  assistant_behavior_notes: '',
153
154
  auto_skill_learning: false,
154
155
  auto_recording_insights: true,
@@ -330,6 +331,13 @@ function getAiSettings(userId, agentId = null) {
330
331
  settings.chat_history_window = Math.max(6, Math.min(Number(settings.chat_history_window) || DEFAULT_AI_SETTINGS.chat_history_window, 40));
331
332
  settings.tool_replay_budget_chars = Math.max(1200, Math.min(Number(settings.tool_replay_budget_chars) || DEFAULT_AI_SETTINGS.tool_replay_budget_chars, 12000));
332
333
  settings.subagent_max_iterations = Math.max(2, Math.min(Number(settings.subagent_max_iterations) || DEFAULT_AI_SETTINGS.subagent_max_iterations, 12));
334
+ settings.subagent_max_children_per_run = Math.max(
335
+ 1,
336
+ Math.min(
337
+ Number(settings.subagent_max_children_per_run) || DEFAULT_AI_SETTINGS.subagent_max_children_per_run,
338
+ 20,
339
+ ),
340
+ );
333
341
  settings.cost_mode = typeof settings.cost_mode === 'string' ? settings.cost_mode : DEFAULT_AI_SETTINGS.cost_mode;
334
342
  settings.assistant_behavior_notes = typeof settings.assistant_behavior_notes === 'string'
335
343
  ? settings.assistant_behavior_notes
@@ -1,8 +1,25 @@
1
1
  const os = require('os');
2
2
 
3
3
  const PROMPT_CACHE_TTL = 30_000;
4
+ const PROMPT_CACHE_MAX = 500;
4
5
  const promptCache = new Map();
5
6
 
7
+ function evictExpiredPromptCache() {
8
+ const now = Date.now();
9
+ for (const [key, entry] of promptCache.entries()) {
10
+ if (now >= entry.expiresAt) promptCache.delete(key);
11
+ }
12
+ if (promptCache.size > PROMPT_CACHE_MAX) {
13
+ const excess = promptCache.size - PROMPT_CACHE_MAX;
14
+ let deleted = 0;
15
+ for (const key of promptCache.keys()) {
16
+ if (deleted >= excess) break;
17
+ promptCache.delete(key);
18
+ deleted++;
19
+ }
20
+ }
21
+ }
22
+
6
23
  function invalidateSystemPromptCache(userId, agentId = null) {
7
24
  const prefix = `${String(userId || 'global')}:${String(agentId || 'main')}:`;
8
25
  for (const key of promptCache.keys()) {
@@ -358,6 +375,7 @@ async function buildSystemPromptSections(userId, context = {}, memoryManager) {
358
375
  };
359
376
 
360
377
  if (!hasExtraContext) {
378
+ evictExpiredPromptCache();
361
379
  promptCache.set(cacheKey, { sections, expiresAt: now + PROMPT_CACHE_TTL });
362
380
  }
363
381
 
@@ -153,6 +153,27 @@ function summarizeToolExecutions(toolExecutions = [], maxItems = 10) {
153
153
  }).join('\n');
154
154
  }
155
155
 
156
+ function isSubstantiveProgressToolName(toolName = '') {
157
+ const name = String(toolName || '').trim();
158
+ if (!name) return false;
159
+ if (name === 'send_message' || name === 'send_interim_update' || name === 'make_call') return false;
160
+ if (name === 'think' || name === 'activate_tools' || name === 'task_complete') return false;
161
+ return true;
162
+ }
163
+
164
+ function isSubstantiveProgressEvidence(item = {}) {
165
+ if (!isSubstantiveProgressToolName(item.toolName)) return false;
166
+ if (item.evidenceSource === 'messaging') return false;
167
+ return Boolean(item.evidenceRelevant || item.stateChanged || item.error);
168
+ }
169
+
170
+ function summarizeProgressToolExecutions(toolExecutions = [], maxItems = 10) {
171
+ return summarizeToolExecutions(
172
+ toolExecutions.filter(isSubstantiveProgressEvidence),
173
+ maxItems,
174
+ );
175
+ }
176
+
156
177
  function summarizeAvailableTools(tools = [], { exclude = [] } = {}) {
157
178
  const excluded = new Set((Array.isArray(exclude) ? exclude : [exclude]).filter(Boolean));
158
179
  return tools
@@ -214,6 +235,9 @@ function buildAutonomousRecoveryContext({ err, toolExecutions = [], tools = [],
214
235
  module.exports = {
215
236
  classifyToolExecution,
216
237
  deriveEvidenceSource,
238
+ isSubstantiveProgressEvidence,
239
+ isSubstantiveProgressToolName,
240
+ summarizeProgressToolExecutions,
217
241
  summarizeToolExecutions,
218
242
  summarizeAvailableTools,
219
243
  inferToolFailureMessage,