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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
henchman/cli/input.py CHANGED
@@ -5,6 +5,7 @@ This module handles user input including @ file references and ! shell commands.
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
+ import contextlib
8
9
  import re
9
10
  from pathlib import Path
10
11
  from typing import Any
@@ -124,9 +125,10 @@ def create_session(
124
125
  # Clear buffer if there is text
125
126
  buffer.text = ""
126
127
  else:
127
- # If buffer is empty, exit the application gracefully
128
- # This signals the user wants to quit
129
- event.app.exit(result="")
128
+ # If buffer is empty, return to prompt with empty result
129
+ # Use suppress to handle case where result is already set
130
+ with contextlib.suppress(Exception):
131
+ event.app.exit(result="")
130
132
 
131
133
  history = FileHistory(str(history_file)) if history_file else None
132
134
 
henchman/config/schema.py CHANGED
@@ -44,6 +44,9 @@ class ToolSettings(BaseModel):
44
44
  max_tool_calls_per_turn: Maximum tool calls allowed per turn.
45
45
  max_protected_ratio: Maximum ratio of context that can be protected.
46
46
  adaptive_limits: Whether to adjust limits based on progress detection.
47
+ network_retries: Number of retries for network tools (0 = no retries).
48
+ retry_base_delay: Base delay in seconds for retry backoff.
49
+ retry_max_delay: Maximum delay in seconds for retry backoff.
47
50
  """
48
51
 
49
52
  auto_approve_read: bool = True
@@ -53,6 +56,9 @@ class ToolSettings(BaseModel):
53
56
  max_tool_calls_per_turn: int = 100
54
57
  max_protected_ratio: float = 0.3
55
58
  adaptive_limits: bool = True
59
+ network_retries: int = 3
60
+ retry_base_delay: float = 1.0
61
+ retry_max_delay: float = 30.0
56
62
 
57
63
 
58
64
  class UISettings(BaseModel):
@@ -1,9 +1,12 @@
1
1
  """Tool registry for managing and executing tools."""
2
2
 
3
+ import asyncio
3
4
  from collections.abc import Awaitable, Callable
5
+ from dataclasses import dataclass
4
6
 
5
7
  from henchman.providers.base import ToolDeclaration
6
- from henchman.tools.base import ConfirmationRequest, Tool, ToolResult
8
+ from henchman.tools.base import ConfirmationRequest, Tool, ToolKind, ToolResult
9
+ from henchman.utils.retry import RetryConfig, retry_async
7
10
 
8
11
  # Type alias for confirmation handler
9
12
  ConfirmationHandler = Callable[[ConfirmationRequest], Awaitable[bool]]
@@ -12,6 +15,21 @@ ConfirmationHandler = Callable[[ConfirmationRequest], Awaitable[bool]]
12
15
  MAX_TOOL_OUTPUT = 50000
13
16
 
14
17
 
18
+ @dataclass
19
+ class BatchResult:
20
+ """Result of a batch tool execution.
21
+
22
+ Attributes:
23
+ results: Mapping of tool names to their results.
24
+ successful: Number of successful executions.
25
+ failed: Number of failed executions.
26
+ """
27
+
28
+ results: dict[str, ToolResult]
29
+ successful: int
30
+ failed: int
31
+
32
+
15
33
  class ToolRegistry:
16
34
  """Registry for managing tools and their execution.
17
35
 
@@ -26,12 +44,29 @@ class ToolRegistry:
26
44
  >>> result = await registry.execute("read_file", {"path": "/etc/hosts"})
27
45
  """
28
46
 
29
- def __init__(self) -> None:
30
- """Initialize an empty tool registry."""
47
+ def __init__(
48
+ self,
49
+ retry_config: RetryConfig | None = None,
50
+ ) -> None:
51
+ """Initialize an empty tool registry.
52
+
53
+ Args:
54
+ retry_config: Optional retry configuration for network tools.
55
+ If None, uses defaults (3 retries, 1s base delay).
56
+ """
31
57
  self._tools: dict[str, Tool] = {}
32
58
  self._confirmation_handler: ConfirmationHandler | None = None
33
59
  self._auto_approve_policies: set[str] = set()
34
60
  self._plan_mode: bool = False
61
+ self._retry_config = retry_config or RetryConfig()
62
+
63
+ def set_retry_config(self, config: RetryConfig) -> None:
64
+ """Set the retry configuration for network tools.
65
+
66
+ Args:
67
+ config: Retry configuration.
68
+ """
69
+ self._retry_config = config
35
70
 
36
71
  def set_plan_mode(self, enabled: bool) -> None:
37
72
  """Enable or disable Plan Mode (Read-Only).
@@ -143,7 +178,7 @@ class ToolRegistry:
143
178
  2. Check Plan Mode restrictions
144
179
  3. Check if confirmation is needed (unless auto-approved or READ tool)
145
180
  4. Call confirmation handler if needed
146
- 5. Execute the tool if approved
181
+ 5. Execute the tool if approved (with retries for NETWORK tools)
147
182
 
148
183
  Args:
149
184
  name: The name of the tool to execute.
@@ -161,9 +196,8 @@ class ToolRegistry:
161
196
  )
162
197
 
163
198
  # Check Plan Mode
164
- from henchman.tools.base import ToolKind
165
199
  if self._plan_mode and tool.kind in (ToolKind.WRITE, ToolKind.EXECUTE, ToolKind.NETWORK):
166
- return ToolResult(
200
+ return ToolResult(
167
201
  content=f"Tool '{name}' is disabled in Plan Mode. Use /plan to toggle.",
168
202
  success=False,
169
203
  error="Tool disabled in Plan Mode",
@@ -181,8 +215,21 @@ class ToolRegistry:
181
215
  error="Execution denied by user",
182
216
  )
183
217
 
184
- # Execute the tool
185
- result = await tool.execute(**params)
218
+ # Execute the tool (with retries for NETWORK tools)
219
+ try:
220
+ if tool.kind == ToolKind.NETWORK and self._retry_config.max_retries > 0:
221
+ result = await retry_async(
222
+ lambda: tool.execute(**params),
223
+ config=self._retry_config,
224
+ )
225
+ else:
226
+ result = await tool.execute(**params)
227
+ except Exception as exc:
228
+ return ToolResult(
229
+ content=f"Error executing tool '{name}': {exc}",
230
+ success=False,
231
+ error=str(exc),
232
+ )
186
233
 
187
234
  # Truncate large outputs to prevent context overflow
188
235
  if result.content and len(result.content) > MAX_TOOL_OUTPUT:
@@ -196,3 +243,59 @@ class ToolRegistry:
196
243
  )
197
244
 
198
245
  return result
246
+
247
+ async def execute_batch(
248
+ self,
249
+ calls: list[tuple[str, dict[str, object]]],
250
+ ) -> BatchResult:
251
+ """Execute multiple tools in parallel.
252
+
253
+ Independent tool calls are executed concurrently using asyncio.gather().
254
+ This is more efficient than sequential execution for I/O-bound operations.
255
+
256
+ Args:
257
+ calls: List of (tool_name, params) tuples to execute.
258
+
259
+ Returns:
260
+ BatchResult containing individual results and success/failure counts.
261
+
262
+ Example:
263
+ >>> results = await registry.execute_batch([
264
+ ... ("read_file", {"path": "/etc/hosts"}),
265
+ ... ("read_file", {"path": "/etc/passwd"}),
266
+ ... ("glob", {"pattern": "*.py"}),
267
+ ... ])
268
+ >>> print(f"Completed: {results.successful} succeeded, {results.failed} failed")
269
+ """
270
+ if not calls:
271
+ return BatchResult(results={}, successful=0, failed=0)
272
+
273
+ # Execute all tools concurrently
274
+ tasks = [self.execute(name, params) for name, params in calls]
275
+ results_list = await asyncio.gather(*tasks, return_exceptions=True)
276
+
277
+ # Collect results
278
+ results: dict[str, ToolResult] = {}
279
+ successful = 0
280
+ failed = 0
281
+
282
+ for i, result in enumerate(results_list):
283
+ tool_name = calls[i][0]
284
+ key = f"{tool_name}_{i}" # Unique key for duplicate tool names
285
+
286
+ if isinstance(result, BaseException):
287
+ results[key] = ToolResult(
288
+ content=f"Error: {result}",
289
+ success=False,
290
+ error=str(result),
291
+ )
292
+ failed += 1
293
+ else:
294
+ tool_result: ToolResult = result
295
+ results[key] = tool_result
296
+ if tool_result.success:
297
+ successful += 1
298
+ else:
299
+ failed += 1
300
+
301
+ return BatchResult(results=results, successful=successful, failed=failed)
@@ -1 +1,15 @@
1
1
  """Utility functions and helpers."""
2
+
3
+ from henchman.utils.retry import (
4
+ RetryConfig,
5
+ calculate_delay,
6
+ retry_async,
7
+ with_retry,
8
+ )
9
+
10
+ __all__ = [
11
+ "RetryConfig",
12
+ "calculate_delay",
13
+ "retry_async",
14
+ "with_retry",
15
+ ]
@@ -0,0 +1,166 @@
1
+ """Retry utilities with exponential backoff for resilient operations.
2
+
3
+ This module provides an async retry decorator with configurable exponential
4
+ backoff for handling transient failures in network operations and other
5
+ potentially flaky operations.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import functools
12
+ import random
13
+ from collections.abc import Awaitable, Callable
14
+ from dataclasses import dataclass
15
+ from typing import ParamSpec, TypeVar
16
+
17
+ P = ParamSpec("P")
18
+ R = TypeVar("R")
19
+
20
+
21
+ @dataclass
22
+ class RetryConfig:
23
+ """Configuration for retry behavior.
24
+
25
+ Attributes:
26
+ max_retries: Maximum number of retry attempts (0 = no retries).
27
+ base_delay: Base delay in seconds between retries.
28
+ max_delay: Maximum delay in seconds (cap for exponential growth).
29
+ exponential_base: Base for exponential backoff (default 2.0).
30
+ jitter: Whether to add random jitter to delay (prevents thundering herd).
31
+ retryable_exceptions: Tuple of exception types to retry on.
32
+ """
33
+
34
+ max_retries: int = 3
35
+ base_delay: float = 1.0
36
+ max_delay: float = 30.0
37
+ exponential_base: float = 2.0
38
+ jitter: bool = True
39
+ retryable_exceptions: tuple[type[Exception], ...] = (
40
+ ConnectionError,
41
+ TimeoutError,
42
+ OSError,
43
+ )
44
+
45
+
46
+ def calculate_delay(attempt: int, config: RetryConfig) -> float:
47
+ """Calculate delay for a retry attempt with exponential backoff.
48
+
49
+ Args:
50
+ attempt: The current retry attempt number (0-indexed).
51
+ config: Retry configuration.
52
+
53
+ Returns:
54
+ Delay in seconds before the next retry.
55
+
56
+ Example:
57
+ >>> cfg = RetryConfig(base_delay=1.0, exponential_base=2.0, jitter=False)
58
+ >>> calculate_delay(0, cfg)
59
+ 1.0
60
+ >>> calculate_delay(1, cfg)
61
+ 2.0
62
+ >>> calculate_delay(2, cfg)
63
+ 4.0
64
+ """
65
+ delay = config.base_delay * (config.exponential_base**attempt)
66
+ delay = min(delay, config.max_delay)
67
+
68
+ if config.jitter:
69
+ # Add random jitter between 0-50% of the delay
70
+ jitter_amount = delay * random.uniform(0, 0.5) # noqa: S311
71
+ delay += jitter_amount
72
+
73
+ return delay
74
+
75
+
76
+ def with_retry(
77
+ config: RetryConfig | None = None,
78
+ ) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]:
79
+ """Decorator for adding retry logic with exponential backoff.
80
+
81
+ Args:
82
+ config: Retry configuration. If None, uses defaults.
83
+
84
+ Returns:
85
+ Decorated async function with retry logic.
86
+
87
+ Example:
88
+ >>> @with_retry(RetryConfig(max_retries=3))
89
+ ... async def fetch_data(url: str) -> str:
90
+ ... # Network operation that might fail
91
+ ... return await http_get(url)
92
+ """
93
+ if config is None:
94
+ config = RetryConfig()
95
+
96
+ def decorator(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
97
+ """Inner decorator that wraps the function with retry logic."""
98
+ @functools.wraps(func)
99
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
100
+ """Wrapper function that implements retry with exponential backoff."""
101
+ last_exception: Exception | None = None
102
+
103
+ for attempt in range(config.max_retries + 1):
104
+ try:
105
+ return await func(*args, **kwargs)
106
+ except config.retryable_exceptions as exc:
107
+ last_exception = exc
108
+
109
+ if attempt < config.max_retries:
110
+ delay = calculate_delay(attempt, config)
111
+ await asyncio.sleep(delay)
112
+ # On last attempt, fall through to raise
113
+
114
+ # Should never reach here without an exception, but satisfy type checker
115
+ if last_exception is not None:
116
+ raise last_exception
117
+ msg = "Retry logic error: no exception captured"
118
+ raise RuntimeError(msg) # pragma: no cover
119
+
120
+ return wrapper
121
+
122
+ return decorator
123
+
124
+
125
+ async def retry_async(
126
+ func: Callable[[], Awaitable[R]],
127
+ config: RetryConfig | None = None,
128
+ ) -> R:
129
+ """Execute an async function with retry logic.
130
+
131
+ This is a functional alternative to the decorator for cases where
132
+ you want to retry a specific call rather than decorating a function.
133
+
134
+ Args:
135
+ func: Zero-argument async callable to execute.
136
+ config: Retry configuration. If None, uses defaults.
137
+
138
+ Returns:
139
+ Result of the function call.
140
+
141
+ Raises:
142
+ Exception: The last exception if all retries fail.
143
+
144
+ Example:
145
+ >>> result = await retry_async(
146
+ ... lambda: fetch_data("https://api.example.com"),
147
+ ... config=RetryConfig(max_retries=3)
148
+ ... )
149
+ """
150
+ if config is None:
151
+ config = RetryConfig()
152
+
153
+ @with_retry(config)
154
+ async def wrapped() -> R:
155
+ """Wrapped function with retry logic applied."""
156
+ return await func()
157
+
158
+ return await wrapped()
159
+
160
+
161
+ __all__ = [
162
+ "RetryConfig",
163
+ "calculate_delay",
164
+ "retry_async",
165
+ "with_retry",
166
+ ]
henchman/version.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """Version information for Henchman-AI."""
2
2
 
3
- VERSION_TUPLE = (0, 1, 7)
3
+ VERSION_TUPLE = (0, 1, 9)
4
4
  VERSION = ".".join(str(v) for v in VERSION_TUPLE)
5
5
 
6
6
  __all__ = ["VERSION", "VERSION_TUPLE"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: henchman-ai
3
- Version: 0.1.8
3
+ Version: 0.1.9
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,10 +1,10 @@
1
1
  henchman/__init__.py,sha256=P_jCbtgAVbk2hn6uMum2UYkE7ptT361mWRkUZz0xKvk,148
2
2
  henchman/__main__.py,sha256=3oRWZvoWON5ErlJFYOOSU5p1PERRyK6MkT2LGEnbb2o,131
3
- henchman/version.py,sha256=__LbucVLec_Xjo5kM2xlVJLc9NovQpC_LE82ONoodKg,160
3
+ henchman/version.py,sha256=FD_uykl80efNIvznILv6PLP9U62_N9a6PJtpNG-nhlA,160
4
4
  henchman/cli/__init__.py,sha256=Gv86a_heuBLqUd-y46JZUyzUaDl5H-9RtcWGr3rMwBw,673
5
5
  henchman/cli/app.py,sha256=AFiMOfqYdwJrzcp5LRqwgwic2A6yhAUr_01w6BQwPq8,6097
6
6
  henchman/cli/console.py,sha256=TOuGBSNUaxxQypmmzC0P1IY7tBNlaTgAZesKy8uuZN4,7850
7
- henchman/cli/input.py,sha256=uALc_0URsLvlCZ8geW4xtkzWgX6lFWb0b1TCz6GzdMA,4538
7
+ henchman/cli/input.py,sha256=0qW36f7f06ct4XXca7ooxkTShID-QXkLtmROh_xso04,4632
8
8
  henchman/cli/json_output.py,sha256=9kP9S5q0xBgP4HQGTT4P6DDT76F9VVTdEY_KiEpoZnI,2669
9
9
  henchman/cli/prompts.py,sha256=AxUN-JfWSetOgIwhVxgouQetNqY8hTc7FnLO5jb00LI,5402
10
10
  henchman/cli/repl.py,sha256=7nO2Lfy5pUoZiSmWH1ne755_g2SRldMB9CIgz1GZXDg,19023
@@ -20,7 +20,7 @@ henchman/cli/commands/skill.py,sha256=azXb6-KXjtZKwHiBV-Ppk6CdJQKZhetr46hNgZ_r45
20
20
  henchman/cli/commands/unlimited.py,sha256=eFMTwrcUFWbfJnXpwBcRqviYt66tDz4xAYBDcton50Y,2101
21
21
  henchman/config/__init__.py,sha256=Q3eooEkht80FPzLJQgc-JNFfnFXS6uJlXyQyGl58QYk,557
22
22
  henchman/config/context.py,sha256=bJSV56bQFAgECNMDBgM2ZwIkn0h50uibUtUWaQnMq_0,5007
23
- henchman/config/schema.py,sha256=tlVriPdxCc8YvrWhDnGDdYx2nLLfux4L95hCrTdrbJg,4028
23
+ henchman/config/schema.py,sha256=Ur-GP9aQv5WVAr9k6ordgK1iHkl3L9UWVDxOEYYe-Zw,4340
24
24
  henchman/config/settings.py,sha256=KwlWlX3Ogqb_ByIchX9i_p8VM16umDNOa0n-Q4SrxTs,4501
25
25
  henchman/core/__init__.py,sha256=BtOHfsJB6eW-QhtK4sbqOzzO6GLqWeVLkewOA-wuqL0,521
26
26
  henchman/core/agent.py,sha256=l9BJO8Zw4bMdUyTDjcZKG84WdZ1Kndm3Y09oUAZFYp0,13475
@@ -51,7 +51,7 @@ henchman/skills/models.py,sha256=Vvr6ObxQe5G8EwuLW2Odzpej3CfyqGkgXgCQcY91wps,833
51
51
  henchman/skills/store.py,sha256=z_qHnVdyHAm-jtGNy5b33d9jQ-3pN-mkjrBAzc7CA5U,5331
52
52
  henchman/tools/__init__.py,sha256=NLQuYRtPECju8TZHg-ZgVUrtdlHdMTu7P8C3D4Nhdqg,440
53
53
  henchman/tools/base.py,sha256=PPeKS6jOCv3jFSgjd5j3kzgjTzw7VpBAY3ikmeDlIcI,4820
54
- henchman/tools/registry.py,sha256=FNcfoqwD9JUh-NrWsx2Vkon3juypWxhrTofrKxWg9Ac,6739
54
+ henchman/tools/registry.py,sha256=YrDz29mwMGPGdJB5RD0QlakHiJ-24p3d_jnIaMY05hI,10228
55
55
  henchman/tools/builtins/__init__.py,sha256=EtrnR1rtFh0uBqDembg236HnZfQXSLUHJachESvYG28,715
56
56
  henchman/tools/builtins/ask_user.py,sha256=xPu74cB0rYahZHajVdjKgdmKU121SWyAgZSkU_wBYjQ,3007
57
57
  henchman/tools/builtins/file_edit.py,sha256=VjfpYVZulpIBufRSIsTx9eD5gYGnSybksyo5vGCL4wo,3709
@@ -62,12 +62,13 @@ henchman/tools/builtins/grep.py,sha256=PV8X2ydnAutrWCS5VR9lABFpfSv0Olzsqa1Ktb5X4
62
62
  henchman/tools/builtins/ls.py,sha256=5iSqHilrEiZ8ziOG4nKwC90fuLEx01V_0BzfS2PNAro,4167
63
63
  henchman/tools/builtins/shell.py,sha256=Gx8x1jBq1NvERFnc-kUNMovFoWg_i4IrV_askSECfEM,4134
64
64
  henchman/tools/builtins/web_fetch.py,sha256=uwgZm0ye3yDuS2U2DPV4D-8bjviYDTKN-cNi7mCMRpw,3370
65
- henchman/utils/__init__.py,sha256=tqyNdgGqZrcISSg2vBtMlVxsOvwaLo3zjqIk5f3QkhM,37
65
+ henchman/utils/__init__.py,sha256=ayu2XRNx3Fw0z8vbIne63A3gBjxu779QE8sUQsjNnm4,240
66
66
  henchman/utils/compaction.py,sha256=jPpJ5tQm-IBn4YChiGrKy8u_K4OJ23lk3Jvq8sNbQYc,22763
67
+ henchman/utils/retry.py,sha256=sobZk9LLGxglSJw_jeNaBYCrvH14YNFrBVyp_OwLWcw,4993
67
68
  henchman/utils/tokens.py,sha256=D9H4ciFNH7l1b05IGbw0U0tmy2yF5aItFZyDufGF53k,5665
68
69
  henchman/utils/validation.py,sha256=moj4LQXVXt2J-3_pWVH_0-EabyRYApOU2Oh5JSTIua8,4146
69
- henchman_ai-0.1.8.dist-info/METADATA,sha256=iMG-vJgyJRKxO3-sah01k1fyqfHTtTcMH21DXdy4Hr0,3492
70
- henchman_ai-0.1.8.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
71
- henchman_ai-0.1.8.dist-info/entry_points.txt,sha256=dtPyd6BzK3A8lmrj1KXTFlHBplIWcWMdryjtR0jw5iU,51
72
- henchman_ai-0.1.8.dist-info/licenses/LICENSE,sha256=TMoSCCG1I1vCMK-Bjtvxe80E8kIdSdrtuQXYHc_ahqg,1064
73
- henchman_ai-0.1.8.dist-info/RECORD,,
70
+ henchman_ai-0.1.9.dist-info/METADATA,sha256=C1bCnWJpK3B-vjpJ0deP2OWikysCGW9hOkBkbfAB9pQ,3492
71
+ henchman_ai-0.1.9.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
72
+ henchman_ai-0.1.9.dist-info/entry_points.txt,sha256=dtPyd6BzK3A8lmrj1KXTFlHBplIWcWMdryjtR0jw5iU,51
73
+ henchman_ai-0.1.9.dist-info/licenses/LICENSE,sha256=TMoSCCG1I1vCMK-Bjtvxe80E8kIdSdrtuQXYHc_ahqg,1064
74
+ henchman_ai-0.1.9.dist-info/RECORD,,