@sschepis/oboto-agent 0.2.1 → 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
@@ -81,10 +81,21 @@ var ContextManager = class {
81
81
  const text = typeof m.content === "string" ? m.content : m.content.filter((b) => b.type === "text").map((b) => b.text).join(" ");
82
82
  return `${m.role}: ${text}`;
83
83
  }).join("\n");
84
- const result = await this.localRuntime.execute(this.summarizeFn, {
85
- conversation
86
- });
87
- return result.data.summary;
84
+ try {
85
+ const result = await this.localRuntime.execute(this.summarizeFn, {
86
+ conversation
87
+ });
88
+ return result.data.summary;
89
+ } catch (err) {
90
+ console.warn(
91
+ "[ContextManager] Summarization failed, using truncation fallback:",
92
+ err instanceof Error ? err.message : err
93
+ );
94
+ return messages.slice(-3).map((m) => {
95
+ const text = typeof m.content === "string" ? m.content : "[complex]";
96
+ return `${m.role}: ${text.slice(-500)}`;
97
+ }).join("\n");
98
+ }
88
99
  });
89
100
  }
90
101
  localRuntime;
@@ -1152,6 +1163,9 @@ var ObotoAgent = class {
1152
1163
  usageTracker;
1153
1164
  usageBridge;
1154
1165
  routerEventBridge;
1166
+ heartbeatTimer = null;
1167
+ heartbeatStart = 0;
1168
+ currentPhase = "request";
1155
1169
  constructor(config) {
1156
1170
  this.config = config;
1157
1171
  this.localProvider = config.localModel;
@@ -1402,6 +1416,75 @@ var ObotoAgent = class {
1402
1416
  const { context } = await this.conversationRAG.retrieve(query);
1403
1417
  return context || void 0;
1404
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
+ }
1405
1488
  // ── Internal ───────────────────────────────────────────────────────
1406
1489
  /**
1407
1490
  * Record a message in the session, context manager, and optionally RAG index.
@@ -1427,6 +1510,7 @@ var ObotoAgent = class {
1427
1510
  }
1428
1511
  }
1429
1512
  async executionLoop(userInput) {
1513
+ this.emitPhase("request", `Processing: ${userInput.substring(0, 80)}${userInput.length > 80 ? "\u2026" : ""}`);
1430
1514
  this.bus.emit("user_input", { text: userInput });
1431
1515
  const userMsg = {
1432
1516
  role: MessageRole4.User,
@@ -1434,6 +1518,7 @@ var ObotoAgent = class {
1434
1518
  };
1435
1519
  await this.recordMessage(userMsg);
1436
1520
  this.bus.emit("state_updated", { reason: "user_input" });
1521
+ this.emitPhase("planning", "Building context and preparing tools\u2026");
1437
1522
  if (this.conversationRAG) {
1438
1523
  try {
1439
1524
  const { context } = await this.conversationRAG.retrieve(userInput);
@@ -1442,16 +1527,24 @@ var ObotoAgent = class {
1442
1527
  role: "system",
1443
1528
  content: context
1444
1529
  });
1530
+ this.bus.emit("agent_thought", {
1531
+ text: "Retrieved relevant past context via RAG.",
1532
+ model: "system"
1533
+ });
1445
1534
  }
1446
1535
  } catch (err) {
1447
1536
  console.warn("[ObotoAgent] RAG retrieval failed:", err instanceof Error ? err.message : err);
1448
1537
  }
1449
1538
  }
1539
+ this.emitPhase("precheck", "Checking if direct answer is possible\u2026");
1540
+ this.startHeartbeat("precheck");
1450
1541
  const triageResult = await this.triage(userInput);
1542
+ this.stopHeartbeat();
1451
1543
  this.bus.emit("triage_result", triageResult);
1452
1544
  if (this.interrupted) return;
1453
1545
  if (!triageResult.escalate && triageResult.directResponse) {
1454
1546
  const response = triageResult.directResponse;
1547
+ this.emitPhase("complete", "Answered directly \u2014 no tools needed.");
1455
1548
  this.bus.emit("agent_thought", { text: response, model: "local" });
1456
1549
  const assistantMsg = {
1457
1550
  role: MessageRole4.Assistant,
@@ -1463,15 +1556,17 @@ var ObotoAgent = class {
1463
1556
  return;
1464
1557
  }
1465
1558
  if (triageResult.escalate) {
1559
+ this.emitPhase("thinking", `Escalating to remote model: ${triageResult.reasoning}`);
1466
1560
  this.bus.emit("agent_thought", {
1467
1561
  text: triageResult.reasoning,
1468
1562
  model: "local",
1469
1563
  escalating: true
1470
1564
  });
1565
+ } else {
1566
+ this.emitPhase("thinking", "This requires tools and deeper reasoning \u2014 entering agent loop.");
1471
1567
  }
1472
1568
  const modelName = triageResult.escalate ? this.config.remoteModelName : this.config.localModelName;
1473
1569
  const runtime = triageResult.escalate ? this.remoteRuntime : this.localRuntime;
1474
- console.log("[ObotoAgent] Executing with model:", modelName, "| via lmscript AgentLoop");
1475
1570
  if (this.onToken) {
1476
1571
  await this.executeWithStreaming(runtime, modelName, userInput);
1477
1572
  } else {
@@ -1504,6 +1599,8 @@ var ObotoAgent = class {
1504
1599
  */
1505
1600
  async executeWithAgentLoop(runtime, modelName, userInput) {
1506
1601
  const { z: z5 } = await import("zod");
1602
+ this.emitPhase("thinking", `Turn 1/${this.maxIterations}: Analyzing request\u2026`);
1603
+ this.startHeartbeat("thinking");
1507
1604
  const agentFn = {
1508
1605
  name: "agent-task",
1509
1606
  model: modelName,
@@ -1526,44 +1623,79 @@ user: ${input}` : input;
1526
1623
  temperature: 0.7,
1527
1624
  maxRetries: 1
1528
1625
  };
1626
+ let iterationTools = [];
1627
+ let totalToolCalls = 0;
1529
1628
  const agentConfig = {
1530
1629
  maxIterations: this.maxIterations,
1531
1630
  onToolCall: (tc) => {
1532
1631
  const command = typeof tc.arguments === "object" && tc.arguments !== null ? tc.arguments.command ?? tc.name : tc.name;
1533
1632
  const kwargs = typeof tc.arguments === "object" && tc.arguments !== null ? tc.arguments.kwargs ?? {} : {};
1534
- this.bus.emit("tool_execution_complete", {
1535
- command,
1536
- kwargs,
1537
- result: typeof tc.result === "string" ? tc.result : JSON.stringify(tc.result)
1538
- });
1539
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++;
1540
1640
  this.recordToolResult(String(command), kwargs, resultStr);
1541
1641
  },
1542
1642
  onIteration: (iteration, response) => {
1543
- this.bus.emit("agent_thought", {
1544
- text: response,
1545
- model: modelName,
1546
- iteration
1547
- });
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");
1548
1664
  if (this.interrupted) return false;
1549
1665
  }
1550
1666
  };
1551
1667
  const agentLoop = new AgentLoop(runtime, agentConfig);
1552
- const result = await agentLoop.run(agentFn, userInput);
1553
- const responseText = result.data.response;
1554
- const assistantMsg = {
1555
- role: MessageRole4.Assistant,
1556
- blocks: [{ kind: "text", text: responseText }]
1557
- };
1558
- await this.recordMessage(assistantMsg);
1559
- this.bus.emit("state_updated", { reason: "assistant_response" });
1560
- this.bus.emit("turn_complete", {
1561
- model: modelName,
1562
- escalated: true,
1563
- iterations: result.iterations,
1564
- toolCalls: result.toolCalls.length,
1565
- usage: result.usage
1566
- });
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
+ }
1567
1699
  }
1568
1700
  /**
1569
1701
  * Execute with streaming token emission.
@@ -1599,6 +1731,8 @@ user: ${input}` : input;
1599
1731
  ];
1600
1732
  let totalToolCalls = 0;
1601
1733
  const callHistory = [];
1734
+ const consecutiveDupes = /* @__PURE__ */ new Map();
1735
+ let doomLoopRedirected = false;
1602
1736
  const turnStartTime = Date.now();
1603
1737
  const totalUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
1604
1738
  const syntheticCtx = {
@@ -1611,13 +1745,22 @@ user: ${input}` : input;
1611
1745
  await this.middleware.runBeforeExecute(syntheticCtx);
1612
1746
  try {
1613
1747
  for (let iteration = 1; iteration <= this.maxIterations; iteration++) {
1614
- 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");
1615
1757
  if (this.costTracker && this.budget) {
1616
1758
  this.costTracker.checkBudget(this.budget);
1617
1759
  }
1618
1760
  await this.rateLimiter?.acquire();
1619
1761
  const isLastIteration = iteration === this.maxIterations;
1620
1762
  if (isLastIteration) {
1763
+ this.emitPhase("continuation", "Maximum iterations reached \u2014 synthesizing final response\u2026");
1621
1764
  messages.push({
1622
1765
  role: "user",
1623
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."
@@ -1633,13 +1776,15 @@ user: ${input}` : input;
1633
1776
  try {
1634
1777
  response = await this.streamAndAggregate(provider, params);
1635
1778
  } catch (err) {
1779
+ this.stopHeartbeat();
1780
+ this.emitPhase("error", `LLM call failed: ${err instanceof Error ? err.message : String(err)}`);
1636
1781
  await this.middleware.runError(
1637
1782
  syntheticCtx,
1638
1783
  err instanceof Error ? err : new Error(String(err))
1639
1784
  );
1640
- console.error("[ObotoAgent] LLM call failed:", err instanceof Error ? err.message : err);
1641
1785
  throw err;
1642
1786
  }
1787
+ this.stopHeartbeat();
1643
1788
  const usage = response?.usage;
1644
1789
  if (usage) {
1645
1790
  const promptTokens = usage.prompt_tokens ?? 0;
@@ -1673,12 +1818,21 @@ user: ${input}` : input;
1673
1818
  });
1674
1819
  }
1675
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");
1676
1829
  const assistantMsg = {
1677
1830
  role: MessageRole4.Assistant,
1678
1831
  blocks: [{ kind: "text", text: content }]
1679
1832
  };
1680
1833
  await this.recordMessage(assistantMsg);
1681
1834
  this.bus.emit("state_updated", { reason: "assistant_response" });
1835
+ this.emitPhase("complete", "Response ready.");
1682
1836
  this.bus.emit("turn_complete", {
1683
1837
  model: modelName,
1684
1838
  escalated: true,
@@ -1693,12 +1847,15 @@ user: ${input}` : input;
1693
1847
  });
1694
1848
  return;
1695
1849
  }
1850
+ this.emitPhase("tools", `Executing ${toolCalls.length} tool(s)\u2026`);
1696
1851
  messages.push({
1697
1852
  role: "assistant",
1698
1853
  content: content || null,
1699
1854
  tool_calls: toolCalls
1700
1855
  });
1701
- for (const tc of toolCalls) {
1856
+ const roundTools = [];
1857
+ for (let ti = 0; ti < toolCalls.length; ti++) {
1858
+ const tc = toolCalls[ti];
1702
1859
  if (this.interrupted) break;
1703
1860
  let args;
1704
1861
  try {
@@ -1717,6 +1874,7 @@ user: ${input}` : input;
1717
1874
  tool_call_id: tc.id,
1718
1875
  content: `Permission denied for tool "${command}": ${outcome.reason ?? "denied by policy"}`
1719
1876
  });
1877
+ roundTools.push({ command, success: false });
1720
1878
  totalToolCalls++;
1721
1879
  continue;
1722
1880
  }
@@ -1729,6 +1887,7 @@ user: ${input}` : input;
1729
1887
  tool_call_id: tc.id,
1730
1888
  content: `Tool "${command}" blocked by pre-use hook: ${hookResult.messages.join("; ")}`
1731
1889
  });
1890
+ roundTools.push({ command, success: false });
1732
1891
  totalToolCalls++;
1733
1892
  continue;
1734
1893
  }
@@ -1736,16 +1895,77 @@ user: ${input}` : input;
1736
1895
  const callSig = JSON.stringify({ command, kwargs });
1737
1896
  const dupeCount = callHistory.filter((s) => s === callSig).length;
1738
1897
  callHistory.push(callSig);
1898
+ const prevConsec = consecutiveDupes.get(command) ?? 0;
1899
+ consecutiveDupes.set(command, prevConsec + 1);
1739
1900
  if (dupeCount >= 2) {
1740
- messages.push({
1741
- role: "tool",
1742
- tool_call_id: tc.id,
1743
- content: `You already called "${command}" with these arguments ${dupeCount} time(s). Use the data you already have.`
1744
- });
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 });
1745
1963
  totalToolCalls++;
1746
1964
  continue;
1965
+ } else {
1966
+ consecutiveDupes.set(command, 0);
1747
1967
  }
1748
- this.bus.emit("tool_execution_start", { command, kwargs });
1968
+ this.bus.emit("tool_execution_start", { command, kwargs, index: ti, total: toolCalls.length });
1749
1969
  let result;
1750
1970
  let isError = false;
1751
1971
  try {
@@ -1761,8 +1981,9 @@ user: ${input}` : input;
1761
1981
  if (this.hookIntegration) {
1762
1982
  this.hookIntegration.runPostToolUse(command, toolInputStr, truncated, isError);
1763
1983
  }
1764
- 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 });
1765
1985
  this.recordToolResult(command, kwargs, truncated);
1986
+ roundTools.push({ command, success: !isError });
1766
1987
  totalToolCalls++;
1767
1988
  messages.push({
1768
1989
  role: "tool",
@@ -1770,13 +1991,24 @@ user: ${input}` : input;
1770
1991
  content: truncated
1771
1992
  });
1772
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
+ }
1773
2003
  }
2004
+ this.emitPhase("continuation", "Maximum iterations reached \u2014 synthesizing response\u2026");
1774
2005
  const fallbackMsg = {
1775
2006
  role: MessageRole4.Assistant,
1776
2007
  blocks: [{ kind: "text", text: "I reached the maximum number of iterations. Here is what I have so far." }]
1777
2008
  };
1778
2009
  await this.recordMessage(fallbackMsg);
1779
2010
  this.bus.emit("state_updated", { reason: "max_iterations" });
2011
+ this.emitPhase("complete", "Response ready.");
1780
2012
  this.bus.emit("turn_complete", {
1781
2013
  model: modelName,
1782
2014
  escalated: true,
@@ -1790,6 +2022,8 @@ user: ${input}` : input;
1790
2022
  usage: totalUsage
1791
2023
  });
1792
2024
  } catch (err) {
2025
+ this.stopHeartbeat();
2026
+ this.emitPhase("error", `Error: ${err instanceof Error ? err.message : String(err)}`);
1793
2027
  if (!(err instanceof Error && err.message.includes("LLM call failed"))) {
1794
2028
  await this.middleware.runError(
1795
2029
  syntheticCtx,
@@ -1797,6 +2031,8 @@ user: ${input}` : input;
1797
2031
  );
1798
2032
  }
1799
2033
  throw err;
2034
+ } finally {
2035
+ this.stopHeartbeat();
1800
2036
  }
1801
2037
  }
1802
2038
  /**