python-agent-harness 1.5.0__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.
- python_agent_harness/__init__.py +20 -0
- python_agent_harness/__main__.py +5 -0
- python_agent_harness/agent.py +703 -0
- python_agent_harness/cli.py +273 -0
- python_agent_harness/client.py +832 -0
- python_agent_harness/commands.py +181 -0
- python_agent_harness/config.py +464 -0
- python_agent_harness/context_manager.py +100 -0
- python_agent_harness/diffrender.py +84 -0
- python_agent_harness/mcp/__init__.py +21 -0
- python_agent_harness/mcp/client.py +161 -0
- python_agent_harness/mcp/config.py +130 -0
- python_agent_harness/mcp/manager.py +290 -0
- python_agent_harness/models.py +149 -0
- python_agent_harness/persistence.py +297 -0
- python_agent_harness/planmode.py +112 -0
- python_agent_harness/prompts/agent.md +362 -0
- python_agent_harness/prompts/build-switch.md +5 -0
- python_agent_harness/prompts/commands/explain.md +13 -0
- python_agent_harness/prompts/compact.md +33 -0
- python_agent_harness/prompts/initialize.md +66 -0
- python_agent_harness/prompts/plan-mode.md +70 -0
- python_agent_harness/prompts/plan.md +26 -0
- python_agent_harness/prompts/review.md +100 -0
- python_agent_harness/prompts/subagent.md +208 -0
- python_agent_harness/prompts/summary.md +11 -0
- python_agent_harness/prompts/task-completion-rules.md +50 -0
- python_agent_harness/prompts/title.md +44 -0
- python_agent_harness/prompts.py +498 -0
- python_agent_harness/session.py +781 -0
- python_agent_harness/subagent.py +61 -0
- python_agent_harness/token_estimator.py +125 -0
- python_agent_harness/tool_runner.py +247 -0
- python_agent_harness/tools/__init__.py +56 -0
- python_agent_harness/tools/agent_tool.py +75 -0
- python_agent_harness/tools/base.py +147 -0
- python_agent_harness/tools/bash.py +298 -0
- python_agent_harness/tools/edit.py +272 -0
- python_agent_harness/tools/filesystem.py +180 -0
- python_agent_harness/tools/glob.py +161 -0
- python_agent_harness/tools/grep.py +149 -0
- python_agent_harness/tools/insert.py +61 -0
- python_agent_harness/tools/mcp.py +203 -0
- python_agent_harness/tools/mkdir.py +30 -0
- python_agent_harness/tools/planexit.py +45 -0
- python_agent_harness/tools/question.py +70 -0
- python_agent_harness/tools/read.py +104 -0
- python_agent_harness/tools/skill.py +32 -0
- python_agent_harness/tools/todo.py +60 -0
- python_agent_harness/tools/write.py +56 -0
- python_agent_harness/tui/__init__.py +68 -0
- python_agent_harness/tui/commands.py +652 -0
- python_agent_harness/tui/core.py +385 -0
- python_agent_harness/tui/input.py +412 -0
- python_agent_harness/tui/render.py +535 -0
- python_agent_harness-1.5.0.dist-info/METADATA +251 -0
- python_agent_harness-1.5.0.dist-info/RECORD +61 -0
- python_agent_harness-1.5.0.dist-info/WHEEL +5 -0
- python_agent_harness-1.5.0.dist-info/entry_points.txt +2 -0
- python_agent_harness-1.5.0.dist-info/licenses/LICENSE +21 -0
- python_agent_harness-1.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Tool base classes and the tool registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..models import ToolSpec
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PendingToolResult:
|
|
13
|
+
"""Handle for an asynchronous tool result (mirrors ``:async t``).
|
|
14
|
+
|
|
15
|
+
An async tool's ``run`` returns this handle instead of a string: it
|
|
16
|
+
starts its background work (e.g. a spawned process) and returns
|
|
17
|
+
immediately, then delivers the final result string later via
|
|
18
|
+
``deliver`` — so the wait never blocks the sequential tool loop.
|
|
19
|
+
|
|
20
|
+
``deliver`` is idempotent (first delivery wins, late duplicates are
|
|
21
|
+
no-ops — mirroring the gptel-agent FSM's idempotent-result advice);
|
|
22
|
+
``wait`` blocks until the result has been delivered.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
self._event = threading.Event()
|
|
27
|
+
self._result: str | None = None
|
|
28
|
+
|
|
29
|
+
def deliver(self, result: str) -> None:
|
|
30
|
+
if not self._event.is_set():
|
|
31
|
+
self._result = result
|
|
32
|
+
self._event.set()
|
|
33
|
+
|
|
34
|
+
def wait(self) -> str:
|
|
35
|
+
self._event.wait()
|
|
36
|
+
return self._result or ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ToolContext:
|
|
40
|
+
"""Runtime context handed to tools.
|
|
41
|
+
|
|
42
|
+
Tools may call back into the session for user questions,
|
|
43
|
+
plan-mode checks, and sub-agent delegation. All methods
|
|
44
|
+
proxy to the session when present; defaults are safe no-ops.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, session: Any = None) -> None:
|
|
48
|
+
self.session = session
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def cwd(self) -> str:
|
|
52
|
+
return self.session.project_dir if self.session else "."
|
|
53
|
+
|
|
54
|
+
def ask_questions(self, questions: list[dict]) -> str:
|
|
55
|
+
if self.session and hasattr(self.session, "ask_questions"):
|
|
56
|
+
return self.session.ask_questions(questions)
|
|
57
|
+
return "Unanswered"
|
|
58
|
+
|
|
59
|
+
def record_diff(self, diff_text: str) -> None:
|
|
60
|
+
"""Attach a unified diff to the currently-executing tool call."""
|
|
61
|
+
if self.session and hasattr(self.session, "record_diff"):
|
|
62
|
+
self.session.record_diff(diff_text)
|
|
63
|
+
|
|
64
|
+
def update_todos(self, todos: list[dict]) -> None:
|
|
65
|
+
if self.session and hasattr(self.session, "update_todos"):
|
|
66
|
+
self.session.update_todos(todos)
|
|
67
|
+
|
|
68
|
+
def find_skill(self, name: str) -> str | None:
|
|
69
|
+
if self.session and hasattr(self.session, "find_skill"):
|
|
70
|
+
return self.session.find_skill(name)
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
def run_subagent(self, subagent_type: str, description: str, prompt: str) -> str:
|
|
74
|
+
if self.session and hasattr(self.session, "run_subagent"):
|
|
75
|
+
return self.session.run_subagent(subagent_type, description, prompt)
|
|
76
|
+
return f"Error: Task {description!r} returned an unexpected response — no session"
|
|
77
|
+
|
|
78
|
+
def plan_exit(self) -> str:
|
|
79
|
+
if self.session and hasattr(self.session, "plan_exit"):
|
|
80
|
+
return self.session.plan_exit()
|
|
81
|
+
return "Not in plan mode; PlanExit has no effect. Continue as normal."
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def cancel_event(self) -> Any:
|
|
85
|
+
"""Session cancel event (set when the user presses Ctrl-C)."""
|
|
86
|
+
if self.session and hasattr(self.session, "cancel_event"):
|
|
87
|
+
return self.session.cancel_event
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Tool(ABC):
|
|
92
|
+
name: str = ""
|
|
93
|
+
description: str = ""
|
|
94
|
+
# NB: Tool is an ABC, not a dataclass, so this is a plain class-level
|
|
95
|
+
# default (never mutated in place — every concrete tool overrides it
|
|
96
|
+
# with its own schema). It must be a real dict: a dataclasses.field()
|
|
97
|
+
# sentinel here would silently become the "parameters" of any tool
|
|
98
|
+
# that forgot to override it and then fail JSON serialization.
|
|
99
|
+
parameters: dict[str, Any] = {}
|
|
100
|
+
|
|
101
|
+
@abstractmethod
|
|
102
|
+
def run(self, args: dict[str, Any], ctx: ToolContext) -> str | PendingToolResult:
|
|
103
|
+
"""Execute the tool and return the result string.
|
|
104
|
+
|
|
105
|
+
Async tools return a ``PendingToolResult`` instead (see
|
|
106
|
+
``Bash``): the background work is spawned here and the final
|
|
107
|
+
string is delivered later via ``PendingToolResult.deliver``.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def spec(self) -> ToolSpec:
|
|
111
|
+
return ToolSpec(
|
|
112
|
+
name=self.name,
|
|
113
|
+
description=self.description,
|
|
114
|
+
parameters=self.parameters,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Registry:
|
|
119
|
+
def __init__(self) -> None:
|
|
120
|
+
self._tools: dict[str, Tool] = {}
|
|
121
|
+
self._lock = threading.Lock()
|
|
122
|
+
|
|
123
|
+
def register(self, tool: Tool) -> None:
|
|
124
|
+
with self._lock:
|
|
125
|
+
self._tools[tool.name] = tool
|
|
126
|
+
|
|
127
|
+
def unregister(self, name: str) -> None:
|
|
128
|
+
with self._lock:
|
|
129
|
+
self._tools.pop(name, None)
|
|
130
|
+
|
|
131
|
+
def get(self, name: str) -> Tool | None:
|
|
132
|
+
return self._tools.get(name)
|
|
133
|
+
|
|
134
|
+
def specs(self, names: list[str] | None = None) -> list[ToolSpec]:
|
|
135
|
+
with self._lock:
|
|
136
|
+
items = list(self._tools.items())
|
|
137
|
+
wanted = set(names) if names is not None else {n for n, _ in items}
|
|
138
|
+
return [t.spec() for name, t in items if name in wanted]
|
|
139
|
+
|
|
140
|
+
def execute(self, name: str, args: dict[str, Any], ctx: ToolContext) -> str | PendingToolResult:
|
|
141
|
+
tool = self._tools.get(name)
|
|
142
|
+
if tool is None:
|
|
143
|
+
return f"Error: unknown tool {name!r}"
|
|
144
|
+
try:
|
|
145
|
+
return tool.run(args, ctx)
|
|
146
|
+
except Exception as e: # noqa: BLE001 - errors become tool results
|
|
147
|
+
return f"Error: tool {name} failed — {e}"
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Bash tool.
|
|
2
|
+
|
|
3
|
+
Asynchronous (mirrors ``:async t`` in gptel-agent-tools): ``run``
|
|
4
|
+
spawns the process and returns a ``PendingToolResult`` immediately; a
|
|
5
|
+
background thread collects the output and delivers it when the process
|
|
6
|
+
exits. A long-running command therefore never blocks the parent's
|
|
7
|
+
sequential tool loop — it runs concurrently with sibling async tools
|
|
8
|
+
(Agent) while sync tools execute one at a time.
|
|
9
|
+
|
|
10
|
+
A session cancel (Ctrl-C) kills the process group and delivers a
|
|
11
|
+
cancelled error.
|
|
12
|
+
|
|
13
|
+
Output is read incrementally with a bounded buffer (head + tail), so a
|
|
14
|
+
huge stream (e.g. ``cat`` of a multi-GB log) can never exhaust memory,
|
|
15
|
+
and delivery never waits on a detached child that keeps the stdout
|
|
16
|
+
pipe open after the shell has exited.
|
|
17
|
+
|
|
18
|
+
Normal completion appends ``Exit code: N`` (N negative = killed by a
|
|
19
|
+
signal). A command that produces no output for ``BASH_TIMEOUT_SILENCE``
|
|
20
|
+
seconds is killed (SIGTERM then SIGKILL) and reported as timed out;
|
|
21
|
+
``BASH_TIMEOUT_MAX`` optionally caps the total runtime. Ctrl-C kills
|
|
22
|
+
the process group immediately.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import codecs
|
|
28
|
+
import contextlib
|
|
29
|
+
import os
|
|
30
|
+
import select
|
|
31
|
+
import signal
|
|
32
|
+
import subprocess
|
|
33
|
+
import threading
|
|
34
|
+
import time
|
|
35
|
+
from collections import deque
|
|
36
|
+
|
|
37
|
+
from ..config import BASH_TIMEOUT_MAX, BASH_TIMEOUT_SILENCE
|
|
38
|
+
from ..config import MAX_OUTPUT_CHARS as _MAX_OUTPUT
|
|
39
|
+
from .base import PendingToolResult, Tool, ToolContext
|
|
40
|
+
|
|
41
|
+
# Tail lines kept after truncation (the head budget is derived from
|
|
42
|
+
# MAX_OUTPUT_CHARS, shared with the filesystem spool threshold).
|
|
43
|
+
_TAIL_LINES = 50 # lines kept from the tail after truncation
|
|
44
|
+
_READ_CHUNK = 64 * 1024 # bytes read from the pipe per iteration
|
|
45
|
+
_POLL_INTERVAL = 0.02 # seconds between cancel/exit checks
|
|
46
|
+
_DRAIN_GRACE = 0.25 # seconds to keep reading after the process exits
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _kill_pgid(pgid: int) -> None:
|
|
50
|
+
"""Kill the process group PGID, ignoring "already gone" errors.
|
|
51
|
+
|
|
52
|
+
The group id is captured at spawn (with ``start_new_session=True``
|
|
53
|
+
the child is the group leader, so its pid IS the pgid): it must
|
|
54
|
+
never be resolved at kill time via ``os.getpgid`` — by then the
|
|
55
|
+
shell may already be dead while a detached child that keeps the
|
|
56
|
+
stdout pipe open is still running in the group.
|
|
57
|
+
"""
|
|
58
|
+
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
|
|
59
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _kill_graceful(pgid: int, proc: subprocess.Popen) -> None:
|
|
63
|
+
"""SIGTERM the process group; SIGKILL if it is still alive 2s later.
|
|
64
|
+
|
|
65
|
+
Used for timeouts so shells/compilers get a chance to clean up
|
|
66
|
+
children before the hard kill.
|
|
67
|
+
"""
|
|
68
|
+
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
|
|
69
|
+
os.killpg(pgid, signal.SIGTERM)
|
|
70
|
+
try:
|
|
71
|
+
proc.wait(timeout=2)
|
|
72
|
+
except subprocess.TimeoutExpired:
|
|
73
|
+
_kill_pgid(pgid)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _timeout_message(out: str, silence: bool) -> str:
|
|
77
|
+
"""Report a timed-out command, preserving any output so far."""
|
|
78
|
+
if silence:
|
|
79
|
+
timeout = BASH_TIMEOUT_SILENCE or 0.0
|
|
80
|
+
reason = f"no output for {timeout:.0f}s"
|
|
81
|
+
else:
|
|
82
|
+
timeout = BASH_TIMEOUT_MAX or 0.0
|
|
83
|
+
reason = f"exceeded the {timeout:.0f}s maximum"
|
|
84
|
+
out = out.rstrip("\n")
|
|
85
|
+
suffix = f"Error: Bash command timed out ({reason})."
|
|
86
|
+
return f"{out}\n\n{suffix}" if out else suffix
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _append_exit_code(out: str, proc: subprocess.Popen) -> str:
|
|
90
|
+
"""Append ``Exit code: N`` (N negative = killed by a signal).
|
|
91
|
+
|
|
92
|
+
The exit-code line is added AFTER truncation, so it is always the
|
|
93
|
+
last line and survives the head+tail retention.
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
rc = proc.wait(timeout=2)
|
|
97
|
+
except subprocess.TimeoutExpired:
|
|
98
|
+
return out # still alive; nothing useful to report
|
|
99
|
+
if out and not out.endswith("\n"):
|
|
100
|
+
out += "\n"
|
|
101
|
+
return f"{out}Exit code: {rc}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _assemble_truncated(head: str, tail: deque[str]) -> str:
|
|
105
|
+
"""Assemble a truncated output within the cap.
|
|
106
|
+
|
|
107
|
+
``head`` holds the first ``_MAX_OUTPUT`` chars, ``tail`` the last
|
|
108
|
+
``_TAIL_LINES`` lines (each already line-capped). The tail is
|
|
109
|
+
preferred: as many trailing lines as fit are kept and the head gets
|
|
110
|
+
the remaining budget, so the delivered string never exceeds
|
|
111
|
+
``_MAX_OUTPUT`` (plus the truncation notice).
|
|
112
|
+
"""
|
|
113
|
+
notice = f"... [truncated: output exceeded {_MAX_OUTPUT} chars] ..."
|
|
114
|
+
budget = _MAX_OUTPUT - len(notice) - 4 # room for the "\n\n" separators
|
|
115
|
+
tail_parts: list[str] = []
|
|
116
|
+
used = 0
|
|
117
|
+
for line in reversed(tail):
|
|
118
|
+
cost = len(line) + (1 if tail_parts else 0)
|
|
119
|
+
if used + cost > budget:
|
|
120
|
+
break
|
|
121
|
+
tail_parts.append(line)
|
|
122
|
+
used += cost
|
|
123
|
+
head = head[: max(0, budget - used)]
|
|
124
|
+
out = f"{head}\n\n{notice}"
|
|
125
|
+
if tail_parts:
|
|
126
|
+
out += "\n\n" + "\n".join(reversed(tail_parts))
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _collect_output(proc: subprocess.Popen, cancel: threading.Event | None) -> tuple[str, str]:
|
|
131
|
+
"""Read PROC's merged output incrementally; return (text, status).
|
|
132
|
+
|
|
133
|
+
Status is one of ``"ok"``, ``"cancelled"``, ``"timeout_silence"``,
|
|
134
|
+
``"timeout_max"``. Keeps the head (first ``_MAX_OUTPUT`` chars) and
|
|
135
|
+
the tail (last ``_TAIL_LINES`` lines) and discards the middle, so
|
|
136
|
+
memory stays bounded no matter how much the process writes. The
|
|
137
|
+
read loop is poll-based: a cancel is noticed promptly, a process
|
|
138
|
+
silent for ``BASH_TIMEOUT_SILENCE`` seconds (or running past
|
|
139
|
+
``BASH_TIMEOUT_MAX``) is reported as timed out, and a process that
|
|
140
|
+
has exited is only drained for ``_DRAIN_GRACE`` seconds — a
|
|
141
|
+
detached child holding the pipe open can never wedge delivery.
|
|
142
|
+
"""
|
|
143
|
+
stdout = proc.stdout
|
|
144
|
+
if stdout is None: # unreachable (stdout=PIPE), kept for the type checker
|
|
145
|
+
return "", "ok"
|
|
146
|
+
fd = stdout.fileno()
|
|
147
|
+
os.set_blocking(fd, False)
|
|
148
|
+
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
149
|
+
head: list[str] = []
|
|
150
|
+
head_len = 0
|
|
151
|
+
tail: deque[str] = deque(maxlen=_TAIL_LINES)
|
|
152
|
+
pending_line = ""
|
|
153
|
+
total = 0
|
|
154
|
+
exited = False
|
|
155
|
+
drain_until: float | None = None
|
|
156
|
+
start = time.monotonic()
|
|
157
|
+
last_output = start
|
|
158
|
+
|
|
159
|
+
def finish() -> str:
|
|
160
|
+
nonlocal pending_line
|
|
161
|
+
if pending_line:
|
|
162
|
+
if len(pending_line) > _MAX_OUTPUT:
|
|
163
|
+
pending_line = pending_line[:_MAX_OUTPUT]
|
|
164
|
+
tail.append(pending_line)
|
|
165
|
+
head_text = "".join(head)
|
|
166
|
+
if total > _MAX_OUTPUT:
|
|
167
|
+
return _assemble_truncated(head_text, tail)
|
|
168
|
+
return head_text
|
|
169
|
+
|
|
170
|
+
while True:
|
|
171
|
+
if cancel is not None and cancel.is_set():
|
|
172
|
+
return "", "cancelled"
|
|
173
|
+
now = time.monotonic()
|
|
174
|
+
if not exited:
|
|
175
|
+
if BASH_TIMEOUT_SILENCE is not None and now - last_output >= BASH_TIMEOUT_SILENCE:
|
|
176
|
+
return finish(), "timeout_silence"
|
|
177
|
+
if BASH_TIMEOUT_MAX is not None and now - start >= BASH_TIMEOUT_MAX:
|
|
178
|
+
return finish(), "timeout_max"
|
|
179
|
+
if exited and drain_until is not None and now >= drain_until:
|
|
180
|
+
break
|
|
181
|
+
readable, _, _ = select.select([fd], [], [], _POLL_INTERVAL)
|
|
182
|
+
if not readable:
|
|
183
|
+
if not exited and proc.poll() is not None:
|
|
184
|
+
exited = True
|
|
185
|
+
drain_until = time.monotonic() + _DRAIN_GRACE
|
|
186
|
+
continue
|
|
187
|
+
try:
|
|
188
|
+
raw = os.read(fd, _READ_CHUNK)
|
|
189
|
+
except BlockingIOError:
|
|
190
|
+
continue
|
|
191
|
+
except OSError:
|
|
192
|
+
break
|
|
193
|
+
if not raw:
|
|
194
|
+
break # EOF: every writer closed the pipe
|
|
195
|
+
chunk = decoder.decode(raw)
|
|
196
|
+
total += len(chunk)
|
|
197
|
+
last_output = time.monotonic()
|
|
198
|
+
if head_len < _MAX_OUTPUT:
|
|
199
|
+
take = chunk[: _MAX_OUTPUT - head_len]
|
|
200
|
+
head.append(take)
|
|
201
|
+
head_len += len(take)
|
|
202
|
+
parts = chunk.split("\n")
|
|
203
|
+
parts[0] = pending_line + parts[0]
|
|
204
|
+
pending_line = parts.pop()
|
|
205
|
+
for line in parts:
|
|
206
|
+
if len(line) > _MAX_OUTPUT:
|
|
207
|
+
line = line[:_MAX_OUTPUT]
|
|
208
|
+
tail.append(line)
|
|
209
|
+
return finish(), "ok"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class Bash(Tool):
|
|
213
|
+
name = "Bash"
|
|
214
|
+
_timeout_silence = BASH_TIMEOUT_SILENCE
|
|
215
|
+
_timeout_note = (
|
|
216
|
+
f"A command silent for {_timeout_silence:.0f}s is killed and reported as timed out. "
|
|
217
|
+
if _timeout_silence is not None
|
|
218
|
+
else ""
|
|
219
|
+
)
|
|
220
|
+
description = (
|
|
221
|
+
"Execute a shell command. Returns stdout followed by 'Exit code: N' "
|
|
222
|
+
"(N is the command's exit status; negative means killed by a signal). "
|
|
223
|
+
+ _timeout_note
|
|
224
|
+
+ "A session cancel (Ctrl-C) kills the process."
|
|
225
|
+
)
|
|
226
|
+
parameters = {
|
|
227
|
+
"type": "object",
|
|
228
|
+
"properties": {
|
|
229
|
+
"command": {"type": "string", "description": "The shell command to run"},
|
|
230
|
+
},
|
|
231
|
+
"required": ["command"],
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
def run(self, args: dict, ctx: ToolContext) -> str | PendingToolResult:
|
|
235
|
+
command = args["command"]
|
|
236
|
+
return self._execute(command, ctx)
|
|
237
|
+
|
|
238
|
+
def _execute(self, command: str, ctx: ToolContext) -> str | PendingToolResult:
|
|
239
|
+
cancel = ctx.cancel_event
|
|
240
|
+
if cancel is not None and cancel.is_set():
|
|
241
|
+
# Ctrl-C already pending: do not spawn a process that would
|
|
242
|
+
# be killed moments later.
|
|
243
|
+
return "Error: Bash command cancelled."
|
|
244
|
+
try:
|
|
245
|
+
proc = subprocess.Popen(
|
|
246
|
+
command,
|
|
247
|
+
shell=True,
|
|
248
|
+
stdin=subprocess.DEVNULL,
|
|
249
|
+
stdout=subprocess.PIPE,
|
|
250
|
+
stderr=subprocess.STDOUT,
|
|
251
|
+
bufsize=0,
|
|
252
|
+
cwd=ctx.cwd,
|
|
253
|
+
start_new_session=True,
|
|
254
|
+
)
|
|
255
|
+
except OSError as e:
|
|
256
|
+
return f"Error: {e}"
|
|
257
|
+
|
|
258
|
+
# start_new_session=True makes the child the session/group
|
|
259
|
+
# leader, so its pid IS the group id — captured once here,
|
|
260
|
+
# never resolved again at kill time.
|
|
261
|
+
pgid = proc.pid
|
|
262
|
+
pending = PendingToolResult()
|
|
263
|
+
|
|
264
|
+
def deliverer() -> None:
|
|
265
|
+
"""Collect output; deliver it once the process exits.
|
|
266
|
+
|
|
267
|
+
The kill happens HERE (not in a separate watcher thread):
|
|
268
|
+
the collector is the thread that observed the condition, so
|
|
269
|
+
there is no race window in which a watcher exits without
|
|
270
|
+
killing and the process group survives. Cancel is an
|
|
271
|
+
immediate SIGKILL; a timeout kills gracefully (SIGTERM,
|
|
272
|
+
then SIGKILL after 2s) so children get a chance to clean up.
|
|
273
|
+
"""
|
|
274
|
+
try:
|
|
275
|
+
out, status = _collect_output(proc, cancel)
|
|
276
|
+
except Exception as e: # noqa: BLE001 - delivered as an error string
|
|
277
|
+
out = f"Error: Bash failed — {e}"
|
|
278
|
+
else:
|
|
279
|
+
if status == "cancelled":
|
|
280
|
+
_kill_pgid(pgid)
|
|
281
|
+
out = "Error: Bash command cancelled."
|
|
282
|
+
elif status == "timeout_silence":
|
|
283
|
+
_kill_graceful(pgid, proc)
|
|
284
|
+
out = _timeout_message(out, silence=True)
|
|
285
|
+
elif status == "timeout_max":
|
|
286
|
+
_kill_graceful(pgid, proc)
|
|
287
|
+
out = _timeout_message(out, silence=False)
|
|
288
|
+
elif status == "ok":
|
|
289
|
+
out = _append_exit_code(out, proc)
|
|
290
|
+
pending.deliver(out)
|
|
291
|
+
with contextlib.suppress(Exception):
|
|
292
|
+
if proc.stdout is not None:
|
|
293
|
+
proc.stdout.close()
|
|
294
|
+
with contextlib.suppress(Exception):
|
|
295
|
+
proc.wait(timeout=2) # reap (bounded; never wedges)
|
|
296
|
+
|
|
297
|
+
threading.Thread(target=deliverer, daemon=True).start()
|
|
298
|
+
return pending
|