@newsails/veil-cli 1.0.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.
Files changed (199) hide show
  1. package/.veil/agents/analyst/AGENT.md +21 -0
  2. package/.veil/agents/analyst/agent.json +23 -0
  3. package/.veil/agents/assistant/AGENT.md +15 -0
  4. package/.veil/agents/assistant/agent.json +19 -0
  5. package/.veil/agents/coder/AGENT.md +18 -0
  6. package/.veil/agents/coder/agent.json +19 -0
  7. package/.veil/agents/hello/AGENT.md +5 -0
  8. package/.veil/agents/hello/agent.json +13 -0
  9. package/.veil/agents/writer/AGENT.md +12 -0
  10. package/.veil/agents/writer/agent.json +17 -0
  11. package/.veil/memory/MEMORY.md +343 -0
  12. package/.veil/memory/agents/analyst/MEMORY.md +55 -0
  13. package/.veil/memory/agents/hello/MEMORY.md +12 -0
  14. package/.veil/runtime.pid +1 -0
  15. package/.veil/settings.json +10 -0
  16. package/.veil-studio/studio.db +0 -0
  17. package/.veil-studio/studio.db-shm +0 -0
  18. package/.veil-studio/studio.db-wal +0 -0
  19. package/PLAN/01-vision.md +26 -0
  20. package/PLAN/02-tech-stack.md +94 -0
  21. package/PLAN/03-agents.md +232 -0
  22. package/PLAN/04-runtime.md +171 -0
  23. package/PLAN/05-tools.md +211 -0
  24. package/PLAN/06-communication.md +243 -0
  25. package/PLAN/07-storage.md +218 -0
  26. package/PLAN/08-api-cli.md +153 -0
  27. package/PLAN/09-permissions.md +108 -0
  28. package/PLAN/10-ably.md +105 -0
  29. package/PLAN/11-file-formats.md +442 -0
  30. package/PLAN/12-folder-structure.md +205 -0
  31. package/PLAN/13-operations.md +212 -0
  32. package/PLAN/README.md +23 -0
  33. package/README.md +128 -0
  34. package/REPORT.md +174 -0
  35. package/TODO.md +45 -0
  36. package/ai-tests/FRONTEND_PROMPT.md +220 -0
  37. package/ai-tests/Research & Planning.md +814 -0
  38. package/ai-tests/prompt-001-basic-api.md +230 -0
  39. package/ai-tests/prompt-002-basic-flows.md +230 -0
  40. package/ai-tests/prompt-003-agent-behaviors.md +220 -0
  41. package/api/middleware.js +60 -0
  42. package/api/routes/agents.js +193 -0
  43. package/api/routes/chat.js +93 -0
  44. package/api/routes/completions.js +122 -0
  45. package/api/routes/daemons.js +80 -0
  46. package/api/routes/memory.js +169 -0
  47. package/api/routes/models.js +40 -0
  48. package/api/routes/remote-methods.js +74 -0
  49. package/api/routes/sessions.js +208 -0
  50. package/api/routes/settings.js +108 -0
  51. package/api/routes/system.js +50 -0
  52. package/api/routes/tasks.js +270 -0
  53. package/api/server.js +120 -0
  54. package/cli/formatter.js +70 -0
  55. package/cli/index.js +443 -0
  56. package/cli/parser.js +113 -0
  57. package/config/config.json +10 -0
  58. package/config/models.json +6826 -0
  59. package/core/agent.js +329 -0
  60. package/core/cancel.js +38 -0
  61. package/core/compaction.js +176 -0
  62. package/core/events.js +13 -0
  63. package/core/loop.js +564 -0
  64. package/core/memory.js +51 -0
  65. package/core/prompt.js +185 -0
  66. package/core/queue.js +96 -0
  67. package/core/registry.js +291 -0
  68. package/core/remote-methods.js +124 -0
  69. package/core/router.js +386 -0
  70. package/core/running-sessions.js +18 -0
  71. package/docs/api/01-system.md +84 -0
  72. package/docs/api/02-agents.md +374 -0
  73. package/docs/api/03-chat.md +269 -0
  74. package/docs/api/04-tasks.md +470 -0
  75. package/docs/api/05-sessions.md +444 -0
  76. package/docs/api/06-daemons.md +142 -0
  77. package/docs/api/07-memory.md +186 -0
  78. package/docs/api/08-settings.md +133 -0
  79. package/docs/api/09-models.md +119 -0
  80. package/docs/api/09-websocket.md +350 -0
  81. package/docs/api/10-completions.md +134 -0
  82. package/docs/api/README.md +116 -0
  83. package/docs/guide/01-quickstart.md +220 -0
  84. package/docs/guide/02-folder-structure.md +185 -0
  85. package/docs/guide/03-configuration.md +252 -0
  86. package/docs/guide/04-agents.md +267 -0
  87. package/docs/guide/05-cli.md +290 -0
  88. package/docs/guide/06-tools.md +643 -0
  89. package/docs/guide/07-permissions.md +236 -0
  90. package/docs/guide/08-memory.md +139 -0
  91. package/docs/guide/09-multi-agent.md +271 -0
  92. package/docs/guide/10-daemons.md +226 -0
  93. package/docs/guide/README.md +53 -0
  94. package/docs/index.html +623 -0
  95. package/examples/README.md +151 -0
  96. package/examples/agents/assistant/AGENT.md +31 -0
  97. package/examples/agents/assistant/SOUL.md +9 -0
  98. package/examples/agents/assistant/agent.json +74 -0
  99. package/examples/agents/hello/AGENT.md +15 -0
  100. package/examples/agents/hello/agent.json +14 -0
  101. package/examples/agents/monitor/AGENT.md +51 -0
  102. package/examples/agents/monitor/agent.json +33 -0
  103. package/examples/agents/monitor/heartbeats/monitor.md +24 -0
  104. package/examples/agents/orchestrator/AGENT.md +70 -0
  105. package/examples/agents/orchestrator/agent.json +30 -0
  106. package/examples/agents/researcher/AGENT.md +52 -0
  107. package/examples/agents/researcher/agent.json +49 -0
  108. package/examples/agents/researcher/skills/web-research.md +28 -0
  109. package/examples/skills/code-review.md +72 -0
  110. package/examples/skills/summarise.md +59 -0
  111. package/examples/skills/web-research.md +42 -0
  112. package/examples/tools/word-count/index.js +27 -0
  113. package/examples/tools/word-count/tool.json +18 -0
  114. package/infrastructure/database.js +563 -0
  115. package/infrastructure/scheduler.js +122 -0
  116. package/llm/client.js +206 -0
  117. package/migrations/001-initial.sql +121 -0
  118. package/migrations/002-debuggability.sql +13 -0
  119. package/migrations/003-drop-orphaned-columns.sql +72 -0
  120. package/migrations/004-session-message-token-fields.sql +78 -0
  121. package/migrations/005-session-thinking.sql +5 -0
  122. package/package.json +30 -0
  123. package/schemas/agent.json +143 -0
  124. package/schemas/settings.json +111 -0
  125. package/scripts/fetch-models.js +93 -0
  126. package/session-debug-scenario.md +248 -0
  127. package/settings/fields.js +52 -0
  128. package/system-prompts/base-core.md +7 -0
  129. package/system-prompts/environment.md +13 -0
  130. package/system-prompts/reminders/anti-drift.md +6 -0
  131. package/system-prompts/reminders/stall-recovery.md +10 -0
  132. package/system-prompts/safety-rules.md +25 -0
  133. package/system-prompts/task-heuristics.md +27 -0
  134. package/test/client.js +71 -0
  135. package/test/integration/01-health.test.js +25 -0
  136. package/test/integration/02-agents.test.js +80 -0
  137. package/test/integration/03-chat-hello.test.js +48 -0
  138. package/test/integration/04-chat-multiturn.test.js +61 -0
  139. package/test/integration/05-chat-writer.test.js +48 -0
  140. package/test/integration/06-task-basic.test.js +68 -0
  141. package/test/integration/07-task-tools.test.js +74 -0
  142. package/test/integration/08-task-code-analysis.test.js +69 -0
  143. package/test/integration/09-memory-analyst.test.js +63 -0
  144. package/test/integration/10-task-advanced.test.js +85 -0
  145. package/test/integration/11-sessions-advanced.test.js +84 -0
  146. package/test/integration/12-assistant-chat-tools.test.js +75 -0
  147. package/test/integration/13-edge-cases.test.js +99 -0
  148. package/test/integration/14-cancel.test.js +62 -0
  149. package/test/integration/15-debug.test.js +106 -0
  150. package/test/integration/16-memory-api.test.js +83 -0
  151. package/test/integration/17-settings-api.test.js +41 -0
  152. package/test/integration/18-tool-search-activation.test.js +119 -0
  153. package/test/results/.gitkeep +0 -0
  154. package/test/runner.js +206 -0
  155. package/test/smoke.js +216 -0
  156. package/tools/agent_message.js +85 -0
  157. package/tools/agent_send.js +80 -0
  158. package/tools/agent_spawn.js +44 -0
  159. package/tools/bash.js +49 -0
  160. package/tools/edit_file.js +41 -0
  161. package/tools/glob.js +64 -0
  162. package/tools/grep.js +82 -0
  163. package/tools/list_dir.js +63 -0
  164. package/tools/log_write.js +31 -0
  165. package/tools/memory_read.js +38 -0
  166. package/tools/memory_search.js +65 -0
  167. package/tools/memory_write.js +42 -0
  168. package/tools/read_file.js +48 -0
  169. package/tools/sleep.js +22 -0
  170. package/tools/task_create.js +41 -0
  171. package/tools/task_respond.js +37 -0
  172. package/tools/task_spawn.js +64 -0
  173. package/tools/task_status.js +39 -0
  174. package/tools/task_subscribe.js +37 -0
  175. package/tools/todo_read.js +26 -0
  176. package/tools/todo_write.js +38 -0
  177. package/tools/tool_activate.js +24 -0
  178. package/tools/tool_search.js +24 -0
  179. package/tools/web_fetch.js +50 -0
  180. package/tools/web_search.js +52 -0
  181. package/tools/write_file.js +28 -0
  182. package/ui/api.js +190 -0
  183. package/ui/app.js +281 -0
  184. package/ui/index.html +382 -0
  185. package/ui/views/agents.js +377 -0
  186. package/ui/views/chat.js +610 -0
  187. package/ui/views/connection.js +96 -0
  188. package/ui/views/daemons.js +129 -0
  189. package/ui/views/feed.js +194 -0
  190. package/ui/views/memory.js +263 -0
  191. package/ui/views/models.js +146 -0
  192. package/ui/views/sessions.js +314 -0
  193. package/ui/views/settings.js +142 -0
  194. package/ui/views/tasks.js +415 -0
  195. package/utils/context.js +49 -0
  196. package/utils/id.js +16 -0
  197. package/utils/models.js +88 -0
  198. package/utils/paths.js +213 -0
  199. package/utils/settings.js +172 -0
package/core/loop.js ADDED
@@ -0,0 +1,564 @@
1
+ 'use strict';
2
+
3
+ const { callLLM, extractMessage, extractUsage } = require('../llm/client');
4
+ const { resolveTool, validateToolInput, buildToolsForLLM, listCustomToolSummaries, activateToolByName } = require('./registry');
5
+ const { assembleSystemPrompt, buildReminder } = require('./prompt');
6
+ const { addMessage, getMessages, updateSession, updateTask, addTaskEvent, saveTodos, getTodos, saveTaskContext } = require('../infrastructure/database');
7
+ const { getModelConfig } = require('../utils/settings');
8
+ const { calculateCost } = require('../utils/models');
9
+ const { manageContext } = require('./compaction');
10
+ const { drainNonFollowup, drainFollowup, postCorrelatedResponse } = require('./queue');
11
+ const F = require('../settings/fields');
12
+ const eventBus = require('./events');
13
+ const remoteMethodQueue = require('./remote-methods');
14
+ const runningSessions = require('./running-sessions');
15
+
16
+ const ANTI_DRIFT_EVERY = 10; // inject anti-drift reminder every N iterations
17
+ const STALL_THRESHOLD = 3; // iterations with no tool calls = stall
18
+
19
+ /**
20
+ * Check permissions for a tool call.
21
+ * @param {{ toolName: string, mode: string, settings: Object, agentConfig: Object }} opts
22
+ * @returns {'allow'|'deny'|'ask'}
23
+ */
24
+ function checkPermission({ toolName, toolInput, mode, settings, agentConfig, modeConfig }) {
25
+ // Always deny auth file reads
26
+ const authDenyPatterns = ['.veil/auth.json', '.veil\\auth.json'];
27
+ const inputStr = JSON.stringify(toolInput || {});
28
+ for (const p of authDenyPatterns) {
29
+ if (inputStr.includes(p)) return 'deny';
30
+ }
31
+
32
+ // Check agent-level per-mode deny list
33
+ const agentDeny = modeConfig.permissions?.deny || [];
34
+ for (const pattern of agentDeny) {
35
+ if (matchPermissionPattern(toolName, toolInput, pattern)) return 'deny';
36
+ }
37
+
38
+ // Check agent-level per-mode allow list
39
+ const agentAllow = modeConfig.permissions?.allow || [];
40
+ for (const pattern of agentAllow) {
41
+ if (matchPermissionPattern(toolName, toolInput, pattern)) return 'allow';
42
+ }
43
+
44
+ // Check settings-level deny
45
+ const settingsDeny = settings.permissions?.deny || [];
46
+ for (const pattern of settingsDeny) {
47
+ if (matchPermissionPattern(toolName, toolInput, pattern)) return 'deny';
48
+ }
49
+
50
+ // Check settings-level ask
51
+ const settingsAsk = settings.permissions?.ask || [];
52
+ for (const pattern of settingsAsk) {
53
+ if (matchPermissionPattern(toolName, toolInput, pattern)) return 'ask';
54
+ }
55
+
56
+ // Check settings-level allow
57
+ const settingsAllow = settings.permissions?.allow || [];
58
+ for (const pattern of settingsAllow) {
59
+ if (matchPermissionPattern(toolName, toolInput, pattern)) return 'allow';
60
+ }
61
+
62
+ // Default: chat=ask, others=deny
63
+ return mode === 'chat' ? 'ask' : 'deny';
64
+ }
65
+
66
+ /**
67
+ * Simple permission pattern matcher: tool_name(glob_pattern)
68
+ * @param {string} toolName
69
+ * @param {Object} toolInput
70
+ * @param {string} pattern
71
+ * @returns {boolean}
72
+ */
73
+ function matchPermissionPattern(toolName, toolInput, pattern) {
74
+ const match = pattern.match(/^([\w:.*]+)(?:\((.+)\))?$/);
75
+ if (!match) return false;
76
+ const [, patTool, patArg] = match;
77
+
78
+ // Tool name match (support wildcards)
79
+ if (!globMatch(toolName, patTool)) return false;
80
+
81
+ // Argument pattern (optional)
82
+ if (patArg) {
83
+ const inputStr = JSON.stringify(toolInput || '');
84
+ if (!inputStr.includes(patArg.replace(/\*\*/g, '').replace(/\*/g, ''))) return false;
85
+ }
86
+
87
+ return true;
88
+ }
89
+
90
+ /**
91
+ * Simple glob matcher (* = any chars except /, ** = any chars including /).
92
+ * @param {string} str
93
+ * @param {string} pattern
94
+ * @returns {boolean}
95
+ */
96
+ function globMatch(str, pattern) {
97
+ if (pattern === '*' || pattern === '**') return true;
98
+ const regexStr = pattern
99
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
100
+ .replace(/\*\*/g, '__DOUBLESTAR__')
101
+ .replace(/\*/g, '[^/]*')
102
+ .replace(/__DOUBLESTAR__/g, '.*');
103
+ return new RegExp(`^${regexStr}$`).test(str);
104
+ }
105
+
106
+
107
+ /**
108
+ * Build a structured error object for task failures.
109
+ * @param {{ code: string, message: string, iteration?: number, tool?: string, tokensUsed?: number, elapsed?: number }} opts
110
+ * @returns {string} JSON string
111
+ */
112
+ function buildStructuredError({ code, message, iteration, tool, tokensUsed, elapsed }) {
113
+ return JSON.stringify({ code, message, ...(iteration !== undefined && { iteration }), ...(tool && { tool }), ...(tokensUsed !== undefined && { tokensUsed }), ...(elapsed !== undefined && { elapsed }) });
114
+ }
115
+
116
+ /**
117
+ * Emit an event on the WebSocket bus.
118
+ * @param {{ type: string, taskId?: string, sessionId?: string, agentName: string, event: Object }} opts
119
+ */
120
+ function emitBusEvent({ type, taskId, sessionId, agentName, event }) {
121
+ try {
122
+ eventBus.emit('event', { type, ...(taskId && { taskId }), ...(sessionId && { sessionId }), agentName, event: { ...event, timestamp: Date.now() } });
123
+ } catch { /* bus errors must never crash the loop */ }
124
+ }
125
+
126
+ /**
127
+ * Core agentic loop — async generator.
128
+ * Yields progress events. Runs identically for all modes.
129
+ *
130
+ * @param {{ agent: Object, messages: Object[], settings: Object, mode: string, taskId?: string, sessionId: string, cwd: string, modeConfig: Object, cancelSignal?: AbortSignal, tokenBudget?: number, thinking?: Object, onStreamChunk?: (text: string) => void }} opts
131
+ */
132
+ async function* runLoop({ agent, messages, settings, mode, taskId, sessionId, cwd, modeConfig, cancelSignal, tokenBudget, thinking, onStreamChunk, onInferenceToolStart }) {
133
+ runningSessions.add(sessionId);
134
+ try {
135
+ const modelConfig = getModelConfig(settings, F.MODEL_MAIN);
136
+ const maxIterations = modeConfig.maxIterations || F.DEFAULT_MAX_ITERATIONS;
137
+ const maxDurationSeconds = modeConfig.maxDurationSeconds || F.DEFAULT_MAX_DURATION_SECONDS;
138
+ const startTime = Date.now();
139
+
140
+ let iteration = 0;
141
+ let stallCount = 0;
142
+ let lastToolCallIteration = -1;
143
+ let totalTokens = { input: 0, output: 0, cache: 0, cost: 0 };
144
+ const modelKey = agent.model || modelConfig[F.MODEL_NAME];
145
+ let consecutiveLlmErrors = 0;
146
+ const MAX_CONSECUTIVE_LLM_ERRORS = 3;
147
+
148
+ // Build tools list for LLM
149
+ let llmTools = buildToolsForLLM({ agentConfig: agent, cwd, modeConfig });
150
+ const customSummaries = listCustomToolSummaries({ agentConfig: agent, cwd });
151
+
152
+ // Append custom tool summaries to system prompt context (Tier 2)
153
+ if (customSummaries.length > 0) {
154
+ const customList = customSummaries.map(t => `- **${t.name}**: ${t.description}`).join('\n');
155
+ const lastSystem = messages.findIndex(m => m.role === 'system');
156
+ if (lastSystem >= 0) {
157
+ messages[lastSystem].content += `\n\n## Additional Available Tools\n\nUse \`tool_search\` to find tools by name or description and inspect their full schemas.\nUse \`tool_activate\` to make a specific tool callable before using it.\n\n${customList}`;
158
+ }
159
+ }
160
+
161
+ let pendingCorrelationIds = [];
162
+
163
+ while (true) {
164
+ iteration++;
165
+
166
+ // ── Cancellation check ───────────────────────────────────────────────────
167
+ if (cancelSignal?.aborted) {
168
+ yield { type: 'status.change', from: 'processing', to: 'canceled', reason: 'user_cancel' };
169
+ if (taskId) {
170
+ updateTask(taskId, { status: 'canceled', finishedAt: new Date().toISOString() });
171
+ addTaskEvent({ taskId, type: 'status.change', data: { from: 'processing', to: 'canceled', reason: 'user_cancel' } });
172
+ emitBusEvent({ type: 'task.status', taskId, agentName: agent.name, event: { status: 'canceled', reason: 'user_cancel' } });
173
+ }
174
+ return;
175
+ }
176
+
177
+ // ── Limits check ────────────────────────────────────────────────────────
178
+ if (iteration > maxIterations) {
179
+ const errStr = buildStructuredError({ code: 'MAX_ITERATIONS', message: `Max iterations (${maxIterations}) reached`, iteration, tokensUsed: totalTokens.input + totalTokens.output });
180
+ yield { type: 'limit.reached', reason: 'maxIterations', iteration };
181
+ if (modeConfig.onExhausted === 'wait') {
182
+ yield { type: 'status.change', from: 'processing', to: 'waiting', reason: 'maxIterations exhausted' };
183
+ if (taskId) updateTask(taskId, { status: 'waiting' });
184
+ return;
185
+ }
186
+ if (taskId) {
187
+ updateTask(taskId, { status: 'failed', error: errStr });
188
+ addTaskEvent({ taskId, type: 'status.change', data: { from: 'processing', to: 'failed', reason: 'maxIterations' } });
189
+ emitBusEvent({ type: 'task.status', taskId, agentName: agent.name, event: { status: 'failed', error: JSON.parse(errStr) } });
190
+ }
191
+ return;
192
+ }
193
+
194
+ const elapsed = (Date.now() - startTime) / 1000;
195
+ if (elapsed > maxDurationSeconds) {
196
+ const errStr = buildStructuredError({ code: 'MAX_DURATION', message: `Max duration (${maxDurationSeconds}s) exceeded`, iteration, elapsed, tokensUsed: totalTokens.input + totalTokens.output });
197
+ yield { type: 'limit.reached', reason: 'maxDurationSeconds', elapsed };
198
+ if (modeConfig.onExhausted === 'wait') {
199
+ yield { type: 'status.change', from: 'processing', to: 'waiting', reason: 'maxDuration exhausted' };
200
+ if (taskId) updateTask(taskId, { status: 'waiting' });
201
+ return;
202
+ }
203
+ if (taskId) {
204
+ updateTask(taskId, { status: 'failed', error: errStr });
205
+ addTaskEvent({ taskId, type: 'status.change', data: { from: 'processing', to: 'failed', reason: 'maxDuration' } });
206
+ emitBusEvent({ type: 'task.status', taskId, agentName: agent.name, event: { status: 'failed', error: JSON.parse(errStr) } });
207
+ }
208
+ return;
209
+ }
210
+
211
+ // ── Inject reminders ─────────────────────────────────────────────────────
212
+ if (iteration > 1 && iteration % ANTI_DRIFT_EVERY === 0) {
213
+ const taskBrief = messages.find(m => m.role === 'user')?.content?.slice(0, 200) || 'current task';
214
+ const reminder = buildReminder({ type: 'anti-drift', vars: { TASK_BRIEF: taskBrief } });
215
+ if (reminder) messages.push({ role: 'user', content: reminder });
216
+ }
217
+
218
+ if (stallCount >= STALL_THRESHOLD) {
219
+ const reminder = buildReminder({ type: 'stall-recovery', vars: { STALL_COUNT: stallCount } });
220
+ if (reminder) messages.push({ role: 'user', content: reminder });
221
+ stallCount = 0;
222
+ }
223
+
224
+ // ── Drain non-followup messages ──────────────────────────────────────────
225
+ const { messages: nonFollowupMsgs, correlationIds: drainedIds } = drainNonFollowup(agent.name, sessionId);
226
+ messages.push(...nonFollowupMsgs);
227
+ if (drainedIds.length > 0) pendingCorrelationIds.push(...drainedIds);
228
+
229
+ // ── Context compaction ──────────────────────────────────────────────────
230
+ const taskBrief = messages.find(m => m.role === 'user')?.content?.slice(0, 500) || '';
231
+ const { messages: managedMessages, compacted } = await manageContext({
232
+ messages,
233
+ settings,
234
+ taskBrief,
235
+ cwd,
236
+ agentName: agent.name,
237
+ });
238
+ if (compacted) {
239
+ messages.length = 0;
240
+ messages.push(...managedMessages);
241
+ yield { type: 'context.compacted', iteration, messageCount: messages.length };
242
+ if (taskId) addTaskEvent({ taskId, type: 'context.compacted', data: { iteration, messageCount: messages.length } });
243
+ }
244
+
245
+ yield { type: 'iteration.start', iteration, tokensSoFar: totalTokens.input + totalTokens.output };
246
+ if (taskId) addTaskEvent({ taskId, type: 'iteration.start', data: { iteration, tokensSoFar: totalTokens.input + totalTokens.output } });
247
+ emitBusEvent({ type: taskId ? 'task.event' : 'chat.event', taskId, sessionId, agentName: agent.name, event: { type: 'iteration.start', iteration, tokensSoFar: totalTokens.input + totalTokens.output } });
248
+
249
+ // ── Context snapshot (for debuggability) ─────────────────────────────────
250
+ if (taskId) {
251
+ try {
252
+ saveTaskContext(taskId, {
253
+ messages: messages.map(m => ({
254
+ role: m.role,
255
+ content: m.content ? String(m.content).slice(0, 2000) : null,
256
+ tool_calls: m.tool_calls || undefined,
257
+ tool_call_id: m.tool_call_id || undefined,
258
+ })),
259
+ tools: llmTools.map(t => t.function.name),
260
+ iteration,
261
+ });
262
+ } catch { /* snapshot failure must not crash the loop */ }
263
+ }
264
+
265
+ // ── LLM call ─────────────────────────────────────────────────────────────
266
+ let response;
267
+ try {
268
+ response = await callLLM({
269
+ baseUrl: modelConfig[F.MODEL_BASE_URL],
270
+ apiKey: modelConfig[F.MODEL_API_KEY],
271
+ model: agent.model || modelConfig[F.MODEL_NAME],
272
+ messages,
273
+ tools: llmTools,
274
+ temperature: agent.temperature,
275
+ reasoning: agent.reasoning,
276
+ maxTokens: agent.maxTokens,
277
+ thinking: thinking || agent.thinking || null,
278
+ onChunk: onStreamChunk || null,
279
+ onToolStart: onInferenceToolStart || null,
280
+ });
281
+ } catch (err) {
282
+ consecutiveLlmErrors++;
283
+ yield { type: 'llm.error', error: err.message, iteration, attempt: consecutiveLlmErrors };
284
+ emitBusEvent({ type: taskId ? 'task.event' : 'chat.event', taskId, sessionId, agentName: agent.name, event: { type: 'llm.error', error: err.message, iteration, attempt: consecutiveLlmErrors } });
285
+ if (consecutiveLlmErrors <= MAX_CONSECUTIVE_LLM_ERRORS) {
286
+ await new Promise(r => setTimeout(r, 1000 * consecutiveLlmErrors));
287
+ continue;
288
+ }
289
+ const errStr = buildStructuredError({ code: 'LLM_ERROR', message: `LLM error after ${consecutiveLlmErrors} attempts: ${err.message}`, iteration, tokensUsed: totalTokens.input + totalTokens.output });
290
+ if (taskId) {
291
+ updateTask(taskId, { status: 'failed', error: errStr });
292
+ emitBusEvent({ type: 'task.status', taskId, agentName: agent.name, event: { status: 'failed', error: JSON.parse(errStr) } });
293
+ }
294
+ return;
295
+ }
296
+
297
+ consecutiveLlmErrors = 0; // reset on successful LLM call
298
+
299
+ const { content, toolCalls, finishReason } = extractMessage(response);
300
+ const usage = extractUsage(response);
301
+ totalTokens.input += usage.input;
302
+ totalTokens.output += usage.output;
303
+ totalTokens.cache += usage.cache;
304
+ const turnCost = usage.cost || calculateCost(modelKey, { input: usage.input, output: usage.output, cache: usage.cache });
305
+ totalTokens.cost += turnCost;
306
+
307
+ // Save assistant message
308
+ const assistantMsg = { role: 'assistant', content: content || null };
309
+ if (toolCalls) assistantMsg.tool_calls = toolCalls;
310
+ messages.push(assistantMsg);
311
+
312
+ // Reply to any pending correlated agent_message senders
313
+ if (content && pendingCorrelationIds.length > 0) {
314
+ for (const cid of pendingCorrelationIds) postCorrelatedResponse(cid, content);
315
+ pendingCorrelationIds = [];
316
+ }
317
+
318
+ // Persist assistant message and capture DB id for message event
319
+ const assistantCreatedAt = new Date().toISOString();
320
+ let assistantMsgId = null;
321
+ if (sessionId) {
322
+ assistantMsgId = addMessage({ sessionId, role: 'assistant', content: content || null, toolCalls,
323
+ modelKey, inputTokens: usage.input, outputTokens: usage.output, cacheTokens: usage.cache, cost: turnCost });
324
+ updateSession(sessionId, {
325
+ totalInputTokens: totalTokens.input, totalOutputTokens: totalTokens.output,
326
+ totalCacheTokens: totalTokens.cache, cost: totalTokens.cost,
327
+ contextSize: usage.input + usage.output,
328
+ });
329
+ }
330
+ if (taskId) {
331
+ updateTask(taskId, { tokenInput: totalTokens.input, tokenOutput: totalTokens.output, tokenCache: totalTokens.cache, iterations: iteration });
332
+ }
333
+
334
+ // Emit session-aligned message event (all modes, all iterations with content or tool calls)
335
+ if (content || toolCalls) yield {
336
+ type: 'message',
337
+ message: {
338
+ id: assistantMsgId,
339
+ session_id: sessionId || null,
340
+ role: 'assistant',
341
+ content: content || null,
342
+ tool_calls: toolCalls || null,
343
+ tool_call_id: null,
344
+ created_at: assistantCreatedAt,
345
+ },
346
+ finishReason,
347
+ iteration,
348
+ tokenUsage: { input: usage.input, output: usage.output, cache: usage.cache, cost: turnCost },
349
+ };
350
+
351
+ // ── Token budget check ───────────────────────────────────────────────────
352
+ const effectiveBudget = tokenBudget || modeConfig.tokenBudget;
353
+ if (effectiveBudget && (totalTokens.input + totalTokens.output) >= effectiveBudget) {
354
+ const tokensUsed = totalTokens.input + totalTokens.output;
355
+ const errStr = buildStructuredError({ code: 'BUDGET_EXCEEDED', message: `Token budget exhausted: ${tokensUsed} / ${effectiveBudget}`, iteration, tokensUsed, elapsed: (Date.now() - startTime) / 1000 });
356
+ yield { type: 'limit.reached', reason: 'tokenBudget', tokensUsed, tokenBudget: effectiveBudget };
357
+ if (taskId) {
358
+ updateTask(taskId, { status: 'failed', error: errStr, finishedAt: new Date().toISOString() });
359
+ addTaskEvent({ taskId, type: 'status.change', data: { from: 'processing', to: 'failed', reason: 'tokenBudget', tokensUsed, tokenBudget: effectiveBudget } });
360
+ emitBusEvent({ type: 'task.status', taskId, agentName: agent.name, event: { status: 'failed', error: JSON.parse(errStr) } });
361
+ }
362
+ return;
363
+ }
364
+
365
+ // ── Tool calls ───────────────────────────────────────────────────────────
366
+ if (toolCalls && toolCalls.length > 0) {
367
+ lastToolCallIteration = iteration;
368
+ stallCount = 0;
369
+
370
+ for (const tc of toolCalls) {
371
+ const toolName = tc.function.name;
372
+ let toolInput;
373
+ try {
374
+ toolInput = JSON.parse(tc.function.arguments || '{}');
375
+ } catch {
376
+ toolInput = {};
377
+ }
378
+
379
+ yield { type: 'tool.start', toolName, toolInput };
380
+ if (taskId) addTaskEvent({ taskId, type: 'tool.start', data: { toolName, toolInput } });
381
+ emitBusEvent({ type: taskId ? 'task.event' : 'chat.tool', taskId, sessionId, agentName: agent.name, event: { type: 'tool.start', toolName, toolInput } });
382
+
383
+ const toolStartTime = Date.now();
384
+
385
+ // Mid-tool cancellation check
386
+ if (cancelSignal?.aborted) {
387
+ yield { type: 'status.change', from: 'processing', to: 'canceled', reason: 'user_cancel' };
388
+ if (taskId) {
389
+ updateTask(taskId, { status: 'canceled', finishedAt: new Date().toISOString() });
390
+ addTaskEvent({ taskId, type: 'status.change', data: { from: 'processing', to: 'canceled', reason: 'user_cancel' } });
391
+ emitBusEvent({ type: 'task.status', taskId, agentName: agent.name, event: { status: 'canceled', reason: 'user_cancel' } });
392
+ }
393
+ return;
394
+ }
395
+
396
+ // Permission check
397
+ const permission = checkPermission({ toolName, toolInput, mode, settings, agentConfig: agent, modeConfig });
398
+ if (permission === 'deny') {
399
+ const toolResult = { role: 'tool', tool_call_id: tc.id, content: `Permission denied for tool "${toolName}"` };
400
+ messages.push(toolResult);
401
+ yield { type: 'tool.end', toolName, toolInput, durationMs: 0, success: false, output: 'Permission denied', outputPreview: 'Permission denied' };
402
+ if (taskId) addTaskEvent({ taskId, type: 'tool.end', data: { toolName, durationMs: 0, success: false, outputPreview: 'Permission denied' } });
403
+ emitBusEvent({ type: taskId ? 'task.event' : 'chat.tool', taskId, sessionId, agentName: agent.name, event: { type: 'tool.end', toolName, durationMs: 0, success: false, outputPreview: 'Permission denied' } });
404
+ continue;
405
+ }
406
+
407
+ // In chat mode with 'ask', we'll treat as allow for now (interactive approval TBD in v2)
408
+
409
+ // Validate input
410
+ const { valid, errors } = validateToolInput(toolName, toolInput);
411
+ if (!valid) {
412
+ const errMsg = `Invalid tool input for "${toolName}": ${errors.join('; ')}`;
413
+ messages.push({ role: 'tool', tool_call_id: tc.id, content: errMsg });
414
+ yield { type: 'tool.end', toolName, toolInput, durationMs: 0, success: false, output: errMsg, outputPreview: errMsg };
415
+ continue;
416
+ }
417
+
418
+ // Resolve and execute
419
+ const tool = resolveTool({ name: toolName, agentConfig: agent, cwd });
420
+ let toolOutput;
421
+ let toolSuccess = true;
422
+
423
+ if (!tool) {
424
+ toolOutput = `Tool "${toolName}" not found`;
425
+ toolSuccess = false;
426
+ } else {
427
+ try {
428
+ // Run PreToolUse hook if configured
429
+ await runHook({ hookType: 'PreToolUse', toolName, toolInput, settings, agent, sessionId, taskId, mode, cwd });
430
+
431
+ const _remoteMethodExecution = ({ method, data, timeoutMs } = {}) =>
432
+ remoteMethodQueue.enqueue({ method, data, timeoutMs });
433
+
434
+ const result = await Promise.race([
435
+ tool.execute({ ...toolInput, _cwd: cwd, _agent: agent, _sessionId: sessionId, _taskId: taskId, _settings: settings, _remoteMethodExecution }),
436
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`Tool timeout after ${tool.schema.timeout || 30}s`)), (tool.schema.timeout || 30) * 1000))
437
+ ]);
438
+
439
+ toolOutput = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
440
+
441
+ // Run PostToolUse hook if configured
442
+ await runHook({ hookType: 'PostToolUse', toolName, toolInput, toolOutput, settings, agent, sessionId, taskId, mode, cwd });
443
+ } catch (err) {
444
+ toolOutput = `Tool error: ${err.message}`;
445
+ toolSuccess = false;
446
+ }
447
+ }
448
+
449
+ const durationMs = Date.now() - toolStartTime;
450
+ const toolContent = String(toolOutput);
451
+ const outputPreview = toolContent.slice(0, 200);
452
+ const toolCreatedAt = new Date().toISOString();
453
+
454
+ messages.push({ role: 'tool', tool_call_id: tc.id, content: toolContent });
455
+ let toolMsgId = null;
456
+ if (sessionId) toolMsgId = addMessage({ sessionId, role: 'tool', toolCallId: tc.id, content: toolContent });
457
+
458
+ yield {
459
+ type: 'message',
460
+ message: {
461
+ id: toolMsgId,
462
+ session_id: sessionId || null,
463
+ role: 'tool',
464
+ content: toolContent,
465
+ tool_calls: null,
466
+ tool_call_id: tc.id,
467
+ created_at: toolCreatedAt,
468
+ },
469
+ };
470
+ yield { type: 'tool.end', toolName, toolInput, durationMs, success: toolSuccess, output: toolContent, outputPreview };
471
+ if (taskId) addTaskEvent({ taskId, type: 'tool.end', data: { toolName, durationMs, success: toolSuccess, outputPreview } });
472
+ emitBusEvent({ type: taskId ? 'task.event' : 'chat.tool', taskId, sessionId, agentName: agent.name, event: { type: 'tool.end', toolName, durationMs, success: toolSuccess, outputPreview } });
473
+
474
+ // Explicit activation: inject the requested custom tool into llmTools for the next LLM call
475
+ if (toolName === 'tool_activate' && toolSuccess) {
476
+ const requestedName = toolInput.name;
477
+ if (requestedName && !llmTools.find(lt => lt.function.name === requestedName)) {
478
+ const oaiFmt = activateToolByName(requestedName, agent, cwd, modeConfig);
479
+ if (oaiFmt) llmTools.push(oaiFmt);
480
+ }
481
+ }
482
+
483
+ // Drain non-followup messages after each tool
484
+ const { messages: toolNonFollowupMsgs, correlationIds: toolDrainedIds } = drainNonFollowup(agent.name, sessionId);
485
+ messages.push(...toolNonFollowupMsgs);
486
+ if (toolDrainedIds.length > 0) pendingCorrelationIds.push(...toolDrainedIds);
487
+ }
488
+
489
+ // Continue loop (send tool results back to LLM)
490
+ continue;
491
+ }
492
+
493
+ // ── No tool calls — agent produced a text response ────────────────────
494
+ stallCount++;
495
+
496
+ // Drain followup messages
497
+ const followupMsgs = drainFollowup(agent.name, sessionId);
498
+ messages.push(...followupMsgs);
499
+
500
+ // Mode-specific completion logic
501
+ if (mode === 'chat') {
502
+ // Chat mode: return response, stop
503
+ yield { type: 'chat.response', content, sessionId, totalTokens: { ...totalTokens }, model: modelKey, iteration };
504
+ return;
505
+ }
506
+
507
+ if (mode === 'task' || mode === 'subagent') {
508
+ // Task done if agent produced a final answer (finish_reason = stop with no tools)
509
+ if (finishReason === 'stop') {
510
+ yield { type: 'task.complete', content, sessionId, taskId };
511
+ if (taskId) {
512
+ updateTask(taskId, { status: 'finished', output: content, finishedAt: new Date().toISOString() });
513
+ addTaskEvent({ taskId, type: 'status.change', data: { from: 'processing', to: 'finished' } });
514
+ emitBusEvent({ type: 'task.status', taskId, agentName: agent.name, event: { status: 'finished', output: content ? content.slice(0, 500) : null } });
515
+ }
516
+ return;
517
+ }
518
+ }
519
+
520
+ if (mode === 'daemon') {
521
+ // Daemon done after one response
522
+ yield { type: 'daemon.tick.complete', content };
523
+ return;
524
+ }
525
+
526
+ // If we reach here with no tool calls and no return, continue (handles edge cases)
527
+ }
528
+ } finally {
529
+ runningSessions.delete(sessionId);
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Run a PreToolUse or PostToolUse hook.
535
+ * @param {{ hookType: string, toolName: string, toolInput: Object, toolOutput?: string, settings: Object, agent: Object, sessionId?: string, taskId?: string, mode: string, cwd: string }} opts
536
+ */
537
+ async function runHook({ hookType, toolName, toolInput, toolOutput, settings, agent, sessionId, taskId, mode, cwd }) {
538
+ const hookCmd = settings.hooks?.[hookType];
539
+ if (!hookCmd) return;
540
+
541
+ const env = {
542
+ ...process.env,
543
+ VEIL_TOOL_NAME: toolName,
544
+ VEIL_TOOL_INPUT: JSON.stringify(toolInput),
545
+ VEIL_AGENT_NAME: agent.name,
546
+ VEIL_SESSION_ID: sessionId || '',
547
+ VEIL_TASK_ID: taskId || '',
548
+ VEIL_MODE: mode,
549
+ VEIL_AGENT_FOLDER: agent.agentFolder || '',
550
+ };
551
+ if (toolOutput !== undefined) env.VEIL_TOOL_OUTPUT = JSON.stringify(toolOutput);
552
+
553
+ try {
554
+ const { execSync } = require('child_process');
555
+ execSync(hookCmd, { cwd, env, timeout: 10000, stdio: 'ignore' });
556
+ } catch (err) {
557
+ if (hookType === 'PreToolUse' && err.status === 1) {
558
+ throw new Error(`PreToolUse hook blocked tool execution`);
559
+ }
560
+ // PostToolUse failures are logged but don't affect tool result
561
+ }
562
+ }
563
+
564
+ module.exports = { runLoop, checkPermission };
package/core/memory.js ADDED
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const paths = require('../utils/paths');
6
+
7
+ const MAX_LINES_DEFAULT = 200;
8
+
9
+ /**
10
+ * Append content to memory, then check line cap and export if needed.
11
+ * @param {{ cwd: string, agentName: string, scope?: 'agent'|'global', content: string, maxLines?: number }} opts
12
+ */
13
+ function writeMemory({ cwd, agentName, scope = 'agent', content, maxLines = MAX_LINES_DEFAULT }) {
14
+ const dir = scope === 'global'
15
+ ? paths.getProjectMemoryDir(cwd)
16
+ : paths.getAgentMemoryDir(cwd, agentName);
17
+
18
+ fs.mkdirSync(dir, { recursive: true });
19
+
20
+ const filePath = path.join(dir, 'MEMORY.md');
21
+ const timestamp = new Date().toISOString().slice(0, 10);
22
+ const entry = `\n\n<!-- ${timestamp} -->\n${content.trim()}`;
23
+
24
+ fs.appendFileSync(filePath, entry, 'utf8');
25
+
26
+ // Check line cap — if exceeded, export oldest content to topic files
27
+ const lines = fs.readFileSync(filePath, 'utf8').split('\n');
28
+ if (lines.length > maxLines) {
29
+ exportOldMemory({ filePath, dir, lines, maxLines });
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Export overflow memory lines to a dated archive file.
35
+ * @param {{ filePath: string, dir: string, lines: string[], maxLines: number }} opts
36
+ */
37
+ function exportOldMemory({ filePath, dir, lines, maxLines }) {
38
+ const keepFrom = lines.length - maxLines;
39
+ const overflow = lines.slice(0, keepFrom).join('\n');
40
+ const keep = lines.slice(keepFrom).join('\n');
41
+
42
+ const date = new Date().toISOString().slice(0, 7); // YYYY-MM
43
+ const archivePath = path.join(dir, `archive-${date}.md`);
44
+
45
+ if (overflow.trim()) {
46
+ fs.appendFileSync(archivePath, overflow + '\n', 'utf8');
47
+ }
48
+ fs.writeFileSync(filePath, keep, 'utf8');
49
+ }
50
+
51
+ module.exports = { writeMemory };