@backendkit-labs/agent-core 0.20.1 → 0.21.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 (43) hide show
  1. package/README.md +3 -0
  2. package/dist/commands/SlashCommandRegistry.d.ts +7 -2
  3. package/dist/commands/SlashCommandRegistry.d.ts.map +1 -1
  4. package/dist/commands/SlashCommandRegistry.js.map +1 -1
  5. package/dist/commands/builtin.js +4 -4
  6. package/dist/commands/builtin.js.map +1 -1
  7. package/dist/delegation/DelegationBus.d.ts +15 -7
  8. package/dist/delegation/DelegationBus.d.ts.map +1 -1
  9. package/dist/delegation/DelegationBus.js +33 -12
  10. package/dist/delegation/DelegationBus.js.map +1 -1
  11. package/dist/engine/AgentEngine.d.ts +9 -45
  12. package/dist/engine/AgentEngine.d.ts.map +1 -1
  13. package/dist/engine/AgentEngine.js +130 -432
  14. package/dist/engine/AgentEngine.js.map +1 -1
  15. package/dist/engine/ApprovalGate.d.ts +33 -0
  16. package/dist/engine/ApprovalGate.d.ts.map +1 -0
  17. package/dist/engine/ApprovalGate.js +76 -0
  18. package/dist/engine/ApprovalGate.js.map +1 -0
  19. package/dist/engine/AuditLogger.d.ts +14 -0
  20. package/dist/engine/AuditLogger.d.ts.map +1 -0
  21. package/dist/engine/AuditLogger.js +34 -0
  22. package/dist/engine/AuditLogger.js.map +1 -0
  23. package/dist/engine/HistoryManager.d.ts +42 -0
  24. package/dist/engine/HistoryManager.d.ts.map +1 -0
  25. package/dist/engine/HistoryManager.js +156 -0
  26. package/dist/engine/HistoryManager.js.map +1 -0
  27. package/dist/engine/MCPRegistrar.d.ts +44 -0
  28. package/dist/engine/MCPRegistrar.d.ts.map +1 -0
  29. package/dist/engine/MCPRegistrar.js +209 -0
  30. package/dist/engine/MCPRegistrar.js.map +1 -0
  31. package/dist/engine/qa-orchestrator.js +2 -2
  32. package/dist/engine/qa-orchestrator.js.map +1 -1
  33. package/dist/mcp/MCPClientManager.d.ts +7 -0
  34. package/dist/mcp/MCPClientManager.d.ts.map +1 -1
  35. package/dist/mcp/MCPClientManager.js +38 -7
  36. package/dist/mcp/MCPClientManager.js.map +1 -1
  37. package/dist/qa/qa-service.d.ts.map +1 -1
  38. package/dist/qa/qa-service.js +43 -34
  39. package/dist/qa/qa-service.js.map +1 -1
  40. package/dist/reflection/lessons-memo-generator.d.ts +1 -1
  41. package/dist/reflection/lessons-memo-generator.js +64 -64
  42. package/dist/reflection/lessons-memo-generator.js.map +1 -1
  43. package/package.json +87 -87
@@ -1,59 +1,16 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.AgentEngine = void 0;
4
- const fs_1 = require("fs");
5
- const path_1 = require("path");
6
- const atomic_write_1 = require("../shared/utils/atomic-write");
4
+ const ApprovalGate_1 = require("./ApprovalGate");
5
+ const AuditLogger_1 = require("./AuditLogger");
6
+ const HistoryManager_1 = require("./HistoryManager");
7
+ const MCPRegistrar_1 = require("./MCPRegistrar");
7
8
  const DelegationBus_1 = require("../delegation/DelegationBus");
8
9
  const SessionMemory_1 = require("../memory/SessionMemory");
9
10
  const activator_1 = require("../skills/activator");
10
11
  const WorkflowRunner_1 = require("../workflow/WorkflowRunner");
11
12
  const IterationManager_1 = require("./IterationManager");
12
13
  const qa_orchestrator_1 = require("./qa-orchestrator");
13
- /**
14
- * Remove messages that would cause a 400 from any OpenAI-compatible provider:
15
- * - `tool` messages that are not part of an active assistant→tool chain
16
- * - `assistant` messages that declare tool_calls but have no following tool results
17
- * (only stripped when the next non-tool message is NOT another turn of the same chain)
18
- *
19
- * Walking forward: we track whether we're "inside" a tool_calls chain.
20
- * A chain starts with assistant(tool_calls) and ends once all IDs have a result.
21
- * Any `tool` result that arrives outside an open chain is dropped.
22
- */
23
- function sanitizeMessages(messages) {
24
- const out = [];
25
- // IDs still waiting for a tool result in the current chain
26
- let pendingIds = new Set();
27
- for (const msg of messages) {
28
- if (msg.role === 'assistant') {
29
- // A new assistant message closes any previous chain (even if incomplete)
30
- // and optionally opens a new one.
31
- pendingIds = new Set();
32
- if (msg.tool_calls?.length) {
33
- for (const tc of msg.tool_calls) {
34
- if (tc.id)
35
- pendingIds.add(tc.id);
36
- }
37
- }
38
- out.push(msg);
39
- }
40
- else if (msg.role === 'tool') {
41
- if (pendingIds.size === 0) {
42
- // Orphaned tool result — no open chain. Drop it.
43
- continue;
44
- }
45
- if (msg.tool_call_id)
46
- pendingIds.delete(msg.tool_call_id);
47
- out.push(msg);
48
- }
49
- else {
50
- // user / system — close any open chain
51
- pendingIds = new Set();
52
- out.push(msg);
53
- }
54
- }
55
- return out;
56
- }
57
14
  const MAX_INPUT_BYTES = 100_000; // Fix #17: 100 KB hard cap on user input
58
15
  const ASK_AGENT_TOOL = {
59
16
  name: 'ask_agent',
@@ -76,70 +33,54 @@ class AgentEngine {
76
33
  memory;
77
34
  bus;
78
35
  skillActivator = new activator_1.SkillActivator();
36
+ history;
37
+ auditLogger;
79
38
  currentAgentId;
80
- messages = [];
81
39
  aborted = false;
82
40
  abortController = null;
83
41
  allSkills;
84
42
  iterationManager;
85
43
  activeSkillAddition = '';
86
44
  yamlSkillsLoaded = false;
87
- mcpInitialized = false;
88
- mcpInitAttempts = 0;
89
- mcpRegisteredServers = new Set();
90
- mcpTriggers = new Map(); // agentId → triggers
91
- mcpToolTriggers = new Map(); // keyword → {agentId, tool}
92
- approvalDisabled = false; // set to true after 'approve_all'
45
+ mcpRegistrar;
46
+ approvalGate;
93
47
  pendingContextLoad = null;
94
- static WRITE_TOOLS = new Set(['write_file', 'edit_file', 'run_command']);
95
- /** Tracks what each MCP server blocked/assigned so it can be fully reverted on disconnect. */
96
- mcpAgentBlocks = new Map();
97
- /**
98
- * Commands that always require user approval regardless of iteration mode or
99
- * approvalDisabled flag. These are irreversible or high-blast-radius operations:
100
- * installing new packages, removing packages, running destructive npm scripts.
101
- */
102
- static ALWAYS_ASK_PATTERNS = [
103
- /^\s*npm\s+(install|i|add)\s+[^-]/i, // npm install <pkg> — new package install
104
- /^\s*npm\s+uninstall\b/i, // npm uninstall
105
- /^\s*yarn\s+add\s+[^-]/i, // yarn add <pkg>
106
- /^\s*pnpm\s+add\s+[^-]/i, // pnpm add <pkg>
107
- /^\s*pip\s+install\b/i, // pip install
108
- /^\s*pip3\s+install\b/i,
109
- ];
110
48
  agentsLoaded = false;
49
+ agentsLoadedPromise = null;
111
50
  memoryContextBlock = '';
51
+ /** Serializes concurrent run() calls — shared mutable state is not reentrant-safe. */
52
+ runMutex = Promise.resolve();
112
53
  constructor(opts) {
113
54
  this.opts = opts;
114
55
  this.memory = opts.sessionMemory ?? new SessionMemory_1.SessionMemory();
115
56
  this.currentAgentId = opts.defaultAgentId;
116
57
  this.allSkills = opts.skills ?? [];
58
+ this.history = new HistoryManager_1.HistoryManager({
59
+ historyPath: opts.historyPath,
60
+ maxContextMessages: opts.maxContextMessages,
61
+ });
62
+ this.auditLogger = new AuditLogger_1.AuditLogger(opts.auditLog);
63
+ this.mcpRegistrar = new MCPRegistrar_1.MCPRegistrar({
64
+ mcpManager: opts.mcpManager,
65
+ tools: opts.tools,
66
+ agents: opts.agents,
67
+ transport: opts.transport,
68
+ });
69
+ this.approvalGate = new ApprovalGate_1.ApprovalGate({
70
+ onToolApproval: opts.onToolApproval,
71
+ transport: opts.transport,
72
+ });
117
73
  this.iterationManager = new IterationManager_1.IterationManager({
118
74
  mode: opts.iterationMode ?? 'interactive',
119
75
  maxIterations: opts.maxIterations ?? 100,
120
76
  onLimitReached: opts.onIterationLimit,
121
77
  onStep: opts.onStep,
122
78
  });
123
- if (opts.auditLog) {
124
- try {
125
- (0, fs_1.mkdirSync)((0, path_1.dirname)(opts.auditLog), { recursive: true });
126
- }
127
- catch { }
128
- }
129
- if (opts.historyPath && (0, fs_1.existsSync)(opts.historyPath)) {
130
- try {
131
- const raw = JSON.parse((0, fs_1.readFileSync)(opts.historyPath, 'utf-8'));
132
- this.messages = raw.filter(m => m.role !== 'system');
133
- }
134
- catch { /* corrupt or missing — start fresh */ }
135
- }
136
79
  this.bus = new DelegationBus_1.DelegationBus({
137
80
  maxParallel: opts.maxParallelAgents ?? 6,
138
81
  agents: opts.agents,
139
- tools: opts.tools,
140
82
  transport: opts.transport,
141
- workingDir: opts.workingDir,
142
- runAgent: (id, question, context) => this.runAgentInternal(id, question, context),
83
+ runAgent: (id, question, context, depth, buf) => this.runAgentInternal(id, question, context, depth, buf),
143
84
  });
144
85
  }
145
86
  /** Access the observability manager when configured via createBaseEngine({ observability }). */
@@ -179,7 +120,7 @@ class AgentEngine {
179
120
  * without requiring a full engine restart.
180
121
  */
181
122
  injectContext(message) {
182
- this.messages.push({ role: 'user', content: message });
123
+ this.history.inject(message);
183
124
  }
184
125
  /**
185
126
  * Returns a partial CommandContext pre-wired to this engine instance.
@@ -193,7 +134,11 @@ class AgentEngine {
193
134
  */
194
135
  getCommandBindings() {
195
136
  return {
196
- onCwdChange: (cwd) => this.updateWorkingDir(cwd),
137
+ onCwdChange: (cwd, contextMessage) => {
138
+ this.updateWorkingDir(cwd);
139
+ if (contextMessage)
140
+ this.injectContext(contextMessage);
141
+ },
197
142
  injectContext: (message) => this.injectContext(message),
198
143
  setIterationMode: (mode) => this.setIterationMode(mode),
199
144
  getIterationMode: () => this.getIterationMode(),
@@ -245,6 +190,21 @@ class AgentEngine {
245
190
  return this.opts.providers.resolve(profile.provider, this.opts.defaultProvider);
246
191
  }
247
192
  async run(input) {
193
+ // Serialize concurrent calls — messages, currentAgentId, and other fields are
194
+ // shared mutable state that is not safe for concurrent access.
195
+ let release;
196
+ const slot = new Promise(r => { release = r; });
197
+ const prev = this.runMutex;
198
+ this.runMutex = prev.then(() => slot);
199
+ await prev;
200
+ try {
201
+ await this._run(input);
202
+ }
203
+ finally {
204
+ release();
205
+ }
206
+ }
207
+ async _run(input) {
248
208
  if (this.pendingContextLoad)
249
209
  await this.pendingContextLoad;
250
210
  this.aborted = false;
@@ -258,27 +218,27 @@ class AgentEngine {
258
218
  this.opts.transport.emit({ type: 'done' });
259
219
  return;
260
220
  }
261
- await this.ensureMCPInitialized();
221
+ await this.mcpRegistrar.ensureInitialized();
262
222
  await this.ensureAgentsLoaded();
263
223
  // Direct MCP routing: bypass general when input matches a configured trigger
264
- const mcpRoute = this.findMCPRoute(input);
224
+ const mcpRoute = this.mcpRegistrar.findRoute(input);
265
225
  if (mcpRoute) {
266
226
  if (mcpRoute.tool) {
267
- // Tool trigger: call the MCP tool directly — no intermediate LLM
227
+ // Tool trigger: call the MCP tool directly — no intermediate LLM.
228
+ // Route through executeToolCall so auth checks and onToolApproval gate apply.
268
229
  const toolName = `mcp__${mcpRoute.agentId}__${mcpRoute.tool}`;
269
230
  const toolDef = this.opts.tools.get(toolName);
270
231
  if (toolDef) {
271
232
  const required = toolDef.parameters.required;
272
233
  const firstParam = required?.[0] ?? 'input';
273
- const ctx = this.buildContext();
274
- const profile = this.opts.agents.get(mcpRoute.agentId);
234
+ const mcpProfile = this.opts.agents.get(mcpRoute.agentId) ?? this.getCurrentProfile();
275
235
  this.opts.transport.emit({
276
236
  type: 'block_start',
277
237
  agent_id: mcpRoute.agentId,
278
- agent_name: profile?.name ?? mcpRoute.agentId,
279
- agent_icon: profile?.icon ?? '◎',
238
+ agent_name: mcpProfile.name ?? mcpRoute.agentId,
239
+ agent_icon: mcpProfile.icon ?? '◎',
280
240
  });
281
- const result = await toolDef.execute({ [firstParam]: input }, ctx);
241
+ const result = await this.executeToolCall({ name: toolName, args: JSON.stringify({ [firstParam]: input }) }, mcpProfile);
282
242
  this.opts.transport.emit({ type: 'token', content: result, agent_id: mcpRoute.agentId });
283
243
  this.opts.transport.emit({ type: 'block_end', status: 'ok', agent_id: mcpRoute.agentId });
284
244
  this.opts.transport.emit({ type: 'done' });
@@ -316,7 +276,7 @@ class AgentEngine {
316
276
  if (this.opts.orchestrator) {
317
277
  orchestrationResult = await this.opts.orchestrator.orchestrate(input).catch(() => undefined);
318
278
  }
319
- this.messages.push({ role: 'user', content: input });
279
+ this.history.push({ role: 'user', content: input });
320
280
  await this.runLoop(profile, orchestrationResult);
321
281
  this.saveHistory();
322
282
  this.opts.transport.emit({ type: 'done' });
@@ -324,12 +284,12 @@ class AgentEngine {
324
284
  async runWorkflow(defOrBuilder, opts = {}) {
325
285
  if (this.pendingContextLoad)
326
286
  await this.pendingContextLoad;
327
- await this.ensureMCPInitialized();
287
+ await this.mcpRegistrar.ensureInitialized();
328
288
  await this.ensureAgentsLoaded();
329
289
  const def = 'build' in defOrBuilder ? defOrBuilder.build() : defOrBuilder;
330
290
  const runner = new WorkflowRunner_1.WorkflowRunner(def, {
331
291
  ...opts,
332
- runAgent: (agentId, input, context) => this.runAgentInternal(agentId, input, context),
292
+ runAgent: (agentId, input, context) => this.runAgentInternal(agentId, input, context, 0),
333
293
  transport: this.opts.transport,
334
294
  });
335
295
  return runner.run();
@@ -349,59 +309,39 @@ class AgentEngine {
349
309
  }
350
310
  /** Delete persisted history file and reset in-memory conversation. */
351
311
  clearHistory() {
352
- this.messages = [];
353
- if (!this.opts.historyPath)
354
- return;
355
- try {
356
- (0, atomic_write_1.atomicWriteSync)(this.opts.historyPath, '[]');
357
- }
358
- catch { /* non-fatal */ }
312
+ this.history.clear();
359
313
  }
360
- /** Serialize current conversation to disk (called automatically after each run). */
361
314
  saveHistory() {
362
- if (!this.opts.historyPath)
363
- return;
364
- const cap = this.opts.maxContextMessages ?? 100;
365
- const toSave = this.messages.filter(m => m.role !== 'system').slice(-cap);
366
- try {
367
- (0, fs_1.mkdirSync)((0, path_1.dirname)(this.opts.historyPath), { recursive: true });
368
- (0, atomic_write_1.atomicWriteSync)(this.opts.historyPath, JSON.stringify(toSave, null, 2));
369
- }
370
- catch { /* non-fatal — history loss is preferable to a crash */ }
315
+ this.history.save();
371
316
  }
372
- /**
373
- * Trim conversation history to maxContextMessages by dropping oldest messages
374
- * at a user-turn boundary. Preserves all assistant↔tool pairs intact.
375
- */
376
317
  trimHistory() {
377
318
  const max = this.opts.maxContextMessages;
378
- if (!max || this.messages.length <= max)
319
+ if (!max)
379
320
  return;
380
- // Walk forward from the target offset to find the next 'user' message.
381
- // Cutting there avoids orphaning assistant tool_calls / tool results.
382
- const target = this.messages.length - max;
383
- let cutAt = target;
384
- while (cutAt < this.messages.length && this.messages[cutAt].role !== 'user') {
385
- cutAt++;
386
- }
387
- if (cutAt > 0 && cutAt < this.messages.length) {
388
- this.messages = this.messages.slice(cutAt);
321
+ const dropped = this.history.trim(max);
322
+ if (dropped > 0) {
389
323
  this.opts.transport.emit({
390
324
  type: 'system',
391
325
  level: 'warn',
392
- text: `[ContextWindow] Dropped ${cutAt} messages — history capped at ${max} (maxContextMessages)`,
326
+ text: `[ContextWindow] Dropped ${dropped} messages — history capped at ${max} (maxContextMessages)`,
393
327
  });
394
328
  }
395
329
  }
396
330
  async ensureAgentsLoaded() {
397
331
  if (this.agentsLoaded || !this.opts.loadAgents)
398
332
  return;
399
- this.agentsLoaded = true; // set early to prevent concurrent double-loads
333
+ // Cache the promise so concurrent run() calls wait for the same load,
334
+ // rather than the early-flag pattern that let callers proceed unguarded.
335
+ if (this.agentsLoadedPromise)
336
+ return this.agentsLoadedPromise;
337
+ this.agentsLoadedPromise = this._loadAgentsOnce();
338
+ return this.agentsLoadedPromise;
339
+ }
340
+ async _loadAgentsOnce() {
400
341
  try {
401
342
  const discovered = await this.opts.loadAgents();
402
343
  for (const agent of discovered)
403
344
  this.opts.agents.upsert(agent);
404
- // If the current default agent still isn't registered, fall back to first available
405
345
  if (!this.opts.agents.has(this.currentAgentId)) {
406
346
  const all = this.opts.agents.getAll();
407
347
  if (all.length > 0)
@@ -414,175 +354,10 @@ class AgentEngine {
414
354
  text: `[AgentDiscovery] Failed to load agents: ${err.message}`,
415
355
  });
416
356
  }
417
- }
418
- async ensureMCPInitialized() {
419
- if (!this.opts.mcpManager)
420
- return;
421
- try {
422
- if (!this.mcpInitialized) {
423
- await this.opts.mcpManager.initialize();
424
- this.mcpInitialized = true;
425
- }
426
- else {
427
- // Subsequent runs: retry only servers that are still offline — bounded by their own counter
428
- const offline = this.opts.mcpManager.getConfiguredNames()
429
- .filter(n => !this.mcpRegisteredServers.has(n));
430
- if (offline.length > 0 && this.mcpInitAttempts < 3) {
431
- this.mcpInitAttempts++;
432
- await this.opts.mcpManager.reconnectFailed();
433
- }
434
- }
435
- }
436
- catch (err) {
437
- this.opts.transport.emit({
438
- type: 'system', level: 'warn',
439
- text: `MCP initialization error: ${err.message}`,
440
- });
441
- }
442
- // Always register any server that is now connected but not yet registered as agent.
443
- // This runs regardless of the retry counter so late-starting servers get picked up.
444
- for (const { name, toolCount } of this.opts.mcpManager.getServerInfo()) {
445
- if (toolCount > 0 && !this.mcpRegisteredServers.has(name)) {
446
- const tools = this.opts.mcpManager.getServerTools(name);
447
- const config = this.opts.mcpManager.getConfig(name);
448
- this.registerMCPAgent(config, tools);
449
- this.mcpRegisteredServers.add(name);
450
- }
451
- }
452
- // Warn about servers still offline (only while retries remain)
453
- const stillOffline = this.opts.mcpManager.getConfiguredNames()
454
- .filter(n => !this.mcpRegisteredServers.has(n));
455
- if (stillOffline.length > 0) {
456
- const retriesLeft = 3 - this.mcpInitAttempts;
457
- const suffix = retriesLeft > 0
458
- ? `Retrying on next message (${retriesLeft} attempts left).`
459
- : `No more automatic retries — call engine.getMCPManager()?.reconnectFailed() to retry manually.`;
460
- this.opts.transport.emit({
461
- type: 'system', level: 'warn',
462
- text: `MCP agents offline: ${stillOffline.join(', ')} — start their servers to enable them. ${suffix}`,
463
- });
357
+ finally {
358
+ this.agentsLoaded = true;
464
359
  }
465
360
  }
466
- registerMCPAgent(config, tools) {
467
- // safeName mirrors the sanitization done by MCPClientManager.wrapTool() so that
468
- // tool lookup `mcp__${safeName}__${tool}` always resolves correctly.
469
- const safeName = config.name.replace(/[^a-zA-Z0-9_-]/g, '_');
470
- // Register tools in the ToolRegistry — skip already-registered ones
471
- // (safe for reconnection scenarios; mcpRegisteredServers normally prevents duplicates)
472
- for (const tool of tools) {
473
- if (!this.opts.tools.has(tool.name)) {
474
- this.opts.tools.register(tool);
475
- }
476
- }
477
- // toolTriggers work in both modes — direct keyword → tool execution, zero LLM hops
478
- if (config.toolTriggers) {
479
- for (const [keyword, tool] of Object.entries(config.toolTriggers)) {
480
- this.mcpToolTriggers.set(keyword.toLowerCase(), { agentId: safeName, tool });
481
- }
482
- }
483
- const mode = config.mode ?? 'tools';
484
- if (mode === 'tools') {
485
- this.registerMCPToolPack(config, tools, safeName);
486
- }
487
- else {
488
- this.registerMCPAgentWrapper(config, tools, safeName);
489
- }
490
- }
491
- /**
492
- * mode='tools' (default): assigns MCP tools directly to local agent allowedTools.
493
- * Also applies blocksCommands to those agents while the server is connected —
494
- * forcing them to use the MCP tools instead of equivalent shell commands.
495
- * Both tool assignments and command blocks are tracked so they can be
496
- * fully reverted when the server disconnects via unregisterMCPServer().
497
- */
498
- registerMCPToolPack(config, tools, safeName) {
499
- const toolNames = tools.map(t => t.name);
500
- const blocksCommands = config.blocksCommands ?? [];
501
- // Resolve target agents: explicit assignTo list, or all registered agents
502
- const assignTo = config.assignTo?.filter(id => id !== '*') ?? [];
503
- const targets = assignTo.length > 0
504
- ? assignTo
505
- : this.opts.agents.getAll().map(a => a.id);
506
- const blockRecord = [];
507
- for (const agentId of targets) {
508
- const agent = this.opts.agents.get(agentId);
509
- if (!agent) {
510
- this.opts.transport.emit({
511
- type: 'system', level: 'warn',
512
- text: `[MCP:${config.name}] assignTo agent "${agentId}" not found — skipped.`,
513
- });
514
- continue;
515
- }
516
- this.opts.agents.upsert({
517
- ...agent,
518
- allowedTools: [...new Set([...agent.allowedTools, ...toolNames])],
519
- blockedCommands: [...new Set([...(agent.blockedCommands ?? []), ...blocksCommands])],
520
- });
521
- blockRecord.push({ agentId, toolNames, blockedCmds: blocksCommands });
522
- }
523
- this.mcpAgentBlocks.set(safeName, blockRecord);
524
- if (config.triggers?.length) {
525
- this.opts.transport.emit({
526
- type: 'system', level: 'warn',
527
- text: `[MCP:${config.name}] 'triggers' is ignored in mode='tools'. Use 'toolTriggers' for direct keyword routing.`,
528
- });
529
- }
530
- const shortNames = tools.map(t => t.name.split('__')[2] ?? t.name).join(', ');
531
- const agentList = targets.join(', ') || 'all agents';
532
- const blockedLabel = blocksCommands.length ? ` blocked: [${blocksCommands.join(', ')}]` : '';
533
- this.opts.transport.emit({
534
- type: 'system', level: 'info',
535
- text: `[MCP:${config.name}] tools [${shortNames}] assigned to: ${agentList}${blockedLabel}`,
536
- });
537
- }
538
- /**
539
- * mode='agent': wraps the MCP server as a delegatable agent in the roster.
540
- * Use only when the remote service has its own reasoning layer or its output
541
- * is so large it needs a dedicated summarization turn.
542
- */
543
- registerMCPAgentWrapper(config, tools, safeName) {
544
- const toolList = tools
545
- .map(t => {
546
- const shortName = t.name.split('__')[2] ?? t.name;
547
- const desc = t.description.replace(/^\[MCP:[^\]]+\]\s*/, '').slice(0, 80);
548
- return `- ${shortName}: ${desc}`;
549
- })
550
- .join('\n');
551
- const enrichedDesc = tools
552
- .map(t => {
553
- const shortName = t.name.split('__')[2] ?? t.name;
554
- const desc = t.description.replace(/^\[MCP:[^\]]+\]\s*/, '').slice(0, 70);
555
- return `${shortName}: ${desc}`;
556
- })
557
- .join(' | ');
558
- this.opts.agents.upsert({
559
- id: safeName,
560
- name: config.agentName ?? toTitleCase(config.name),
561
- icon: config.icon ?? '◎',
562
- description: config.agentDescription ?? `External specialist — ${enrichedDesc}`,
563
- systemPrompt: `You are a specialized remote agent: ${config.name}.\n` +
564
- `Use ONLY your available tools to answer the request. ` +
565
- `Do NOT use run_command, read_file, or other local tools.\n\n` +
566
- `Your tools:\n${toolList}`,
567
- allowedTools: tools.map(t => t.name),
568
- source: 'mcp',
569
- });
570
- if (config.triggers?.length) {
571
- this.mcpTriggers.set(safeName, config.triggers);
572
- }
573
- }
574
- findMCPRoute(input) {
575
- const lower = input.toLowerCase();
576
- for (const [keyword, route] of this.mcpToolTriggers) {
577
- if (lower.includes(keyword))
578
- return route;
579
- }
580
- for (const [agentId, triggers] of this.mcpTriggers) {
581
- if (triggers.some(t => lower.includes(t.toLowerCase())))
582
- return { agentId };
583
- }
584
- return undefined;
585
- }
586
361
  switchAgent(agentId) {
587
362
  if (!this.opts.agents.has(agentId))
588
363
  throw new Error(`Agent "${agentId}" not found`);
@@ -697,7 +472,7 @@ class AgentEngine {
697
472
  if (explicit)
698
473
  parts.push(explicit);
699
474
  if (trimmed)
700
- parts.push(`## Análisis del orquestador\n${trimmed}`);
475
+ parts.push(`## Orchestrator Analysis\n${trimmed}`);
701
476
  return parts.join('\n\n');
702
477
  }
703
478
  async runLoop(profile, orchestrationResult) {
@@ -717,7 +492,7 @@ class AgentEngine {
717
492
  this.trimHistory();
718
493
  const systemMsg = { role: 'system', content: this.buildSystemPrompt(currentProfile, orchestrationResult) };
719
494
  const tools = this.getToolsForAgent(currentProfile);
720
- const safeHistory = sanitizeMessages(this.messages);
495
+ const safeHistory = this.history.sanitized();
721
496
  let streamBuffer = '';
722
497
  const askAgentCalls = [];
723
498
  const otherCalls = [];
@@ -760,7 +535,7 @@ class AgentEngine {
760
535
  break;
761
536
  if (!finalMessage)
762
537
  break;
763
- this.messages.push(finalMessage);
538
+ this.history.push(finalMessage);
764
539
  const toolResults = [];
765
540
  // ── ask_agent: parallel if 2+ calls ──────────────────────────────
766
541
  if (askAgentCalls.length > 0) {
@@ -776,6 +551,7 @@ class AgentEngine {
776
551
  agentId: c.agentId,
777
552
  question: c.question,
778
553
  context: this.buildSubAgentContext(c.context, streamBuffer),
554
+ fromAgentId: currentProfile.id,
779
555
  }));
780
556
  const results = validCalls.length >= 2
781
557
  ? await this.bus.runParallel(requests)
@@ -794,7 +570,7 @@ class AgentEngine {
794
570
  if (this.aborted)
795
571
  break;
796
572
  iterManager.recordToolCall();
797
- const result = await this.executeToolCall(call, currentProfile);
573
+ const result = await this.executeToolCall(call, currentProfile, 0);
798
574
  toolResults.push({ role: 'tool', tool_call_id: call.id, content: result });
799
575
  }
800
576
  // ── QA orchestration: runs when response is final (no tool calls) ─
@@ -806,7 +582,7 @@ class AgentEngine {
806
582
  orchestrator: this.opts.orchestrator,
807
583
  qaService: this.opts.qaService,
808
584
  allAgents: this.opts.agents.getAll(),
809
- messages: this.messages,
585
+ messages: this.history.getAll(),
810
586
  effectiveAgentId: currentProfile.id,
811
587
  defaultAgentId: this.opts.defaultAgentId,
812
588
  qaAgentId,
@@ -834,11 +610,11 @@ class AgentEngine {
834
610
  }
835
611
  if (toolResults.length === 0)
836
612
  break;
837
- this.messages.push(...toolResults);
613
+ this.history.pushAll(toolResults);
838
614
  }
839
615
  this.opts.transport.emit({ type: 'block_end', status: 'ok', agent_id: profile.id });
840
616
  }
841
- async runAgentInternal(agentId, question, context) {
617
+ async runAgentInternal(agentId, question, context, depth = 0, outputBuffer) {
842
618
  const profile = this.opts.agents.get(agentId);
843
619
  if (!profile)
844
620
  throw new Error(`Agent "${agentId}" not registered`);
@@ -848,28 +624,38 @@ class AgentEngine {
848
624
  // without requiring the orchestrator to manually copy it into context.
849
625
  let historySlice = [];
850
626
  const maxCtxMsgs = this.opts.subAgentContextMessages;
851
- if (maxCtxMsgs && maxCtxMsgs > 0 && this.messages.length > 0) {
852
- historySlice = this.messages.slice(-maxCtxMsgs);
627
+ const allMessages = this.history.getAll();
628
+ if (maxCtxMsgs && maxCtxMsgs > 0 && allMessages.length > 0) {
629
+ historySlice = allMessages.slice(-maxCtxMsgs);
853
630
  // Trim to the first user-turn boundary to avoid orphaning tool results.
854
631
  const firstUser = historySlice.findIndex(m => m.role === 'user');
855
632
  if (firstUser > 0)
856
633
  historySlice = historySlice.slice(firstUser);
857
634
  else if (firstUser < 0)
858
635
  historySlice = [];
859
- // Sanitize: drop any tool messages whose assistant(tool_calls) was cut off
860
- historySlice = sanitizeMessages(historySlice);
636
+ historySlice = (0, HistoryManager_1.sanitizeMessages)(historySlice);
861
637
  }
862
638
  const messages = [
863
639
  { role: 'system', content: this.buildSystemPrompt(profile) },
864
640
  ...historySlice,
865
- { role: 'user', content: context ? `${question}\n\nContexto:\n${context}` : question },
641
+ { role: 'user', content: context ? `${question}\n\nContext:\n${context}` : question },
866
642
  ];
867
643
  let finalContent = '';
868
644
  let totalInputTokens = 0;
869
645
  let totalOutputTokens = 0;
870
- const maxIter = 30;
646
+ const maxIter = this.opts.maxIterations ?? 100;
871
647
  let iter = 0;
872
- this.opts.transport.emit({ type: 'block_start', agent_id: profile.id, agent_name: profile.name, agent_icon: profile.icon });
648
+ // When running in parallel (outputBuffer provided), route output events to the
649
+ // buffer instead of the real transport. The caller flushes the buffer atomically
650
+ // after the agent finishes, preventing interleaved output from parallel agents.
651
+ // tool_approval_request always bypasses buffering — it needs an immediate response.
652
+ const emit = (event) => {
653
+ if (outputBuffer)
654
+ outputBuffer.push(event);
655
+ else
656
+ this.opts.transport.emit(event);
657
+ };
658
+ emit({ type: 'block_start', agent_id: profile.id, agent_name: profile.name, agent_icon: profile.icon });
873
659
  while (iter < maxIter) {
874
660
  iter++;
875
661
  let streamContent = '';
@@ -879,12 +665,12 @@ class AgentEngine {
879
665
  onChunk: (delta) => {
880
666
  streamContent += delta;
881
667
  if (!profile.suppressDefaultOutput) {
882
- this.opts.transport.emit({ type: 'token', content: delta, agent_id: profile.id });
668
+ emit({ type: 'token', content: delta, agent_id: profile.id });
883
669
  }
884
670
  },
885
671
  onToolCall: (name, args, id) => {
886
672
  if (!profile.suppressDefaultOutput) {
887
- this.opts.transport.emit({ type: 'tool_call', name, args_preview: args.slice(0, 200) });
673
+ emit({ type: 'tool_call', name, args_preview: args.slice(0, 200) });
888
674
  }
889
675
  toolCalls.push({ id, name, args });
890
676
  },
@@ -893,7 +679,7 @@ class AgentEngine {
893
679
  onMetrics: (inputTokens, outputTokens) => {
894
680
  totalInputTokens += inputTokens;
895
681
  totalOutputTokens += outputTokens;
896
- this.opts.transport.emit({ type: 'metrics', input_tokens: inputTokens, output_tokens: outputTokens });
682
+ emit({ type: 'metrics', input_tokens: inputTokens, output_tokens: outputTokens });
897
683
  },
898
684
  model: profile.model,
899
685
  });
@@ -906,12 +692,12 @@ class AgentEngine {
906
692
  break;
907
693
  const toolResults = [];
908
694
  for (const call of toolCalls) {
909
- const result = await this.executeToolCall(call, profile);
695
+ const result = await this.executeToolCall(call, profile, depth, emit);
910
696
  toolResults.push({ role: 'tool', tool_call_id: call.id, content: result });
911
697
  }
912
698
  messages.push(...toolResults);
913
699
  }
914
- this.opts.transport.emit({ type: 'block_end', status: 'ok', agent_id: profile.id });
700
+ emit({ type: 'block_end', status: 'ok', agent_id: profile.id });
915
701
  return { agentId, content: finalContent, inputTokens: totalInputTokens, outputTokens: totalOutputTokens, elapsedMs: Date.now() - start };
916
702
  }
917
703
  /**
@@ -919,87 +705,47 @@ class AgentEngine {
919
705
  * runAgentInternal (sub-agents via ask_agent).
920
706
  * Handles: allowedTools authorization → onToolApproval gate → execute → audit → transport.
921
707
  */
922
- async executeToolCall(call, profile) {
708
+ async executeToolCall(call, profile, depth = 0, emit) {
709
+ const emitEvent = emit ?? ((e) => this.opts.transport.emit(e));
923
710
  const callStart = Date.now();
924
711
  const toolDef = this.opts.tools.get(call.name);
925
712
  let result;
713
+ let success;
926
714
  if (!toolDef) {
927
715
  result = `Tool "${call.name}" not found`;
716
+ success = false;
928
717
  }
929
718
  else if (!profile.allowedTools.includes(call.name) &&
930
719
  (profile.allowedTools.length > 0 || (profile.delegatesTo && profile.delegatesTo.length > 0))) {
931
720
  result = `Tool "${call.name}" is not authorized for agent "${profile.id}". ` +
932
721
  `Use ask_agent to delegate to a specialist.`;
933
- }
934
- else if (this.opts.onToolApproval && this.requiresAlwaysAsk(call)) {
935
- // High-blast-radius operations (npm install, pip install, etc.) always
936
- // require approval — cannot be bypassed by approve_all or auto mode.
937
- const preview = call.args.slice(0, 200);
938
- this.opts.transport.emit({
939
- type: 'tool_approval_request',
940
- tool_name: call.name,
941
- agent_id: profile.id,
942
- args_preview: preview,
943
- });
944
- const decision = await this.opts.onToolApproval(call.name, profile.id, preview);
945
- result = decision === 'reject'
946
- ? `Tool call "${call.name}" was rejected by the user.`
947
- : await this.runToolDef(call, toolDef, profile.id);
948
- }
949
- else if (!this.approvalDisabled &&
950
- this.opts.onToolApproval &&
951
- AgentEngine.WRITE_TOOLS.has(call.name)) {
952
- const preview = call.args.slice(0, 200);
953
- this.opts.transport.emit({
954
- type: 'tool_approval_request',
955
- tool_name: call.name,
956
- agent_id: profile.id,
957
- args_preview: preview,
958
- });
959
- const decision = await this.opts.onToolApproval(call.name, profile.id, preview);
960
- if (decision === 'approve_all')
961
- this.approvalDisabled = true;
962
- result = decision === 'reject'
963
- ? `Tool call "${call.name}" was rejected by the user.`
964
- : await this.runToolDef(call, toolDef, profile.id);
722
+ success = false;
965
723
  }
966
724
  else {
967
- result = await this.runToolDef(call, toolDef, profile.id);
725
+ const gateDecision = await this.approvalGate.check(call, profile.id);
726
+ if (gateDecision === 'rejected') {
727
+ result = `Tool call "${call.name}" was rejected by the user.`;
728
+ success = false;
729
+ }
730
+ else {
731
+ ({ result, success } = await this.runToolDef(call, toolDef, profile.id, depth));
732
+ }
968
733
  }
969
- const success = !result.startsWith('Error:');
970
- this.writeAuditRecord(profile.id, call.name, call.args, success, Date.now() - callStart);
734
+ await this.auditLogger.log({ agentId: profile.id, tool: call.name, inputPreview: call.args, success, durationMs: Date.now() - callStart, sessionId: this.opts.sessionId });
971
735
  // Strip ANSI before slicing to avoid partial escape sequences in the preview
972
736
  // eslint-disable-next-line no-control-regex
973
737
  const cleanPreview = result.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
974
- this.opts.transport.emit({ type: 'tool_result', name: call.name, success, preview: cleanPreview.slice(0, 120) });
738
+ emitEvent({ type: 'tool_result', name: call.name, success, preview: cleanPreview.slice(0, 120) });
975
739
  return result;
976
740
  }
977
- async runToolDef(call, toolDef, agentId) {
978
- const ctx = this.buildContext(agentId);
741
+ async runToolDef(call, toolDef, agentId, depth = 0) {
742
+ const ctx = this.buildContext(agentId, depth);
979
743
  try {
980
744
  const args = JSON.parse(call.args);
981
- return await toolDef.execute(args, ctx);
745
+ return { result: await toolDef.execute(args, ctx), success: true };
982
746
  }
983
747
  catch (err) {
984
- return `Error: ${err instanceof Error ? err.message : String(err)}`;
985
- }
986
- }
987
- /**
988
- * Returns true when a tool call matches a high-blast-radius pattern that
989
- * always requires explicit approval — cannot be bypassed by approve_all or
990
- * auto iteration mode.
991
- */
992
- requiresAlwaysAsk(call) {
993
- if (call.name !== 'run_command')
994
- return false;
995
- try {
996
- const { command } = JSON.parse(call.args);
997
- if (!command)
998
- return false;
999
- return AgentEngine.ALWAYS_ASK_PATTERNS.some(re => re.test(command));
1000
- }
1001
- catch {
1002
- return false;
748
+ return { result: `Error: ${err instanceof Error ? err.message : String(err)}`, success: false };
1003
749
  }
1004
750
  }
1005
751
  /**
@@ -1012,56 +758,13 @@ class AgentEngine {
1012
758
  * For mode='tools' servers, agents fall back to shell commands automatically.
1013
759
  */
1014
760
  unregisterMCPServer(serverName) {
1015
- const safeName = serverName.replace(/[^a-zA-Z0-9_-]/g, '_');
1016
- // Revert tool assignments and command blocks on affected agents
1017
- const records = this.mcpAgentBlocks.get(safeName) ?? [];
1018
- for (const { agentId, toolNames, blockedCmds } of records) {
1019
- const agent = this.opts.agents.get(agentId);
1020
- if (agent) {
1021
- this.opts.agents.upsert({
1022
- ...agent,
1023
- allowedTools: agent.allowedTools.filter(t => !toolNames.includes(t)),
1024
- blockedCommands: (agent.blockedCommands ?? []).filter(c => !blockedCmds.includes(c)),
1025
- });
1026
- }
1027
- for (const toolName of toolNames) {
1028
- this.opts.tools.unregister(toolName);
1029
- }
1030
- }
1031
- this.mcpAgentBlocks.delete(safeName);
1032
- // Remove wrapper agent if mode='agent'
1033
- this.opts.agents.delete(safeName);
1034
- // Clean up routing tables
1035
- this.mcpTriggers.delete(safeName);
1036
- for (const [keyword, route] of this.mcpToolTriggers) {
1037
- if (route.agentId === safeName)
1038
- this.mcpToolTriggers.delete(keyword);
1039
- }
1040
- this.mcpRegisteredServers.delete(serverName);
1041
- this.opts.transport.emit({
1042
- type: 'system', level: 'info',
1043
- text: `[MCP:${serverName}] unregistered — tools removed, commands unblocked.`,
1044
- });
761
+ this.mcpRegistrar.unregisterServer(serverName);
1045
762
  }
1046
763
  /** Disable approval gate for the rest of this session (approve_all). */
1047
- disableToolApproval() { this.approvalDisabled = true; }
764
+ disableToolApproval() { this.approvalGate.disable(); }
1048
765
  /** Re-enable approval gate (e.g. after /iteration manual). */
1049
- enableToolApproval() { this.approvalDisabled = false; }
1050
- writeAuditRecord(agentId, tool, inputPreview, success, durationMs) {
1051
- if (!this.opts.auditLog)
1052
- return;
1053
- const line = JSON.stringify({
1054
- ts: new Date().toISOString(),
1055
- session: this.opts.sessionId ?? 'default',
1056
- agent: agentId,
1057
- tool,
1058
- input: inputPreview.slice(0, 200),
1059
- success,
1060
- durationMs,
1061
- });
1062
- (0, fs_1.appendFileSync)(this.opts.auditLog, line + '\n');
1063
- }
1064
- buildContext(agentId) {
766
+ enableToolApproval() { this.approvalGate.enable(); }
767
+ buildContext(agentId, depth = 0) {
1065
768
  const id = agentId ?? this.currentAgentId;
1066
769
  const profile = this.opts.agents.get(id);
1067
770
  const storeRoot = this.opts.store?.projectDir;
@@ -1079,14 +782,14 @@ class AgentEngine {
1079
782
  ...(additionalRoots.length ? { additionalRoots } : {}),
1080
783
  ...(profile?.blockedCommands?.length ? { blockedCommands: profile.blockedCommands } : {}),
1081
784
  askAgent: async (agentId, question, context) => {
1082
- const result = await this.bus.runSingle({ agentId, question, context });
785
+ const result = await this.bus.runSingle({ agentId, question, context, fromAgentId: id, depth: depth + 1 });
1083
786
  return result.content;
1084
787
  },
1085
788
  emitCompacting: (phase, label) => {
1086
789
  this.opts.transport.emit({ type: 'compacting', phase, label });
1087
790
  },
1088
791
  llmCall: async (prompt) => {
1089
- const profile = this.opts.agents.get(this.currentAgentId);
792
+ const profile = this.opts.agents.get(id);
1090
793
  if (!profile)
1091
794
  return '';
1092
795
  const provider = this.getProviderForProfile(profile);
@@ -1103,9 +806,4 @@ class AgentEngine {
1103
806
  }
1104
807
  }
1105
808
  exports.AgentEngine = AgentEngine;
1106
- function toTitleCase(s) {
1107
- return s
1108
- .replace(/[-_](.)/g, (_, c) => ' ' + c.toUpperCase())
1109
- .replace(/^(.)/, (c) => c.toUpperCase());
1110
- }
1111
809
  //# sourceMappingURL=AgentEngine.js.map