henchman-ai 0.1.7__py3-none-any.whl → 0.1.9__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.
- henchman/cli/commands/builtins.py +2 -0
- henchman/cli/commands/chat.py +29 -0
- henchman/cli/commands/unlimited.py +70 -0
- henchman/cli/input.py +6 -4
- henchman/cli/prompts.py +2 -2
- henchman/cli/repl.py +105 -14
- henchman/config/schema.py +14 -0
- henchman/core/__init__.py +11 -1
- henchman/core/agent.py +70 -18
- henchman/core/events.py +2 -0
- henchman/core/session.py +47 -0
- henchman/core/turn.py +247 -0
- henchman/tools/registry.py +111 -8
- henchman/utils/__init__.py +14 -0
- henchman/utils/compaction.py +174 -51
- henchman/utils/retry.py +166 -0
- henchman/utils/tokens.py +1 -1
- henchman/utils/validation.py +5 -0
- henchman/version.py +1 -1
- {henchman_ai-0.1.7.dist-info → henchman_ai-0.1.9.dist-info}/METADATA +1 -1
- {henchman_ai-0.1.7.dist-info → henchman_ai-0.1.9.dist-info}/RECORD +24 -21
- {henchman_ai-0.1.7.dist-info → henchman_ai-0.1.9.dist-info}/WHEEL +0 -0
- {henchman_ai-0.1.7.dist-info → henchman_ai-0.1.9.dist-info}/entry_points.txt +0 -0
- {henchman_ai-0.1.7.dist-info → henchman_ai-0.1.9.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
]
|
henchman/cli/commands/chat.py
CHANGED
|
@@ -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
|
@@ -5,6 +5,7 @@ This module handles user input including @ file references and ! shell commands.
|
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
+
import contextlib
|
|
8
9
|
import re
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
from typing import Any
|
|
@@ -112,7 +113,7 @@ def create_session(
|
|
|
112
113
|
bindings = KeyBindings()
|
|
113
114
|
|
|
114
115
|
@bindings.add(Keys.ControlC)
|
|
115
|
-
def _(
|
|
116
|
+
def _(_event: Any) -> None:
|
|
116
117
|
"""Handle Ctrl+C: raise KeyboardInterrupt to exit cleanly."""
|
|
117
118
|
raise KeyboardInterrupt()
|
|
118
119
|
|
|
@@ -124,9 +125,10 @@ def create_session(
|
|
|
124
125
|
# Clear buffer if there is text
|
|
125
126
|
buffer.text = ""
|
|
126
127
|
else:
|
|
127
|
-
# If buffer is empty,
|
|
128
|
-
#
|
|
129
|
-
|
|
128
|
+
# If buffer is empty, return to prompt with empty result
|
|
129
|
+
# Use suppress to handle case where result is already set
|
|
130
|
+
with contextlib.suppress(Exception):
|
|
131
|
+
event.app.exit(result="")
|
|
130
132
|
|
|
131
133
|
history = FileHistory(str(history_file)) if history_file else None
|
|
132
134
|
|
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
|
-
#
|
|
305
|
-
|
|
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,
|
|
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,25 @@ 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.
|
|
47
|
+
network_retries: Number of retries for network tools (0 = no retries).
|
|
48
|
+
retry_base_delay: Base delay in seconds for retry backoff.
|
|
49
|
+
retry_max_delay: Maximum delay in seconds for retry backoff.
|
|
43
50
|
"""
|
|
44
51
|
|
|
45
52
|
auto_approve_read: bool = True
|
|
46
53
|
shell_timeout: int = 60
|
|
47
54
|
sandbox: Literal["none", "docker"] = "none"
|
|
55
|
+
base_tool_iterations: int = 25
|
|
56
|
+
max_tool_calls_per_turn: int = 100
|
|
57
|
+
max_protected_ratio: float = 0.3
|
|
58
|
+
adaptive_limits: bool = True
|
|
59
|
+
network_retries: int = 3
|
|
60
|
+
retry_base_delay: float = 1.0
|
|
61
|
+
retry_max_delay: float = 30.0
|
|
48
62
|
|
|
49
63
|
|
|
50
64
|
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
|
|
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(
|
|
82
|
-
|
|
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(
|
|
112
|
-
|
|
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
|
-
#
|
|
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=
|
|
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
|
|
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
|
|
278
|
+
# Get stream from provider - use validated messages
|
|
227
279
|
async for chunk in self.provider.chat_completion_stream(
|
|
228
|
-
messages=
|
|
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
|
|