henchman-ai 0.1.11__py3-none-any.whl → 0.1.13__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/builtins.py +6 -0
- henchman/cli/commands/chat.py +50 -36
- henchman/cli/commands/rag.py +9 -4
- henchman/cli/console.py +5 -1
- henchman/cli/input.py +65 -0
- henchman/cli/repl.py +190 -33
- henchman/core/turn.py +15 -9
- henchman/rag/system.py +33 -1
- henchman/utils/compaction.py +4 -3
- henchman/version.py +1 -1
- {henchman_ai-0.1.11.dist-info → henchman_ai-0.1.13.dist-info}/METADATA +1 -1
- {henchman_ai-0.1.11.dist-info → henchman_ai-0.1.13.dist-info}/RECORD +16 -16
- {henchman_ai-0.1.11.dist-info → henchman_ai-0.1.13.dist-info}/WHEEL +0 -0
- {henchman_ai-0.1.11.dist-info → henchman_ai-0.1.13.dist-info}/entry_points.txt +0 -0
- {henchman_ai-0.1.11.dist-info → henchman_ai-0.1.13.dist-info}/licenses/LICENSE +0 -0
henchman/cli/app.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
5
6
|
import os
|
|
6
7
|
from typing import TYPE_CHECKING
|
|
7
8
|
|
|
@@ -58,15 +59,18 @@ def _get_provider() -> ModelProvider:
|
|
|
58
59
|
)
|
|
59
60
|
|
|
60
61
|
|
|
61
|
-
def _run_interactive(output_format: str, plan_mode: bool = False) -> None:
|
|
62
|
+
def _run_interactive(output_format: str, plan_mode: bool = False, yes: bool = False) -> None:
|
|
62
63
|
"""Run interactive REPL mode.
|
|
63
64
|
|
|
64
65
|
Args:
|
|
65
|
-
output_format: Output format (text, json, stream-json).
|
|
66
|
+
output_format: Output format (text, json, stream-json, json-complete).
|
|
66
67
|
plan_mode: Whether to start in plan mode.
|
|
68
|
+
yes: Auto-approve all tool executions.
|
|
67
69
|
"""
|
|
70
|
+
from pathlib import Path
|
|
68
71
|
from henchman.cli.repl import Repl, ReplConfig
|
|
69
72
|
from henchman.config import ContextLoader, load_settings
|
|
73
|
+
from henchman.core.session import SessionManager
|
|
70
74
|
from henchman.rag import initialize_rag
|
|
71
75
|
|
|
72
76
|
provider = _get_provider()
|
|
@@ -76,7 +80,7 @@ def _run_interactive(output_format: str, plan_mode: bool = False) -> None:
|
|
|
76
80
|
context_loader = ContextLoader()
|
|
77
81
|
system_prompt = context_loader.load()
|
|
78
82
|
|
|
79
|
-
config = ReplConfig(system_prompt=system_prompt)
|
|
83
|
+
config = ReplConfig(system_prompt=system_prompt, auto_approve_tools=yes)
|
|
80
84
|
repl = Repl(
|
|
81
85
|
provider=provider,
|
|
82
86
|
console=console,
|
|
@@ -84,8 +88,16 @@ def _run_interactive(output_format: str, plan_mode: bool = False) -> None:
|
|
|
84
88
|
settings=settings
|
|
85
89
|
)
|
|
86
90
|
|
|
91
|
+
# Initialize session management
|
|
92
|
+
session_manager = SessionManager()
|
|
93
|
+
project_hash = session_manager.compute_project_hash(Path.cwd())
|
|
94
|
+
session = session_manager.create_session(project_hash)
|
|
95
|
+
|
|
96
|
+
repl.session_manager = session_manager
|
|
97
|
+
repl.session = session
|
|
98
|
+
|
|
87
99
|
# Initialize RAG system
|
|
88
|
-
rag_system = initialize_rag(settings.rag, console=console)
|
|
100
|
+
rag_system = initialize_rag(settings.rag, console=console, index=False)
|
|
89
101
|
if rag_system:
|
|
90
102
|
repl.tool_registry.register(rag_system.search_tool)
|
|
91
103
|
repl.rag_system = rag_system
|
|
@@ -108,18 +120,21 @@ def _run_interactive(output_format: str, plan_mode: bool = False) -> None:
|
|
|
108
120
|
anyio.run(repl.run)
|
|
109
121
|
|
|
110
122
|
|
|
111
|
-
def _run_headless(prompt: str, output_format: str, plan_mode: bool = False) -> None:
|
|
123
|
+
def _run_headless(prompt: str, output_format: str, plan_mode: bool = False, yes: bool = False) -> None:
|
|
112
124
|
"""Run headless mode with a single prompt.
|
|
113
125
|
|
|
114
126
|
Args:
|
|
115
127
|
prompt: The prompt to process.
|
|
116
|
-
output_format: Output format (text, json, stream-json).
|
|
128
|
+
output_format: Output format (text, json, stream-json, json-complete).
|
|
117
129
|
plan_mode: Whether to run in plan mode.
|
|
130
|
+
yes: Auto-approve all tool executions.
|
|
118
131
|
"""
|
|
132
|
+
from pathlib import Path
|
|
119
133
|
from henchman.cli.json_output import JsonOutputRenderer
|
|
120
134
|
from henchman.cli.repl import Repl, ReplConfig
|
|
121
135
|
from henchman.config import ContextLoader, load_settings
|
|
122
136
|
from henchman.core.events import EventType
|
|
137
|
+
from henchman.core.session import SessionManager
|
|
123
138
|
from henchman.rag import initialize_rag
|
|
124
139
|
|
|
125
140
|
provider = _get_provider()
|
|
@@ -134,11 +149,19 @@ def _run_headless(prompt: str, output_format: str, plan_mode: bool = False) -> N
|
|
|
134
149
|
from henchman.cli.commands.plan import PLAN_MODE_PROMPT
|
|
135
150
|
system_prompt += PLAN_MODE_PROMPT
|
|
136
151
|
|
|
137
|
-
config = ReplConfig(system_prompt=system_prompt)
|
|
152
|
+
config = ReplConfig(system_prompt=system_prompt, auto_approve_tools=yes)
|
|
138
153
|
repl = Repl(provider=provider, console=console, config=config, settings=settings)
|
|
139
154
|
|
|
155
|
+
# Initialize session management
|
|
156
|
+
session_manager = SessionManager()
|
|
157
|
+
project_hash = session_manager.compute_project_hash(Path.cwd())
|
|
158
|
+
session = session_manager.create_session(project_hash)
|
|
159
|
+
|
|
160
|
+
repl.session_manager = session_manager
|
|
161
|
+
repl.session = session
|
|
162
|
+
|
|
140
163
|
# Initialize RAG system
|
|
141
|
-
rag_system = initialize_rag(settings.rag) # No console output in headless
|
|
164
|
+
rag_system = initialize_rag(settings.rag, index=False) # No console output in headless
|
|
142
165
|
if rag_system:
|
|
143
166
|
repl.tool_registry.register(rag_system.search_tool)
|
|
144
167
|
repl.rag_system = rag_system
|
|
@@ -150,23 +173,100 @@ def _run_headless(prompt: str, output_format: str, plan_mode: bool = False) -> N
|
|
|
150
173
|
|
|
151
174
|
async def run_single_prompt_text() -> None: # pragma: no cover
|
|
152
175
|
"""Process a single prompt and exit with text output."""
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
elif event.type == EventType.FINISHED:
|
|
157
|
-
console.print()
|
|
176
|
+
if repl.rag_system:
|
|
177
|
+
asyncio.create_task(repl.rag_system.index_async())
|
|
178
|
+
await repl.process_input(prompt)
|
|
158
179
|
|
|
159
180
|
async def run_single_prompt_json() -> None:
|
|
160
181
|
"""Process a single prompt and exit with JSON output."""
|
|
182
|
+
from henchman.core.events import AgentEvent, EventType
|
|
183
|
+
from henchman.providers.base import ToolCall
|
|
184
|
+
from collections.abc import AsyncIterator
|
|
185
|
+
|
|
186
|
+
if repl.rag_system:
|
|
187
|
+
asyncio.create_task(repl.rag_system.index_async())
|
|
188
|
+
|
|
161
189
|
json_renderer = JsonOutputRenderer(console)
|
|
162
|
-
|
|
163
|
-
|
|
190
|
+
|
|
191
|
+
# We need to manually run the tool loop for JSON output
|
|
192
|
+
# to ensure all events are rendered as JSON
|
|
193
|
+
async def run_and_render(stream: AsyncIterator[AgentEvent]) -> None:
|
|
194
|
+
pending_tool_calls = []
|
|
195
|
+
async for event in stream:
|
|
196
|
+
json_renderer.render(event)
|
|
197
|
+
if event.type == EventType.TOOL_CALL_REQUEST:
|
|
198
|
+
pending_tool_calls.append(event.data)
|
|
199
|
+
|
|
200
|
+
if pending_tool_calls:
|
|
201
|
+
# Execute tool calls
|
|
202
|
+
for tool_call in pending_tool_calls:
|
|
203
|
+
result = await repl.tool_registry.execute(tool_call.name, tool_call.arguments)
|
|
204
|
+
repl.agent.submit_tool_result(tool_call.id, result.content)
|
|
205
|
+
# Emit tool result event
|
|
206
|
+
json_renderer.render(AgentEvent(type=EventType.TOOL_RESULT, data=result))
|
|
207
|
+
|
|
208
|
+
# Continue
|
|
209
|
+
await run_and_render(repl.agent.continue_with_tool_results())
|
|
210
|
+
|
|
211
|
+
await run_and_render(repl.agent.run(prompt))
|
|
164
212
|
|
|
165
213
|
async def run_single_prompt_stream_json() -> None:
|
|
166
214
|
"""Process a single prompt and exit with streaming JSON output."""
|
|
215
|
+
from henchman.core.events import AgentEvent, EventType
|
|
216
|
+
from henchman.providers.base import ToolCall
|
|
217
|
+
from collections.abc import AsyncIterator
|
|
218
|
+
|
|
219
|
+
if repl.rag_system:
|
|
220
|
+
asyncio.create_task(repl.rag_system.index_async())
|
|
221
|
+
|
|
167
222
|
json_renderer = JsonOutputRenderer(console)
|
|
168
|
-
|
|
169
|
-
|
|
223
|
+
|
|
224
|
+
async def run_and_render_stream(stream: AsyncIterator[AgentEvent]) -> None:
|
|
225
|
+
pending_tool_calls = []
|
|
226
|
+
async for event in stream:
|
|
227
|
+
json_renderer.render_stream_json(event)
|
|
228
|
+
if event.type == EventType.TOOL_CALL_REQUEST:
|
|
229
|
+
pending_tool_calls.append(event.data)
|
|
230
|
+
|
|
231
|
+
if pending_tool_calls:
|
|
232
|
+
for tool_call in pending_tool_calls:
|
|
233
|
+
result = await repl.tool_registry.execute(tool_call.name, tool_call.arguments)
|
|
234
|
+
repl.agent.submit_tool_result(tool_call.id, result.content)
|
|
235
|
+
json_renderer.render_stream_json(AgentEvent(type=EventType.TOOL_RESULT, data=result))
|
|
236
|
+
|
|
237
|
+
await run_and_render_stream(repl.agent.continue_with_tool_results())
|
|
238
|
+
|
|
239
|
+
await run_and_render_stream(repl.agent.run(prompt))
|
|
240
|
+
|
|
241
|
+
async def run_single_prompt_json_complete() -> None:
|
|
242
|
+
"""Process a single prompt and exit with complete JSON output."""
|
|
243
|
+
from henchman.core.events import AgentEvent, EventType
|
|
244
|
+
from henchman.providers.base import ToolCall
|
|
245
|
+
from collections.abc import AsyncIterator
|
|
246
|
+
|
|
247
|
+
if repl.rag_system:
|
|
248
|
+
asyncio.create_task(repl.rag_system.index_async())
|
|
249
|
+
|
|
250
|
+
json_renderer = JsonOutputRenderer(console)
|
|
251
|
+
all_events: list[AgentEvent] = []
|
|
252
|
+
|
|
253
|
+
async def run_and_collect(stream: AsyncIterator[AgentEvent]) -> None:
|
|
254
|
+
pending_tool_calls = []
|
|
255
|
+
async for event in stream:
|
|
256
|
+
all_events.append(event)
|
|
257
|
+
if event.type == EventType.TOOL_CALL_REQUEST:
|
|
258
|
+
pending_tool_calls.append(event.data)
|
|
259
|
+
|
|
260
|
+
if pending_tool_calls:
|
|
261
|
+
for tool_call in pending_tool_calls:
|
|
262
|
+
result = await repl.tool_registry.execute(tool_call.name, tool_call.arguments)
|
|
263
|
+
repl.agent.submit_tool_result(tool_call.id, result.content)
|
|
264
|
+
all_events.append(AgentEvent(type=EventType.TOOL_RESULT, data=result))
|
|
265
|
+
|
|
266
|
+
await run_and_collect(repl.agent.continue_with_tool_results())
|
|
267
|
+
|
|
268
|
+
await run_and_collect(repl.agent.run(prompt))
|
|
269
|
+
json_renderer.render_final_json(all_events)
|
|
170
270
|
|
|
171
271
|
if output_format == "text":
|
|
172
272
|
anyio.run(run_single_prompt_text)
|
|
@@ -174,16 +274,18 @@ def _run_headless(prompt: str, output_format: str, plan_mode: bool = False) -> N
|
|
|
174
274
|
anyio.run(run_single_prompt_json)
|
|
175
275
|
elif output_format == "stream-json":
|
|
176
276
|
anyio.run(run_single_prompt_stream_json)
|
|
277
|
+
elif output_format == "json-complete":
|
|
278
|
+
anyio.run(run_single_prompt_json_complete)
|
|
177
279
|
else: # pragma: no cover
|
|
178
280
|
pass
|
|
179
281
|
|
|
180
282
|
|
|
181
283
|
@click.command()
|
|
182
|
-
@click.version_option(version=VERSION, prog_name="
|
|
284
|
+
@click.version_option(version=VERSION, prog_name="henchman")
|
|
183
285
|
@click.option("-p", "--prompt", help="Run with a single prompt and exit")
|
|
184
286
|
@click.option(
|
|
185
287
|
"--output-format",
|
|
186
|
-
type=click.Choice(["text", "json", "stream-json"]),
|
|
288
|
+
type=click.Choice(["text", "json", "stream-json", "json-complete"]),
|
|
187
289
|
default="text",
|
|
188
290
|
help="Output format for responses",
|
|
189
291
|
)
|
|
@@ -193,15 +295,22 @@ def _run_headless(prompt: str, output_format: str, plan_mode: bool = False) -> N
|
|
|
193
295
|
default=False,
|
|
194
296
|
help="Start in plan mode (read-only)",
|
|
195
297
|
)
|
|
196
|
-
|
|
298
|
+
@click.option(
|
|
299
|
+
"-y",
|
|
300
|
+
"--yes",
|
|
301
|
+
is_flag=True,
|
|
302
|
+
default=False,
|
|
303
|
+
help="Auto-approve all tool executions (non-interactive mode)",
|
|
304
|
+
)
|
|
305
|
+
def cli(prompt: str | None, output_format: str, plan: bool, yes: bool) -> None:
|
|
197
306
|
"""Henchman-AI: A model-agnostic AI agent CLI.
|
|
198
307
|
|
|
199
308
|
Start an interactive session or run with --prompt for headless mode.
|
|
200
309
|
"""
|
|
201
310
|
if prompt:
|
|
202
|
-
_run_headless(prompt, output_format, plan)
|
|
311
|
+
_run_headless(prompt, output_format, plan, yes)
|
|
203
312
|
else:
|
|
204
|
-
_run_interactive(output_format, plan)
|
|
313
|
+
_run_interactive(output_format, plan, yes)
|
|
205
314
|
|
|
206
315
|
|
|
207
316
|
def main() -> None: # pragma: no cover
|
|
@@ -6,6 +6,8 @@ This module provides the default slash commands like /help, /quit, /clear, /tool
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
8
|
from henchman.cli.commands import Command, CommandContext
|
|
9
|
+
from henchman.cli.commands.chat import ChatCommand
|
|
10
|
+
from henchman.cli.commands.mcp import McpCommand
|
|
9
11
|
from henchman.cli.commands.plan import PlanCommand
|
|
10
12
|
from henchman.cli.commands.rag import RagCommand
|
|
11
13
|
from henchman.cli.commands.skill import SkillCommand
|
|
@@ -53,6 +55,8 @@ class HelpCommand(Command):
|
|
|
53
55
|
ctx.console.print(" /plan - Toggle Plan Mode (Read-Only)")
|
|
54
56
|
ctx.console.print(" /rag - Manage semantic search index")
|
|
55
57
|
ctx.console.print(" /skill - Manage and execute learned skills")
|
|
58
|
+
ctx.console.print(" /chat - Manage chat sessions (save, list, resume)")
|
|
59
|
+
ctx.console.print(" /mcp - Manage MCP server connections")
|
|
56
60
|
ctx.console.print(" /quit - Exit the CLI")
|
|
57
61
|
ctx.console.print(" /clear - Clear the screen")
|
|
58
62
|
ctx.console.print(" /tools - List available tools")
|
|
@@ -206,6 +210,8 @@ def get_builtin_commands() -> list[Command]:
|
|
|
206
210
|
QuitCommand(),
|
|
207
211
|
ClearCommand(),
|
|
208
212
|
ToolsCommand(),
|
|
213
|
+
ChatCommand(),
|
|
214
|
+
McpCommand(),
|
|
209
215
|
PlanCommand(),
|
|
210
216
|
RagCommand(),
|
|
211
217
|
SkillCommand(),
|
henchman/cli/commands/chat.py
CHANGED
|
@@ -67,9 +67,9 @@ class ChatCommand(Command):
|
|
|
67
67
|
async def _show_help(self, ctx: CommandContext) -> None:
|
|
68
68
|
"""Show help for /chat command."""
|
|
69
69
|
ctx.console.print("\n[bold blue]Chat Session Commands[/]\n")
|
|
70
|
-
ctx.console.print(" /chat save [tag]
|
|
71
|
-
ctx.console.print(" /chat list
|
|
72
|
-
ctx.console.print(" /chat resume <tag> - Resume a saved session")
|
|
70
|
+
ctx.console.print(" /chat save [tag] - Save current session")
|
|
71
|
+
ctx.console.print(" /chat list - List saved sessions")
|
|
72
|
+
ctx.console.print(" /chat resume <tag|id> - Resume a saved session")
|
|
73
73
|
ctx.console.print("")
|
|
74
74
|
|
|
75
75
|
async def _save(self, ctx: CommandContext) -> None:
|
|
@@ -124,46 +124,60 @@ class ChatCommand(Command):
|
|
|
124
124
|
return
|
|
125
125
|
|
|
126
126
|
if len(ctx.args) < 2:
|
|
127
|
-
ctx.console.print("[yellow]Usage: /chat resume <tag>[/]")
|
|
127
|
+
ctx.console.print("[yellow]Usage: /chat resume <tag|id>[/]")
|
|
128
128
|
return
|
|
129
129
|
|
|
130
|
-
|
|
130
|
+
tag_or_id = ctx.args[1]
|
|
131
131
|
project_hash = getattr(ctx, "project_hash", None)
|
|
132
132
|
|
|
133
|
-
|
|
133
|
+
# Try loading by tag first
|
|
134
|
+
session = manager.load_by_tag(tag_or_id, project_hash)
|
|
135
|
+
|
|
136
|
+
# If that fails, try loading by ID
|
|
134
137
|
if session is None:
|
|
135
|
-
|
|
138
|
+
try:
|
|
139
|
+
session = manager.load(tag_or_id)
|
|
140
|
+
except (ValueError, FileNotFoundError):
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
if session is None:
|
|
144
|
+
ctx.console.print(f"[red]Session not found: {tag_or_id}[/]")
|
|
136
145
|
return
|
|
137
146
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
#
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
#
|
|
146
|
-
|
|
147
|
-
#
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
147
|
+
# Use repl.set_session if available to handle all sync logic
|
|
148
|
+
if ctx.repl and hasattr(ctx.repl, "set_session"):
|
|
149
|
+
ctx.repl.set_session(session)
|
|
150
|
+
else:
|
|
151
|
+
# Fallback for when REPL isn't available (e.g. some tests)
|
|
152
|
+
manager.set_current(session)
|
|
153
|
+
|
|
154
|
+
# Restore session messages to agent history
|
|
155
|
+
if ctx.agent is not None:
|
|
156
|
+
# Clear agent history (keeping system prompt)
|
|
157
|
+
ctx.agent.clear_history()
|
|
158
|
+
|
|
159
|
+
# Convert SessionMessage objects to Message objects
|
|
160
|
+
for session_msg in session.messages:
|
|
161
|
+
# Convert tool_calls from dicts to ToolCall objects if present
|
|
162
|
+
tool_calls = None
|
|
163
|
+
if session_msg.tool_calls:
|
|
164
|
+
tool_calls = [
|
|
165
|
+
ToolCall(
|
|
166
|
+
id=tc.get("id", ""),
|
|
167
|
+
name=tc.get("name", ""),
|
|
168
|
+
arguments=tc.get("arguments", {}),
|
|
169
|
+
)
|
|
170
|
+
for tc in session_msg.tool_calls
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
msg = Message(
|
|
174
|
+
role=session_msg.role,
|
|
175
|
+
content=session_msg.content,
|
|
176
|
+
tool_calls=tool_calls,
|
|
177
|
+
tool_call_id=session_msg.tool_call_id,
|
|
178
|
+
)
|
|
179
|
+
ctx.agent.messages.append(msg)
|
|
166
180
|
|
|
167
181
|
ctx.console.print(
|
|
168
|
-
f"[green]✓[/] Resumed session '{
|
|
182
|
+
f"[green]✓[/] Resumed session '{tag_or_id}' ({len(session.messages)} messages)"
|
|
169
183
|
)
|
henchman/cli/commands/rag.py
CHANGED
|
@@ -125,12 +125,17 @@ class RagCommand(Command):
|
|
|
125
125
|
)
|
|
126
126
|
return
|
|
127
127
|
|
|
128
|
+
if getattr(rag_system, "is_indexing", False):
|
|
129
|
+
ctx.console.print("[yellow]RAG indexing is already in progress in the background.[/]")
|
|
130
|
+
return
|
|
131
|
+
|
|
128
132
|
ctx.console.print("[dim]Forcing full reindex...[/]")
|
|
129
133
|
stats = rag_system.index(console=ctx.console, force=True)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
+
if stats:
|
|
135
|
+
ctx.console.print(
|
|
136
|
+
f"[green]Reindex complete: {stats.files_added} files, "
|
|
137
|
+
f"{stats.total_chunks} chunks[/]"
|
|
138
|
+
)
|
|
134
139
|
|
|
135
140
|
async def _clear(self, ctx: CommandContext) -> None:
|
|
136
141
|
"""Clear the RAG index."""
|
henchman/cli/console.py
CHANGED
|
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|
|
7
7
|
|
|
8
8
|
from dataclasses import dataclass
|
|
9
9
|
|
|
10
|
+
import anyio
|
|
10
11
|
from rich.console import Console
|
|
11
12
|
from rich.markdown import Markdown
|
|
12
13
|
from rich.markup import escape
|
|
@@ -302,4 +303,7 @@ class OutputRenderer:
|
|
|
302
303
|
True if confirmed.
|
|
303
304
|
"""
|
|
304
305
|
from rich.prompt import Confirm
|
|
305
|
-
|
|
306
|
+
import anyio
|
|
307
|
+
return await anyio.to_thread.run_sync(
|
|
308
|
+
lambda: Confirm.ask(message, console=self.console)
|
|
309
|
+
)
|
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 asyncio
|
|
8
9
|
import contextlib
|
|
9
10
|
import re
|
|
10
11
|
from pathlib import Path
|
|
@@ -168,3 +169,67 @@ class InputHandler:
|
|
|
168
169
|
The prompt string.
|
|
169
170
|
"""
|
|
170
171
|
return self._prompt
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class KeyMonitor:
|
|
175
|
+
"""Monitors for specific keys while an async task is running.
|
|
176
|
+
|
|
177
|
+
This is used to detect Escape or Ctrl+C while the agent is generating output.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def __init__(self) -> None:
|
|
181
|
+
"""Initialize the key monitor."""
|
|
182
|
+
from prompt_toolkit.input import create_input
|
|
183
|
+
self.input = create_input()
|
|
184
|
+
self._stop_event = asyncio.Event()
|
|
185
|
+
self._exit_event = asyncio.Event()
|
|
186
|
+
self._suspended = asyncio.Event()
|
|
187
|
+
self._suspended.set() # Not suspended initially
|
|
188
|
+
self._in_raw_mode = False
|
|
189
|
+
|
|
190
|
+
async def suspend(self) -> None:
|
|
191
|
+
"""Suspend monitoring to allow other input reading."""
|
|
192
|
+
self._suspended.clear()
|
|
193
|
+
# Wait for the monitor loop to exit the raw mode block
|
|
194
|
+
while self._in_raw_mode:
|
|
195
|
+
await asyncio.sleep(0.01)
|
|
196
|
+
|
|
197
|
+
def resume(self) -> None:
|
|
198
|
+
"""Resume monitoring."""
|
|
199
|
+
self._suspended.set()
|
|
200
|
+
|
|
201
|
+
async def monitor(self) -> None:
|
|
202
|
+
"""Monitor input for keys. Run this in a background task."""
|
|
203
|
+
while not self._stop_event.is_set() and not self._exit_event.is_set():
|
|
204
|
+
if not self._suspended.is_set():
|
|
205
|
+
await asyncio.sleep(0.1)
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
with self.input.raw_mode():
|
|
210
|
+
self._in_raw_mode = True
|
|
211
|
+
with self.input.attach(lambda: None):
|
|
212
|
+
while not self._stop_event.is_set() and not self._exit_event.is_set() and self._suspended.is_set():
|
|
213
|
+
# Check for keys every 100ms
|
|
214
|
+
keys = self.input.read_keys()
|
|
215
|
+
for key in keys:
|
|
216
|
+
if key.key == Keys.Escape:
|
|
217
|
+
self._stop_event.set()
|
|
218
|
+
elif key.key == Keys.ControlC:
|
|
219
|
+
self._exit_event.set()
|
|
220
|
+
await asyncio.sleep(0.1)
|
|
221
|
+
finally:
|
|
222
|
+
self._in_raw_mode = False
|
|
223
|
+
# If we were suspended, wait a bit before potentially re-entering raw mode
|
|
224
|
+
if not self._suspended.is_set():
|
|
225
|
+
await asyncio.sleep(0.1)
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def stop_requested(self) -> bool:
|
|
229
|
+
"""Check if Escape was pressed."""
|
|
230
|
+
return self._stop_event.is_set()
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def exit_requested(self) -> bool:
|
|
234
|
+
"""Check if Ctrl+C was pressed."""
|
|
235
|
+
return self._exit_event.is_set()
|
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
|
|
@@ -306,6 +390,12 @@ class Repl:
|
|
|
306
390
|
session=self.session,
|
|
307
391
|
repl=self,
|
|
308
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
|
+
|
|
309
399
|
await cmd.execute(ctx)
|
|
310
400
|
return True
|
|
311
401
|
|
|
@@ -322,16 +412,48 @@ class Repl:
|
|
|
322
412
|
# Collect assistant response - now also tracks tool calls for session
|
|
323
413
|
assistant_content: list[str] = []
|
|
324
414
|
|
|
325
|
-
|
|
326
|
-
|
|
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(
|
|
327
423
|
self.agent.run(user_input),
|
|
328
424
|
assistant_content
|
|
329
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
|
+
|
|
330
451
|
except Exception as e:
|
|
331
452
|
self.renderer.error(f"Error: {e}")
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
453
|
+
finally:
|
|
454
|
+
# Ensure monitor task is cleaned up
|
|
455
|
+
monitor._stop_event.set()
|
|
456
|
+
await monitor_task
|
|
335
457
|
|
|
336
458
|
async def _process_agent_stream(
|
|
337
459
|
self,
|
|
@@ -445,35 +567,38 @@ class Repl:
|
|
|
445
567
|
# Increment iteration counter (one batch of tool calls = one iteration)
|
|
446
568
|
self.agent.turn.increment_iteration()
|
|
447
569
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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)
|
|
459
592
|
self.agent.turn.record_tool_call(
|
|
460
|
-
tool_call_id=
|
|
461
|
-
tool_name=
|
|
462
|
-
arguments=
|
|
593
|
+
tool_call_id=tc.id,
|
|
594
|
+
tool_name=tc.name,
|
|
595
|
+
arguments=tc.arguments,
|
|
463
596
|
result=result,
|
|
464
597
|
)
|
|
465
|
-
|
|
466
|
-
# Submit result to agent
|
|
467
|
-
self.agent.submit_tool_result(tool_call.id, result.content)
|
|
468
|
-
|
|
469
|
-
# Record tool result to session
|
|
598
|
+
self.agent.submit_tool_result(tc.id, result.content)
|
|
470
599
|
if self.session is not None:
|
|
471
600
|
self.session.messages.append(
|
|
472
|
-
SessionMessage(
|
|
473
|
-
role="tool",
|
|
474
|
-
content=result.content,
|
|
475
|
-
tool_call_id=tool_call.id,
|
|
476
|
-
)
|
|
601
|
+
SessionMessage(role="tool", content=result.content, tool_call_id=tc.id)
|
|
477
602
|
)
|
|
478
603
|
|
|
479
604
|
# Show result
|
|
@@ -482,6 +607,38 @@ class Repl:
|
|
|
482
607
|
else:
|
|
483
608
|
self.renderer.error(f"[error] {result.error}")
|
|
484
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
|
+
|
|
485
642
|
# Show turn status after tool execution
|
|
486
643
|
self._show_turn_status()
|
|
487
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.
|
henchman/rag/system.py
CHANGED
|
@@ -6,6 +6,7 @@ the RAG system in the CLI.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import asyncio
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
from typing import TYPE_CHECKING
|
|
11
12
|
|
|
@@ -78,6 +79,7 @@ class RagSystem:
|
|
|
78
79
|
self.git_root = git_root
|
|
79
80
|
self.settings = settings
|
|
80
81
|
self.read_only = read_only
|
|
82
|
+
self.is_indexing = False
|
|
81
83
|
|
|
82
84
|
# Get cache directory
|
|
83
85
|
cache_dir = Path(settings.cache_dir) if settings.cache_dir else None
|
|
@@ -193,12 +195,37 @@ class RagSystem:
|
|
|
193
195
|
f"Could not acquire RAG lock at {self._lock.lock_path}"
|
|
194
196
|
)
|
|
195
197
|
|
|
198
|
+
self.is_indexing = True
|
|
196
199
|
try:
|
|
197
200
|
# Run indexing with lock held
|
|
198
201
|
return self._indexer.index(console=console, force=force)
|
|
199
202
|
finally:
|
|
200
203
|
# Always release the lock
|
|
201
204
|
self._lock.release()
|
|
205
|
+
self.is_indexing = False
|
|
206
|
+
|
|
207
|
+
async def index_async(
|
|
208
|
+
self,
|
|
209
|
+
console: Console | None = None,
|
|
210
|
+
force: bool = False,
|
|
211
|
+
skip_if_locked: bool = True,
|
|
212
|
+
) -> IndexStats | None:
|
|
213
|
+
"""Run indexing operation asynchronously in a separate thread.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
console: Rich console for progress display.
|
|
217
|
+
force: If True, force full reindex.
|
|
218
|
+
skip_if_locked: If True and lock cannot be acquired,
|
|
219
|
+
skip indexing and return None.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
Statistics about the indexing operation.
|
|
223
|
+
"""
|
|
224
|
+
loop = asyncio.get_running_loop()
|
|
225
|
+
return await loop.run_in_executor(
|
|
226
|
+
None,
|
|
227
|
+
lambda: self.index(console=console, force=force, skip_if_locked=skip_if_locked)
|
|
228
|
+
)
|
|
202
229
|
|
|
203
230
|
def get_stats(self) -> IndexStats:
|
|
204
231
|
"""Get current index statistics.
|
|
@@ -220,6 +247,7 @@ def initialize_rag(
|
|
|
220
247
|
settings: RagSettings,
|
|
221
248
|
console: Console | None = None,
|
|
222
249
|
git_root: Path | None = None,
|
|
250
|
+
index: bool = True,
|
|
223
251
|
) -> RagSystem | None:
|
|
224
252
|
"""Initialize the RAG system if in a git repository.
|
|
225
253
|
|
|
@@ -227,6 +255,7 @@ def initialize_rag(
|
|
|
227
255
|
settings: RAG settings from configuration.
|
|
228
256
|
console: Rich console for output.
|
|
229
257
|
git_root: Optional pre-computed git root.
|
|
258
|
+
index: Whether to run indexing immediately (blocking).
|
|
230
259
|
|
|
231
260
|
Returns:
|
|
232
261
|
RagSystem instance if successful, None if not in a git repo
|
|
@@ -253,7 +282,10 @@ def initialize_rag(
|
|
|
253
282
|
|
|
254
283
|
rag_system = RagSystem(git_root=root, settings=settings)
|
|
255
284
|
|
|
256
|
-
|
|
285
|
+
if not index:
|
|
286
|
+
return rag_system
|
|
287
|
+
|
|
288
|
+
# Run indexing (blocking)
|
|
257
289
|
stats = rag_system.index(console=console)
|
|
258
290
|
|
|
259
291
|
# Show summary
|
henchman/utils/compaction.py
CHANGED
|
@@ -259,13 +259,14 @@ class ContextCompactor:
|
|
|
259
259
|
)
|
|
260
260
|
protected_tokens = TokenCounter.count_messages(protected_msgs)
|
|
261
261
|
|
|
262
|
-
# Group unprotected messages into atomic sequences
|
|
263
|
-
sequences = self._group_into_sequences(unprotected_msgs)
|
|
264
|
-
|
|
265
262
|
# Separate system messages (always kept)
|
|
266
263
|
system_msgs = [msg for msg in unprotected_msgs if msg.role == "system"]
|
|
267
264
|
system_tokens = TokenCounter.count_messages(system_msgs)
|
|
268
265
|
|
|
266
|
+
# Group non-system unprotected messages into atomic sequences
|
|
267
|
+
non_system_unprotected = [msg for msg in unprotected_msgs if msg.role != "system"]
|
|
268
|
+
sequences = self._group_into_sequences(non_system_unprotected)
|
|
269
|
+
|
|
269
270
|
# Calculate budget for unprotected sequences
|
|
270
271
|
# Must fit: system + kept sequences + protected zone
|
|
271
272
|
budget = self.max_tokens - system_tokens - protected_tokens
|
henchman/version.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: henchman-ai
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.13
|
|
4
4
|
Summary: A model-agnostic AI agent CLI - your AI henchman for the terminal
|
|
5
5
|
Project-URL: Homepage, https://github.com/MGPowerlytics/henchman-ai
|
|
6
6
|
Project-URL: Repository, https://github.com/MGPowerlytics/henchman-ai
|
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
henchman/__init__.py,sha256=P_jCbtgAVbk2hn6uMum2UYkE7ptT361mWRkUZz0xKvk,148
|
|
2
2
|
henchman/__main__.py,sha256=3oRWZvoWON5ErlJFYOOSU5p1PERRyK6MkT2LGEnbb2o,131
|
|
3
|
-
henchman/version.py,sha256=
|
|
3
|
+
henchman/version.py,sha256=g1Eesg-ffAqlRayLSNQqW5rBPWm91ti9pjNfGtTq5W4,161
|
|
4
4
|
henchman/cli/__init__.py,sha256=Gv86a_heuBLqUd-y46JZUyzUaDl5H-9RtcWGr3rMwBw,673
|
|
5
|
-
henchman/cli/app.py,sha256=
|
|
6
|
-
henchman/cli/console.py,sha256=
|
|
7
|
-
henchman/cli/input.py,sha256=
|
|
5
|
+
henchman/cli/app.py,sha256=ausKDDrRJ5KgiespK2P9vhX1yn-DxdJhYyBJ6tB5sb4,11507
|
|
6
|
+
henchman/cli/console.py,sha256=S4Jvq0UTmu9KtOkLNsIsvG_8X9eg1Guc6NAh8T_JeNI,8017
|
|
7
|
+
henchman/cli/input.py,sha256=oMKMF1CQCZrON5gqy8mtbYqIoGUvXcBEiDZeTxC9B6s,7129
|
|
8
8
|
henchman/cli/json_output.py,sha256=9kP9S5q0xBgP4HQGTT4P6DDT76F9VVTdEY_KiEpoZnI,2669
|
|
9
9
|
henchman/cli/prompts.py,sha256=m3Velzi2tXBIHinN9jIpU9kDMYL80ngYQsv2EYo7IZU,6647
|
|
10
|
-
henchman/cli/repl.py,sha256=
|
|
10
|
+
henchman/cli/repl.py,sha256=fkeaMEGnFaFZ-HIjBLI1DUfos9ebGkHrPUUNMWS_LLU,26535
|
|
11
11
|
henchman/cli/repl.py.backup,sha256=3iagruUgsvtcfpDv1mTAYg4I14X4CaNSEeMQjj91src,15638
|
|
12
12
|
henchman/cli/repl.py.backup2,sha256=-zgSUrnobd_sHq3jG-8NbwPTVlPc3FaqSkv32gAFdPo,11328
|
|
13
13
|
henchman/cli/commands/__init__.py,sha256=8s6NBCPlc4jKTCdvnKJCmdLwRCQ4QLCARjQbr7ICipw,3828
|
|
14
|
-
henchman/cli/commands/builtins.py,sha256
|
|
15
|
-
henchman/cli/commands/chat.py,sha256=
|
|
14
|
+
henchman/cli/commands/builtins.py,sha256=-XOAY0EzvyHYnoOc6QMwVve7aMEWPYiMUUjor4OzBqg,5439
|
|
15
|
+
henchman/cli/commands/chat.py,sha256=ePPRh68ZHHS_l1Uj7fUtjBQrVKOx6WvZQsuIzXdxgjY,6204
|
|
16
16
|
henchman/cli/commands/extensions.py,sha256=r7PfvbBjwBr5WhF8G49p29z7FKx6geRJiR-R67pj6i0,1758
|
|
17
17
|
henchman/cli/commands/mcp.py,sha256=bbW1J9-fIpvDBIba3L1MAkNqCjFBTZnZLNIgf6LjJEA,3554
|
|
18
18
|
henchman/cli/commands/plan.py,sha256=5ZXePoMVIKBxugSnDB6N2TEDpl2xZszQDz9wTQffzpY,2486
|
|
19
|
-
henchman/cli/commands/rag.py,sha256=
|
|
19
|
+
henchman/cli/commands/rag.py,sha256=sXY7MCZ4UMVzNX2ALVM8wt7q82PZovwVHOSMDfot8jQ,7308
|
|
20
20
|
henchman/cli/commands/skill.py,sha256=azXb6-KXjtZKwHiBV-Ppk6CdJQKZhetr46hNgZ_r45Q,8096
|
|
21
21
|
henchman/cli/commands/unlimited.py,sha256=eFMTwrcUFWbfJnXpwBcRqviYt66tDz4xAYBDcton50Y,2101
|
|
22
22
|
henchman/config/__init__.py,sha256=28UtrhPye0MEmbdvi1jCqO3uIXfmqSAZVWvnpJv-qTo,637
|
|
@@ -28,7 +28,7 @@ henchman/core/agent.py,sha256=l9BJO8Zw4bMdUyTDjcZKG84WdZ1Kndm3Y09oUAZFYp0,13475
|
|
|
28
28
|
henchman/core/agent.py.backup,sha256=Tq0IhWAPMRQTxjETeH7WTosEmzuUVz7um0YbCnuNbLQ,7417
|
|
29
29
|
henchman/core/events.py,sha256=Uijv3NGNV8yJnQfY48u0pBBvEauAEczAbkGARJy8mfI,1423
|
|
30
30
|
henchman/core/session.py,sha256=NkwEG2ZS2uh2ZeX8_LkSN7MwQlBxwhTXjx0BoNUZLDw,12475
|
|
31
|
-
henchman/core/turn.py,sha256=
|
|
31
|
+
henchman/core/turn.py,sha256=iRaTr_8hGSGQUt0vqmUE8w5D-drvkLeZlNEHmNUfX0M,8617
|
|
32
32
|
henchman/extensions/__init__.py,sha256=C7LrK50uiHwmLlOGkQyngbFvuUYdCcZEb6ucOklY_ws,310
|
|
33
33
|
henchman/extensions/base.py,sha256=cHUzWu4OGFju9Wr1xAiGHZOOW7eQbcC1-dqEd2Oe3QM,2290
|
|
34
34
|
henchman/extensions/manager.py,sha256=xHxMo0-BxzyFfD3fZgvsiovORYpMM6sPnDPOTfULZSU,6666
|
|
@@ -52,7 +52,7 @@ henchman/rag/embedder.py,sha256=J2-cIEIoS2iUh4k6PM-rgl7wkTOXSG1NrOQvXHTQPho,4080
|
|
|
52
52
|
henchman/rag/indexer.py,sha256=6oVOkv4lD_elACivPL9Noe5zgpterYDZ3f1XlLyyULc,11806
|
|
53
53
|
henchman/rag/repo_id.py,sha256=ZRPKM8fzwmETgrOYwE1PGjRp3c8XQFrR493BrDZlbd8,5755
|
|
54
54
|
henchman/rag/store.py,sha256=eN0Rj2Lo6zJp2iWCXsJ-q24l2T_pnlTF3Oeea60gnfs,8826
|
|
55
|
-
henchman/rag/system.py,sha256=
|
|
55
|
+
henchman/rag/system.py,sha256=uEftMJJCy0wlHvt09j2YP7xDdN0if8eCYD4MnS7_Xvc,9869
|
|
56
56
|
henchman/skills/__init__.py,sha256=cvCl6HRxsUdag-RTpMP__Ww_hee37ggpAXQ41wXemEU,149
|
|
57
57
|
henchman/skills/executor.py,sha256=sYss_83zduFLB_AACTSXMZHLA_lv-T1iKHSxelpv13U,1105
|
|
58
58
|
henchman/skills/learner.py,sha256=lzIrLU5_oLbqDYF673F-rwb1IaWeeOqjzcsBGC-IKlM,1644
|
|
@@ -73,12 +73,12 @@ henchman/tools/builtins/rag_search.py,sha256=yk0z0mIVRH-yl47uteNXTy76aXP8PLxBq51
|
|
|
73
73
|
henchman/tools/builtins/shell.py,sha256=Gx8x1jBq1NvERFnc-kUNMovFoWg_i4IrV_askSECfEM,4134
|
|
74
74
|
henchman/tools/builtins/web_fetch.py,sha256=uwgZm0ye3yDuS2U2DPV4D-8bjviYDTKN-cNi7mCMRpw,3370
|
|
75
75
|
henchman/utils/__init__.py,sha256=ayu2XRNx3Fw0z8vbIne63A3gBjxu779QE8sUQsjNnm4,240
|
|
76
|
-
henchman/utils/compaction.py,sha256=
|
|
76
|
+
henchman/utils/compaction.py,sha256=ARS0jUDI2adsoCTfJjygRom31N16QtWbRzNXDKzX6cA,22871
|
|
77
77
|
henchman/utils/retry.py,sha256=sobZk9LLGxglSJw_jeNaBYCrvH14YNFrBVyp_OwLWcw,4993
|
|
78
78
|
henchman/utils/tokens.py,sha256=D9H4ciFNH7l1b05IGbw0U0tmy2yF5aItFZyDufGF53k,5665
|
|
79
79
|
henchman/utils/validation.py,sha256=moj4LQXVXt2J-3_pWVH_0-EabyRYApOU2Oh5JSTIua8,4146
|
|
80
|
-
henchman_ai-0.1.
|
|
81
|
-
henchman_ai-0.1.
|
|
82
|
-
henchman_ai-0.1.
|
|
83
|
-
henchman_ai-0.1.
|
|
84
|
-
henchman_ai-0.1.
|
|
80
|
+
henchman_ai-0.1.13.dist-info/METADATA,sha256=Gf-ViR-NIQx7-rnxdm0Otk39hZmRrfoFlShbOFSalkQ,3552
|
|
81
|
+
henchman_ai-0.1.13.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
82
|
+
henchman_ai-0.1.13.dist-info/entry_points.txt,sha256=dtPyd6BzK3A8lmrj1KXTFlHBplIWcWMdryjtR0jw5iU,51
|
|
83
|
+
henchman_ai-0.1.13.dist-info/licenses/LICENSE,sha256=TMoSCCG1I1vCMK-Bjtvxe80E8kIdSdrtuQXYHc_ahqg,1064
|
|
84
|
+
henchman_ai-0.1.13.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|