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,100 @@
1
+ """Context management for the agent loop: ratio tracking + compaction.
2
+
3
+ Extracted from agent.py (no logic changes): the FSM driver delegates
4
+ ``_update_context_ratio`` / ``_need_compaction`` / ``compact`` to
5
+ ``ContextManager``, which reads/writes the loop's shared state
6
+ (``messages`` / ``session``) through the loop reference.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from . import config
14
+ from .prompts import compact_summary, compacted_messages, user_prompt_texts
15
+
16
+
17
+ class ContextManager:
18
+ """Context-ratio tracking and compaction for one agent loop.
19
+
20
+ ``update_context_ratio`` receives the two token-estimator functions
21
+ from the loop's delegate so the call site keeps resolving them
22
+ through the ``agent`` module namespace (tests patch
23
+ ``python_agent_harness.agent.estimate_payload_tokens``).
24
+ """
25
+
26
+ def __init__(self, loop: Any) -> None:
27
+ self.loop = loop
28
+
29
+ def update_context_ratio(
30
+ self,
31
+ estimate_payload_tokens: Any,
32
+ context_window_for: Any,
33
+ ) -> None:
34
+ loop = self.loop
35
+ raw = estimate_payload_tokens(
36
+ loop.system,
37
+ [m.to_api() for m in loop.messages],
38
+ [t.to_api() for t in loop.session.tool_specs()],
39
+ )
40
+ loop.session.calibrator.last_raw_estimate = raw
41
+ calibrated = loop.session.calibrator.calibrate(raw)
42
+ window = context_window_for(loop.session.model)
43
+ loop.session.context_ratio = calibrated / float(window)
44
+ loop.session.notify("context")
45
+
46
+ def need_compaction(self) -> bool:
47
+ loop = self.loop
48
+ return (
49
+ loop.top_level
50
+ and loop.session.tools_enabled
51
+ and not loop.session.compacting
52
+ and loop.session.context_ratio is not None
53
+ and loop.session.context_ratio > config.CONTEXT_TRIGGER
54
+ )
55
+
56
+ def compact(self) -> bool:
57
+ """Compact the conversation; return True on success.
58
+
59
+ On success the history is replaced by the summary frame followed
60
+ by every real user prompt (nudges and other harness-injected
61
+ messages excluded), so the model keeps the actual requests; the
62
+ last prompt is the resume request for the next round.
63
+ """
64
+ loop = self.loop
65
+ prompts = user_prompt_texts(loop.messages)
66
+ if not prompts:
67
+ return False
68
+ loop.session.compacting = True
69
+ try:
70
+ conversation = "\n\n".join(f"{m.role}: {m.text()}" for m in loop.messages if m.text())
71
+ summary = compact_summary(
72
+ loop.session.client, conversation, cancel_check=loop._is_cancelled
73
+ )
74
+ if not summary:
75
+ return False
76
+ # The summary replaces the whole conversation history EXCEPT
77
+ # the system prompt (loop.system is passed separately and
78
+ # stays untouched): it is part of the user turn, never a
79
+ # system message. Every real user prompt (nudges and other
80
+ # harness-injected messages excluded) is preserved verbatim
81
+ # after the frame, so the model keeps the actual requests.
82
+ loop.messages = compacted_messages(summary, prompts)
83
+ # The shared conversation now is the compacted one: mirror it
84
+ # onto session.last_messages so the TUI (renders from it) and
85
+ # a later manual /compact start from the summary, not the old
86
+ # full history.
87
+ if loop.top_level and not loop._is_cancelled():
88
+ loop.session.last_messages = list(loop.messages)
89
+ # Fresh start for the resumed conversation: the pre-compaction
90
+ # nudge budget must not carry over, or the first terminal
91
+ # answer after compaction ends the run immediately.
92
+ loop.supervisor.reset_nudges()
93
+ loop.session.notify("compact")
94
+ return True
95
+ except Exception as e: # noqa: BLE001 - compaction failure is non-fatal
96
+ loop.session.notify("error")
97
+ loop.session.log(f"compaction failed: {e}")
98
+ return False
99
+ finally:
100
+ loop.session.compacting = False
@@ -0,0 +1,84 @@
1
+ """Unified-diff generation and rich rendering for file-changing tools.
2
+
3
+ ``unified_diff`` builds a standard unified diff between two file
4
+ contents (used by Edit/Write to record what actually changed).
5
+ ``render_diff`` turns that text into a red/green ``rich`` renderable
6
+ suitable for the TUI's tool-output panel.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import difflib
12
+
13
+ from rich.console import Group
14
+ from rich.text import Text
15
+
16
+ MAX_DIFF_LINES = 400 # truncation cap for the rendered (not stored) diff
17
+
18
+
19
+ def unified_diff(
20
+ old_content: str,
21
+ new_content: str,
22
+ path: str,
23
+ context_lines: int = 3,
24
+ ) -> str:
25
+ """Return a unified diff string between OLD_CONTENT and NEW_CONTENT.
26
+
27
+ Empty string when the two are identical (nothing to show).
28
+ Lines at EOF without a trailing newline get a git-style
29
+ ``\`` marker so the diff round-trips
30
+ (the Edit tool's diff mode parses markers and applies them).
31
+ """
32
+ if old_content == new_content:
33
+ return ""
34
+ old_lines = old_content.splitlines(keepends=True)
35
+ new_lines = new_content.splitlines(keepends=True)
36
+ diff = difflib.unified_diff(
37
+ old_lines,
38
+ new_lines,
39
+ fromfile=f"a/{path}",
40
+ tofile=f"b/{path}",
41
+ n=context_lines,
42
+ lineterm="\n",
43
+ )
44
+ out: list[str] = []
45
+ for line in diff:
46
+ out.append(line)
47
+ if line[:1] in ("-", "+", " ") and not line.endswith("\n"):
48
+ # a content line at EOF without a trailing newline: difflib
49
+ # emits it bare, which would corrupt the joined text; mark
50
+ # it like git does so the diff round-trips (the Edit tool's
51
+ # diff mode parses markers and applies them)
52
+ out.append("\n\\n")
53
+ return "".join(out)
54
+
55
+
56
+ def render_diff(diff_text: str, max_lines: int = MAX_DIFF_LINES) -> Group:
57
+ """Render a unified diff as a rich renderable (red '-' / green '+').
58
+
59
+ Hunk headers (@@ ...@@) and file headers (---/+++) are dimmed;
60
+ added lines are green, removed lines are red, context lines are
61
+ plain. Long diffs are truncated with a marker line.
62
+ """
63
+ lines = diff_text.splitlines()
64
+ truncated = len(lines) > max_lines
65
+ if truncated:
66
+ lines = lines[:max_lines]
67
+
68
+ rows: list[Text] = []
69
+ for line in lines:
70
+ if line.startswith("+++") or line.startswith("---"):
71
+ rows.append(Text(line, style="dim bold"))
72
+ elif line.startswith("@@"):
73
+ rows.append(Text(line, style="cyan"))
74
+ elif line.startswith("+"):
75
+ rows.append(Text(line, style="green"))
76
+ elif line.startswith("-"):
77
+ rows.append(Text(line, style="red"))
78
+ else:
79
+ rows.append(Text(line, style="dim"))
80
+ if truncated:
81
+ rows.append(Text("… [diff truncated]", style="dim italic"))
82
+ if not rows:
83
+ rows.append(Text("(no changes)", style="dim italic"))
84
+ return Group(*rows)
@@ -0,0 +1,21 @@
1
+ """MCP (Model Context Protocol) client support.
2
+
3
+ Optional: requires the ``mcp`` extra (``pip install -e ".[mcp]"``).
4
+ Importing this package never requires the SDK — only actually
5
+ connecting to an MCP server does.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .client import MCPClient, MCPUnavailableError
11
+ from .config import MCPConfig, MCPServerConfig
12
+ from .manager import MCPManager, MCPToolSpec
13
+
14
+ __all__ = [
15
+ "MCPClient",
16
+ "MCPConfig",
17
+ "MCPManager",
18
+ "MCPServerConfig",
19
+ "MCPToolSpec",
20
+ "MCPUnavailableError",
21
+ ]
@@ -0,0 +1,161 @@
1
+ """Thin wrapper around the official MCP Python SDK (client side).
2
+
3
+ This module is the ONLY place that touches the SDK. The rest of the
4
+ harness sees plain dicts and strings, so a future SDK API change (the
5
+ v1 → v2 line already renamed FastMCP → MCPServer and moved the client
6
+ onto ``mcp.Client``) stays contained here.
7
+
8
+ The SDK is optional: importing this module never fails without it. The
9
+ ``mcp`` extra (``pip install -e ".[mcp]"``) provides ``mcp>=2.0,<3``;
10
+ without it every operation raises :class:`MCPUnavailableError`.
11
+
12
+ The wrapper is async (the SDK is async); :class:`MCPManager` drives it
13
+ from the harness's synchronous world through a dedicated event-loop
14
+ thread. SDK types never leak out: ``list_tools`` returns plain dicts
15
+ and ``call_tool`` returns a plain dict with keys ``content`` (list of
16
+ content-block dicts), ``structured_content`` and ``is_error``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ from typing import Any
23
+
24
+ try:
25
+ from mcp import Client
26
+ from mcp.client.sse import sse_client
27
+ from mcp.client.stdio import StdioServerParameters, stdio_client
28
+ from mcp.client.streamable_http import streamable_http_client
29
+
30
+ _MCP_AVAILABLE = True
31
+ except ImportError: # pragma: no cover - depends on the optional extra
32
+ _MCP_AVAILABLE = False
33
+
34
+
35
+ class MCPUnavailableError(RuntimeError):
36
+ """Raised when MCP is configured but the optional SDK is not installed.
37
+
38
+ Fix: ``pip install -e ".[mcp]"``.
39
+ """
40
+
41
+
42
+ def _require_sdk() -> None:
43
+ if not _MCP_AVAILABLE:
44
+ raise MCPUnavailableError(
45
+ "MCP support requires the optional `mcp` extra — install with `pip install -e '.[mcp]'`"
46
+ )
47
+
48
+
49
+ class MCPClient:
50
+ """One connection to one MCP server; hides the official SDK.
51
+
52
+ Use as an async context manager::
53
+
54
+ async with MCPClient(config) as client:
55
+ tools = await client.list_tools()
56
+ result = await client.call_tool("search", {"q": "x"})
57
+
58
+ All SDK exceptions propagate as-is (connection refused, protocol
59
+ errors, ...); the manager/tool layer turns them into normal tool
60
+ error strings.
61
+ """
62
+
63
+ def __init__(self, config: Any) -> None:
64
+ from .config import MCPServerConfig
65
+
66
+ self.config: MCPServerConfig = config
67
+ self._client: Any = None
68
+ self._http_client: Any = None
69
+
70
+ async def __aenter__(self) -> MCPClient:
71
+ await self.connect()
72
+ return self
73
+
74
+ async def __aexit__(self, *exc: Any) -> None:
75
+ await self.close()
76
+
77
+ def _transport(self) -> Any:
78
+ """Build the SDK transport for this server's config."""
79
+ cfg = self.config
80
+ if cfg.transport not in ("stdio", "streamable-http", "sse"):
81
+ raise ValueError(
82
+ f"MCP server {cfg.name or '(unnamed)'!r}: unknown transport {cfg.transport!r}"
83
+ )
84
+ if cfg.transport == "stdio":
85
+ # command is validated non-None by MCPServerConfig.validate
86
+ assert cfg.command is not None
87
+ env = None
88
+ if cfg.env:
89
+ env = {name: os.environ[name] for name in cfg.env if name in os.environ}
90
+ return stdio_client(StdioServerParameters(command=cfg.command, args=cfg.args, env=env))
91
+ # url is validated non-None by MCPServerConfig.validate for the
92
+ # HTTP transports
93
+ assert cfg.url is not None
94
+ if cfg.transport == "streamable-http":
95
+ if cfg.headers:
96
+ import httpx2 # shipped as part of the mcp SDK
97
+
98
+ self._http_client = httpx2.AsyncClient(headers=cfg.headers)
99
+ return streamable_http_client(cfg.url, http_client=self._http_client)
100
+ return streamable_http_client(cfg.url)
101
+ if cfg.transport == "sse":
102
+ return sse_client(cfg.url, headers=cfg.headers or None)
103
+ # unreachable: the transport whitelist at the top rejects
104
+ # everything else
105
+ raise ValueError(
106
+ f"MCP server {cfg.name!r}: unknown transport {cfg.transport!r}"
107
+ ) # pragma: no cover
108
+
109
+ async def connect(self) -> None:
110
+ """Establish the connection (spawn process / open HTTP session)."""
111
+ _require_sdk()
112
+ self._client = Client(
113
+ server=self._transport(),
114
+ read_timeout_seconds=self.config.timeout,
115
+ )
116
+ await self._client.__aenter__()
117
+
118
+ async def close(self) -> None:
119
+ """Tear down the connection (best effort, never raises)."""
120
+ import contextlib
121
+
122
+ client, self._client = self._client, None
123
+ if client is not None:
124
+ with contextlib.suppress(Exception): # teardown noise is not an error
125
+ await client.__aexit__(None, None, None)
126
+ http_client, self._http_client = self._http_client, None
127
+ if http_client is not None:
128
+ with contextlib.suppress(Exception): # teardown noise is not an error
129
+ await http_client.aclose()
130
+
131
+ async def list_tools(self) -> list[dict[str, Any]]:
132
+ """tools/list — the server's tool descriptors as plain dicts."""
133
+ _require_sdk()
134
+ result = await self._client.list_tools()
135
+ return [
136
+ {
137
+ "name": t.name,
138
+ "description": t.description or "",
139
+ "input_schema": t.input_schema if isinstance(t.input_schema, dict) else {},
140
+ }
141
+ for t in result.tools
142
+ ]
143
+
144
+ async def call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
145
+ """tools/call — returns a plain dict (content blocks, structured
146
+ content, is_error). Server-reported failures surface as
147
+ ``is_error=True``, NOT as exceptions."""
148
+ _require_sdk()
149
+ result = await self._client.call_tool(name, arguments or {})
150
+ return {
151
+ "content": [_content_block_to_dict(b) for b in result.content],
152
+ "structured_content": result.structured_content,
153
+ "is_error": bool(result.is_error),
154
+ }
155
+
156
+
157
+ def _content_block_to_dict(block: Any) -> dict[str, Any]:
158
+ """One MCP content block → plain dict (text/image/audio/resource/...)."""
159
+ if hasattr(block, "model_dump"):
160
+ return block.model_dump(exclude_none=True)
161
+ return {"type": "unknown", "raw": str(block)}
@@ -0,0 +1,130 @@
1
+ """MCP server configuration for python-agent-harness.
2
+
3
+ MCP support is OPTIONAL: install the harness with ``pip install -e ".[mcp]"``
4
+ to get the official ``mcp`` SDK (the ``mcp`` extra). This module only
5
+ defines plain configuration data classes — importing it never imports the
6
+ SDK, so the base harness stays dependency-free.
7
+
8
+ Transports (per the MCP spec):
9
+
10
+ - ``stdio``: spawn ``command`` with ``args`` as a subprocess; ``env``
11
+ lists environment variable names passed through from the harness
12
+ process (e.g. ``["GITHUB_TOKEN"]``).
13
+ - ``streamable-http``: connect to ``url``; ``headers`` (e.g.
14
+ Authorization) are sent with every request. The direction to target
15
+ for new remote deployments.
16
+ - ``sse``: connect to ``url`` over the legacy SSE transport.
17
+
18
+ Concurrency policy: ``parallel`` marks the server's tools as safe for
19
+ concurrent execution (read-only servers); the tool then runs in the
20
+ background like Bash/Agent. The default is conservative serial
21
+ execution. The harness — not the MCP protocol — retains authority
22
+ over this.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from collections.abc import Mapping
28
+ from dataclasses import dataclass, field
29
+ from typing import Any
30
+
31
+ TRANSPORTS = ("stdio", "streamable-http", "sse")
32
+
33
+
34
+ @dataclass
35
+ class MCPServerConfig:
36
+ """Configuration for one MCP server connection.
37
+
38
+ ``name`` is optional: when the server lives in an ``MCPConfig``
39
+ dict the dict key is authoritative (it fills ``name`` on
40
+ construction), so the design's compact form works::
41
+
42
+ MCPConfig(servers={"github": MCPServerConfig(command="npx", ...)})
43
+
44
+ ``enabled=False`` keeps the server in the config file without
45
+ connecting it (a documented example, or a temporarily-disabled
46
+ server).
47
+ """
48
+
49
+ name: str = ""
50
+ transport: str = "stdio"
51
+ command: str | None = None
52
+ args: list[str] = field(default_factory=list)
53
+ env: list[str] = field(default_factory=list)
54
+ url: str | None = None
55
+ headers: dict[str, str] = field(default_factory=dict)
56
+ parallel: bool = False
57
+ timeout: float | None = None
58
+ enabled: bool = True
59
+
60
+ def validate(self) -> None:
61
+ label = self.name or "(unnamed)"
62
+ if self.transport not in TRANSPORTS:
63
+ raise ValueError(
64
+ f"MCP server {label!r}: unknown transport {self.transport!r} "
65
+ f"(expected one of {', '.join(TRANSPORTS)})"
66
+ )
67
+ if self.transport == "stdio":
68
+ if not self.command:
69
+ raise ValueError(f"MCP server {label!r}: stdio transport requires `command`")
70
+ elif not self.url:
71
+ raise ValueError(f"MCP server {label!r}: {self.transport} transport requires `url`")
72
+
73
+
74
+ @dataclass
75
+ class MCPConfig:
76
+ """The set of MCP servers for one session.
77
+
78
+ Usage (the compact form — the dict key IS the server name)::
79
+
80
+ config = MCPConfig(
81
+ servers={
82
+ "github": MCPServerConfig(
83
+ transport="stdio",
84
+ command="npx",
85
+ args=["-y", "@modelcontextprotocol/server-github"],
86
+ env=["GITHUB_TOKEN"],
87
+ ),
88
+ "remote": MCPServerConfig(
89
+ transport="streamable-http",
90
+ url="http://localhost:8000/mcp",
91
+ ),
92
+ }
93
+ )
94
+ """
95
+
96
+ servers: dict[str, MCPServerConfig] = field(default_factory=dict)
97
+
98
+ def __post_init__(self) -> None:
99
+ # The dict key is the authoritative server name: fill in any
100
+ # config whose name was left unset (compact construction).
101
+ for key, server in self.servers.items():
102
+ if not server.name:
103
+ server.name = key
104
+
105
+ @classmethod
106
+ def from_dict(cls, data: Mapping[str, Any] | None) -> MCPConfig:
107
+ """Build from a plain mapping (e.g. the config file's ``mcp.servers``).
108
+
109
+ Raises ValueError on malformed entries (unknown transport, missing
110
+ command/url) so config errors surface at session start, not mid-run.
111
+ """
112
+ config = cls()
113
+ for name, raw in (data or {}).items():
114
+ if not isinstance(raw, dict):
115
+ raise ValueError(f"MCP server {name!r}: expected an object")
116
+ server = MCPServerConfig(
117
+ name=name,
118
+ transport=str(raw.get("transport", "stdio")),
119
+ command=raw.get("command"),
120
+ args=[str(a) for a in (raw.get("args") or [])],
121
+ env=[str(e) for e in (raw.get("env") or [])],
122
+ url=raw.get("url"),
123
+ headers={str(k): str(v) for k, v in (raw.get("headers") or {}).items()},
124
+ parallel=bool(raw.get("parallel", False)),
125
+ timeout=raw.get("timeout"),
126
+ enabled=bool(raw.get("enabled", True)),
127
+ )
128
+ server.validate()
129
+ config.servers[name] = server
130
+ return config