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.
Files changed (61) hide show
  1. python_agent_harness/__init__.py +20 -0
  2. python_agent_harness/__main__.py +5 -0
  3. python_agent_harness/agent.py +703 -0
  4. python_agent_harness/cli.py +273 -0
  5. python_agent_harness/client.py +832 -0
  6. python_agent_harness/commands.py +181 -0
  7. python_agent_harness/config.py +464 -0
  8. python_agent_harness/context_manager.py +100 -0
  9. python_agent_harness/diffrender.py +84 -0
  10. python_agent_harness/mcp/__init__.py +21 -0
  11. python_agent_harness/mcp/client.py +161 -0
  12. python_agent_harness/mcp/config.py +130 -0
  13. python_agent_harness/mcp/manager.py +290 -0
  14. python_agent_harness/models.py +149 -0
  15. python_agent_harness/persistence.py +297 -0
  16. python_agent_harness/planmode.py +112 -0
  17. python_agent_harness/prompts/agent.md +362 -0
  18. python_agent_harness/prompts/build-switch.md +5 -0
  19. python_agent_harness/prompts/commands/explain.md +13 -0
  20. python_agent_harness/prompts/compact.md +33 -0
  21. python_agent_harness/prompts/initialize.md +66 -0
  22. python_agent_harness/prompts/plan-mode.md +70 -0
  23. python_agent_harness/prompts/plan.md +26 -0
  24. python_agent_harness/prompts/review.md +100 -0
  25. python_agent_harness/prompts/subagent.md +208 -0
  26. python_agent_harness/prompts/summary.md +11 -0
  27. python_agent_harness/prompts/task-completion-rules.md +50 -0
  28. python_agent_harness/prompts/title.md +44 -0
  29. python_agent_harness/prompts.py +498 -0
  30. python_agent_harness/session.py +781 -0
  31. python_agent_harness/subagent.py +61 -0
  32. python_agent_harness/token_estimator.py +125 -0
  33. python_agent_harness/tool_runner.py +247 -0
  34. python_agent_harness/tools/__init__.py +56 -0
  35. python_agent_harness/tools/agent_tool.py +75 -0
  36. python_agent_harness/tools/base.py +147 -0
  37. python_agent_harness/tools/bash.py +298 -0
  38. python_agent_harness/tools/edit.py +272 -0
  39. python_agent_harness/tools/filesystem.py +180 -0
  40. python_agent_harness/tools/glob.py +161 -0
  41. python_agent_harness/tools/grep.py +149 -0
  42. python_agent_harness/tools/insert.py +61 -0
  43. python_agent_harness/tools/mcp.py +203 -0
  44. python_agent_harness/tools/mkdir.py +30 -0
  45. python_agent_harness/tools/planexit.py +45 -0
  46. python_agent_harness/tools/question.py +70 -0
  47. python_agent_harness/tools/read.py +104 -0
  48. python_agent_harness/tools/skill.py +32 -0
  49. python_agent_harness/tools/todo.py +60 -0
  50. python_agent_harness/tools/write.py +56 -0
  51. python_agent_harness/tui/__init__.py +68 -0
  52. python_agent_harness/tui/commands.py +652 -0
  53. python_agent_harness/tui/core.py +385 -0
  54. python_agent_harness/tui/input.py +412 -0
  55. python_agent_harness/tui/render.py +535 -0
  56. python_agent_harness-1.5.0.dist-info/METADATA +251 -0
  57. python_agent_harness-1.5.0.dist-info/RECORD +61 -0
  58. python_agent_harness-1.5.0.dist-info/WHEEL +5 -0
  59. python_agent_harness-1.5.0.dist-info/entry_points.txt +2 -0
  60. python_agent_harness-1.5.0.dist-info/licenses/LICENSE +21 -0
  61. python_agent_harness-1.5.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,290 @@
1
+ """MCPManager: lifecycle + one-time tool discovery for MCP servers.
2
+
3
+ Synchronous facade over the async MCPClient wrapper: the harness's
4
+ agent loop is synchronous, so every SDK interaction runs on a DEDICATED
5
+ event-loop thread (``asyncio.run`` per call would bind SDK resources —
6
+ subprocess pipes, anyio memory streams — to a fresh loop each time and
7
+ break on the next call).
8
+
9
+ Lifecycle (mirrors the session lifecycle; see ``Session``)::
10
+
11
+ manager = MCPManager(config)
12
+ failures = manager.connect_all() # connect every configured server
13
+ specs = manager.discover_tools() # tools/list ONCE per session
14
+ ... register MCPTool instances from the specs ...
15
+ manager.call_tool("github", "search", {...})
16
+ manager.close_all()
17
+
18
+ The agent loop never sees this class: MCP tools are ordinary registry
19
+ tools, and the manager only ever returns plain dicts or raises the
20
+ SDK/connection errors that the tool adapter turns into error strings.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import contextlib
27
+ import threading
28
+ import time
29
+ from collections.abc import Callable, Coroutine
30
+ from typing import Any
31
+
32
+ from .client import MCPClient, MCPUnavailableError
33
+ from .config import MCPConfig
34
+
35
+
36
+ class MCPCallCancelled(Exception):
37
+ """Raised when an in-flight MCP call is cancelled (Ctrl-C).
38
+
39
+ The manager's cancel check is polled while waiting for the SDK
40
+ call; when it fires the underlying future is cancelled and this
41
+ exception propagates, so a hung server call can never wedge the
42
+ agent-loop thread forever.
43
+ """
44
+
45
+
46
+ class MCPToolSpec:
47
+ """A tool advertised by an MCP server (one tools/list entry)."""
48
+
49
+ __slots__ = ("server", "name", "description", "input_schema")
50
+
51
+ def __init__(
52
+ self,
53
+ server: str,
54
+ name: str,
55
+ description: str,
56
+ input_schema: dict[str, Any],
57
+ ) -> None:
58
+ self.server = server
59
+ self.name = name
60
+ self.description = description
61
+ self.input_schema = input_schema
62
+
63
+
64
+ class _LoopThread:
65
+ """A background thread running a persistent asyncio event loop.
66
+
67
+ All SDK interactions for one MCPManager run on this loop, so
68
+ transport resources stay bound to a single loop for their whole
69
+ lifetime. ``run`` blocks the caller until the coroutine completes
70
+ (or TIMEOUT elapses) and re-raises its exception here.
71
+ """
72
+
73
+ def __init__(self) -> None:
74
+ self._loop = asyncio.new_event_loop()
75
+ self._thread = threading.Thread(target=self._run, daemon=True, name="mcp-loop")
76
+ self._thread.start()
77
+
78
+ def _run(self) -> None:
79
+ asyncio.set_event_loop(self._loop)
80
+ self._loop.run_forever()
81
+
82
+ def run(
83
+ self,
84
+ coro: Coroutine[Any, Any, Any],
85
+ timeout: float | None = None,
86
+ cancel_check: Callable[[], bool] | None = None,
87
+ ) -> Any:
88
+ """Run CORO on the loop thread; raise its exception here.
89
+
90
+ Waits in short slices instead of one blocking
91
+ ``future.result(timeout=...)``: a ``cancel_check`` is polled
92
+ between slices, so Ctrl-C unblocks a hung SDK call (the future
93
+ is cancelled and :class:`MCPCallCancelled` raised) instead of
94
+ wedging the caller forever. ``timeout`` is a wall-clock
95
+ deadline; on expiry the future is cancelled and TimeoutError
96
+ raised. A TimeoutError raised BY the coroutine itself (SDK
97
+ read timeout) propagates untouched — ``future.exception`` keeps
98
+ it distinct from the poll timeout.
99
+ """
100
+ future = asyncio.run_coroutine_threadsafe(coro, self._loop)
101
+ deadline = time.monotonic() + timeout if timeout is not None else None
102
+ while True:
103
+ try:
104
+ exc = future.exception(timeout=0.1)
105
+ except TimeoutError:
106
+ # future still running: enforce deadline / cancellation
107
+ if deadline is not None and time.monotonic() >= deadline:
108
+ future.cancel()
109
+ raise TimeoutError(f"MCP call timed out after {timeout}s") from None
110
+ if cancel_check is not None and cancel_check():
111
+ future.cancel()
112
+ raise MCPCallCancelled("MCP call cancelled") from None
113
+ continue
114
+ if exc is not None:
115
+ raise exc
116
+ return future.result()
117
+
118
+ def close(self) -> None:
119
+ self._loop.call_soon_threadsafe(self._loop.stop)
120
+ self._thread.join(timeout=5)
121
+ # close the loop object itself (it is stopped and idle by now),
122
+ # or the GC reports ResourceWarning for every manager lifetime
123
+ with contextlib.suppress(Exception): # best effort teardown
124
+ self._loop.close()
125
+
126
+
127
+ class MCPManager:
128
+ """Owns the MCP server connections for one session."""
129
+
130
+ def __init__(self, config: MCPConfig | None = None) -> None:
131
+ self.config = config if config is not None else MCPConfig()
132
+ self._loop: _LoopThread | None = None
133
+ self._clients: dict[str, MCPClient] = {}
134
+ self._tools: dict[str, MCPToolSpec] = {} # "server__tool" -> spec
135
+ self._discovered: set[str] = set()
136
+ # (server, error) pairs from the last connect_all / discovery
137
+ self.errors: list[tuple[str, str]] = []
138
+
139
+ # ------------------------------------------------------------------
140
+ # plumbing
141
+ # ------------------------------------------------------------------
142
+ def _ensure_loop(self) -> _LoopThread:
143
+ if self._loop is None:
144
+ self._loop = _LoopThread()
145
+ return self._loop
146
+
147
+ def _call(
148
+ self,
149
+ coro: Coroutine[Any, Any, Any],
150
+ timeout: float | None = None,
151
+ cancel_check: Callable[[], bool] | None = None,
152
+ ) -> Any:
153
+ return self._ensure_loop().run(coro, timeout=timeout, cancel_check=cancel_check)
154
+
155
+ @property
156
+ def connected(self) -> list[str]:
157
+ """Names of the currently-connected servers (sorted)."""
158
+ return sorted(self._clients)
159
+
160
+ # ------------------------------------------------------------------
161
+ # lifecycle
162
+ # ------------------------------------------------------------------
163
+ def connect_all(self) -> list[tuple[str, str]]:
164
+ """Connect every configured server; return ``[(name, error)]`` failures.
165
+
166
+ A failing server never takes the session down: it is skipped
167
+ (with its error recorded) and the rest keep working. Idempotent:
168
+ already-connected servers are left alone.
169
+ """
170
+ failures: list[tuple[str, str]] = []
171
+ for name, server_config in self.config.servers.items():
172
+ if not server_config.enabled:
173
+ continue
174
+ error = self._connect_one(name)
175
+ if error is not None:
176
+ failures.append((name, error))
177
+ self.errors = list(failures)
178
+ return failures
179
+
180
+ def _connect_one(self, name: str) -> str | None:
181
+ if name in self._clients:
182
+ return None
183
+ client = MCPClient(self.config.servers[name])
184
+ try:
185
+ self._call(client.connect(), timeout=self.config.servers[name].timeout)
186
+ except MCPUnavailableError as e:
187
+ error = str(e)
188
+ except Exception as e: # noqa: BLE001 - per-server failure, never fatal
189
+ error = f"MCP server {name!r} failed to connect: {e}"
190
+ else:
191
+ self._clients[name] = client
192
+ return None
193
+ # A failed connect may still have spawned the server process /
194
+ # opened an HTTP session before failing (e.g. timeout mid-
195
+ # handshake) — close the client so nothing leaks. Best effort:
196
+ # teardown noise must never mask the original error.
197
+ with contextlib.suppress(Exception): # teardown noise
198
+ self._call(client.close(), timeout=self.config.servers[name].timeout)
199
+ return error
200
+
201
+ def disconnect(self, name: str) -> None:
202
+ """Disconnect one server and drop its discovered tools."""
203
+ import contextlib
204
+
205
+ client = self._clients.pop(name, None)
206
+ if client is None:
207
+ return
208
+ with contextlib.suppress(Exception): # teardown noise
209
+ self._call(client.close())
210
+ self._discovered.discard(name)
211
+ for key in [k for k in self._tools if k.startswith(name + "__")]:
212
+ del self._tools[key]
213
+
214
+ def close_all(self) -> None:
215
+ """Disconnect every server and stop the event-loop thread."""
216
+ for name in list(self._clients):
217
+ self.disconnect(name)
218
+ self._tools.clear()
219
+ self._discovered.clear()
220
+ if self._loop is not None:
221
+ self._loop.close()
222
+ self._loop = None
223
+
224
+ # ------------------------------------------------------------------
225
+ # tool discovery (once per session, not per turn)
226
+ # ------------------------------------------------------------------
227
+ def discover_tools(self) -> list[MCPToolSpec]:
228
+ """tools/list each connected server; the result is cached.
229
+
230
+ Called once at session start; a refresh requires reconnecting
231
+ the server (``disconnect`` + ``connect_all``). Returns the
232
+ specs discovered by THIS call (newly discovered only), so a
233
+ caller can register exactly what changed.
234
+ """
235
+ specs: list[MCPToolSpec] = []
236
+ for name, client in self._clients.items():
237
+ if name in self._discovered:
238
+ continue
239
+ self._discovered.add(name)
240
+ try:
241
+ raw_tools = self._call(
242
+ client.list_tools(), timeout=self.config.servers[name].timeout
243
+ )
244
+ except Exception as e: # noqa: BLE001 - per-server failure, never fatal
245
+ self.errors.append((name, f"MCP server {name!r} tool discovery failed: {e}"))
246
+ continue
247
+ for t in raw_tools:
248
+ spec = MCPToolSpec(
249
+ server=name,
250
+ name=t["name"],
251
+ description=t.get("description") or "",
252
+ input_schema=t.get("input_schema") or {},
253
+ )
254
+ self._tools[f"{name}__{t['name']}"] = spec
255
+ specs.append(spec)
256
+ return specs
257
+
258
+ def tool_specs(self) -> list[MCPToolSpec]:
259
+ """All discovered specs, for building harness tools."""
260
+ return list(self._tools.values())
261
+
262
+ # ------------------------------------------------------------------
263
+ # tool calls
264
+ # ------------------------------------------------------------------
265
+ def call_tool(
266
+ self,
267
+ server: str,
268
+ tool: str,
269
+ arguments: dict[str, Any],
270
+ timeout: float | None = None,
271
+ cancel_check: Callable[[], bool] | None = None,
272
+ ) -> dict[str, Any]:
273
+ """Call TOOL on SERVER; returns the plain result dict from
274
+ :meth:`MCPClient.call_tool`.
275
+
276
+ Raises on connection/protocol errors (the tool adapter turns
277
+ them into ``Error: ...`` strings); server-reported failures are
278
+ flagged with ``is_error`` in the result dict, not raised.
279
+ ``cancel_check`` (when given) is polled while waiting, so a
280
+ Ctrl-C unblocks a hung call (see ``_LoopThread.run``).
281
+ """
282
+ client = self._clients.get(server)
283
+ if client is None:
284
+ raise ConnectionError(f"MCP server {server!r} is not connected")
285
+ effective_timeout = client.config.timeout if timeout is None else timeout
286
+ return self._call(
287
+ client.call_tool(tool, arguments),
288
+ timeout=effective_timeout,
289
+ cancel_check=cancel_check,
290
+ )
@@ -0,0 +1,149 @@
1
+ """Data model classes for the agent harness."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ import json
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+
11
+ class AgentMode(enum.Enum):
12
+ BUILD = "build"
13
+ PLAN = "plan"
14
+
15
+
16
+ @dataclass
17
+ class ToolCall:
18
+ """A tool invocation requested by the model."""
19
+
20
+ id: str
21
+ name: str
22
+ arguments: dict[str, Any] | str
23
+ result: str | None = None
24
+ diff: str | None = None # unified diff for Edit/Write, for TUI rendering
25
+ elapsed: float | None = None # execution wall-time in seconds (TUI display)
26
+
27
+
28
+ @dataclass
29
+ class Message:
30
+ """One conversation message in OpenAI-compatible format.
31
+
32
+ ``role`` is one of system/user/assistant/tool.
33
+ ``content`` may be a str or a list of parts (multimodal).
34
+ ``tool_calls`` carries requested tool invocations on assistant messages.
35
+ ``tool_call_id`` links a tool message to its assistant tool call.
36
+ ``reasoning`` holds reasoning content if the backend reports it.
37
+ """
38
+
39
+ role: str
40
+ content: str | list[Any] | None = None
41
+ tool_calls: list[ToolCall] | None = None
42
+ tool_call_id: str | None = None
43
+ reasoning: str | None = None
44
+ name: str | None = None
45
+ injected: bool = False # harness-injected (nudge/plan/build-switch), not user input
46
+
47
+ def to_api(self) -> dict[str, Any]:
48
+ d: dict[str, Any] = {"role": self.role}
49
+ if self.content is not None:
50
+ d["content"] = self._api_content()
51
+ if self.tool_calls:
52
+ d["tool_calls"] = [
53
+ {
54
+ "id": tc.id,
55
+ "type": "function",
56
+ "function": {
57
+ "name": tc.name,
58
+ "arguments": tc.arguments
59
+ if isinstance(tc.arguments, str)
60
+ else json.dumps(tc.arguments, ensure_ascii=False),
61
+ },
62
+ }
63
+ for tc in self.tool_calls
64
+ ]
65
+ if self.tool_call_id:
66
+ d["tool_call_id"] = self.tool_call_id
67
+ if self.name:
68
+ d["name"] = self.name
69
+ return d
70
+
71
+ def _api_content(self) -> str | list[Any] | None:
72
+ """Content as sent over the wire, with the reasoning preamble removed.
73
+
74
+ The client merges streamed ``reasoning_content`` ahead of the
75
+ answer into ``content`` (so the live stream and stored history
76
+ show the model's thinking). That reasoning is bookkeeping for
77
+ the current turn only — re-sending it on later turns just
78
+ inflates the context (and skews token estimation) and can
79
+ confuse the model, so it is stripped here at the API boundary.
80
+ The stored ``content`` is left untouched (the TUI collapses the
81
+ reasoning for display via its own helper).
82
+ """
83
+ content = self.content
84
+ if self.reasoning and isinstance(content, str):
85
+ if content.startswith(self.reasoning):
86
+ return content[len(self.reasoning) :].lstrip("\n")
87
+ stripped = content.lstrip()
88
+ if stripped.startswith(self.reasoning):
89
+ return stripped[len(self.reasoning) :].lstrip("\n")
90
+ return content
91
+
92
+ def text(self) -> str:
93
+ """Plain text of the message; empty when no text parts exist."""
94
+ if isinstance(self.content, str):
95
+ return self.content
96
+ if isinstance(self.content, list):
97
+ parts: list[str] = []
98
+ for p in self.content:
99
+ if isinstance(p, str):
100
+ parts.append(p)
101
+ elif isinstance(p, dict):
102
+ if isinstance(p.get("text"), str):
103
+ parts.append(p["text"])
104
+ elif isinstance(p.get("thinking"), str):
105
+ parts.append(p["thinking"])
106
+ return "".join(parts)
107
+ return ""
108
+
109
+ def text_without_reasoning(self) -> str:
110
+ """Plain text with the reasoning preamble stripped.
111
+
112
+ Use this for one-shot results (compaction, summary, title) where
113
+ the reasoning chain should not leak into the stored output.
114
+ """
115
+ t = self.text()
116
+ if self.reasoning and t:
117
+ if t.startswith(self.reasoning):
118
+ return t[len(self.reasoning) :].lstrip("\n")
119
+ stripped = t.lstrip()
120
+ if stripped.startswith(self.reasoning):
121
+ return stripped[len(self.reasoning) :].lstrip("\n")
122
+ # Fallback: remove the reasoning anywhere in the text
123
+ return t.replace(self.reasoning, "").strip()
124
+ return t
125
+
126
+
127
+ @dataclass
128
+ class ToolSpec:
129
+ """A tool exposed to the model (JSON schema)."""
130
+
131
+ name: str
132
+ description: str
133
+ parameters: dict[str, Any] = field(default_factory=dict)
134
+
135
+ def to_api(self) -> dict[str, Any]:
136
+ return {
137
+ "type": "function",
138
+ "function": {
139
+ "name": self.name,
140
+ "description": self.description,
141
+ "parameters": self.parameters,
142
+ },
143
+ }
144
+
145
+
146
+ @dataclass
147
+ class Usage:
148
+ input_tokens: int = 0
149
+ output_tokens: int = 0