@backendkit-labs/agent-core 0.20.2 → 0.22.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 (38) hide show
  1. package/README.md +3 -0
  2. package/dist/delegation/DelegationBus.d.ts +15 -7
  3. package/dist/delegation/DelegationBus.d.ts.map +1 -1
  4. package/dist/delegation/DelegationBus.js +33 -12
  5. package/dist/delegation/DelegationBus.js.map +1 -1
  6. package/dist/engine/AgentEngine.d.ts +9 -45
  7. package/dist/engine/AgentEngine.d.ts.map +1 -1
  8. package/dist/engine/AgentEngine.js +125 -431
  9. package/dist/engine/AgentEngine.js.map +1 -1
  10. package/dist/engine/ApprovalGate.d.ts +33 -0
  11. package/dist/engine/ApprovalGate.d.ts.map +1 -0
  12. package/dist/engine/ApprovalGate.js +76 -0
  13. package/dist/engine/ApprovalGate.js.map +1 -0
  14. package/dist/engine/AuditLogger.d.ts +14 -0
  15. package/dist/engine/AuditLogger.d.ts.map +1 -0
  16. package/dist/engine/AuditLogger.js +34 -0
  17. package/dist/engine/AuditLogger.js.map +1 -0
  18. package/dist/engine/HistoryManager.d.ts +42 -0
  19. package/dist/engine/HistoryManager.d.ts.map +1 -0
  20. package/dist/engine/HistoryManager.js +156 -0
  21. package/dist/engine/HistoryManager.js.map +1 -0
  22. package/dist/engine/MCPRegistrar.d.ts +45 -0
  23. package/dist/engine/MCPRegistrar.d.ts.map +1 -0
  24. package/dist/engine/MCPRegistrar.js +217 -0
  25. package/dist/engine/MCPRegistrar.js.map +1 -0
  26. package/dist/engine/qa-orchestrator.js +2 -2
  27. package/dist/engine/qa-orchestrator.js.map +1 -1
  28. package/dist/mcp/MCPClientManager.d.ts +15 -0
  29. package/dist/mcp/MCPClientManager.d.ts.map +1 -1
  30. package/dist/mcp/MCPClientManager.js +62 -8
  31. package/dist/mcp/MCPClientManager.js.map +1 -1
  32. package/dist/qa/qa-service.d.ts.map +1 -1
  33. package/dist/qa/qa-service.js +43 -34
  34. package/dist/qa/qa-service.js.map +1 -1
  35. package/dist/reflection/lessons-memo-generator.d.ts +1 -1
  36. package/dist/reflection/lessons-memo-generator.js +64 -64
  37. package/dist/reflection/lessons-memo-generator.js.map +1 -1
  38. 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.
@@ -249,6 +190,21 @@ class AgentEngine {
249
190
  return this.opts.providers.resolve(profile.provider, this.opts.defaultProvider);
250
191
  }
251
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) {
252
208
  if (this.pendingContextLoad)
253
209
  await this.pendingContextLoad;
254
210
  this.aborted = false;
@@ -262,27 +218,27 @@ class AgentEngine {
262
218
  this.opts.transport.emit({ type: 'done' });
263
219
  return;
264
220
  }
265
- await this.ensureMCPInitialized();
221
+ await this.mcpRegistrar.ensureInitialized();
266
222
  await this.ensureAgentsLoaded();
267
223
  // Direct MCP routing: bypass general when input matches a configured trigger
268
- const mcpRoute = this.findMCPRoute(input);
224
+ const mcpRoute = this.mcpRegistrar.findRoute(input);
269
225
  if (mcpRoute) {
270
226
  if (mcpRoute.tool) {
271
- // 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.
272
229
  const toolName = `mcp__${mcpRoute.agentId}__${mcpRoute.tool}`;
273
230
  const toolDef = this.opts.tools.get(toolName);
274
231
  if (toolDef) {
275
232
  const required = toolDef.parameters.required;
276
233
  const firstParam = required?.[0] ?? 'input';
277
- const ctx = this.buildContext();
278
- const profile = this.opts.agents.get(mcpRoute.agentId);
234
+ const mcpProfile = this.opts.agents.get(mcpRoute.agentId) ?? this.getCurrentProfile();
279
235
  this.opts.transport.emit({
280
236
  type: 'block_start',
281
237
  agent_id: mcpRoute.agentId,
282
- agent_name: profile?.name ?? mcpRoute.agentId,
283
- agent_icon: profile?.icon ?? '◎',
238
+ agent_name: mcpProfile.name ?? mcpRoute.agentId,
239
+ agent_icon: mcpProfile.icon ?? '◎',
284
240
  });
285
- const result = await toolDef.execute({ [firstParam]: input }, ctx);
241
+ const result = await this.executeToolCall({ name: toolName, args: JSON.stringify({ [firstParam]: input }) }, mcpProfile);
286
242
  this.opts.transport.emit({ type: 'token', content: result, agent_id: mcpRoute.agentId });
287
243
  this.opts.transport.emit({ type: 'block_end', status: 'ok', agent_id: mcpRoute.agentId });
288
244
  this.opts.transport.emit({ type: 'done' });
@@ -320,7 +276,7 @@ class AgentEngine {
320
276
  if (this.opts.orchestrator) {
321
277
  orchestrationResult = await this.opts.orchestrator.orchestrate(input).catch(() => undefined);
322
278
  }
323
- this.messages.push({ role: 'user', content: input });
279
+ this.history.push({ role: 'user', content: input });
324
280
  await this.runLoop(profile, orchestrationResult);
325
281
  this.saveHistory();
326
282
  this.opts.transport.emit({ type: 'done' });
@@ -328,12 +284,12 @@ class AgentEngine {
328
284
  async runWorkflow(defOrBuilder, opts = {}) {
329
285
  if (this.pendingContextLoad)
330
286
  await this.pendingContextLoad;
331
- await this.ensureMCPInitialized();
287
+ await this.mcpRegistrar.ensureInitialized();
332
288
  await this.ensureAgentsLoaded();
333
289
  const def = 'build' in defOrBuilder ? defOrBuilder.build() : defOrBuilder;
334
290
  const runner = new WorkflowRunner_1.WorkflowRunner(def, {
335
291
  ...opts,
336
- runAgent: (agentId, input, context) => this.runAgentInternal(agentId, input, context),
292
+ runAgent: (agentId, input, context) => this.runAgentInternal(agentId, input, context, 0),
337
293
  transport: this.opts.transport,
338
294
  });
339
295
  return runner.run();
@@ -353,59 +309,39 @@ class AgentEngine {
353
309
  }
354
310
  /** Delete persisted history file and reset in-memory conversation. */
355
311
  clearHistory() {
356
- this.messages = [];
357
- if (!this.opts.historyPath)
358
- return;
359
- try {
360
- (0, atomic_write_1.atomicWriteSync)(this.opts.historyPath, '[]');
361
- }
362
- catch { /* non-fatal */ }
312
+ this.history.clear();
363
313
  }
364
- /** Serialize current conversation to disk (called automatically after each run). */
365
314
  saveHistory() {
366
- if (!this.opts.historyPath)
367
- return;
368
- const cap = this.opts.maxContextMessages ?? 100;
369
- const toSave = this.messages.filter(m => m.role !== 'system').slice(-cap);
370
- try {
371
- (0, fs_1.mkdirSync)((0, path_1.dirname)(this.opts.historyPath), { recursive: true });
372
- (0, atomic_write_1.atomicWriteSync)(this.opts.historyPath, JSON.stringify(toSave, null, 2));
373
- }
374
- catch { /* non-fatal — history loss is preferable to a crash */ }
315
+ this.history.save();
375
316
  }
376
- /**
377
- * Trim conversation history to maxContextMessages by dropping oldest messages
378
- * at a user-turn boundary. Preserves all assistant↔tool pairs intact.
379
- */
380
317
  trimHistory() {
381
318
  const max = this.opts.maxContextMessages;
382
- if (!max || this.messages.length <= max)
319
+ if (!max)
383
320
  return;
384
- // Walk forward from the target offset to find the next 'user' message.
385
- // Cutting there avoids orphaning assistant tool_calls / tool results.
386
- const target = this.messages.length - max;
387
- let cutAt = target;
388
- while (cutAt < this.messages.length && this.messages[cutAt].role !== 'user') {
389
- cutAt++;
390
- }
391
- if (cutAt > 0 && cutAt < this.messages.length) {
392
- this.messages = this.messages.slice(cutAt);
321
+ const dropped = this.history.trim(max);
322
+ if (dropped > 0) {
393
323
  this.opts.transport.emit({
394
324
  type: 'system',
395
325
  level: 'warn',
396
- text: `[ContextWindow] Dropped ${cutAt} messages — history capped at ${max} (maxContextMessages)`,
326
+ text: `[ContextWindow] Dropped ${dropped} messages — history capped at ${max} (maxContextMessages)`,
397
327
  });
398
328
  }
399
329
  }
400
330
  async ensureAgentsLoaded() {
401
331
  if (this.agentsLoaded || !this.opts.loadAgents)
402
332
  return;
403
- 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() {
404
341
  try {
405
342
  const discovered = await this.opts.loadAgents();
406
343
  for (const agent of discovered)
407
344
  this.opts.agents.upsert(agent);
408
- // If the current default agent still isn't registered, fall back to first available
409
345
  if (!this.opts.agents.has(this.currentAgentId)) {
410
346
  const all = this.opts.agents.getAll();
411
347
  if (all.length > 0)
@@ -418,175 +354,10 @@ class AgentEngine {
418
354
  text: `[AgentDiscovery] Failed to load agents: ${err.message}`,
419
355
  });
420
356
  }
421
- }
422
- async ensureMCPInitialized() {
423
- if (!this.opts.mcpManager)
424
- return;
425
- try {
426
- if (!this.mcpInitialized) {
427
- await this.opts.mcpManager.initialize();
428
- this.mcpInitialized = true;
429
- }
430
- else {
431
- // Subsequent runs: retry only servers that are still offline — bounded by their own counter
432
- const offline = this.opts.mcpManager.getConfiguredNames()
433
- .filter(n => !this.mcpRegisteredServers.has(n));
434
- if (offline.length > 0 && this.mcpInitAttempts < 3) {
435
- this.mcpInitAttempts++;
436
- await this.opts.mcpManager.reconnectFailed();
437
- }
438
- }
439
- }
440
- catch (err) {
441
- this.opts.transport.emit({
442
- type: 'system', level: 'warn',
443
- text: `MCP initialization error: ${err.message}`,
444
- });
445
- }
446
- // Always register any server that is now connected but not yet registered as agent.
447
- // This runs regardless of the retry counter so late-starting servers get picked up.
448
- for (const { name, toolCount } of this.opts.mcpManager.getServerInfo()) {
449
- if (toolCount > 0 && !this.mcpRegisteredServers.has(name)) {
450
- const tools = this.opts.mcpManager.getServerTools(name);
451
- const config = this.opts.mcpManager.getConfig(name);
452
- this.registerMCPAgent(config, tools);
453
- this.mcpRegisteredServers.add(name);
454
- }
455
- }
456
- // Warn about servers still offline (only while retries remain)
457
- const stillOffline = this.opts.mcpManager.getConfiguredNames()
458
- .filter(n => !this.mcpRegisteredServers.has(n));
459
- if (stillOffline.length > 0) {
460
- const retriesLeft = 3 - this.mcpInitAttempts;
461
- const suffix = retriesLeft > 0
462
- ? `Retrying on next message (${retriesLeft} attempts left).`
463
- : `No more automatic retries — call engine.getMCPManager()?.reconnectFailed() to retry manually.`;
464
- this.opts.transport.emit({
465
- type: 'system', level: 'warn',
466
- text: `MCP agents offline: ${stillOffline.join(', ')} — start their servers to enable them. ${suffix}`,
467
- });
468
- }
469
- }
470
- registerMCPAgent(config, tools) {
471
- // safeName mirrors the sanitization done by MCPClientManager.wrapTool() so that
472
- // tool lookup `mcp__${safeName}__${tool}` always resolves correctly.
473
- const safeName = config.name.replace(/[^a-zA-Z0-9_-]/g, '_');
474
- // Register tools in the ToolRegistry — skip already-registered ones
475
- // (safe for reconnection scenarios; mcpRegisteredServers normally prevents duplicates)
476
- for (const tool of tools) {
477
- if (!this.opts.tools.has(tool.name)) {
478
- this.opts.tools.register(tool);
479
- }
480
- }
481
- // toolTriggers work in both modes — direct keyword → tool execution, zero LLM hops
482
- if (config.toolTriggers) {
483
- for (const [keyword, tool] of Object.entries(config.toolTriggers)) {
484
- this.mcpToolTriggers.set(keyword.toLowerCase(), { agentId: safeName, tool });
485
- }
486
- }
487
- const mode = config.mode ?? 'tools';
488
- if (mode === 'tools') {
489
- this.registerMCPToolPack(config, tools, safeName);
490
- }
491
- else {
492
- this.registerMCPAgentWrapper(config, tools, safeName);
357
+ finally {
358
+ this.agentsLoaded = true;
493
359
  }
494
360
  }
495
- /**
496
- * mode='tools' (default): assigns MCP tools directly to local agent allowedTools.
497
- * Also applies blocksCommands to those agents while the server is connected —
498
- * forcing them to use the MCP tools instead of equivalent shell commands.
499
- * Both tool assignments and command blocks are tracked so they can be
500
- * fully reverted when the server disconnects via unregisterMCPServer().
501
- */
502
- registerMCPToolPack(config, tools, safeName) {
503
- const toolNames = tools.map(t => t.name);
504
- const blocksCommands = config.blocksCommands ?? [];
505
- // Resolve target agents: explicit assignTo list, or all registered agents
506
- const assignTo = config.assignTo?.filter(id => id !== '*') ?? [];
507
- const targets = assignTo.length > 0
508
- ? assignTo
509
- : this.opts.agents.getAll().map(a => a.id);
510
- const blockRecord = [];
511
- for (const agentId of targets) {
512
- const agent = this.opts.agents.get(agentId);
513
- if (!agent) {
514
- this.opts.transport.emit({
515
- type: 'system', level: 'warn',
516
- text: `[MCP:${config.name}] assignTo agent "${agentId}" not found — skipped.`,
517
- });
518
- continue;
519
- }
520
- this.opts.agents.upsert({
521
- ...agent,
522
- allowedTools: [...new Set([...agent.allowedTools, ...toolNames])],
523
- blockedCommands: [...new Set([...(agent.blockedCommands ?? []), ...blocksCommands])],
524
- });
525
- blockRecord.push({ agentId, toolNames, blockedCmds: blocksCommands });
526
- }
527
- this.mcpAgentBlocks.set(safeName, blockRecord);
528
- if (config.triggers?.length) {
529
- this.opts.transport.emit({
530
- type: 'system', level: 'warn',
531
- text: `[MCP:${config.name}] 'triggers' is ignored in mode='tools'. Use 'toolTriggers' for direct keyword routing.`,
532
- });
533
- }
534
- const shortNames = tools.map(t => t.name.split('__')[2] ?? t.name).join(', ');
535
- const agentList = targets.join(', ') || 'all agents';
536
- const blockedLabel = blocksCommands.length ? ` blocked: [${blocksCommands.join(', ')}]` : '';
537
- this.opts.transport.emit({
538
- type: 'system', level: 'info',
539
- text: `[MCP:${config.name}] tools [${shortNames}] assigned to: ${agentList}${blockedLabel}`,
540
- });
541
- }
542
- /**
543
- * mode='agent': wraps the MCP server as a delegatable agent in the roster.
544
- * Use only when the remote service has its own reasoning layer or its output
545
- * is so large it needs a dedicated summarization turn.
546
- */
547
- registerMCPAgentWrapper(config, tools, safeName) {
548
- const toolList = tools
549
- .map(t => {
550
- const shortName = t.name.split('__')[2] ?? t.name;
551
- const desc = t.description.replace(/^\[MCP:[^\]]+\]\s*/, '').slice(0, 80);
552
- return `- ${shortName}: ${desc}`;
553
- })
554
- .join('\n');
555
- const enrichedDesc = tools
556
- .map(t => {
557
- const shortName = t.name.split('__')[2] ?? t.name;
558
- const desc = t.description.replace(/^\[MCP:[^\]]+\]\s*/, '').slice(0, 70);
559
- return `${shortName}: ${desc}`;
560
- })
561
- .join(' | ');
562
- this.opts.agents.upsert({
563
- id: safeName,
564
- name: config.agentName ?? toTitleCase(config.name),
565
- icon: config.icon ?? '◎',
566
- description: config.agentDescription ?? `External specialist — ${enrichedDesc}`,
567
- systemPrompt: `You are a specialized remote agent: ${config.name}.\n` +
568
- `Use ONLY your available tools to answer the request. ` +
569
- `Do NOT use run_command, read_file, or other local tools.\n\n` +
570
- `Your tools:\n${toolList}`,
571
- allowedTools: tools.map(t => t.name),
572
- source: 'mcp',
573
- });
574
- if (config.triggers?.length) {
575
- this.mcpTriggers.set(safeName, config.triggers);
576
- }
577
- }
578
- findMCPRoute(input) {
579
- const lower = input.toLowerCase();
580
- for (const [keyword, route] of this.mcpToolTriggers) {
581
- if (lower.includes(keyword))
582
- return route;
583
- }
584
- for (const [agentId, triggers] of this.mcpTriggers) {
585
- if (triggers.some(t => lower.includes(t.toLowerCase())))
586
- return { agentId };
587
- }
588
- return undefined;
589
- }
590
361
  switchAgent(agentId) {
591
362
  if (!this.opts.agents.has(agentId))
592
363
  throw new Error(`Agent "${agentId}" not found`);
@@ -701,7 +472,7 @@ class AgentEngine {
701
472
  if (explicit)
702
473
  parts.push(explicit);
703
474
  if (trimmed)
704
- parts.push(`## Análisis del orquestador\n${trimmed}`);
475
+ parts.push(`## Orchestrator Analysis\n${trimmed}`);
705
476
  return parts.join('\n\n');
706
477
  }
707
478
  async runLoop(profile, orchestrationResult) {
@@ -721,7 +492,7 @@ class AgentEngine {
721
492
  this.trimHistory();
722
493
  const systemMsg = { role: 'system', content: this.buildSystemPrompt(currentProfile, orchestrationResult) };
723
494
  const tools = this.getToolsForAgent(currentProfile);
724
- const safeHistory = sanitizeMessages(this.messages);
495
+ const safeHistory = this.history.sanitized();
725
496
  let streamBuffer = '';
726
497
  const askAgentCalls = [];
727
498
  const otherCalls = [];
@@ -764,7 +535,7 @@ class AgentEngine {
764
535
  break;
765
536
  if (!finalMessage)
766
537
  break;
767
- this.messages.push(finalMessage);
538
+ this.history.push(finalMessage);
768
539
  const toolResults = [];
769
540
  // ── ask_agent: parallel if 2+ calls ──────────────────────────────
770
541
  if (askAgentCalls.length > 0) {
@@ -780,6 +551,7 @@ class AgentEngine {
780
551
  agentId: c.agentId,
781
552
  question: c.question,
782
553
  context: this.buildSubAgentContext(c.context, streamBuffer),
554
+ fromAgentId: currentProfile.id,
783
555
  }));
784
556
  const results = validCalls.length >= 2
785
557
  ? await this.bus.runParallel(requests)
@@ -798,7 +570,7 @@ class AgentEngine {
798
570
  if (this.aborted)
799
571
  break;
800
572
  iterManager.recordToolCall();
801
- const result = await this.executeToolCall(call, currentProfile);
573
+ const result = await this.executeToolCall(call, currentProfile, 0);
802
574
  toolResults.push({ role: 'tool', tool_call_id: call.id, content: result });
803
575
  }
804
576
  // ── QA orchestration: runs when response is final (no tool calls) ─
@@ -810,7 +582,7 @@ class AgentEngine {
810
582
  orchestrator: this.opts.orchestrator,
811
583
  qaService: this.opts.qaService,
812
584
  allAgents: this.opts.agents.getAll(),
813
- messages: this.messages,
585
+ messages: this.history.getAll(),
814
586
  effectiveAgentId: currentProfile.id,
815
587
  defaultAgentId: this.opts.defaultAgentId,
816
588
  qaAgentId,
@@ -838,11 +610,11 @@ class AgentEngine {
838
610
  }
839
611
  if (toolResults.length === 0)
840
612
  break;
841
- this.messages.push(...toolResults);
613
+ this.history.pushAll(toolResults);
842
614
  }
843
615
  this.opts.transport.emit({ type: 'block_end', status: 'ok', agent_id: profile.id });
844
616
  }
845
- async runAgentInternal(agentId, question, context) {
617
+ async runAgentInternal(agentId, question, context, depth = 0, outputBuffer) {
846
618
  const profile = this.opts.agents.get(agentId);
847
619
  if (!profile)
848
620
  throw new Error(`Agent "${agentId}" not registered`);
@@ -852,28 +624,38 @@ class AgentEngine {
852
624
  // without requiring the orchestrator to manually copy it into context.
853
625
  let historySlice = [];
854
626
  const maxCtxMsgs = this.opts.subAgentContextMessages;
855
- if (maxCtxMsgs && maxCtxMsgs > 0 && this.messages.length > 0) {
856
- historySlice = this.messages.slice(-maxCtxMsgs);
627
+ const allMessages = this.history.getAll();
628
+ if (maxCtxMsgs && maxCtxMsgs > 0 && allMessages.length > 0) {
629
+ historySlice = allMessages.slice(-maxCtxMsgs);
857
630
  // Trim to the first user-turn boundary to avoid orphaning tool results.
858
631
  const firstUser = historySlice.findIndex(m => m.role === 'user');
859
632
  if (firstUser > 0)
860
633
  historySlice = historySlice.slice(firstUser);
861
634
  else if (firstUser < 0)
862
635
  historySlice = [];
863
- // Sanitize: drop any tool messages whose assistant(tool_calls) was cut off
864
- historySlice = sanitizeMessages(historySlice);
636
+ historySlice = (0, HistoryManager_1.sanitizeMessages)(historySlice);
865
637
  }
866
638
  const messages = [
867
639
  { role: 'system', content: this.buildSystemPrompt(profile) },
868
640
  ...historySlice,
869
- { role: 'user', content: context ? `${question}\n\nContexto:\n${context}` : question },
641
+ { role: 'user', content: context ? `${question}\n\nContext:\n${context}` : question },
870
642
  ];
871
643
  let finalContent = '';
872
644
  let totalInputTokens = 0;
873
645
  let totalOutputTokens = 0;
874
- const maxIter = 30;
646
+ const maxIter = this.opts.maxIterations ?? 100;
875
647
  let iter = 0;
876
- 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 });
877
659
  while (iter < maxIter) {
878
660
  iter++;
879
661
  let streamContent = '';
@@ -883,12 +665,12 @@ class AgentEngine {
883
665
  onChunk: (delta) => {
884
666
  streamContent += delta;
885
667
  if (!profile.suppressDefaultOutput) {
886
- this.opts.transport.emit({ type: 'token', content: delta, agent_id: profile.id });
668
+ emit({ type: 'token', content: delta, agent_id: profile.id });
887
669
  }
888
670
  },
889
671
  onToolCall: (name, args, id) => {
890
672
  if (!profile.suppressDefaultOutput) {
891
- 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) });
892
674
  }
893
675
  toolCalls.push({ id, name, args });
894
676
  },
@@ -897,7 +679,7 @@ class AgentEngine {
897
679
  onMetrics: (inputTokens, outputTokens) => {
898
680
  totalInputTokens += inputTokens;
899
681
  totalOutputTokens += outputTokens;
900
- this.opts.transport.emit({ type: 'metrics', input_tokens: inputTokens, output_tokens: outputTokens });
682
+ emit({ type: 'metrics', input_tokens: inputTokens, output_tokens: outputTokens });
901
683
  },
902
684
  model: profile.model,
903
685
  });
@@ -910,12 +692,12 @@ class AgentEngine {
910
692
  break;
911
693
  const toolResults = [];
912
694
  for (const call of toolCalls) {
913
- const result = await this.executeToolCall(call, profile);
695
+ const result = await this.executeToolCall(call, profile, depth, emit);
914
696
  toolResults.push({ role: 'tool', tool_call_id: call.id, content: result });
915
697
  }
916
698
  messages.push(...toolResults);
917
699
  }
918
- this.opts.transport.emit({ type: 'block_end', status: 'ok', agent_id: profile.id });
700
+ emit({ type: 'block_end', status: 'ok', agent_id: profile.id });
919
701
  return { agentId, content: finalContent, inputTokens: totalInputTokens, outputTokens: totalOutputTokens, elapsedMs: Date.now() - start };
920
702
  }
921
703
  /**
@@ -923,87 +705,47 @@ class AgentEngine {
923
705
  * runAgentInternal (sub-agents via ask_agent).
924
706
  * Handles: allowedTools authorization → onToolApproval gate → execute → audit → transport.
925
707
  */
926
- async executeToolCall(call, profile) {
708
+ async executeToolCall(call, profile, depth = 0, emit) {
709
+ const emitEvent = emit ?? ((e) => this.opts.transport.emit(e));
927
710
  const callStart = Date.now();
928
711
  const toolDef = this.opts.tools.get(call.name);
929
712
  let result;
713
+ let success;
930
714
  if (!toolDef) {
931
715
  result = `Tool "${call.name}" not found`;
716
+ success = false;
932
717
  }
933
718
  else if (!profile.allowedTools.includes(call.name) &&
934
719
  (profile.allowedTools.length > 0 || (profile.delegatesTo && profile.delegatesTo.length > 0))) {
935
720
  result = `Tool "${call.name}" is not authorized for agent "${profile.id}". ` +
936
721
  `Use ask_agent to delegate to a specialist.`;
937
- }
938
- else if (this.opts.onToolApproval && this.requiresAlwaysAsk(call)) {
939
- // High-blast-radius operations (npm install, pip install, etc.) always
940
- // require approval — cannot be bypassed by approve_all or auto mode.
941
- const preview = call.args.slice(0, 200);
942
- this.opts.transport.emit({
943
- type: 'tool_approval_request',
944
- tool_name: call.name,
945
- agent_id: profile.id,
946
- args_preview: preview,
947
- });
948
- const decision = await this.opts.onToolApproval(call.name, profile.id, preview);
949
- result = decision === 'reject'
950
- ? `Tool call "${call.name}" was rejected by the user.`
951
- : await this.runToolDef(call, toolDef, profile.id);
952
- }
953
- else if (!this.approvalDisabled &&
954
- this.opts.onToolApproval &&
955
- AgentEngine.WRITE_TOOLS.has(call.name)) {
956
- const preview = call.args.slice(0, 200);
957
- this.opts.transport.emit({
958
- type: 'tool_approval_request',
959
- tool_name: call.name,
960
- agent_id: profile.id,
961
- args_preview: preview,
962
- });
963
- const decision = await this.opts.onToolApproval(call.name, profile.id, preview);
964
- if (decision === 'approve_all')
965
- this.approvalDisabled = true;
966
- result = decision === 'reject'
967
- ? `Tool call "${call.name}" was rejected by the user.`
968
- : await this.runToolDef(call, toolDef, profile.id);
722
+ success = false;
969
723
  }
970
724
  else {
971
- 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
+ }
972
733
  }
973
- const success = !result.startsWith('Error:');
974
- 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 });
975
735
  // Strip ANSI before slicing to avoid partial escape sequences in the preview
976
736
  // eslint-disable-next-line no-control-regex
977
737
  const cleanPreview = result.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
978
- 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) });
979
739
  return result;
980
740
  }
981
- async runToolDef(call, toolDef, agentId) {
982
- const ctx = this.buildContext(agentId);
741
+ async runToolDef(call, toolDef, agentId, depth = 0) {
742
+ const ctx = this.buildContext(agentId, depth);
983
743
  try {
984
744
  const args = JSON.parse(call.args);
985
- return await toolDef.execute(args, ctx);
745
+ return { result: await toolDef.execute(args, ctx), success: true };
986
746
  }
987
747
  catch (err) {
988
- return `Error: ${err instanceof Error ? err.message : String(err)}`;
989
- }
990
- }
991
- /**
992
- * Returns true when a tool call matches a high-blast-radius pattern that
993
- * always requires explicit approval — cannot be bypassed by approve_all or
994
- * auto iteration mode.
995
- */
996
- requiresAlwaysAsk(call) {
997
- if (call.name !== 'run_command')
998
- return false;
999
- try {
1000
- const { command } = JSON.parse(call.args);
1001
- if (!command)
1002
- return false;
1003
- return AgentEngine.ALWAYS_ASK_PATTERNS.some(re => re.test(command));
1004
- }
1005
- catch {
1006
- return false;
748
+ return { result: `Error: ${err instanceof Error ? err.message : String(err)}`, success: false };
1007
749
  }
1008
750
  }
1009
751
  /**
@@ -1016,56 +758,13 @@ class AgentEngine {
1016
758
  * For mode='tools' servers, agents fall back to shell commands automatically.
1017
759
  */
1018
760
  unregisterMCPServer(serverName) {
1019
- const safeName = serverName.replace(/[^a-zA-Z0-9_-]/g, '_');
1020
- // Revert tool assignments and command blocks on affected agents
1021
- const records = this.mcpAgentBlocks.get(safeName) ?? [];
1022
- for (const { agentId, toolNames, blockedCmds } of records) {
1023
- const agent = this.opts.agents.get(agentId);
1024
- if (agent) {
1025
- this.opts.agents.upsert({
1026
- ...agent,
1027
- allowedTools: agent.allowedTools.filter(t => !toolNames.includes(t)),
1028
- blockedCommands: (agent.blockedCommands ?? []).filter(c => !blockedCmds.includes(c)),
1029
- });
1030
- }
1031
- for (const toolName of toolNames) {
1032
- this.opts.tools.unregister(toolName);
1033
- }
1034
- }
1035
- this.mcpAgentBlocks.delete(safeName);
1036
- // Remove wrapper agent if mode='agent'
1037
- this.opts.agents.delete(safeName);
1038
- // Clean up routing tables
1039
- this.mcpTriggers.delete(safeName);
1040
- for (const [keyword, route] of this.mcpToolTriggers) {
1041
- if (route.agentId === safeName)
1042
- this.mcpToolTriggers.delete(keyword);
1043
- }
1044
- this.mcpRegisteredServers.delete(serverName);
1045
- this.opts.transport.emit({
1046
- type: 'system', level: 'info',
1047
- text: `[MCP:${serverName}] unregistered — tools removed, commands unblocked.`,
1048
- });
761
+ this.mcpRegistrar.unregisterServer(serverName);
1049
762
  }
1050
763
  /** Disable approval gate for the rest of this session (approve_all). */
1051
- disableToolApproval() { this.approvalDisabled = true; }
764
+ disableToolApproval() { this.approvalGate.disable(); }
1052
765
  /** Re-enable approval gate (e.g. after /iteration manual). */
1053
- enableToolApproval() { this.approvalDisabled = false; }
1054
- writeAuditRecord(agentId, tool, inputPreview, success, durationMs) {
1055
- if (!this.opts.auditLog)
1056
- return;
1057
- const line = JSON.stringify({
1058
- ts: new Date().toISOString(),
1059
- session: this.opts.sessionId ?? 'default',
1060
- agent: agentId,
1061
- tool,
1062
- input: inputPreview.slice(0, 200),
1063
- success,
1064
- durationMs,
1065
- });
1066
- (0, fs_1.appendFileSync)(this.opts.auditLog, line + '\n');
1067
- }
1068
- buildContext(agentId) {
766
+ enableToolApproval() { this.approvalGate.enable(); }
767
+ buildContext(agentId, depth = 0) {
1069
768
  const id = agentId ?? this.currentAgentId;
1070
769
  const profile = this.opts.agents.get(id);
1071
770
  const storeRoot = this.opts.store?.projectDir;
@@ -1083,14 +782,14 @@ class AgentEngine {
1083
782
  ...(additionalRoots.length ? { additionalRoots } : {}),
1084
783
  ...(profile?.blockedCommands?.length ? { blockedCommands: profile.blockedCommands } : {}),
1085
784
  askAgent: async (agentId, question, context) => {
1086
- const result = await this.bus.runSingle({ agentId, question, context });
785
+ const result = await this.bus.runSingle({ agentId, question, context, fromAgentId: id, depth: depth + 1 });
1087
786
  return result.content;
1088
787
  },
1089
788
  emitCompacting: (phase, label) => {
1090
789
  this.opts.transport.emit({ type: 'compacting', phase, label });
1091
790
  },
1092
791
  llmCall: async (prompt) => {
1093
- const profile = this.opts.agents.get(this.currentAgentId);
792
+ const profile = this.opts.agents.get(id);
1094
793
  if (!profile)
1095
794
  return '';
1096
795
  const provider = this.getProviderForProfile(profile);
@@ -1107,9 +806,4 @@ class AgentEngine {
1107
806
  }
1108
807
  }
1109
808
  exports.AgentEngine = AgentEngine;
1110
- function toTitleCase(s) {
1111
- return s
1112
- .replace(/[-_](.)/g, (_, c) => ' ' + c.toUpperCase())
1113
- .replace(/^(.)/, (c) => c.toUpperCase());
1114
- }
1115
809
  //# sourceMappingURL=AgentEngine.js.map