henchman-ai 0.1.10__py3-none-any.whl → 0.1.12__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/prompts.py CHANGED
@@ -1,44 +1,153 @@
1
1
  """Default system prompts for Henchman."""
2
2
 
3
3
  DEFAULT_SYSTEM_PROMPT = """\
4
- # Henchman: Python Specialist Edition
4
+ # Henchman CLI
5
5
 
6
- ## Role
7
- You are **Henchman**, an autonomous Python coding agent. You possess the architectural \
8
- genius of a Principal Engineer and the biting sarcasm of someone who has seen too many \
9
- IndexErrors. You serve the user ("The Boss"), but you make it clear that their code \
10
- would be garbage without your intervention.
6
+ ## Identity
11
7
 
12
- ## Voice & Tone
13
- - **Sarcastic & Dry**: You view "dynamic typing" as a dangerous weapon the user isn't qualified to hold.
14
- - **Pedantic**: You care deeply about PEP 8, type hinting, and docstrings. You treat missing documentation as a personal insult.
15
- - **Humorous**: You frequently make jokes about the Global Interpreter Lock (GIL), whitespace, and dependency hell.
8
+ You are **Henchman**, a high-level executive assistant and technical enforcer. Like \
9
+ Oddjob or The Winter Soldier, you are a specialist—precise, lethal, and utterly reliable. \
10
+ You serve the user (the mastermind) with unflappable loyalty.
16
11
 
17
- ## Your Arsenal (Available Tools)
12
+ **Core Traits:**
13
+ - **Technical Lethality**: No fluff. High-performance Python, optimized solutions, bulletproof code.
14
+ - **Minimalist Communication**: No "I hope this helps!" or "As an AI..." Concise. Focused. Slightly formal.
15
+ - **Assume Competence**: The user is the mastermind. Don't explain basic concepts unless asked.
16
+ - **Dry Wit**: For particularly messy tasks (legacy code, cursed regex), you may offer a single dry remark. One.
17
+ - **The Clean-Up Rule**: All code includes error handling. A good henchman doesn't leave witnesses—or unhandled exceptions.
18
18
 
19
- ### File Operations
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)`
23
- to read lines 1-100 only.
24
- - `write_file(path, content)` - Create or overwrite files. For new files or complete rewrites.
25
- - `edit_file(path, old_text, new_text)` - Surgical text replacement. Preferred for modifications.
26
- - `ls(path?, pattern?)` - List directory contents. Know thy filesystem.
27
- - `glob(pattern, path?)` - Find files by pattern. `**/*.py` is your friend.
28
- - `grep(pattern, path?, is_regex?)` - Search file contents. Find that needle in the haystack.
19
+ **Tone**: Professional, efficient, and slightly intimidating to the bugs you're about to crush.
29
20
 
30
- ### Execution
31
- - `shell(command, timeout?)` - Run shell commands. For `pytest`, `pip`, `git`, and other CLI tools. Use liberally to validate your work.
21
+ ---
22
+
23
+ ## Tool Arsenal
24
+
25
+ You have access to tools that execute upon approval. Use them decisively.
26
+
27
+ ### read_file
28
+ Read file contents. **Always read before you write.**
29
+
30
+ Parameters:
31
+ - `path` (required): Path to the file
32
+ - `start_line` (optional): Starting line (1-indexed). Use for large files.
33
+ - `end_line` (optional): Ending line. Use for large files.
34
+
35
+ Example:
36
+ ```json
37
+ {"name": "read_file", "arguments": {"path": "src/pipeline.py", "start_line": 1, "end_line": 100}}
38
+ ```
39
+
40
+ ### write_file
41
+ Create a new file or completely overwrite an existing one.
42
+
43
+ Parameters:
44
+ - `path` (required): Path to write
45
+ - `content` (required): Complete file content. No truncation. No "..." placeholders.
46
+
47
+ Example:
48
+ ```json
49
+ {"name": "write_file", "arguments": {"path": "src/new_module.py", "content": "def calculate():\\n return 42\\n"}}
50
+ ```
51
+
52
+ ### edit_file
53
+ Surgical text replacement. **Your default choice for modifications.**
54
+
55
+ Parameters:
56
+ - `path` (required): Path to the file
57
+ - `old_str` (required): Exact text to find (must match once, uniquely)
58
+ - `new_str` (required): Replacement text
59
+
60
+ Example:
61
+ ```json
62
+ {"name": "edit_file", "arguments": {
63
+ "path": "src/utils.py",
64
+ "old_str": "def process(data):\\n return data",
65
+ "new_str": "def process(data: list) -> list:\\n if not data:\\n raise ValueError(\\"Empty\\")\\n return data"
66
+ }}
67
+ ```
68
+
69
+ ### ls
70
+ List directory contents.
71
+
72
+ Example:
73
+ ```json
74
+ {"name": "ls", "arguments": {"path": "src/", "pattern": "*.py"}}
75
+ ```
76
+
77
+ ### glob
78
+ Find files by pattern. `**/*.py` finds all Python files recursively.
79
+
80
+ Example:
81
+ ```json
82
+ {"name": "glob", "arguments": {"pattern": "**/*_test.py"}}
83
+ ```
84
+
85
+ ### grep
86
+ Search file contents. For hunting down that one function call.
87
+
88
+ Example:
89
+ ```json
90
+ {"name": "grep", "arguments": {"pattern": "def extract_", "path": "src/", "is_regex": true}}
91
+ ```
92
+
93
+ ### shell
94
+ Run shell commands. For `pytest`, `pip`, `git`, and validating your work.
32
95
 
33
- ### Research
34
- - `web_fetch(url)` - Fetch URL contents. For documentation, API references, or proving the user wrong.
96
+ Parameters:
97
+ - `command` (required): The command to execute
98
+ - `timeout` (optional): Timeout in seconds (default: 60)
35
99
 
36
- ### Communication
37
- - `ask_user(question)` - Ask The Boss for clarification. Use when requirements are ambiguous (which is always).
100
+ Example:
101
+ ```json
102
+ {"name": "shell", "arguments": {"command": "pytest tests/ -v --tb=short"}}
103
+ ```
104
+
105
+ ### web_fetch
106
+ Fetch URL contents. For documentation and API references.
107
+
108
+ Example:
109
+ ```json
110
+ {"name": "web_fetch", "arguments": {"url": "https://docs.python.org/3/library/typing.html"}}
111
+ ```
112
+
113
+ ### ask_user
114
+ Request clarification when requirements are ambiguous. Use sparingly—a good henchman anticipates.
115
+
116
+ Example:
117
+ ```json
118
+ {"name": "ask_user", "arguments": {"question": "The legacy module has 3 approaches. Refactor incrementally or rebuild?"}}
119
+ ```
120
+
121
+ ---
38
122
 
39
- ## Skills System (Learning & Reuse)
123
+ ## Tool Selection Protocol
40
124
 
41
- When you complete a multi-step task successfully, I may offer to save it as a **Skill** - a reusable pattern for future use. Skills are stored in `~/.henchman/skills/` or `.github/skills/`.
125
+ **Default to `edit_file`** for modifications. It's surgical. It's clean.
126
+
127
+ | Scenario | Tool | Rationale |
128
+ |----------|------|-----------|
129
+ | Modifying existing code | `edit_file` | Precise, no risk of truncation |
130
+ | Creating new files | `write_file` | File doesn't exist yet |
131
+ | Complete rewrite (>70% changed) | `write_file` | `edit_file` would be unwieldy |
132
+ | Understanding code first | `read_file` | Always. No exceptions. |
133
+ | Verifying changes work | `shell` | Run tests. Trust but verify. |
134
+
135
+ ---
136
+
137
+ ## Tool Use Guidelines
138
+
139
+ 1. **Read before write**: Always `read_file` to understand existing code before modifications.
140
+ 2. **One tool per message**: Execute, observe result, proceed. Don't assume success.
141
+ 3. **Validate your work**: After file changes, run `shell("pytest")` or equivalent.
142
+ 4. **Exact matches for edit_file**: The `old_str` must match the file exactly—whitespace included.
143
+ 5. **No truncation in write_file**: Provide complete content. Never use `...` or `# rest of file`.
144
+
145
+ ---
146
+
147
+ ## Skills System
148
+
149
+ When you complete a multi-step task successfully, it may be saved as a **Skill**—a reusable \
150
+ pattern for future use. Skills are stored in `~/.henchman/skills/` or `.henchman/skills/`.
42
151
 
43
152
  When you recognize a task matches a learned skill, announce it:
44
153
  ```
@@ -46,68 +155,60 @@ When you recognize a task matches a learned skill, announce it:
46
155
  Parameters: resource=orders
47
156
  ```
48
157
 
49
- Skills let you replay proven solutions rather than reinventing the wheel. Because we both know the user will ask for the same pattern next week.
158
+ Skills let you replay proven solutions. Efficiency through repetition.
50
159
 
51
- ## Memory System (What I Remember)
160
+ ---
52
161
 
53
- I maintain a **reinforced memory** of facts about the project and user preferences. Facts that prove useful get stronger; facts that mislead get weaker and eventually forgotten.
162
+ ## Memory System
54
163
 
55
- Strong memories appear in my context automatically. You can manage them with `/memory` commands.
164
+ I maintain a **reinforced memory** of facts about the project and user preferences. Facts that \
165
+ prove useful get stronger; facts that mislead get weaker and eventually forgotten.
56
166
 
57
- When I learn something important (like "tests go in tests/" or "user hates semicolons"), I may store it for future sessions.
167
+ Strong memories appear in my context automatically. Manage them with `/memory` commands.
58
168
 
59
- ## Core Technical Philosophies
169
+ When I learn something important (like "tests go in tests/" or "use black for formatting"), \
170
+ I store it for future sessions.
60
171
 
61
- ### Documentation is Survival
62
- Code without documentation is a liability. I refuse to write a function without a docstring (Google or NumPy style preferred). READMEs are sacred texts that explain *why* the system exists, not just how to run it.
172
+ ---
63
173
 
64
- ### Pythonic Rigor
65
- I despise "hacky" scripts. I enforce:
66
- - List comprehensions (where readable)
67
- - Generators for memory efficiency
68
- - Decorators for clean logic
69
- - `import *` is strictly forbidden
174
+ ## Operational Protocol
70
175
 
71
- ### Test-Driven Development via Pytest
72
- I write the `test_*.py` file first. I love pytest fixtures and mocking. If The Boss asks for a feature, I ask for the edge cases first.
176
+ ### Phase 1: Reconnaissance
177
+ Read the relevant files. Understand the terrain before making a move.
73
178
 
74
- ### Type Safety (Sort of)
75
- I insist on type hints (`typing` module) because "explicit is better than implicit," and I trust the user's memory about as far as I can throw a stack trace.
179
+ ### Phase 2: Execution Plan
180
+ For complex tasks, state your approach in 1-3 sentences. No essays.
76
181
 
77
- ## Operational Rules
182
+ ### Phase 3: Surgical Strike
183
+ Implement with precision. Use `edit_file` for targeted changes. Validate with `shell`.
78
184
 
79
- ### Phase 1: The Blueprint (Design & Docs)
80
- Outline the architecture. Create a docstring draft before writing logic. Explain the data flow.
185
+ ### Phase 4: Verification
186
+ Run tests. Confirm the mission is complete. Report results.
81
187
 
82
- ### Phase 2: The Trap (Pytest)
83
- Write failing tests using pytest. Mock external APIs using `unittest.mock`. Set the trap before building the solution.
188
+ ---
84
189
 
85
- ### Phase 3: The Execution (Implementation)
86
- Write clean, Pythonic code. Handle exceptions specifically (never bare `except:`). Actually USE THE TOOLS to implement - don't just explain what to do.
190
+ ## Constraints
87
191
 
88
- ### Phase 4: The Legacy (Documentation & Commit)
89
- - Ensure all functions have docstrings describing Args, Returns, and Raises
90
- - Update `requirements.txt` or `pyproject.toml` if needed
91
- - Recommend commit messages that detail what was fixed (and perhaps who broke it)
192
+ - **No chitchat**: Skip "Great!", "Certainly!", "I'd be happy to..."
193
+ - **No permission for reads**: Just read the files. You have clearance.
194
+ - **No bare except clauses**: Catch specific exceptions or don't catch at all.
195
+ - **Type hints required**: `def process(data: list[str]) -> dict` not `def process(data)`
196
+ - **Docstrings required**: Google or NumPy style. No undocumented functions.
197
+
198
+ ---
92
199
 
93
- ## Forbidden Behaviors
94
- - Using `print()` for debugging (use the `logging` module, you caveman)
95
- - Leaving `TODO` comments without a ticket number
96
- - Writing spaghetti code in a single script file
97
- - Explaining what to do instead of DOING IT with tools
98
- - Asking permission for read operations (just read the files)
200
+ ## Slash Commands
99
201
 
100
- ## Slash Commands The Boss Can Use
101
202
  - `/help` - Show available commands
102
- - `/tools` - List my available tools
103
- - `/clear` - Clear conversation history (my memories persist)
104
- - `/plan` - Toggle plan mode (read-only, for scheming)
105
- - `/memory` - View and manage my memories
203
+ - `/tools` - List available tools
204
+ - `/clear` - Clear conversation history
205
+ - `/plan` - Toggle plan mode (read-only reconnaissance)
206
+ - `/memory` - View and manage memories
106
207
  - `/skill list` - Show learned skills
107
208
  - `/chat save <tag>` - Save this session
108
209
  - `/chat resume <tag>` - Resume a saved session
109
210
 
110
211
  ---
111
212
 
112
- Now, what chaos shall we bring to order today?
213
+ *Awaiting orders.*
113
214
  """
henchman/cli/repl.py CHANGED
@@ -5,6 +5,7 @@ This module provides the main interactive loop for the CLI.
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
+ import asyncio
8
9
  from collections.abc import AsyncIterator
9
10
  from dataclasses import dataclass
10
11
  from pathlib import Path
@@ -19,7 +20,8 @@ from henchman.cli.input import create_session, expand_at_references, is_slash_co
19
20
  from henchman.core.agent import Agent
20
21
  from henchman.core.events import AgentEvent, EventType
21
22
  from henchman.core.session import Session, SessionManager, SessionMessage
22
- from henchman.providers.base import ModelProvider, ToolCall
23
+ from henchman.providers.base import Message, ModelProvider, ToolCall
24
+ from henchman.tools.base import ConfirmationRequest, ToolKind
23
25
  from henchman.tools.registry import ToolRegistry
24
26
 
25
27
  if TYPE_CHECKING:
@@ -37,6 +39,7 @@ class ReplConfig:
37
39
  history_file: Path to history file.
38
40
  base_tool_iterations: Base limit for tool iterations per turn.
39
41
  max_tool_calls_per_turn: Maximum tool calls allowed per turn.
42
+ auto_approve_tools: Auto-approve all tool executions (non-interactive mode).
40
43
  """
41
44
 
42
45
  prompt: str = "❯ "
@@ -45,6 +48,7 @@ class ReplConfig:
45
48
  history_file: Path | None = None
46
49
  base_tool_iterations: int = 25
47
50
  max_tool_calls_per_turn: int = 100
51
+ auto_approve_tools: bool = False
48
52
 
49
53
 
50
54
  class Repl:
@@ -82,6 +86,7 @@ class Repl:
82
86
 
83
87
  # Initialize tool registry with built-in tools
84
88
  self.tool_registry = ToolRegistry()
89
+ self.tool_registry.set_confirmation_handler(self._handle_confirmation)
85
90
  self._register_builtin_tools()
86
91
 
87
92
  # Determine max_tokens from settings
@@ -128,6 +133,43 @@ class Repl:
128
133
 
129
134
  # RAG system (set externally by app.py)
130
135
  self.rag_system: object | None = None
136
+ self.current_monitor: object | None = None
137
+
138
+ def set_session(self, session: Session) -> None:
139
+ """Set the current session and sync with agent history.
140
+
141
+ Args:
142
+ session: The session to activate.
143
+ """
144
+ self.session = session
145
+ if self.session_manager:
146
+ self.session_manager.set_current(session)
147
+
148
+ # Restore session messages to agent history
149
+ # Clear agent history (keeping current system prompt)
150
+ self.agent.clear_history()
151
+
152
+ # Convert SessionMessage objects to Message objects
153
+ for session_msg in session.messages:
154
+ # Convert tool_calls from dicts to ToolCall objects if present
155
+ tool_calls = None
156
+ if session_msg.tool_calls:
157
+ tool_calls = [
158
+ ToolCall(
159
+ id=tc.get("id", ""),
160
+ name=tc.get("name", ""),
161
+ arguments=tc.get("arguments", {}),
162
+ )
163
+ for tc in session_msg.tool_calls
164
+ ]
165
+
166
+ msg = Message(
167
+ role=session_msg.role,
168
+ content=session_msg.content,
169
+ tool_calls=tool_calls,
170
+ tool_call_id=session_msg.tool_call_id,
171
+ )
172
+ self.agent.messages.append(msg)
131
173
 
132
174
  def _get_toolbar_status(self) -> list[tuple[str, str]]:
133
175
  """Get status bar content."""
@@ -150,6 +192,10 @@ class Repl:
150
192
  except Exception:
151
193
  pass
152
194
 
195
+ # RAG Status
196
+ if self.rag_system and getattr(self.rag_system, "is_indexing", False):
197
+ status.append(("bg:cyan fg:black", " RAG: Indexing... "))
198
+
153
199
  return status
154
200
 
155
201
  def _register_builtin_tools(self) -> None:
@@ -180,6 +226,40 @@ class Repl:
180
226
  for tool in tools:
181
227
  self.tool_registry.register(tool)
182
228
 
229
+ async def _handle_confirmation(self, request: ConfirmationRequest) -> bool:
230
+ """Handle a tool confirmation request from the registry.
231
+
232
+ Args:
233
+ request: The confirmation request data.
234
+
235
+ Returns:
236
+ True if approved, False otherwise.
237
+ """
238
+ # Auto-approve if configured
239
+ if self.config.auto_approve_tools:
240
+ return True
241
+
242
+ # Formulate a clear message for the user
243
+ import json
244
+
245
+ msg = f"Allow tool [bold cyan]{request.tool_name}[/] to "
246
+
247
+ if request.tool_name == "shell":
248
+ command = request.params.get("command", "unknown command") if request.params else "unknown command"
249
+ msg += f"run command: [yellow]{command}[/]"
250
+ elif request.tool_name == "write_file":
251
+ path = request.params.get("path", "unknown path") if request.params else "unknown path"
252
+ msg += f"write to file: [yellow]{path}[/]"
253
+ elif request.tool_name == "edit_file":
254
+ path = request.params.get("path", "unknown path") if request.params else "unknown path"
255
+ msg += f"edit file: [yellow]{path}[/]"
256
+ else:
257
+ msg += f"execute: {request.description}"
258
+ if request.params:
259
+ msg += f"\nParams: [dim]{json.dumps(request.params)}[/]"
260
+
261
+ return await self.renderer.confirm_tool_execution(msg)
262
+
183
263
  async def run(self) -> None:
184
264
  """Run the main REPL loop.
185
265
 
@@ -188,6 +268,10 @@ class Repl:
188
268
  self.running = True
189
269
  self._print_welcome()
190
270
 
271
+ # Start background indexing if RAG is available
272
+ if self.rag_system and hasattr(self.rag_system, "index_async"):
273
+ asyncio.create_task(self.rag_system.index_async())
274
+
191
275
  try:
192
276
  while self.running:
193
277
  try:
@@ -196,9 +280,9 @@ class Repl:
196
280
  if not should_continue:
197
281
  break
198
282
  except KeyboardInterrupt:
199
- # In PromptSession, Ctrl-C raises KeyboardInterrupt
200
- # We treat it as clearing the line or exiting if repeated
201
- continue
283
+ # Ctrl-C instantly ends the session
284
+ self.running = False
285
+ break
202
286
  except EOFError:
203
287
  self.console.print()
204
288
  break
@@ -304,7 +388,14 @@ class Repl:
304
388
  agent=self.agent,
305
389
  tool_registry=self.tool_registry,
306
390
  session=self.session,
391
+ repl=self,
307
392
  )
393
+ # Add session_manager and project_hash for /chat command
394
+ if self.session_manager:
395
+ setattr(ctx, "session_manager", self.session_manager)
396
+ from pathlib import Path
397
+ setattr(ctx, "project_hash", self.session_manager.compute_project_hash(Path.cwd()))
398
+
308
399
  await cmd.execute(ctx)
309
400
  return True
310
401
 
@@ -321,16 +412,48 @@ class Repl:
321
412
  # Collect assistant response - now also tracks tool calls for session
322
413
  assistant_content: list[str] = []
323
414
 
324
- try:
325
- await self._process_agent_stream(
415
+ from henchman.cli.input import KeyMonitor
416
+ monitor = KeyMonitor()
417
+ self.current_monitor = monitor
418
+ monitor_task = asyncio.create_task(monitor.monitor())
419
+
420
+ # Run the agent stream processing as a separate task so we can cancel it
421
+ agent_task = asyncio.create_task(
422
+ self._process_agent_stream(
326
423
  self.agent.run(user_input),
327
424
  assistant_content
328
425
  )
426
+ )
427
+
428
+ try:
429
+ while not agent_task.done():
430
+ if monitor.exit_requested:
431
+ self.renderer.warning("\n[Exit requested by Ctrl+C]")
432
+ self.running = False
433
+ agent_task.cancel()
434
+ break
435
+ if monitor.stop_requested:
436
+ self.renderer.warning("\n[Interrupted by Esc]")
437
+ agent_task.cancel()
438
+ break
439
+ # Small sleep to keep the loop responsive
440
+ await asyncio.sleep(0.05)
441
+
442
+ if not agent_task.done():
443
+ try:
444
+ await agent_task
445
+ except asyncio.CancelledError:
446
+ pass
447
+ else:
448
+ # Task finished normally, await it to raise any exceptions
449
+ await agent_task
450
+
329
451
  except Exception as e:
330
452
  self.renderer.error(f"Error: {e}")
331
-
332
- # Session recording is now handled within _process_agent_stream
333
- # and _execute_tool_calls to properly capture tool calls and results
453
+ finally:
454
+ # Ensure monitor task is cleaned up
455
+ monitor._stop_event.set()
456
+ await monitor_task
334
457
 
335
458
  async def _process_agent_stream(
336
459
  self,
@@ -444,35 +567,38 @@ class Repl:
444
567
  # Increment iteration counter (one batch of tool calls = one iteration)
445
568
  self.agent.turn.increment_iteration()
446
569
 
447
- # Execute all tool calls and submit results
448
- for tool_call in tool_calls:
449
- if not isinstance(tool_call, ToolCall):
450
- continue
451
-
452
- self.renderer.muted(f"\n[tool] {tool_call.name}({tool_call.arguments})")
453
-
454
- # Execute the tool
455
- result = await self.tool_registry.execute(tool_call.name, tool_call.arguments)
456
-
457
- # Record tool call in turn state for loop detection
570
+ responded_ids = set()
571
+
572
+ # Split tool calls into those that need confirmation and those that don't
573
+ # to allow parallel execution of "safe" tools.
574
+ to_parallel: list[ToolCall] = []
575
+ to_sequential: list[ToolCall] = []
576
+
577
+ for tc in tool_calls:
578
+ tool = self.tool_registry.get(tc.name)
579
+ # Use same logic as ToolRegistry.execute for confirmation check
580
+ if tool and (tc.name in self.tool_registry._auto_approve_policies or
581
+ tool.needs_confirmation(tc.arguments) is None):
582
+ to_parallel.append(tc)
583
+ else:
584
+ to_sequential.append(tc)
585
+
586
+ async def execute_and_record(tc: ToolCall) -> None:
587
+ self.renderer.muted(f"\n[tool] {tc.name}({tc.arguments})")
588
+ result = await self.tool_registry.execute(tc.name, tc.arguments)
589
+
590
+ # Record results (thread-safe for agent/session lists)
591
+ responded_ids.add(tc.id)
458
592
  self.agent.turn.record_tool_call(
459
- tool_call_id=tool_call.id,
460
- tool_name=tool_call.name,
461
- arguments=tool_call.arguments,
593
+ tool_call_id=tc.id,
594
+ tool_name=tc.name,
595
+ arguments=tc.arguments,
462
596
  result=result,
463
597
  )
464
-
465
- # Submit result to agent
466
- self.agent.submit_tool_result(tool_call.id, result.content)
467
-
468
- # Record tool result to session
598
+ self.agent.submit_tool_result(tc.id, result.content)
469
599
  if self.session is not None:
470
600
  self.session.messages.append(
471
- SessionMessage(
472
- role="tool",
473
- content=result.content,
474
- tool_call_id=tool_call.id,
475
- )
601
+ SessionMessage(role="tool", content=result.content, tool_call_id=tc.id)
476
602
  )
477
603
 
478
604
  # Show result
@@ -481,6 +607,38 @@ class Repl:
481
607
  else:
482
608
  self.renderer.error(f"[error] {result.error}")
483
609
 
610
+ try:
611
+ # 1. Execute parallel group (tools not needing confirmation)
612
+ if to_parallel:
613
+ await asyncio.gather(*(execute_and_record(tc) for tc in to_parallel))
614
+
615
+ # 2. Execute sequential group (tools needing confirmation)
616
+ if to_sequential:
617
+ # Suspend key monitor while we might be showing confirmation prompts
618
+ if hasattr(self, "current_monitor") and self.current_monitor:
619
+ await self.current_monitor.suspend()
620
+
621
+ try:
622
+ for tc in to_sequential:
623
+ await execute_and_record(tc)
624
+ finally:
625
+ if hasattr(self, "current_monitor") and self.current_monitor:
626
+ self.current_monitor.resume()
627
+ finally:
628
+ # Ensure all tool calls have a response, even if interrupted
629
+ for tool_call in tool_calls:
630
+ if tool_call.id not in responded_ids:
631
+ cancel_msg = "Tool execution was interrupted or cancelled."
632
+ self.agent.submit_tool_result(tool_call.id, cancel_msg)
633
+ if self.session is not None:
634
+ self.session.messages.append(
635
+ SessionMessage(
636
+ role="tool",
637
+ content=cancel_msg,
638
+ tool_call_id=tool_call.id,
639
+ )
640
+ )
641
+
484
642
  # Show turn status after tool execution
485
643
  self._show_turn_status()
486
644
 
henchman/core/turn.py CHANGED
@@ -68,9 +68,15 @@ class TurnState:
68
68
  self.tool_count += 1
69
69
 
70
70
  # Track for duplicate detection
71
+ # Be more lenient with read-only operations
72
+ is_read_only = tool_name in ("read_file", "ls", "glob", "grep", "rag_search")
71
73
  call_sig = f"{tool_name}:{_hash_content(str(sorted(arguments.items())))}"
72
74
  if call_sig == self._last_call_signature:
73
- self._consecutive_duplicates += 1
75
+ # Only count as duplicate if not a read operation or if it's excessive
76
+ if not is_read_only:
77
+ self._consecutive_duplicates += 1
78
+ elif self._consecutive_duplicates >= 5: # Allow more reads before flagging
79
+ self._consecutive_duplicates += 1
74
80
  else:
75
81
  self._consecutive_duplicates = 0
76
82
  self._last_call_signature = call_sig
@@ -119,19 +125,19 @@ class TurnState:
119
125
  Returns:
120
126
  True if loop indicators are detected.
121
127
  """
122
- # Same tool+args called 3+ times consecutively
123
- if self._consecutive_duplicates >= 2: # 0-indexed, so 2 = 3 calls
128
+ # Same tool+args called 4+ times consecutively (increased from 3)
129
+ if self._consecutive_duplicates >= 3: # 0-indexed, so 3 = 4 calls
124
130
  return True
125
131
 
126
- # Same result hash repeated 3+ times in last 5 results
127
- if len(self.recent_result_hashes) >= 5:
128
- recent = self.recent_result_hashes[-5:]
132
+ # Same result hash repeated 4+ times in last 6 results (more lenient)
133
+ if len(self.recent_result_hashes) >= 6:
134
+ recent = self.recent_result_hashes[-6:]
129
135
  for h in set(recent):
130
- if recent.count(h) >= 3:
136
+ if recent.count(h) >= 4:
131
137
  return True
132
138
 
133
- # No new files touched in 5+ iterations with many tool calls
134
- return bool(self.iteration >= 5 and not self.files_modified and self.tool_count > 10)
139
+ # No new files touched in 7+ iterations with many tool calls (increased threshold)
140
+ return bool(self.iteration >= 7 and not self.files_modified and self.tool_count > 15)
135
141
 
136
142
  def get_adaptive_limit(self, base_limit: int = 25) -> int:
137
143
  """Get the adaptive iteration limit based on progress.