neoagent 2.5.2-beta.9 → 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.
Files changed (52) hide show
  1. package/README.md +5 -1
  2. package/docs/integrations.md +11 -7
  3. package/flutter_app/lib/main_controller.dart +34 -0
  4. package/flutter_app/lib/main_security.dart +19 -0
  5. package/lib/schema_migrations.js +224 -0
  6. package/package.json +2 -2
  7. package/server/admin/admin.css +29 -0
  8. package/server/admin/admin.js +81 -1
  9. package/server/admin/index.html +97 -9
  10. package/server/db/database.js +57 -1
  11. package/server/public/.last_build_id +1 -1
  12. package/server/public/flutter_bootstrap.js +1 -1
  13. package/server/public/main.dart.js +32788 -32752
  14. package/server/routes/security.js +19 -0
  15. package/server/routes/settings.js +1 -0
  16. package/server/routes/skills.js +9 -5
  17. package/server/services/ai/deliverables/artifact_helpers.js +92 -9
  18. package/server/services/ai/deliverables/selector.js +17 -0
  19. package/server/services/ai/hooks.js +3 -2
  20. package/server/services/ai/learning.js +8 -3
  21. package/server/services/ai/loop/agent_engine_core.js +67 -0
  22. package/server/services/ai/loop/blank_recovery.js +37 -0
  23. package/server/services/ai/loop/conversation_loop.js +391 -252
  24. package/server/services/ai/loop/error_recovery.js +38 -0
  25. package/server/services/ai/loop/messaging_delivery.js +52 -6
  26. package/server/services/ai/loop/progress_classification.js +178 -0
  27. package/server/services/ai/loop/progress_monitor.js +6 -5
  28. package/server/services/ai/loop/tool_dispatch.js +1 -0
  29. package/server/services/ai/loopPolicy.js +15 -6
  30. package/server/services/ai/messagingFallback.js +23 -1
  31. package/server/services/ai/preModelCompaction.js +31 -0
  32. package/server/services/ai/repetitionGuard.js +47 -2
  33. package/server/services/ai/settings.js +8 -0
  34. package/server/services/ai/systemPrompt.js +24 -0
  35. package/server/services/ai/taskAnalysis.js +10 -0
  36. package/server/services/ai/toolEvidence.js +39 -3
  37. package/server/services/ai/toolResult.js +29 -0
  38. package/server/services/ai/toolRunner.js +262 -55
  39. package/server/services/ai/toolSelector.js +20 -3
  40. package/server/services/ai/tools.js +201 -31
  41. package/server/services/cli/shell_worker_pool.js +87 -5
  42. package/server/services/integrations/github/common.js +2 -2
  43. package/server/services/integrations/github/repos.js +123 -55
  44. package/server/services/manager.js +16 -0
  45. package/server/services/memory/manager.js +39 -24
  46. package/server/services/runtime/docker-vm-manager.js +21 -1
  47. package/server/services/runtime/manager.js +6 -2
  48. package/server/services/security/approval_gate_service.js +108 -5
  49. package/server/services/security/tool_categories.js +113 -4
  50. package/server/services/security/tool_security_hook.js +7 -2
  51. package/server/services/skills/runtime.js +2 -0
  52. package/server/services/workspace/manager.js +56 -0
@@ -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) {
@@ -72,10 +72,78 @@ const CANDIDATE_KEYS = [
72
72
  'mediaPaths',
73
73
  'screenshotPath',
74
74
  'uiDumpPath',
75
- 'url',
76
- 'urls',
75
+ 'artifact',
76
+ 'artifacts',
77
+ 'artifactPath',
78
+ 'artifactPaths',
79
+ 'artifactUrl',
80
+ 'artifactUrls',
81
+ 'artifactUri',
82
+ 'artifactUris',
83
+ 'downloadUrl',
84
+ 'downloadUrls',
85
+ 'downloadUri',
86
+ 'downloadUris',
77
87
  ];
78
88
 
89
+ const GENERIC_CANDIDATE_KEYS = new Set([
90
+ 'path',
91
+ 'paths',
92
+ 'file',
93
+ 'files',
94
+ 'filePath',
95
+ 'filePaths',
96
+ 'fullPath',
97
+ 'fullPaths',
98
+ 'downloadUrl',
99
+ 'downloadUrls',
100
+ 'downloadUri',
101
+ 'downloadUris',
102
+ ]);
103
+
104
+ const EXPLICIT_CANDIDATE_KEYS = new Set(
105
+ CANDIDATE_KEYS.filter((key) => !GENERIC_CANDIDATE_KEYS.has(key))
106
+ );
107
+
108
+ const ARTIFACT_CONTAINER_KEYS = new Set([
109
+ 'artifact',
110
+ 'artifacts',
111
+ 'attachment',
112
+ 'attachments',
113
+ 'deliverable',
114
+ 'deliverables',
115
+ 'download',
116
+ 'downloads',
117
+ 'file',
118
+ 'files',
119
+ 'media',
120
+ 'preview',
121
+ 'screenshot',
122
+ 'screenshots',
123
+ ]);
124
+
125
+ const CONTAINER_URL_KEYS = new Set(['url', 'urls', 'uri', 'uris', 'href', 'hrefs']);
126
+
127
+ const EVIDENCE_RESULT_TOOLS = /^(execute_command|github_|list_|search_|read_|get_|find_|http_request|web_search|browser_get|browser_read|code_navigate|query_structured_data|memory_|session_search|recordings_|read_health_data)/;
128
+
129
+ function allowsGenericCandidateKeys(toolName = '') {
130
+ return !EVIDENCE_RESULT_TOOLS.test(String(toolName || ''));
131
+ }
132
+
133
+ function isExplicitCandidateKey(keyHint = '', parentKeyHint = '', options = {}) {
134
+ if (EXPLICIT_CANDIDATE_KEYS.has(keyHint)) return true;
135
+ if (
136
+ ARTIFACT_CONTAINER_KEYS.has(parentKeyHint)
137
+ && CANDIDATE_KEYS.includes(keyHint)
138
+ && (!GENERIC_CANDIDATE_KEYS.has(parentKeyHint) || options.allowGenericKeys === true)
139
+ ) {
140
+ return true;
141
+ }
142
+ if (GENERIC_CANDIDATE_KEYS.has(keyHint)) return options.allowGenericKeys === true;
143
+ if (!CONTAINER_URL_KEYS.has(keyHint)) return false;
144
+ return ARTIFACT_CONTAINER_KEYS.has(parentKeyHint);
145
+ }
146
+
79
147
  function inferExtension(candidate = '') {
80
148
  return path.extname(String(candidate || '').split('?')[0]).toLowerCase();
81
149
  }
@@ -102,8 +170,17 @@ function normalizePathOrUri(value) {
102
170
  if (!text) return null;
103
171
  if (text.startsWith('/api/artifacts/')) return { uri: text, path: null };
104
172
  if (/^https?:\/\//i.test(text)) return { uri: text, path: null };
105
- if (/^[A-Za-z]:\\/.test(text)) return { path: text, uri: null };
106
- if (path.isAbsolute(text)) return { path: text, uri: null };
173
+ if (text.startsWith('//')) return null;
174
+ if (/^[A-Za-z]:\\/.test(text)) {
175
+ const ext = path.extname(text.split('?')[0]).toLowerCase();
176
+ if (!FILE_EXTENSION_TO_KIND[ext]) return null;
177
+ return { path: text, uri: null };
178
+ }
179
+ if (path.isAbsolute(text)) {
180
+ const ext = path.extname(text.split('?')[0]).toLowerCase();
181
+ if (!FILE_EXTENSION_TO_KIND[ext]) return null;
182
+ return { path: text, uri: null };
183
+ }
107
184
  return null;
108
185
  }
109
186
 
@@ -154,6 +231,7 @@ async function extractArtifactsFromResult(toolName, result) {
154
231
  const seen = new Set();
155
232
  const seenCandidates = new Set();
156
233
  const fallbackKind = inferArtifactKind(toolName, 'artifact');
234
+ const allowGenericKeys = allowsGenericCandidateKeys(toolName);
157
235
 
158
236
  async function pushCandidate(candidate) {
159
237
  const candidateKey = String(candidate || '').trim();
@@ -167,23 +245,26 @@ async function extractArtifactsFromResult(toolName, result) {
167
245
  artifacts.push(artifact);
168
246
  }
169
247
 
170
- async function visit(value, keyHint = '') {
248
+ async function visit(value, keyHint = '', parentKeyHint = '') {
171
249
  if (value == null) return;
172
250
  if (typeof value === 'string') {
173
- const explicit = CANDIDATE_KEYS.includes(keyHint);
174
- if (explicit) await pushCandidate(value);
251
+ const explicit = isExplicitCandidateKey(keyHint, parentKeyHint, { allowGenericKeys });
252
+ if (explicit) {
253
+ if (normalizePathOrUri(value)) await pushCandidate(value);
254
+ return;
255
+ }
175
256
  for (const candidate of scanStringForCandidates(value, { explicit })) {
176
257
  await pushCandidate(candidate);
177
258
  }
178
259
  return;
179
260
  }
180
261
  if (Array.isArray(value)) {
181
- for (const item of value) await visit(item, keyHint);
262
+ for (const item of value) await visit(item, keyHint, parentKeyHint);
182
263
  return;
183
264
  }
184
265
  if (typeof value === 'object') {
185
266
  for (const [key, nested] of Object.entries(value)) {
186
- await visit(nested, key);
267
+ await visit(nested, key, keyHint);
187
268
  }
188
269
  }
189
270
  }
@@ -193,8 +274,10 @@ async function extractArtifactsFromResult(toolName, result) {
193
274
  }
194
275
 
195
276
  module.exports = {
277
+ allowsGenericCandidateKeys,
196
278
  extractArtifactsFromResult,
197
279
  inferArtifactKind,
198
280
  inferMimeType,
281
+ isExplicitCandidateKey,
199
282
  normalizePathOrUri,
200
283
  };
@@ -47,6 +47,23 @@ async function selectDeliverableWorkflow({
47
47
  tools = [],
48
48
  options = {},
49
49
  }) {
50
+ if (!engine || typeof engine.requestStructuredJson !== 'function') {
51
+ return {
52
+ selection: {
53
+ status: 'standard',
54
+ type: null,
55
+ confidence: 0,
56
+ goal: '',
57
+ requestedOutputs: [],
58
+ supportingCapabilities: [],
59
+ },
60
+ usage: 0,
61
+ raw: '',
62
+ skipped: true,
63
+ reason: 'structured selector unavailable',
64
+ };
65
+ }
66
+
50
67
  const response = await engine.requestStructuredJson({
51
68
  provider,
52
69
  providerName,
@@ -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(
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+
3
+ const { summarizeForLog } = require('../logFormat');
4
+
5
+ function latestFailedToolExecution(toolExecutions = []) {
6
+ return [...toolExecutions].reverse().find((item) => item && item.ok === false) || null;
7
+ }
8
+
9
+ function shouldContinueAfterBlankToolFailure({
10
+ lastContent = '',
11
+ failedStepCount = 0,
12
+ remainingIterations = 0,
13
+ toolExecutions = [],
14
+ } = {}) {
15
+ return !String(lastContent || '').trim()
16
+ && Number(failedStepCount || 0) > 0
17
+ && Number(remainingIterations || 0) > 0
18
+ && Boolean(latestFailedToolExecution(toolExecutions));
19
+ }
20
+
21
+ function buildBlankAfterToolFailureGuidance(toolExecutions = []) {
22
+ const failedExecution = latestFailedToolExecution(toolExecutions);
23
+ if (!failedExecution) return '';
24
+ const toolName = failedExecution.toolName || failedExecution.tool || 'the previous tool';
25
+ const failure = failedExecution.error || failedExecution.summary || failedExecution.status || 'unknown failure';
26
+ return [
27
+ `The previous tool "${toolName}" failed with: ${summarizeForLog(failure, 240)}.`,
28
+ 'The latest assistant turn returned no user-facing answer and no tool call, so the task is not terminal.',
29
+ 'Continue with the next safe recovery action: retry with corrected arguments, use another available tool, verify from existing evidence, or report a real blocker only if no autonomous path remains.',
30
+ ].join(' ');
31
+ }
32
+
33
+ module.exports = {
34
+ buildBlankAfterToolFailureGuidance,
35
+ latestFailedToolExecution,
36
+ shouldContinueAfterBlankToolFailure,
37
+ };