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/app.py +131 -22
- henchman/cli/commands/__init__.py +2 -0
- henchman/cli/commands/builtins.py +6 -0
- henchman/cli/commands/chat.py +50 -36
- henchman/cli/commands/rag.py +26 -20
- henchman/cli/console.py +11 -6
- henchman/cli/input.py +65 -0
- henchman/cli/prompts.py +171 -70
- henchman/cli/repl.py +191 -33
- henchman/core/turn.py +15 -9
- henchman/rag/concurrency.py +206 -0
- henchman/rag/repo_id.py +7 -7
- henchman/rag/store.py +45 -11
- henchman/rag/system.py +93 -7
- henchman/utils/compaction.py +4 -3
- henchman/version.py +1 -1
- {henchman_ai-0.1.10.dist-info → henchman_ai-0.1.12.dist-info}/METADATA +1 -1
- {henchman_ai-0.1.10.dist-info → henchman_ai-0.1.12.dist-info}/RECORD +21 -20
- {henchman_ai-0.1.10.dist-info → henchman_ai-0.1.12.dist-info}/WHEEL +0 -0
- {henchman_ai-0.1.10.dist-info → henchman_ai-0.1.12.dist-info}/entry_points.txt +0 -0
- {henchman_ai-0.1.10.dist-info → henchman_ai-0.1.12.dist-info}/licenses/LICENSE +0 -0
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
|
|
4
|
+
# Henchman CLI
|
|
5
5
|
|
|
6
|
-
##
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
- `
|
|
96
|
+
Parameters:
|
|
97
|
+
- `command` (required): The command to execute
|
|
98
|
+
- `timeout` (optional): Timeout in seconds (default: 60)
|
|
35
99
|
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
##
|
|
123
|
+
## Tool Selection Protocol
|
|
40
124
|
|
|
41
|
-
|
|
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
|
|
158
|
+
Skills let you replay proven solutions. Efficiency through repetition.
|
|
50
159
|
|
|
51
|
-
|
|
160
|
+
---
|
|
52
161
|
|
|
53
|
-
|
|
162
|
+
## Memory System
|
|
54
163
|
|
|
55
|
-
|
|
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
|
-
|
|
167
|
+
Strong memories appear in my context automatically. Manage them with `/memory` commands.
|
|
58
168
|
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
72
|
-
|
|
176
|
+
### Phase 1: Reconnaissance
|
|
177
|
+
Read the relevant files. Understand the terrain before making a move.
|
|
73
178
|
|
|
74
|
-
###
|
|
75
|
-
|
|
179
|
+
### Phase 2: Execution Plan
|
|
180
|
+
For complex tasks, state your approach in 1-3 sentences. No essays.
|
|
76
181
|
|
|
77
|
-
|
|
182
|
+
### Phase 3: Surgical Strike
|
|
183
|
+
Implement with precision. Use `edit_file` for targeted changes. Validate with `shell`.
|
|
78
184
|
|
|
79
|
-
### Phase
|
|
80
|
-
|
|
185
|
+
### Phase 4: Verification
|
|
186
|
+
Run tests. Confirm the mission is complete. Report results.
|
|
81
187
|
|
|
82
|
-
|
|
83
|
-
Write failing tests using pytest. Mock external APIs using `unittest.mock`. Set the trap before building the solution.
|
|
188
|
+
---
|
|
84
189
|
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
-
|
|
90
|
-
-
|
|
91
|
-
-
|
|
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
|
-
##
|
|
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
|
|
103
|
-
- `/clear` - Clear conversation history
|
|
104
|
-
- `/plan` - Toggle plan mode (read-only
|
|
105
|
-
- `/memory` - View and manage
|
|
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
|
-
|
|
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
|
-
#
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
325
|
-
|
|
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
|
-
|
|
333
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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=
|
|
460
|
-
tool_name=
|
|
461
|
-
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
|
-
|
|
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
|
|
123
|
-
if self._consecutive_duplicates >=
|
|
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
|
|
127
|
-
if len(self.recent_result_hashes) >=
|
|
128
|
-
recent = self.recent_result_hashes[-
|
|
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) >=
|
|
136
|
+
if recent.count(h) >= 4:
|
|
131
137
|
return True
|
|
132
138
|
|
|
133
|
-
# No new files touched in
|
|
134
|
-
return bool(self.iteration >=
|
|
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.
|