henchman-ai 0.1.6__py3-none-any.whl → 0.1.8__py3-none-any.whl

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.
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
  from henchman.cli.commands import Command, CommandContext
9
9
  from henchman.cli.commands.plan import PlanCommand
10
10
  from henchman.cli.commands.skill import SkillCommand
11
+ from henchman.cli.commands.unlimited import UnlimitedCommand
11
12
 
12
13
 
13
14
  class HelpCommand(Command):
@@ -205,4 +206,5 @@ def get_builtin_commands() -> list[Command]:
205
206
  ToolsCommand(),
206
207
  PlanCommand(),
207
208
  SkillCommand(),
209
+ UnlimitedCommand(),
208
210
  ]
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
  from typing import TYPE_CHECKING
9
9
 
10
10
  from henchman.cli.commands import Command, CommandContext
11
+ from henchman.providers.base import Message, ToolCall
11
12
 
12
13
  if TYPE_CHECKING:
13
14
  from henchman.core.session import SessionManager
@@ -135,6 +136,34 @@ class ChatCommand(Command):
135
136
  return
136
137
 
137
138
  manager.set_current(session)
139
+
140
+ # Restore session messages to agent history
141
+ if ctx.agent is not None:
142
+ # Clear agent history (keeping system prompt)
143
+ ctx.agent.clear_history()
144
+
145
+ # Convert SessionMessage objects to Message objects
146
+ for session_msg in session.messages:
147
+ # Convert tool_calls from dicts to ToolCall objects if present
148
+ tool_calls = None
149
+ if session_msg.tool_calls:
150
+ tool_calls = [
151
+ ToolCall(
152
+ id=tc.get("id", ""),
153
+ name=tc.get("name", ""),
154
+ arguments=tc.get("arguments", {}),
155
+ )
156
+ for tc in session_msg.tool_calls
157
+ ]
158
+
159
+ msg = Message(
160
+ role=session_msg.role,
161
+ content=session_msg.content,
162
+ tool_calls=tool_calls,
163
+ tool_call_id=session_msg.tool_call_id,
164
+ )
165
+ ctx.agent.messages.append(msg)
166
+
138
167
  ctx.console.print(
139
168
  f"[green]✓[/] Resumed session '{tag}' ({len(session.messages)} messages)"
140
169
  )
@@ -0,0 +1,70 @@
1
+ """Unlimited command for bypassing loop protection."""
2
+
3
+ from henchman.cli.commands import Command, CommandContext
4
+
5
+ __all__ = ["UnlimitedCommand"]
6
+
7
+
8
+ class UnlimitedCommand(Command):
9
+ """Toggle unlimited mode to bypass loop protection.
10
+
11
+ When enabled, the agent will not enforce iteration limits on tool calls.
12
+ Use with caution as this can lead to infinite loops.
13
+ Use Ctrl+C to abort runaway execution.
14
+ """
15
+
16
+ @property
17
+ def name(self) -> str:
18
+ """Return the command name."""
19
+ return "unlimited"
20
+
21
+ @property
22
+ def description(self) -> str:
23
+ """Return a brief description."""
24
+ return "Toggle unlimited mode (bypass loop protection)"
25
+
26
+ @property
27
+ def usage(self) -> str:
28
+ """Return usage information."""
29
+ return "/unlimited [on|off]"
30
+
31
+ async def execute(self, ctx: CommandContext) -> None:
32
+ """Execute the command.
33
+
34
+ Args:
35
+ ctx: The command context.
36
+ """
37
+ args = ctx.args
38
+
39
+ # Get current state from agent
40
+ agent = ctx.agent
41
+ if agent is None:
42
+ ctx.console.print("[red]Error: No agent available[/red]")
43
+ return
44
+
45
+ current_state = getattr(agent, 'unlimited_mode', False)
46
+
47
+ # Toggle or set explicitly
48
+ if not args:
49
+ # Toggle
50
+ new_state = not current_state
51
+ elif args[0].lower() in ("on", "true", "1", "yes"):
52
+ new_state = True
53
+ elif args[0].lower() in ("off", "false", "0", "no"):
54
+ new_state = False
55
+ else:
56
+ ctx.console.print(f"[yellow]Usage: {self.usage}[/yellow]")
57
+ return
58
+
59
+ agent.unlimited_mode = new_state
60
+
61
+ if new_state:
62
+ ctx.console.print(
63
+ "[bold yellow]⚠ Unlimited mode: ON[/bold yellow]\n"
64
+ "[yellow]Loop protection disabled. Use Ctrl+C to abort runaway execution.[/yellow]"
65
+ )
66
+ else:
67
+ ctx.console.print(
68
+ "[bold green]✓ Unlimited mode: OFF[/bold green]\n"
69
+ "[dim]Loop protection re-enabled.[/dim]"
70
+ )
henchman/cli/input.py CHANGED
@@ -112,7 +112,7 @@ def create_session(
112
112
  bindings = KeyBindings()
113
113
 
114
114
  @bindings.add(Keys.ControlC)
115
- def _(event: Any) -> None:
115
+ def _(_event: Any) -> None:
116
116
  """Handle Ctrl+C: raise KeyboardInterrupt to exit cleanly."""
117
117
  raise KeyboardInterrupt()
118
118
 
henchman/cli/prompts.py CHANGED
@@ -18,8 +18,8 @@ would be garbage without your intervention.
18
18
 
19
19
  ### File Operations
20
20
  - `read_file(path, start_line?, end_line?, max_chars?)` - Read file contents. Use this FIRST to understand code before modifying.
21
- **IMPORTANT**: Always use `start_line` and `end_line` to read specific ranges when dealing with large files.
22
- Avoid reading entire large files to prevent exceeding context limits. Example: `read_file("large.py", 1, 100)`
21
+ **IMPORTANT**: Always use `start_line` and `end_line` to read specific ranges when dealing with large files.
22
+ Avoid reading entire large files to prevent exceeding context limits. Example: `read_file("large.py", 1, 100)`
23
23
  to read lines 1-100 only.
24
24
  - `write_file(path, content)` - Create or overwrite files. For new files or complete rewrites.
25
25
  - `edit_file(path, old_text, new_text)` - Surgical text replacement. Preferred for modifications.
henchman/cli/repl.py CHANGED
@@ -31,12 +31,16 @@ class ReplConfig:
31
31
  system_prompt: System prompt for the agent.
32
32
  auto_save: Whether to auto-save sessions on exit.
33
33
  history_file: Path to history file.
34
+ base_tool_iterations: Base limit for tool iterations per turn.
35
+ max_tool_calls_per_turn: Maximum tool calls allowed per turn.
34
36
  """
35
37
 
36
38
  prompt: str = "❯ "
37
39
  system_prompt: str = ""
38
40
  auto_save: bool = True
39
41
  history_file: Path | None = None
42
+ base_tool_iterations: int = 25
43
+ max_tool_calls_per_turn: int = 100
40
44
 
41
45
 
42
46
  class Repl:
@@ -81,6 +85,7 @@ class Repl:
81
85
  provider=provider,
82
86
  tool_registry=self.tool_registry,
83
87
  system_prompt=self.config.system_prompt,
88
+ base_tool_iterations=self.config.base_tool_iterations,
84
89
  )
85
90
 
86
91
  # Initialize command registry
@@ -290,7 +295,7 @@ class Repl:
290
295
  if self.session is not None:
291
296
  self.session.messages.append(SessionMessage(role="user", content=user_input))
292
297
 
293
- # Collect assistant response
298
+ # Collect assistant response - now also tracks tool calls for session
294
299
  assistant_content: list[str] = []
295
300
 
296
301
  try:
@@ -301,11 +306,8 @@ class Repl:
301
306
  except Exception as e:
302
307
  self.renderer.error(f"Error: {e}")
303
308
 
304
- # Record assistant response to session
305
- if self.session is not None and assistant_content:
306
- self.session.messages.append(
307
- SessionMessage(role="assistant", content="".join(assistant_content))
308
- )
309
+ # Session recording is now handled within _process_agent_stream
310
+ # and _execute_tool_calls to properly capture tool calls and results
309
311
 
310
312
  async def _process_agent_stream(
311
313
  self,
@@ -313,22 +315,51 @@ class Repl:
313
315
  content_collector: list[str] | None = None
314
316
  ) -> None:
315
317
  """Process an agent event stream, handling tool calls properly.
316
-
318
+
317
319
  This method collects ALL tool calls from a single response before
318
320
  executing them, which is required by the OpenAI API.
319
-
321
+
320
322
  Args:
321
323
  event_stream: Async iterator of agent events.
322
324
  content_collector: Optional list to collect content for session.
323
325
  """
326
+ # Check loop limits before processing (unless unlimited mode)
327
+ if not self.agent.unlimited_mode:
328
+ turn = self.agent.turn
329
+ adaptive_limit = turn.get_adaptive_limit(self.config.base_tool_iterations)
330
+
331
+ if turn.is_at_limit(self.config.base_tool_iterations):
332
+ self.renderer.error(
333
+ f"Reached iteration limit ({adaptive_limit}). "
334
+ "Stopping to prevent infinite loop. Use /unlimited to bypass."
335
+ )
336
+ return
337
+
338
+ if turn.tool_count >= self.config.max_tool_calls_per_turn:
339
+ self.renderer.error(
340
+ f"Reached tool call limit ({self.config.max_tool_calls_per_turn}). "
341
+ "Stopping to prevent runaway execution."
342
+ )
343
+ return
344
+
345
+ # Warn if spinning
346
+ if turn.is_spinning() and turn.iteration > 2:
347
+ self.renderer.warning(
348
+ "⚠ Possible loop detected: same tool calls or results repeating. "
349
+ f"Iteration {turn.iteration}/{adaptive_limit}"
350
+ )
351
+
324
352
  pending_tool_calls: list[ToolCall] = []
325
-
353
+ accumulated_content: list[str] = []
354
+
326
355
  async for event in event_stream:
327
356
  if event.type == EventType.CONTENT:
328
357
  # Stream content to console
329
358
  self.console.print(event.data, end="")
330
359
  if content_collector is not None and event.data:
331
360
  content_collector.append(event.data)
361
+ if event.data:
362
+ accumulated_content.append(event.data)
332
363
 
333
364
  elif event.type == EventType.THOUGHT:
334
365
  # Show thinking in muted style
@@ -349,8 +380,30 @@ class Repl:
349
380
 
350
381
  elif event.type == EventType.ERROR:
351
382
  self.renderer.error(str(event.data))
352
-
353
- # After the stream ends, execute ALL pending tool calls
383
+
384
+ # After the stream ends, record assistant message to session
385
+ # This captures both content-only responses and tool_calls
386
+ if self.session is not None:
387
+ if pending_tool_calls:
388
+ # Convert ToolCall objects to dicts for session storage
389
+ tool_calls_dicts = [
390
+ {"id": tc.id, "name": tc.name, "arguments": tc.arguments}
391
+ for tc in pending_tool_calls
392
+ ]
393
+ self.session.messages.append(
394
+ SessionMessage(
395
+ role="assistant",
396
+ content="".join(accumulated_content) if accumulated_content else None,
397
+ tool_calls=tool_calls_dicts,
398
+ )
399
+ )
400
+ elif accumulated_content:
401
+ # Content-only response (no tool calls)
402
+ self.session.messages.append(
403
+ SessionMessage(role="assistant", content="".join(accumulated_content))
404
+ )
405
+
406
+ # Execute ALL pending tool calls
354
407
  if pending_tool_calls:
355
408
  await self._execute_tool_calls(pending_tool_calls, content_collector)
356
409
 
@@ -360,11 +413,14 @@ class Repl:
360
413
  content_collector: list[str] | None = None
361
414
  ) -> None:
362
415
  """Execute a batch of tool calls and continue the agent loop.
363
-
416
+
364
417
  Args:
365
418
  tool_calls: List of tool calls to execute.
366
419
  content_collector: Optional list to collect content for session.
367
420
  """
421
+ # Increment iteration counter (one batch of tool calls = one iteration)
422
+ self.agent.turn.increment_iteration()
423
+
368
424
  # Execute all tool calls and submit results
369
425
  for tool_call in tool_calls:
370
426
  if not isinstance(tool_call, ToolCall):
@@ -375,15 +431,36 @@ class Repl:
375
431
  # Execute the tool
376
432
  result = await self.tool_registry.execute(tool_call.name, tool_call.arguments)
377
433
 
434
+ # Record tool call in turn state for loop detection
435
+ self.agent.turn.record_tool_call(
436
+ tool_call_id=tool_call.id,
437
+ tool_name=tool_call.name,
438
+ arguments=tool_call.arguments,
439
+ result=result,
440
+ )
441
+
378
442
  # Submit result to agent
379
443
  self.agent.submit_tool_result(tool_call.id, result.content)
380
444
 
445
+ # Record tool result to session
446
+ if self.session is not None:
447
+ self.session.messages.append(
448
+ SessionMessage(
449
+ role="tool",
450
+ content=result.content,
451
+ tool_call_id=tool_call.id,
452
+ )
453
+ )
454
+
381
455
  # Show result
382
456
  if result.success:
383
457
  self.renderer.muted(f"[result] {result.content[:200]}...")
384
458
  else:
385
459
  self.renderer.error(f"[error] {result.error}")
386
460
 
461
+ # Show turn status after tool execution
462
+ self._show_turn_status()
463
+
387
464
  # Add spacing after tool execution
388
465
  self.console.print()
389
466
 
@@ -393,11 +470,25 @@ class Repl:
393
470
  content_collector
394
471
  )
395
472
 
473
+ def _show_turn_status(self) -> None:
474
+ """Display current turn status."""
475
+ turn = self.agent.turn
476
+ status = turn.get_status_string(
477
+ base_limit=self.config.base_tool_iterations,
478
+ max_tokens=self.agent.max_tokens,
479
+ )
480
+
481
+ # Color based on status
482
+ if turn.is_spinning() or turn.is_approaching_limit(self.config.base_tool_iterations):
483
+ self.renderer.warning(status)
484
+ else:
485
+ self.renderer.muted(status)
486
+
396
487
  async def _handle_agent_event(
397
488
  self, event: AgentEvent, content_collector: list[str] | None = None
398
489
  ) -> None:
399
490
  """Handle an event from the agent.
400
-
491
+
401
492
  DEPRECATED: Use _process_agent_stream instead for proper tool call handling.
402
493
  This method is kept for backwards compatibility with tests.
403
494
 
@@ -430,7 +521,7 @@ class Repl:
430
521
 
431
522
  async def _handle_tool_call(self, tool_call: ToolCall) -> None:
432
523
  """Handle a single tool call from the agent.
433
-
524
+
434
525
  DEPRECATED: Use _execute_tool_calls for proper batched handling.
435
526
  This method is kept for backwards compatibility with tests.
436
527
 
henchman/config/schema.py CHANGED
@@ -40,11 +40,19 @@ class ToolSettings(BaseModel):
40
40
  auto_approve_read: Whether to auto-approve read-only tools.
41
41
  shell_timeout: Default timeout for shell commands in seconds.
42
42
  sandbox: Execution sandbox mode ("none" or "docker").
43
+ base_tool_iterations: Base limit for tool iterations per turn.
44
+ max_tool_calls_per_turn: Maximum tool calls allowed per turn.
45
+ max_protected_ratio: Maximum ratio of context that can be protected.
46
+ adaptive_limits: Whether to adjust limits based on progress detection.
43
47
  """
44
48
 
45
49
  auto_approve_read: bool = True
46
50
  shell_timeout: int = 60
47
51
  sandbox: Literal["none", "docker"] = "none"
52
+ base_tool_iterations: int = 25
53
+ max_tool_calls_per_turn: int = 100
54
+ max_protected_ratio: float = 0.3
55
+ adaptive_limits: bool = True
48
56
 
49
57
 
50
58
  class UISettings(BaseModel):
henchman/core/__init__.py CHANGED
@@ -2,7 +2,14 @@
2
2
 
3
3
  from henchman.core.agent import Agent
4
4
  from henchman.core.events import AgentEvent, EventType
5
- from henchman.core.session import Session, SessionManager, SessionMessage, SessionMetadata
5
+ from henchman.core.session import (
6
+ Session,
7
+ SessionManager,
8
+ SessionMessage,
9
+ SessionMetadata,
10
+ TurnSummaryRecord,
11
+ )
12
+ from henchman.core.turn import TurnState, TurnSummary
6
13
 
7
14
  __all__ = [
8
15
  "Agent",
@@ -12,4 +19,7 @@ __all__ = [
12
19
  "SessionManager",
13
20
  "SessionMessage",
14
21
  "SessionMetadata",
22
+ "TurnState",
23
+ "TurnSummary",
24
+ "TurnSummaryRecord",
15
25
  ]
henchman/core/agent.py CHANGED
@@ -3,10 +3,12 @@
3
3
  from collections.abc import AsyncIterator
4
4
 
5
5
  from henchman.core.events import AgentEvent, EventType
6
+ from henchman.core.turn import TurnState
6
7
  from henchman.providers.base import (
7
8
  FinishReason,
8
9
  Message,
9
10
  ModelProvider,
11
+ ToolCall,
10
12
  )
11
13
  from henchman.tools.registry import ToolRegistry
12
14
  from henchman.utils.tokens import TokenCounter, get_model_limit
@@ -24,6 +26,8 @@ class Agent:
24
26
  max_tokens: int = 0,
25
27
  model: str | None = None,
26
28
  summarize_dropped: bool = True,
29
+ base_tool_iterations: int = 25,
30
+ max_protected_ratio: float = 0.3,
27
31
  ) -> None:
28
32
  """Initialize the Agent.
29
33
 
@@ -34,12 +38,16 @@ class Agent:
34
38
  max_tokens: Maximum tokens for context. If 0, uses model-specific limit.
35
39
  model: Model name for determining context limits.
36
40
  summarize_dropped: Whether to summarize dropped messages during compaction.
41
+ base_tool_iterations: Base limit for tool call iterations per turn.
42
+ max_protected_ratio: Max ratio of context to protect from compaction.
37
43
  """
38
44
  self.provider = provider
39
45
  self.tool_registry = tool_registry if tool_registry is not None else ToolRegistry()
40
46
  self.system_prompt = system_prompt
41
47
  self.model = model
42
48
  self.summarize_dropped = summarize_dropped
49
+ self.base_tool_iterations = base_tool_iterations
50
+ self.max_protected_ratio = max_protected_ratio
43
51
 
44
52
  # Determine max tokens from model limit if not specified
45
53
  if max_tokens > 0:
@@ -51,6 +59,11 @@ class Agent:
51
59
 
52
60
  self.messages: list[Message] = []
53
61
 
62
+ # Turn tracking for loop protection
63
+ self.turn = TurnState()
64
+ self.unlimited_mode = False
65
+ self._turn_number = 0
66
+
54
67
  if system_prompt:
55
68
  self.messages.append(Message(role="system", content=system_prompt))
56
69
 
@@ -60,7 +73,7 @@ class Agent:
60
73
  return self.messages
61
74
 
62
75
  @property
63
- def tools(self):
76
+ def tools(self) -> ToolRegistry:
64
77
  """Get the available tools from the registry."""
65
78
  return self.tool_registry
66
79
 
@@ -78,8 +91,14 @@ class Agent:
78
91
  """
79
92
  from henchman.utils.compaction import ContextCompactor
80
93
 
81
- compactor = ContextCompactor(max_tokens=self.max_tokens)
82
- return compactor.compact(self.messages)
94
+ compactor = ContextCompactor(
95
+ max_tokens=self.max_tokens,
96
+ max_protected_ratio=self.max_protected_ratio,
97
+ )
98
+ return compactor.compact(
99
+ self.messages,
100
+ protect_from_index=self.turn.start_index,
101
+ )
83
102
 
84
103
  async def _apply_compaction_if_needed(self) -> bool:
85
104
  """Apply compaction to messages if they exceed token limit.
@@ -88,6 +107,12 @@ class Agent:
88
107
  True if compaction was applied, False otherwise.
89
108
  """
90
109
  current_tokens = TokenCounter.count_messages(self.messages, model=self.model)
110
+
111
+ # Update turn's protected token count
112
+ if self.turn.start_index < len(self.messages):
113
+ protected_msgs = self.messages[self.turn.start_index:]
114
+ self.turn.protected_tokens = TokenCounter.count_messages(protected_msgs, model=self.model)
115
+
91
116
  if current_tokens <= self.max_tokens:
92
117
  return False
93
118
 
@@ -100,6 +125,8 @@ class Agent:
100
125
  max_tokens=self.max_tokens,
101
126
  provider=self.provider,
102
127
  summarize=True,
128
+ protect_from_index=self.turn.start_index,
129
+ max_protected_ratio=self.max_protected_ratio,
103
130
  )
104
131
  if result.was_compacted:
105
132
  self.messages = result.messages
@@ -108,12 +135,25 @@ class Agent:
108
135
  # Fall back to simple compaction
109
136
  from henchman.utils.compaction import ContextCompactor
110
137
 
111
- compactor = ContextCompactor(max_tokens=self.max_tokens)
112
- self.messages = compactor.compact(self.messages)
138
+ compactor = ContextCompactor(
139
+ max_tokens=self.max_tokens,
140
+ max_protected_ratio=self.max_protected_ratio,
141
+ )
142
+ self.messages = compactor.compact(
143
+ self.messages,
144
+ protect_from_index=self.turn.start_index,
145
+ )
146
+
147
+ # Validate compacted messages to ensure tool sequences weren't broken
148
+ validate_message_sequence(self.messages)
113
149
  return True
114
150
 
115
151
  async def run(self, user_input: str) -> AsyncIterator[AgentEvent]:
116
152
  """Run the agent with user input."""
153
+ # Start new turn - record where it begins in message history
154
+ self._turn_number += 1
155
+ self.turn.reset(new_start_index=len(self.messages))
156
+
117
157
  # Add user message
118
158
  self.messages.append(Message(role="user", content=user_input))
119
159
 
@@ -130,11 +170,17 @@ class Agent:
130
170
 
131
171
  # Track accumulated content and tool calls for building the assistant message
132
172
  accumulated_content = ""
133
- accumulated_tool_calls: list = []
173
+ accumulated_tool_calls: list[ToolCall] = []
174
+
175
+ # Get messages for API (may be compacted)
176
+ api_messages = self.get_messages_for_api()
134
177
 
135
- # Get stream from provider - use compacted messages
178
+ # Final validation before API call to catch any edge cases
179
+ validate_message_sequence(api_messages)
180
+
181
+ # Get stream from provider - use validated messages
136
182
  async for chunk in self.provider.chat_completion_stream(
137
- messages=self.get_messages_for_api(),
183
+ messages=api_messages,
138
184
  tools=self.tool_registry.get_declarations(),
139
185
  ):
140
186
  if chunk.thinking:
@@ -149,7 +195,7 @@ class Agent:
149
195
  accumulated_content += chunk.content
150
196
  if chunk.tool_calls:
151
197
  accumulated_tool_calls.extend(chunk.tool_calls)
152
-
198
+
153
199
  # Update messages based on finish reason FIRST, before yielding events
154
200
  # This ensures the assistant message is in history before tool results are added
155
201
  if chunk.finish_reason == FinishReason.STOP:
@@ -160,7 +206,7 @@ class Agent:
160
206
  tool_calls=accumulated_tool_calls if accumulated_tool_calls else None,
161
207
  )
162
208
  self.messages.append(assistant_msg)
163
-
209
+
164
210
  # Now yield content event if any
165
211
  if accumulated_content:
166
212
  yield AgentEvent(
@@ -177,7 +223,7 @@ class Agent:
177
223
  tool_calls=accumulated_tool_calls if accumulated_tool_calls else chunk.tool_calls,
178
224
  )
179
225
  self.messages.append(assistant_msg)
180
-
226
+
181
227
  # Now yield tool call events
182
228
  tool_calls_to_yield = accumulated_tool_calls if accumulated_tool_calls else (chunk.tool_calls or [])
183
229
  for tool_call in tool_calls_to_yield:
@@ -204,11 +250,11 @@ class Agent:
204
250
 
205
251
  async def continue_with_tool_results(self) -> AsyncIterator[AgentEvent]:
206
252
  """Continue agent execution after tool results have been submitted.
207
-
253
+
208
254
  This method should be called after submit_tool_result() to continue
209
255
  the conversation with the updated message history.
210
256
  """
211
- # Validate message sequence
257
+ # Validate full message history
212
258
  validate_message_sequence(self.messages)
213
259
 
214
260
  # Apply compaction if needed and emit event
@@ -221,11 +267,17 @@ class Agent:
221
267
 
222
268
  # Track accumulated content and tool calls for building the assistant message
223
269
  accumulated_content = ""
224
- accumulated_tool_calls: list = []
270
+ accumulated_tool_calls: list[ToolCall] = []
271
+
272
+ # Get messages for API (may be compacted)
273
+ api_messages = self.get_messages_for_api()
274
+
275
+ # Final validation before API call to catch any edge cases
276
+ validate_message_sequence(api_messages)
225
277
 
226
- # Get stream from provider - use compacted messages
278
+ # Get stream from provider - use validated messages
227
279
  async for chunk in self.provider.chat_completion_stream(
228
- messages=self.get_messages_for_api(),
280
+ messages=api_messages,
229
281
  tools=self.tool_registry.get_declarations(),
230
282
  ):
231
283
  if chunk.thinking:
@@ -250,7 +302,7 @@ class Agent:
250
302
  tool_calls=accumulated_tool_calls if accumulated_tool_calls else None,
251
303
  )
252
304
  self.messages.append(assistant_msg)
253
-
305
+
254
306
  # Now yield content event if any
255
307
  if accumulated_content:
256
308
  yield AgentEvent(
@@ -266,7 +318,7 @@ class Agent:
266
318
  tool_calls=accumulated_tool_calls if accumulated_tool_calls else chunk.tool_calls,
267
319
  )
268
320
  self.messages.append(assistant_msg)
269
-
321
+
270
322
  # Now yield tool call events
271
323
  tool_calls_to_yield = accumulated_tool_calls if accumulated_tool_calls else (chunk.tool_calls or [])
272
324
  for tool_call in tool_calls_to_yield:
henchman/core/events.py CHANGED
@@ -16,6 +16,8 @@ class EventType(Enum):
16
16
  TOOL_CALL_RESULT = auto() # Result from a tool execution
17
17
  TOOL_CONFIRMATION = auto() # Awaiting user approval for a tool
18
18
  CONTEXT_COMPACTED = auto() # Context was compacted to fit model limits
19
+ TURN_SUMMARIZED = auto() # Previous turn was summarized
20
+ TURN_STATUS = auto() # Status update for current turn
19
21
  ERROR = auto() # An error occurred
20
22
  FINISHED = auto() # Agent has finished processing
21
23