@sschepis/oboto-agent 0.2.2 → 0.2.3

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.
package/dist/index.d.ts CHANGED
@@ -74,7 +74,38 @@ interface ObotoAgentConfig {
74
74
  /** as-agent Wasm runtime for slash commands and utilities */
75
75
  agentRuntime?: AgentRuntime;
76
76
  }
77
- type AgentEventType = "user_input" | "agent_thought" | "token" | "triage_result" | "tool_execution_start" | "tool_execution_complete" | "tool_round_complete" | "state_updated" | "interruption" | "error" | "cost_update" | "turn_complete" | "permission_denied" | "session_compacted" | "hook_denied" | "hook_message" | "router_event" | "slash_command";
77
+ type AgentEventType = "user_input" | "agent_thought" | "token" | "phase" | "triage_result" | "tool_execution_start" | "tool_execution_complete" | "tool_round_complete" | "state_updated" | "interruption" | "error" | "cost_update" | "turn_complete" | "permission_denied" | "session_compacted" | "hook_denied" | "hook_message" | "router_event" | "slash_command" | "doom_loop" | "heartbeat";
78
+ /** Phase identifiers matching the unified provider's phase system. */
79
+ type AgentPhase = "request" | "precheck" | "planning" | "thinking" | "tools" | "validation" | "memory" | "continuation" | "error" | "doom" | "cancel" | "complete";
80
+ /** Payload for phase events. */
81
+ interface PhaseEvent {
82
+ phase: AgentPhase;
83
+ message: string;
84
+ }
85
+ /** Payload for heartbeat events. */
86
+ interface HeartbeatEvent {
87
+ phase: AgentPhase;
88
+ elapsedMs: number;
89
+ message: string;
90
+ }
91
+ /** Payload for doom loop events. */
92
+ interface DoomLoopEvent {
93
+ reason: string;
94
+ command: string;
95
+ count: number;
96
+ redirected: boolean;
97
+ }
98
+ /** Payload for tool round completion with narrative. */
99
+ interface ToolRoundEvent {
100
+ iteration: number;
101
+ tools: Array<{
102
+ command: string;
103
+ success: boolean;
104
+ durationMs?: number;
105
+ }>;
106
+ totalToolCalls: number;
107
+ narrative: string;
108
+ }
78
109
  interface AgentEvent<T = unknown> {
79
110
  type: AgentEventType;
80
111
  payload: T;
@@ -670,6 +701,9 @@ declare class ObotoAgent {
670
701
  private usageTracker;
671
702
  private usageBridge;
672
703
  private routerEventBridge;
704
+ private heartbeatTimer;
705
+ private heartbeatStart;
706
+ private currentPhase;
673
707
  constructor(config: ObotoAgentConfig);
674
708
  /** Subscribe to agent events. Returns an unsubscribe function. */
675
709
  on(type: AgentEventType, handler: EventHandler): () => void;
@@ -731,6 +765,17 @@ declare class ObotoAgent {
731
765
  * Returns undefined if RAG is not configured.
732
766
  */
733
767
  retrieveContext(query: string): Promise<string | undefined>;
768
+ /** Emit a phase transition event with a human-readable message. */
769
+ private emitPhase;
770
+ /** Start a heartbeat that re-emits the current phase every intervalMs. */
771
+ private startHeartbeat;
772
+ /** Stop the heartbeat timer. */
773
+ private stopHeartbeat;
774
+ /**
775
+ * Build a human-readable narrative for a batch of tool executions.
776
+ * E.g. "Just read file data, and ran a command. Sending results back to AI for next steps…"
777
+ */
778
+ private buildToolRoundNarrative;
734
779
  /**
735
780
  * Record a message in the session, context manager, and optionally RAG index.
736
781
  * Centralizes message recording to ensure RAG indexing stays in sync.
@@ -1167,4 +1212,4 @@ type TriageInput = {
1167
1212
  */
1168
1213
  declare function createTriageFunction(modelName: string): LScriptFunction<TriageInput, typeof TriageSchema>;
1169
1214
 
1170
- export { AgentDynamicTools, type AgentEvent, AgentEventBus, type AgentEventType, type AgentPipelineConfig, type AgentToolTreeConfig, AgentUsageTracker, ContextManager, ConversationRAG, type ConversationRAGConfig, type DynamicToolEntry, type DynamicToolProvider, type ExecutionOutput, ExecutionSchema, HookIntegration, ObotoAgent, type ObotoAgentConfig, PermissionGuard, type PlanOutput, PlanSchema, type ProviderLike$1 as ProviderLike, type RAGRetrievalResult, RouterEventBridge, SessionCompactor, SlashCommandRegistry, type SummaryOutput, SummarySchema, type ToolExecutionEvent, type TriagePipelineOutput, TriagePipelineSchema, type TriageResult, TriageSchema, type UnifiedCostSummary, UsageBridge, asTokenUsageToLmscript, createAgentToolTree, createAnalyzeRespondPipeline, createEmptySession, createExecutionStep, createFullPipeline, createPlanStep, createRouterTool, createSummaryStep, createToolAuditMiddleware, createToolTimeoutMiddleware, createToolTimingMiddleware, createTriageFunction, createTriagePlanExecutePipeline, createTriageStep, estimateCostFromAsAgent, fromChat, isLLMRouter, lmscriptToAsTokenUsage, runAgentPipeline, sessionToHistory, toChat, toLmscriptProvider };
1215
+ export { AgentDynamicTools, type AgentEvent, AgentEventBus, type AgentEventType, type AgentPhase, type AgentPipelineConfig, type AgentToolTreeConfig, AgentUsageTracker, ContextManager, ConversationRAG, type ConversationRAGConfig, type DoomLoopEvent, type DynamicToolEntry, type DynamicToolProvider, type ExecutionOutput, ExecutionSchema, type HeartbeatEvent, HookIntegration, ObotoAgent, type ObotoAgentConfig, PermissionGuard, type PhaseEvent, type PlanOutput, PlanSchema, type ProviderLike$1 as ProviderLike, type RAGRetrievalResult, RouterEventBridge, SessionCompactor, SlashCommandRegistry, type SummaryOutput, SummarySchema, type ToolExecutionEvent, type ToolRoundEvent, type TriagePipelineOutput, TriagePipelineSchema, type TriageResult, TriageSchema, type UnifiedCostSummary, UsageBridge, asTokenUsageToLmscript, createAgentToolTree, createAnalyzeRespondPipeline, createEmptySession, createExecutionStep, createFullPipeline, createPlanStep, createRouterTool, createSummaryStep, createToolAuditMiddleware, createToolTimeoutMiddleware, createToolTimingMiddleware, createTriageFunction, createTriagePlanExecutePipeline, createTriageStep, estimateCostFromAsAgent, fromChat, isLLMRouter, lmscriptToAsTokenUsage, runAgentPipeline, sessionToHistory, toChat, toLmscriptProvider };
package/dist/index.js CHANGED
@@ -1163,6 +1163,9 @@ var ObotoAgent = class {
1163
1163
  usageTracker;
1164
1164
  usageBridge;
1165
1165
  routerEventBridge;
1166
+ heartbeatTimer = null;
1167
+ heartbeatStart = 0;
1168
+ currentPhase = "request";
1166
1169
  constructor(config) {
1167
1170
  this.config = config;
1168
1171
  this.localProvider = config.localModel;
@@ -1413,6 +1416,75 @@ var ObotoAgent = class {
1413
1416
  const { context } = await this.conversationRAG.retrieve(query);
1414
1417
  return context || void 0;
1415
1418
  }
1419
+ // ── Conversational helpers ──────────────────────────────────────────
1420
+ /** Emit a phase transition event with a human-readable message. */
1421
+ emitPhase(phase, message) {
1422
+ this.currentPhase = phase;
1423
+ this.bus.emit("phase", { phase, message });
1424
+ }
1425
+ /** Start a heartbeat that re-emits the current phase every intervalMs. */
1426
+ startHeartbeat(phase, intervalMs = 3e3) {
1427
+ this.stopHeartbeat();
1428
+ this.heartbeatStart = Date.now();
1429
+ this.currentPhase = phase;
1430
+ this.heartbeatTimer = setInterval(() => {
1431
+ const elapsed = Date.now() - this.heartbeatStart;
1432
+ const secs = Math.round(elapsed / 1e3);
1433
+ const message = phase === "thinking" ? `Waiting for AI response\u2026 (${secs}s)` : `${phase}\u2026 (${secs}s)`;
1434
+ this.bus.emit("heartbeat", { phase: this.currentPhase, elapsedMs: elapsed, message });
1435
+ }, intervalMs);
1436
+ }
1437
+ /** Stop the heartbeat timer. */
1438
+ stopHeartbeat() {
1439
+ if (this.heartbeatTimer) {
1440
+ clearInterval(this.heartbeatTimer);
1441
+ this.heartbeatTimer = null;
1442
+ }
1443
+ }
1444
+ /**
1445
+ * Build a human-readable narrative for a batch of tool executions.
1446
+ * E.g. "Just read file data, and ran a command. Sending results back to AI for next steps…"
1447
+ */
1448
+ buildToolRoundNarrative(tools) {
1449
+ if (tools.length === 0) return "No tools executed.";
1450
+ const verbs = /* @__PURE__ */ new Map();
1451
+ for (const t of tools) {
1452
+ const cmd = t.command.toLowerCase();
1453
+ let verb;
1454
+ if (cmd.includes("read") || cmd.includes("cat") || cmd.includes("get")) {
1455
+ verb = "read file data";
1456
+ } else if (cmd.includes("write") || cmd.includes("edit") || cmd.includes("patch")) {
1457
+ verb = "edited files";
1458
+ } else if (cmd.includes("search") || cmd.includes("grep") || cmd.includes("find") || cmd.includes("glob")) {
1459
+ verb = "searched for information";
1460
+ } else if (cmd.includes("run") || cmd.includes("exec") || cmd.includes("bash") || cmd.includes("shell")) {
1461
+ verb = "ran a command";
1462
+ } else if (cmd.includes("browse") || cmd.includes("web") || cmd.includes("fetch")) {
1463
+ verb = "browsed the web";
1464
+ } else {
1465
+ verb = `used ${t.command}`;
1466
+ }
1467
+ if (!verbs.has(verb)) verbs.set(verb, []);
1468
+ verbs.get(verb).push(t.command);
1469
+ }
1470
+ const parts = Array.from(verbs.keys());
1471
+ let narrative;
1472
+ if (parts.length === 1) {
1473
+ narrative = `Just ${parts[0]}.`;
1474
+ } else if (parts.length === 2) {
1475
+ narrative = `Just ${parts[0]}, and ${parts[1]}.`;
1476
+ } else {
1477
+ const last = parts.pop();
1478
+ narrative = `Just ${parts.join(", ")}, and ${last}.`;
1479
+ }
1480
+ const errors = tools.filter((t) => !t.success);
1481
+ if (errors.length > 0) {
1482
+ const errNames = errors.map((e) => e.command).join(", ");
1483
+ narrative += ` (${errors.length} error${errors.length > 1 ? "s" : ""}: ${errNames})`;
1484
+ }
1485
+ narrative += " Sending results back to AI for next steps\u2026";
1486
+ return narrative;
1487
+ }
1416
1488
  // ── Internal ───────────────────────────────────────────────────────
1417
1489
  /**
1418
1490
  * Record a message in the session, context manager, and optionally RAG index.
@@ -1438,6 +1510,7 @@ var ObotoAgent = class {
1438
1510
  }
1439
1511
  }
1440
1512
  async executionLoop(userInput) {
1513
+ this.emitPhase("request", `Processing: ${userInput.substring(0, 80)}${userInput.length > 80 ? "\u2026" : ""}`);
1441
1514
  this.bus.emit("user_input", { text: userInput });
1442
1515
  const userMsg = {
1443
1516
  role: MessageRole4.User,
@@ -1445,6 +1518,7 @@ var ObotoAgent = class {
1445
1518
  };
1446
1519
  await this.recordMessage(userMsg);
1447
1520
  this.bus.emit("state_updated", { reason: "user_input" });
1521
+ this.emitPhase("planning", "Building context and preparing tools\u2026");
1448
1522
  if (this.conversationRAG) {
1449
1523
  try {
1450
1524
  const { context } = await this.conversationRAG.retrieve(userInput);
@@ -1453,16 +1527,24 @@ var ObotoAgent = class {
1453
1527
  role: "system",
1454
1528
  content: context
1455
1529
  });
1530
+ this.bus.emit("agent_thought", {
1531
+ text: "Retrieved relevant past context via RAG.",
1532
+ model: "system"
1533
+ });
1456
1534
  }
1457
1535
  } catch (err) {
1458
1536
  console.warn("[ObotoAgent] RAG retrieval failed:", err instanceof Error ? err.message : err);
1459
1537
  }
1460
1538
  }
1539
+ this.emitPhase("precheck", "Checking if direct answer is possible\u2026");
1540
+ this.startHeartbeat("precheck");
1461
1541
  const triageResult = await this.triage(userInput);
1542
+ this.stopHeartbeat();
1462
1543
  this.bus.emit("triage_result", triageResult);
1463
1544
  if (this.interrupted) return;
1464
1545
  if (!triageResult.escalate && triageResult.directResponse) {
1465
1546
  const response = triageResult.directResponse;
1547
+ this.emitPhase("complete", "Answered directly \u2014 no tools needed.");
1466
1548
  this.bus.emit("agent_thought", { text: response, model: "local" });
1467
1549
  const assistantMsg = {
1468
1550
  role: MessageRole4.Assistant,
@@ -1474,15 +1556,17 @@ var ObotoAgent = class {
1474
1556
  return;
1475
1557
  }
1476
1558
  if (triageResult.escalate) {
1559
+ this.emitPhase("thinking", `Escalating to remote model: ${triageResult.reasoning}`);
1477
1560
  this.bus.emit("agent_thought", {
1478
1561
  text: triageResult.reasoning,
1479
1562
  model: "local",
1480
1563
  escalating: true
1481
1564
  });
1565
+ } else {
1566
+ this.emitPhase("thinking", "This requires tools and deeper reasoning \u2014 entering agent loop.");
1482
1567
  }
1483
1568
  const modelName = triageResult.escalate ? this.config.remoteModelName : this.config.localModelName;
1484
1569
  const runtime = triageResult.escalate ? this.remoteRuntime : this.localRuntime;
1485
- console.log("[ObotoAgent] Executing with model:", modelName, "| via lmscript AgentLoop");
1486
1570
  if (this.onToken) {
1487
1571
  await this.executeWithStreaming(runtime, modelName, userInput);
1488
1572
  } else {
@@ -1515,6 +1599,8 @@ var ObotoAgent = class {
1515
1599
  */
1516
1600
  async executeWithAgentLoop(runtime, modelName, userInput) {
1517
1601
  const { z: z5 } = await import("zod");
1602
+ this.emitPhase("thinking", `Turn 1/${this.maxIterations}: Analyzing request\u2026`);
1603
+ this.startHeartbeat("thinking");
1518
1604
  const agentFn = {
1519
1605
  name: "agent-task",
1520
1606
  model: modelName,
@@ -1537,44 +1623,79 @@ user: ${input}` : input;
1537
1623
  temperature: 0.7,
1538
1624
  maxRetries: 1
1539
1625
  };
1626
+ let iterationTools = [];
1627
+ let totalToolCalls = 0;
1540
1628
  const agentConfig = {
1541
1629
  maxIterations: this.maxIterations,
1542
1630
  onToolCall: (tc) => {
1543
1631
  const command = typeof tc.arguments === "object" && tc.arguments !== null ? tc.arguments.command ?? tc.name : tc.name;
1544
1632
  const kwargs = typeof tc.arguments === "object" && tc.arguments !== null ? tc.arguments.kwargs ?? {} : {};
1545
- this.bus.emit("tool_execution_complete", {
1546
- command,
1547
- kwargs,
1548
- result: typeof tc.result === "string" ? tc.result : JSON.stringify(tc.result)
1549
- });
1550
1633
  const resultStr = typeof tc.result === "string" ? tc.result : JSON.stringify(tc.result);
1634
+ const isError = resultStr.startsWith("Error:");
1635
+ this.emitPhase("tools", `Running tool: ${String(command)}`);
1636
+ this.bus.emit("tool_execution_start", { command, kwargs });
1637
+ this.bus.emit("tool_execution_complete", { command, kwargs, result: resultStr });
1638
+ iterationTools.push({ command: String(command), success: !isError });
1639
+ totalToolCalls++;
1551
1640
  this.recordToolResult(String(command), kwargs, resultStr);
1552
1641
  },
1553
1642
  onIteration: (iteration, response) => {
1554
- this.bus.emit("agent_thought", {
1555
- text: response,
1556
- model: modelName,
1557
- iteration
1558
- });
1643
+ this.stopHeartbeat();
1644
+ if (iterationTools.length > 0) {
1645
+ const narrative = this.buildToolRoundNarrative(iterationTools);
1646
+ const roundEvent = {
1647
+ iteration,
1648
+ tools: iterationTools,
1649
+ totalToolCalls,
1650
+ narrative
1651
+ };
1652
+ this.bus.emit("tool_round_complete", roundEvent);
1653
+ iterationTools = [];
1654
+ }
1655
+ if (response) {
1656
+ this.bus.emit("agent_thought", {
1657
+ text: response,
1658
+ model: modelName,
1659
+ iteration
1660
+ });
1661
+ }
1662
+ this.emitPhase("thinking", `Turn ${iteration + 1}/${this.maxIterations}: Analyzing results\u2026`);
1663
+ this.startHeartbeat("thinking");
1559
1664
  if (this.interrupted) return false;
1560
1665
  }
1561
1666
  };
1562
1667
  const agentLoop = new AgentLoop(runtime, agentConfig);
1563
- const result = await agentLoop.run(agentFn, userInput);
1564
- const responseText = result.data.response;
1565
- const assistantMsg = {
1566
- role: MessageRole4.Assistant,
1567
- blocks: [{ kind: "text", text: responseText }]
1568
- };
1569
- await this.recordMessage(assistantMsg);
1570
- this.bus.emit("state_updated", { reason: "assistant_response" });
1571
- this.bus.emit("turn_complete", {
1572
- model: modelName,
1573
- escalated: true,
1574
- iterations: result.iterations,
1575
- toolCalls: result.toolCalls.length,
1576
- usage: result.usage
1577
- });
1668
+ try {
1669
+ const result = await agentLoop.run(agentFn, userInput);
1670
+ this.stopHeartbeat();
1671
+ if (iterationTools.length > 0) {
1672
+ const narrative = this.buildToolRoundNarrative(iterationTools);
1673
+ this.bus.emit("tool_round_complete", {
1674
+ iteration: result.iterations,
1675
+ tools: iterationTools,
1676
+ totalToolCalls,
1677
+ narrative
1678
+ });
1679
+ }
1680
+ this.emitPhase("memory", "Recording interaction\u2026");
1681
+ const responseText = result.data.response;
1682
+ const assistantMsg = {
1683
+ role: MessageRole4.Assistant,
1684
+ blocks: [{ kind: "text", text: responseText }]
1685
+ };
1686
+ await this.recordMessage(assistantMsg);
1687
+ this.bus.emit("state_updated", { reason: "assistant_response" });
1688
+ this.emitPhase("complete", "Response ready.");
1689
+ this.bus.emit("turn_complete", {
1690
+ model: modelName,
1691
+ escalated: true,
1692
+ iterations: result.iterations,
1693
+ toolCalls: result.toolCalls.length,
1694
+ usage: result.usage
1695
+ });
1696
+ } finally {
1697
+ this.stopHeartbeat();
1698
+ }
1578
1699
  }
1579
1700
  /**
1580
1701
  * Execute with streaming token emission.
@@ -1610,6 +1731,8 @@ user: ${input}` : input;
1610
1731
  ];
1611
1732
  let totalToolCalls = 0;
1612
1733
  const callHistory = [];
1734
+ const consecutiveDupes = /* @__PURE__ */ new Map();
1735
+ let doomLoopRedirected = false;
1613
1736
  const turnStartTime = Date.now();
1614
1737
  const totalUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
1615
1738
  const syntheticCtx = {
@@ -1622,13 +1745,22 @@ user: ${input}` : input;
1622
1745
  await this.middleware.runBeforeExecute(syntheticCtx);
1623
1746
  try {
1624
1747
  for (let iteration = 1; iteration <= this.maxIterations; iteration++) {
1625
- if (this.interrupted) break;
1748
+ if (this.interrupted) {
1749
+ this.emitPhase("cancel", "Interrupted by user.");
1750
+ break;
1751
+ }
1752
+ this.emitPhase(
1753
+ "thinking",
1754
+ `Turn ${iteration}/${this.maxIterations}: ${iteration === 1 ? "Analyzing request\u2026" : "Analyzing results\u2026"}`
1755
+ );
1756
+ this.startHeartbeat("thinking");
1626
1757
  if (this.costTracker && this.budget) {
1627
1758
  this.costTracker.checkBudget(this.budget);
1628
1759
  }
1629
1760
  await this.rateLimiter?.acquire();
1630
1761
  const isLastIteration = iteration === this.maxIterations;
1631
1762
  if (isLastIteration) {
1763
+ this.emitPhase("continuation", "Maximum iterations reached \u2014 synthesizing final response\u2026");
1632
1764
  messages.push({
1633
1765
  role: "user",
1634
1766
  content: "You have used all available tool iterations. Please provide your final response now based on what you have gathered so far. Do not call any more tools."
@@ -1644,13 +1776,15 @@ user: ${input}` : input;
1644
1776
  try {
1645
1777
  response = await this.streamAndAggregate(provider, params);
1646
1778
  } catch (err) {
1779
+ this.stopHeartbeat();
1780
+ this.emitPhase("error", `LLM call failed: ${err instanceof Error ? err.message : String(err)}`);
1647
1781
  await this.middleware.runError(
1648
1782
  syntheticCtx,
1649
1783
  err instanceof Error ? err : new Error(String(err))
1650
1784
  );
1651
- console.error("[ObotoAgent] LLM call failed:", err instanceof Error ? err.message : err);
1652
1785
  throw err;
1653
1786
  }
1787
+ this.stopHeartbeat();
1654
1788
  const usage = response?.usage;
1655
1789
  if (usage) {
1656
1790
  const promptTokens = usage.prompt_tokens ?? 0;
@@ -1684,12 +1818,21 @@ user: ${input}` : input;
1684
1818
  });
1685
1819
  }
1686
1820
  if (!toolCalls || toolCalls.length === 0) {
1821
+ if (!content) {
1822
+ this.bus.emit("agent_thought", {
1823
+ text: `Empty response from AI \u2014 iteration ${iteration}`,
1824
+ model: "system"
1825
+ });
1826
+ if (iteration < this.maxIterations) continue;
1827
+ }
1828
+ this.emitPhase("memory", "Recording interaction\u2026");
1687
1829
  const assistantMsg = {
1688
1830
  role: MessageRole4.Assistant,
1689
1831
  blocks: [{ kind: "text", text: content }]
1690
1832
  };
1691
1833
  await this.recordMessage(assistantMsg);
1692
1834
  this.bus.emit("state_updated", { reason: "assistant_response" });
1835
+ this.emitPhase("complete", "Response ready.");
1693
1836
  this.bus.emit("turn_complete", {
1694
1837
  model: modelName,
1695
1838
  escalated: true,
@@ -1704,12 +1847,15 @@ user: ${input}` : input;
1704
1847
  });
1705
1848
  return;
1706
1849
  }
1850
+ this.emitPhase("tools", `Executing ${toolCalls.length} tool(s)\u2026`);
1707
1851
  messages.push({
1708
1852
  role: "assistant",
1709
1853
  content: content || null,
1710
1854
  tool_calls: toolCalls
1711
1855
  });
1712
- for (const tc of toolCalls) {
1856
+ const roundTools = [];
1857
+ for (let ti = 0; ti < toolCalls.length; ti++) {
1858
+ const tc = toolCalls[ti];
1713
1859
  if (this.interrupted) break;
1714
1860
  let args;
1715
1861
  try {
@@ -1728,6 +1874,7 @@ user: ${input}` : input;
1728
1874
  tool_call_id: tc.id,
1729
1875
  content: `Permission denied for tool "${command}": ${outcome.reason ?? "denied by policy"}`
1730
1876
  });
1877
+ roundTools.push({ command, success: false });
1731
1878
  totalToolCalls++;
1732
1879
  continue;
1733
1880
  }
@@ -1740,6 +1887,7 @@ user: ${input}` : input;
1740
1887
  tool_call_id: tc.id,
1741
1888
  content: `Tool "${command}" blocked by pre-use hook: ${hookResult.messages.join("; ")}`
1742
1889
  });
1890
+ roundTools.push({ command, success: false });
1743
1891
  totalToolCalls++;
1744
1892
  continue;
1745
1893
  }
@@ -1747,16 +1895,77 @@ user: ${input}` : input;
1747
1895
  const callSig = JSON.stringify({ command, kwargs });
1748
1896
  const dupeCount = callHistory.filter((s) => s === callSig).length;
1749
1897
  callHistory.push(callSig);
1898
+ const prevConsec = consecutiveDupes.get(command) ?? 0;
1899
+ consecutiveDupes.set(command, prevConsec + 1);
1750
1900
  if (dupeCount >= 2) {
1751
- messages.push({
1752
- role: "tool",
1753
- tool_call_id: tc.id,
1754
- content: `You already called "${command}" with these arguments ${dupeCount} time(s). Use the data you already have.`
1755
- });
1901
+ if (prevConsec >= 3 && !doomLoopRedirected) {
1902
+ doomLoopRedirected = true;
1903
+ this.emitPhase("doom", `Doom loop detected: repeated calls to "${command}"`);
1904
+ this.bus.emit("doom_loop", {
1905
+ reason: `Repeated calls to "${command}"`,
1906
+ command,
1907
+ count: prevConsec + 1,
1908
+ redirected: true
1909
+ });
1910
+ messages.push({
1911
+ role: "tool",
1912
+ tool_call_id: tc.id,
1913
+ content: `STOP: You have been calling "${command}" repeatedly with the same arguments ${prevConsec + 1} times. This is a doom loop. You MUST take a different approach. Summarize what you know so far and either try a completely different strategy or provide your best answer.`
1914
+ });
1915
+ } else if (doomLoopRedirected && prevConsec >= 5) {
1916
+ this.emitPhase("doom", `Persistent doom loop \u2014 terminating.`);
1917
+ this.bus.emit("doom_loop", {
1918
+ reason: `Persistent doom loop on "${command}"`,
1919
+ command,
1920
+ count: prevConsec + 1,
1921
+ redirected: false
1922
+ });
1923
+ roundTools.push({ command, success: false });
1924
+ totalToolCalls++;
1925
+ if (roundTools.length > 0) {
1926
+ const narrative = this.buildToolRoundNarrative(roundTools);
1927
+ this.bus.emit("tool_round_complete", {
1928
+ iteration,
1929
+ tools: roundTools,
1930
+ totalToolCalls,
1931
+ narrative
1932
+ });
1933
+ }
1934
+ this.emitPhase("continuation", "Synthesizing response after doom loop\u2026");
1935
+ const assistantMsg = {
1936
+ role: MessageRole4.Assistant,
1937
+ blocks: [{ kind: "text", text: content || "I encountered a repeating pattern and am unable to make further progress. Here is what I have so far." }]
1938
+ };
1939
+ await this.recordMessage(assistantMsg);
1940
+ this.bus.emit("state_updated", { reason: "doom_loop" });
1941
+ this.emitPhase("complete", "Response ready.");
1942
+ this.bus.emit("turn_complete", {
1943
+ model: modelName,
1944
+ escalated: true,
1945
+ iterations: iteration,
1946
+ toolCalls: totalToolCalls,
1947
+ usage: totalUsage
1948
+ });
1949
+ await this.middleware.runComplete(syntheticCtx, {
1950
+ data: content || "doom_loop_terminated",
1951
+ raw: content,
1952
+ usage: totalUsage
1953
+ });
1954
+ return;
1955
+ } else {
1956
+ messages.push({
1957
+ role: "tool",
1958
+ tool_call_id: tc.id,
1959
+ content: `You already called "${command}" with these arguments ${dupeCount} time(s). Use the data you already have.`
1960
+ });
1961
+ }
1962
+ roundTools.push({ command, success: false });
1756
1963
  totalToolCalls++;
1757
1964
  continue;
1965
+ } else {
1966
+ consecutiveDupes.set(command, 0);
1758
1967
  }
1759
- this.bus.emit("tool_execution_start", { command, kwargs });
1968
+ this.bus.emit("tool_execution_start", { command, kwargs, index: ti, total: toolCalls.length });
1760
1969
  let result;
1761
1970
  let isError = false;
1762
1971
  try {
@@ -1772,8 +1981,9 @@ user: ${input}` : input;
1772
1981
  if (this.hookIntegration) {
1773
1982
  this.hookIntegration.runPostToolUse(command, toolInputStr, truncated, isError);
1774
1983
  }
1775
- this.bus.emit("tool_execution_complete", { command, kwargs, result: truncated });
1984
+ this.bus.emit("tool_execution_complete", { command, kwargs, result: truncated, error: isError ? truncated : void 0 });
1776
1985
  this.recordToolResult(command, kwargs, truncated);
1986
+ roundTools.push({ command, success: !isError });
1777
1987
  totalToolCalls++;
1778
1988
  messages.push({
1779
1989
  role: "tool",
@@ -1781,13 +1991,24 @@ user: ${input}` : input;
1781
1991
  content: truncated
1782
1992
  });
1783
1993
  }
1994
+ if (roundTools.length > 0) {
1995
+ const narrative = this.buildToolRoundNarrative(roundTools);
1996
+ this.bus.emit("tool_round_complete", {
1997
+ iteration,
1998
+ tools: roundTools,
1999
+ totalToolCalls,
2000
+ narrative
2001
+ });
2002
+ }
1784
2003
  }
2004
+ this.emitPhase("continuation", "Maximum iterations reached \u2014 synthesizing response\u2026");
1785
2005
  const fallbackMsg = {
1786
2006
  role: MessageRole4.Assistant,
1787
2007
  blocks: [{ kind: "text", text: "I reached the maximum number of iterations. Here is what I have so far." }]
1788
2008
  };
1789
2009
  await this.recordMessage(fallbackMsg);
1790
2010
  this.bus.emit("state_updated", { reason: "max_iterations" });
2011
+ this.emitPhase("complete", "Response ready.");
1791
2012
  this.bus.emit("turn_complete", {
1792
2013
  model: modelName,
1793
2014
  escalated: true,
@@ -1801,6 +2022,8 @@ user: ${input}` : input;
1801
2022
  usage: totalUsage
1802
2023
  });
1803
2024
  } catch (err) {
2025
+ this.stopHeartbeat();
2026
+ this.emitPhase("error", `Error: ${err instanceof Error ? err.message : String(err)}`);
1804
2027
  if (!(err instanceof Error && err.message.includes("LLM call failed"))) {
1805
2028
  await this.middleware.runError(
1806
2029
  syntheticCtx,
@@ -1808,6 +2031,8 @@ user: ${input}` : input;
1808
2031
  );
1809
2032
  }
1810
2033
  throw err;
2034
+ } finally {
2035
+ this.stopHeartbeat();
1811
2036
  }
1812
2037
  }
1813
2038
  /**