python-agent-harness 1.5.6.2__tar.gz → 1.5.6.3__tar.gz
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-1.5.6.2/python_agent_harness.egg-info → python_agent_harness-1.5.6.3}/PKG-INFO +1 -1
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/pyproject.toml +1 -1
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/__init__.py +1 -1
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/core/models.py +37 -5
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/entry/headless.py +21 -4
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/entry/server.py +19 -2
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/io/persistence.py +10 -1
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/lsp/client.py +209 -30
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/agent.md +3 -3
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/session/session.py +5 -1
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/edit.py +43 -11
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tui/commands.py +4 -2
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tui/render.py +35 -5
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3/python_agent_harness.egg-info}/PKG-INFO +1 -1
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/LICENSE +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/README.md +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/__main__.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/core/__init__.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/core/agent.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/core/context_manager.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/core/planmode.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/core/subagent.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/core/token_estimator.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/core/tool_runner.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/entry/__init__.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/entry/cli.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/entry/controller.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/entry/view.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/io/__init__.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/io/attachments.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/io/diffrender.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/io/text_filter.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/llm/__init__.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/llm/client.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/lsp/__init__.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/lsp/config.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/lsp/manager.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/mcp/__init__.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/mcp/client.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/mcp/config.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/mcp/manager.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/__init__.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/agents/reviewer.md +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/build-switch.md +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/commands/explain.md +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/compact.md +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/core.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/initialize.md +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/plan-mode.md +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/plan.md +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/review.md +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/subagent.md +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/summary.md +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/task-completion-rules.md +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/title.md +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/session/__init__.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/session/commands.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/session/config.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/__init__.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/agent_tool.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/base.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/bash.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/diffapply.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/edit_mac.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/filesystem.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/glob.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/glob_mac.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/grep.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/grep_mac.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/insert.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/lsp.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/mcp.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/mkdir.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/planexit.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/question.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/read.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/skill.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/todo.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/write.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tui/__init__.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tui/core.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tui/input.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness.egg-info/SOURCES.txt +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness.egg-info/dependency_links.txt +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness.egg-info/entry_points.txt +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness.egg-info/requires.txt +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness.egg-info/top_level.txt +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/setup.cfg +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/tests/test_invariants.py +0 -0
- {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/tests/test_scenarios.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-agent-harness"
|
|
7
|
-
version = "1.5.6.
|
|
7
|
+
version = "1.5.6.3"
|
|
8
8
|
description = "A lightweight, hackable mini-OpenCode written in Python."
|
|
9
9
|
authors = [
|
|
10
10
|
{name = "Huming Chen", email = "chenhuming@gmail.com"}
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/core/models.py
RENAMED
|
@@ -5,9 +5,30 @@ from __future__ import annotations
|
|
|
5
5
|
import base64
|
|
6
6
|
import enum
|
|
7
7
|
import json
|
|
8
|
+
import re
|
|
8
9
|
from dataclasses import dataclass, field
|
|
9
10
|
from typing import Any, Union
|
|
10
11
|
|
|
12
|
+
# Lone surrogates (\ud800-\udfff) reach a str when a file is decoded with
|
|
13
|
+
# errors="surrogateescape" — which every tool deliberately does, so a
|
|
14
|
+
# non-UTF-8 source file round-trips instead of becoming U+FFFD. Such a
|
|
15
|
+
# string cannot be encoded to UTF-8, so anything that leaves the process
|
|
16
|
+
# (a JSON request body, a terminal write) must replace them first.
|
|
17
|
+
_SURROGATE_RE = re.compile(r"[\ud800-\udfff]")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def api_safe(text: str) -> str:
|
|
21
|
+
"""TEXT with lone surrogates replaced by U+FFFD, safe to serialize.
|
|
22
|
+
|
|
23
|
+
The tools read files with ``errors="surrogateescape"``, so a
|
|
24
|
+
conversation can carry lone surrogates from a non-UTF-8 (Latin-1,
|
|
25
|
+
GBK, ...) source. A request body containing one cannot be encoded
|
|
26
|
+
to UTF-8 — ``httpx`` raised ``UnicodeEncodeError`` — so they are
|
|
27
|
+
replaced at the OUTBOUND boundary only: the stored history, the
|
|
28
|
+
session file and the file on disk keep the exact bytes.
|
|
29
|
+
"""
|
|
30
|
+
return _SURROGATE_RE.sub("\ufffd", text)
|
|
31
|
+
|
|
11
32
|
|
|
12
33
|
class AgentMode(enum.Enum):
|
|
13
34
|
BUILD = "build"
|
|
@@ -136,7 +157,10 @@ class Message:
|
|
|
136
157
|
def to_api(self) -> dict[str, Any]:
|
|
137
158
|
d: dict[str, Any] = {"role": self.role}
|
|
138
159
|
if self.content is not None:
|
|
139
|
-
|
|
160
|
+
content = self._api_content()
|
|
161
|
+
# outbound boundary: a lone surrogate cannot be encoded into
|
|
162
|
+
# the JSON body (see api_safe)
|
|
163
|
+
d["content"] = api_safe(content) if isinstance(content, str) else content
|
|
140
164
|
if self.tool_calls:
|
|
141
165
|
d["tool_calls"] = [
|
|
142
166
|
{
|
|
@@ -144,9 +168,13 @@ class Message:
|
|
|
144
168
|
"type": "function",
|
|
145
169
|
"function": {
|
|
146
170
|
"name": tc.name,
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
171
|
+
# ensure_ascii=False keeps non-ASCII readable;
|
|
172
|
+
# api_safe then removes the unencodable surrogates
|
|
173
|
+
"arguments": api_safe(
|
|
174
|
+
tc.arguments
|
|
175
|
+
if isinstance(tc.arguments, str)
|
|
176
|
+
else json.dumps(tc.arguments, ensure_ascii=False)
|
|
177
|
+
),
|
|
150
178
|
},
|
|
151
179
|
}
|
|
152
180
|
for tc in self.tool_calls
|
|
@@ -188,7 +216,11 @@ class Message:
|
|
|
188
216
|
@staticmethod
|
|
189
217
|
def _part_to_api(part: ContentPart) -> Any:
|
|
190
218
|
"""Serialize one content part to its provider-specific dict."""
|
|
191
|
-
if isinstance(part,
|
|
219
|
+
if isinstance(part, TextPart):
|
|
220
|
+
# text parts are a second carrier of file content into the
|
|
221
|
+
# payload, so they need the same outbound treatment
|
|
222
|
+
return {"type": "text", "text": api_safe(part.text)}
|
|
223
|
+
if isinstance(part, ImagePart):
|
|
192
224
|
return part.to_api()
|
|
193
225
|
return part
|
|
194
226
|
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/entry/headless.py
RENAMED
|
@@ -22,6 +22,7 @@ import sys
|
|
|
22
22
|
import threading
|
|
23
23
|
from typing import Any, TextIO
|
|
24
24
|
|
|
25
|
+
from ..core.models import api_safe
|
|
25
26
|
from ..io.text_filter import strip_final_check
|
|
26
27
|
from ..session.session import Session
|
|
27
28
|
from .controller import Controller
|
|
@@ -166,7 +167,13 @@ def final_answer_text(session: Session) -> str:
|
|
|
166
167
|
continue
|
|
167
168
|
text = strip_final_check(msg.text_without_reasoning()).strip()
|
|
168
169
|
if text:
|
|
169
|
-
|
|
170
|
+
# api_safe: the answer is written to stdout (headless prints it
|
|
171
|
+
# verbatim; --json and serve JSON-encode it), and a lone
|
|
172
|
+
# surrogate from a restored non-UTF-8 session raised
|
|
173
|
+
# UnicodeEncodeError there — headless died before writing the
|
|
174
|
+
# result line, and serve's _write_line SWALLOWED it (it is a
|
|
175
|
+
# ValueError), reporting success with an empty stream.
|
|
176
|
+
return api_safe(text)
|
|
170
177
|
return ""
|
|
171
178
|
|
|
172
179
|
|
|
@@ -295,7 +302,10 @@ def restore_session(controller: Controller, spec: str, err: TextIO) -> bool:
|
|
|
295
302
|
if not path or not os.path.isfile(path):
|
|
296
303
|
err.write(f"no session found: {spec or 'latest'}\n")
|
|
297
304
|
return False
|
|
298
|
-
|
|
305
|
+
# surrogateescape must mirror SessionPersistence.save: the saved body
|
|
306
|
+
# may carry lone surrogates (the tools read non-UTF-8 files that way),
|
|
307
|
+
# and a strict decode raised UnicodeDecodeError here.
|
|
308
|
+
with open(path, encoding="utf-8", errors="surrogateescape") as fh:
|
|
299
309
|
text = fh.read()
|
|
300
310
|
meta = SessionPersistence.parse_metadata(text)
|
|
301
311
|
body = SessionPersistence.strip_metadata(text)
|
|
@@ -417,7 +427,10 @@ def run_headless(
|
|
|
417
427
|
return 1
|
|
418
428
|
answer = final_answer_text(session)
|
|
419
429
|
if answer:
|
|
420
|
-
|
|
430
|
+
# api_safe: the answer carries whatever the conversation carried
|
|
431
|
+
# (a restored non-UTF-8 session), and a strict-UTF-8 stdout
|
|
432
|
+
# raised UnicodeEncodeError here — losing the result line.
|
|
433
|
+
view.out.write(api_safe(answer) + "\n")
|
|
421
434
|
view.out.flush()
|
|
422
435
|
return 1 if view.errors else 0
|
|
423
436
|
|
|
@@ -512,7 +525,11 @@ class JsonlView(HeadlessView):
|
|
|
512
525
|
line = {"seq": self._seq, "protocol": PROTOCOL_VERSION, **payload}
|
|
513
526
|
if self.run_id is not None:
|
|
514
527
|
line["run_id"] = self.run_id
|
|
515
|
-
|
|
528
|
+
# api_safe at the sink: the JSON body is written to the driver's
|
|
529
|
+
# stdout, and a lone surrogate (restored non-UTF-8 session) made
|
|
530
|
+
# the write raise UnicodeEncodeError — the run then ended without
|
|
531
|
+
# its terminal `result` line.
|
|
532
|
+
self.out.write(api_safe(json.dumps(line, ensure_ascii=False, default=str)) + "\n")
|
|
516
533
|
self.out.flush()
|
|
517
534
|
|
|
518
535
|
def _emit(self, payload: dict[str, Any]) -> None:
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/entry/server.py
RENAMED
|
@@ -127,6 +127,7 @@ import time
|
|
|
127
127
|
import uuid
|
|
128
128
|
from typing import Any, TextIO
|
|
129
129
|
|
|
130
|
+
from ..core.models import api_safe
|
|
130
131
|
from ..session.session import Session
|
|
131
132
|
from .controller import Controller
|
|
132
133
|
from .headless import PROTOCOL_VERSION, JsonlView, _restorable_handler, final_answer_text
|
|
@@ -530,7 +531,11 @@ class AgentServer:
|
|
|
530
531
|
live run lines get ``seq``/``run_id`` via the view's ``_write``.
|
|
531
532
|
"""
|
|
532
533
|
text = (
|
|
533
|
-
|
|
534
|
+
api_safe(
|
|
535
|
+
json.dumps(
|
|
536
|
+
{"protocol": PROTOCOL_VERSION, **payload}, ensure_ascii=False, default=str
|
|
537
|
+
)
|
|
538
|
+
)
|
|
534
539
|
+ "\n"
|
|
535
540
|
)
|
|
536
541
|
view = self.view
|
|
@@ -892,7 +897,19 @@ class AgentServer:
|
|
|
892
897
|
def _read_ops(self) -> None:
|
|
893
898
|
"""The reader loop proper: dispatch ops until EOF/shutdown."""
|
|
894
899
|
while not self._stopped.is_set():
|
|
895
|
-
|
|
900
|
+
try:
|
|
901
|
+
line = self.inp.readline()
|
|
902
|
+
except (UnicodeDecodeError, ValueError, OSError) as e:
|
|
903
|
+
# Unreadable input must never take the resident process
|
|
904
|
+
# down. A single non-UTF-8 byte on a strict-UTF-8 stdin
|
|
905
|
+
# (the common C.UTF-8/LANG=en_US.UTF-8 container setup)
|
|
906
|
+
# raised out of serve_forever, which skipped the run drain
|
|
907
|
+
# below: an in-flight run then emitted NO result line and
|
|
908
|
+
# its token usage was lost. Report it as a protocol error
|
|
909
|
+
# and stop reading, so the drain still runs. ValueError
|
|
910
|
+
# covers a stdin closed underneath us.
|
|
911
|
+
self._error("protocol", f"cannot read op: {e}")
|
|
912
|
+
break
|
|
896
913
|
if not line: # EOF: the host closed the pipe
|
|
897
914
|
break
|
|
898
915
|
line = line.strip()
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/io/persistence.py
RENAMED
|
@@ -195,7 +195,16 @@ class SessionPersistence:
|
|
|
195
195
|
session_dir().mkdir(parents=True, exist_ok=True)
|
|
196
196
|
content = conversation_text.rstrip("\n") + "\n\n" + self.metadata_block() + "\n"
|
|
197
197
|
tmp = path + ".tmp"
|
|
198
|
-
|
|
198
|
+
# surrogateescape: the tools deliberately read files with it
|
|
199
|
+
# (see atomic_write_text / Edit._string_replace), so a
|
|
200
|
+
# conversation can legitimately carry lone surrogates from a
|
|
201
|
+
# non-UTF-8 source file. A strict write raised
|
|
202
|
+
# UnicodeEncodeError, which is NOT an OSError, so it escaped
|
|
203
|
+
# auto_save's handler: the turn was never saved and a stray
|
|
204
|
+
# <name>.md.tmp was left behind. The matching reads
|
|
205
|
+
# (headless restore_session, TUI /restore and /sessions) use
|
|
206
|
+
# the same error handler so the bytes round-trip.
|
|
207
|
+
Path(tmp).write_text(content, encoding="utf-8", errors="surrogateescape")
|
|
199
208
|
os.replace(tmp, path)
|
|
200
209
|
return path
|
|
201
210
|
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/lsp/client.py
RENAMED
|
@@ -13,6 +13,7 @@ import os
|
|
|
13
13
|
import queue
|
|
14
14
|
import subprocess
|
|
15
15
|
import threading
|
|
16
|
+
import time
|
|
16
17
|
from pathlib import Path
|
|
17
18
|
from typing import Any
|
|
18
19
|
|
|
@@ -21,6 +22,13 @@ class LSPError(RuntimeError):
|
|
|
21
22
|
pass
|
|
22
23
|
|
|
23
24
|
|
|
25
|
+
# Upper bound for one framed JSON-RPC message. A Content-Length header is
|
|
26
|
+
# untrusted input: a bogus or malicious value must not make the reader
|
|
27
|
+
# reserve gigabytes. Real LSP responses (even documentSymbol on a huge
|
|
28
|
+
# file) are orders of magnitude smaller.
|
|
29
|
+
MAX_MESSAGE_BYTES = 64 * 1024 * 1024
|
|
30
|
+
|
|
31
|
+
|
|
24
32
|
class LSPClient:
|
|
25
33
|
def __init__(self, command: list[str], root: str, language_id: str, timeout: float = 30.0):
|
|
26
34
|
self.command = command
|
|
@@ -39,15 +47,75 @@ class LSPClient:
|
|
|
39
47
|
self._capabilities: dict[str, Any] = {}
|
|
40
48
|
self.position_encoding = "utf-16"
|
|
41
49
|
self._closed = False
|
|
50
|
+
# Set when the reader thread exits for ANY reason other than an
|
|
51
|
+
# explicit close(): the transport can no longer deliver responses,
|
|
52
|
+
# so the client must count as dead even while the child process is
|
|
53
|
+
# still running (a framing error, a crashed/hung server). Without
|
|
54
|
+
# this, `alive` kept reporting True and `manager.get_client` never
|
|
55
|
+
# replaced the broken client -- every later LSP call then burned
|
|
56
|
+
# the full request timeout for the rest of the session.
|
|
57
|
+
self._reader_dead = False
|
|
58
|
+
# Identity of the CURRENT reader thread's generation. A reader
|
|
59
|
+
# superseded by start() can finish after the new one is installed;
|
|
60
|
+
# without this counter it would mark the fresh (healthy) transport
|
|
61
|
+
# dead in its finally block -- the proc identity check alone does
|
|
62
|
+
# not cover the window before the new proc is assigned.
|
|
63
|
+
self._reader_gen = 0
|
|
64
|
+
# True while start() is running its initialize handshake. start()
|
|
65
|
+
# reaches request(), which calls start_if_needed(): a server that
|
|
66
|
+
# dies immediately leaves `alive` False, so without this flag each
|
|
67
|
+
# level re-entered start() -- spawning another process -- until
|
|
68
|
+
# RecursionError instead of a clean "failed to start" error.
|
|
69
|
+
self._starting = False
|
|
42
70
|
|
|
43
71
|
@property
|
|
44
72
|
def alive(self) -> bool:
|
|
45
|
-
return
|
|
73
|
+
return (
|
|
74
|
+
self.proc is not None
|
|
75
|
+
and self.proc.poll() is None
|
|
76
|
+
and not self._closed
|
|
77
|
+
and not self._reader_dead
|
|
78
|
+
)
|
|
46
79
|
|
|
47
80
|
def start(self) -> None:
|
|
48
|
-
if
|
|
81
|
+
"""Start the server if it is not already running.
|
|
82
|
+
|
|
83
|
+
Re-entrancy-safe: ``_start()`` performs the initialize handshake
|
|
84
|
+
through ``request()``, which calls ``start_if_needed()``. A server
|
|
85
|
+
that dies immediately (reader EOF before the request is written)
|
|
86
|
+
leaves ``alive`` False, so without the ``_starting`` guard every
|
|
87
|
+
level re-entered ``start()`` and spawned another process until
|
|
88
|
+
RecursionError. With the guard the inner call is a no-op and the
|
|
89
|
+
handshake fails fast with a clean LSPError.
|
|
90
|
+
"""
|
|
91
|
+
if self.alive or self._starting:
|
|
49
92
|
return
|
|
93
|
+
self._starting = True
|
|
94
|
+
try:
|
|
95
|
+
self._start()
|
|
96
|
+
finally:
|
|
97
|
+
self._starting = False
|
|
98
|
+
|
|
99
|
+
def _start(self) -> None:
|
|
100
|
+
# Invalidate any superseded reader FIRST: _release_proc() below
|
|
101
|
+
# unblocks it, and if it reached its finally block while the
|
|
102
|
+
# generation still matched it would mark this fresh transport dead
|
|
103
|
+
# (and fail its waiters) before the replacement is installed.
|
|
104
|
+
self._reader_gen += 1
|
|
105
|
+
# A previous child can still be running when only the reader died
|
|
106
|
+
# (framing error / crashed transport): release it before the pipes
|
|
107
|
+
# are replaced, or the process and its descriptors leak.
|
|
108
|
+
if self.proc is not None:
|
|
109
|
+
self._release_proc()
|
|
50
110
|
self._closed = False
|
|
111
|
+
self._reader_dead = False
|
|
112
|
+
# A fresh server has no documents open: forget the previous
|
|
113
|
+
# session's bookkeeping, or open_document() would send didChange
|
|
114
|
+
# for a document this process never opened.
|
|
115
|
+
with self._state_lock:
|
|
116
|
+
self._opened.clear()
|
|
117
|
+
self._texts.clear()
|
|
118
|
+
self.position_encoding = "utf-16"
|
|
51
119
|
try:
|
|
52
120
|
self.proc = subprocess.Popen(
|
|
53
121
|
self.command,
|
|
@@ -59,8 +127,9 @@ class LSPClient:
|
|
|
59
127
|
)
|
|
60
128
|
except OSError as e:
|
|
61
129
|
raise LSPError(f"failed to start LSP server {' '.join(self.command)!r}: {e}") from e
|
|
130
|
+
gen = self._reader_gen
|
|
62
131
|
self._reader = threading.Thread(
|
|
63
|
-
target=self._read_loop, daemon=True, name=f"lsp-reader:{self.command[0]}"
|
|
132
|
+
target=self._read_loop, args=(gen,), daemon=True, name=f"lsp-reader:{self.command[0]}"
|
|
64
133
|
)
|
|
65
134
|
self._reader.start()
|
|
66
135
|
try:
|
|
@@ -111,7 +180,7 @@ class LSPClient:
|
|
|
111
180
|
message["params"] = params
|
|
112
181
|
try:
|
|
113
182
|
self._send(message)
|
|
114
|
-
kind, value =
|
|
183
|
+
kind, value = self._wait_response(waiter, method)
|
|
115
184
|
except queue.Empty as e:
|
|
116
185
|
with self._state_lock:
|
|
117
186
|
self._pending.pop(request_id, None)
|
|
@@ -130,6 +199,27 @@ class LSPClient:
|
|
|
130
199
|
raise LSPError(f"LSP {method} failed ({code}): {msg}")
|
|
131
200
|
return value
|
|
132
201
|
|
|
202
|
+
def _wait_response(self, waiter: queue.Queue, method: str) -> tuple[str, Any]:
|
|
203
|
+
"""Wait for WAITER, failing fast once the transport is dead.
|
|
204
|
+
|
|
205
|
+
The reader drains every waiter when it exits, but a request that
|
|
206
|
+
registers AFTER that drain finds an empty pending map and could
|
|
207
|
+
only wait out the full request timeout for a response nobody can
|
|
208
|
+
deliver. Polling in slices lets the wait end as soon as the
|
|
209
|
+
transport is known to be dead. Raises ``queue.Empty`` on a real
|
|
210
|
+
timeout so the caller keeps its single timeout message.
|
|
211
|
+
"""
|
|
212
|
+
deadline = time.monotonic() + self.timeout
|
|
213
|
+
while True:
|
|
214
|
+
remaining = deadline - time.monotonic()
|
|
215
|
+
if remaining <= 0:
|
|
216
|
+
raise queue.Empty
|
|
217
|
+
try:
|
|
218
|
+
return waiter.get(timeout=min(0.1, remaining))
|
|
219
|
+
except queue.Empty:
|
|
220
|
+
if self._reader_dead:
|
|
221
|
+
raise LSPError(f"LSP server exited while waiting for {method}") from None
|
|
222
|
+
|
|
133
223
|
def notify(self, method: str, params: Any = None) -> None:
|
|
134
224
|
self.start_if_needed()
|
|
135
225
|
message: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
|
|
@@ -149,6 +239,7 @@ class LSPClient:
|
|
|
149
239
|
# write can deadlock the reader thread (which needs _state_lock to
|
|
150
240
|
# deliver responses/diagnostics) if the pipe buffer fills.
|
|
151
241
|
with self._state_lock:
|
|
242
|
+
gen = self._reader_gen
|
|
152
243
|
version = self._opened.get(uri, 0)
|
|
153
244
|
if version:
|
|
154
245
|
if self._texts.get(uri) == text:
|
|
@@ -174,6 +265,16 @@ class LSPClient:
|
|
|
174
265
|
}
|
|
175
266
|
}
|
|
176
267
|
self.notify(method, params)
|
|
268
|
+
if self._reader_gen != gen:
|
|
269
|
+
# The transport restarted between the bookkeeping above and the
|
|
270
|
+
# send: start() cleared _opened, and this notification either
|
|
271
|
+
# went to a server that is gone or to a replacement that never
|
|
272
|
+
# received didOpen. Drop the stale claim so the next call
|
|
273
|
+
# re-opens the document against the current server instead of
|
|
274
|
+
# sending didChange for a document it does not know.
|
|
275
|
+
with self._state_lock:
|
|
276
|
+
self._opened.pop(uri, None)
|
|
277
|
+
self._texts.pop(uri, None)
|
|
177
278
|
|
|
178
279
|
def close_document(self, uri: str) -> None:
|
|
179
280
|
with self._state_lock:
|
|
@@ -192,28 +293,20 @@ class LSPClient:
|
|
|
192
293
|
if self._closed:
|
|
193
294
|
return
|
|
194
295
|
proc = self.proc
|
|
195
|
-
|
|
296
|
+
# The graceful path (shutdown request + exit notification) goes
|
|
297
|
+
# through request()/notify(), which call start_if_needed() — and
|
|
298
|
+
# with the reader dead `alive` is False, so closing would SPAWN A
|
|
299
|
+
# FRESH SERVER just to shut it down, leaking the old process.
|
|
300
|
+
# When the transport is already broken there is nobody to talk to:
|
|
301
|
+
# go straight to terminate.
|
|
302
|
+
was_alive = proc is not None and proc.poll() is None and not self._reader_dead
|
|
196
303
|
if was_alive:
|
|
197
304
|
with contextlib.suppress(Exception):
|
|
198
305
|
self.request("shutdown", None)
|
|
199
306
|
with contextlib.suppress(Exception):
|
|
200
307
|
self.notify("exit", None)
|
|
201
308
|
self._closed = True
|
|
202
|
-
|
|
203
|
-
if proc is not None and proc.poll() is None:
|
|
204
|
-
with contextlib.suppress(Exception):
|
|
205
|
-
proc.terminate()
|
|
206
|
-
proc.wait(timeout=2)
|
|
207
|
-
with contextlib.suppress(Exception):
|
|
208
|
-
proc.kill()
|
|
209
|
-
# Close the pipe file objects so their file descriptors are
|
|
210
|
-
# released (subprocess does not close them on terminate/kill —
|
|
211
|
-
# leaving them open leaks fds and emits ResourceWarning).
|
|
212
|
-
if proc is not None:
|
|
213
|
-
for stream in (proc.stdin, proc.stdout):
|
|
214
|
-
if stream is not None:
|
|
215
|
-
with contextlib.suppress(Exception):
|
|
216
|
-
stream.close()
|
|
309
|
+
self._release_proc()
|
|
217
310
|
with self._state_lock:
|
|
218
311
|
with contextlib.suppress(queue.Full):
|
|
219
312
|
for waiter in self._pending.values():
|
|
@@ -222,6 +315,33 @@ class LSPClient:
|
|
|
222
315
|
self._opened.clear()
|
|
223
316
|
self._texts.clear()
|
|
224
317
|
|
|
318
|
+
def _release_proc(self) -> None:
|
|
319
|
+
"""Terminate + reap the current server process and close its pipes.
|
|
320
|
+
|
|
321
|
+
Shared by ``close()`` (explicit teardown) and ``start()`` (a
|
|
322
|
+
restart after the reader died): in the latter case the child may
|
|
323
|
+
still be running, and the pipes MUST be released either way --
|
|
324
|
+
``subprocess`` does not close them on terminate/kill, so leaving
|
|
325
|
+
them open leaks descriptors and emits ResourceWarning.
|
|
326
|
+
"""
|
|
327
|
+
proc = self.proc
|
|
328
|
+
if proc is None:
|
|
329
|
+
return
|
|
330
|
+
if proc.poll() is None:
|
|
331
|
+
with contextlib.suppress(Exception):
|
|
332
|
+
proc.terminate()
|
|
333
|
+
proc.wait(timeout=2)
|
|
334
|
+
with contextlib.suppress(Exception):
|
|
335
|
+
proc.kill()
|
|
336
|
+
# Reap the killed child so it does not linger as a zombie
|
|
337
|
+
# until subprocess's global cleanup runs.
|
|
338
|
+
with contextlib.suppress(Exception):
|
|
339
|
+
proc.wait(timeout=2)
|
|
340
|
+
for stream in (getattr(proc, "stdin", None), getattr(proc, "stdout", None)):
|
|
341
|
+
if stream is not None:
|
|
342
|
+
with contextlib.suppress(Exception):
|
|
343
|
+
stream.close()
|
|
344
|
+
|
|
225
345
|
def _send(self, message: dict[str, Any]) -> None:
|
|
226
346
|
data = json.dumps(message, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
227
347
|
packet = b"Content-Length: " + str(len(data)).encode("ascii") + b"\r\n\r\n" + data
|
|
@@ -235,16 +355,24 @@ class LSPClient:
|
|
|
235
355
|
except OSError as e:
|
|
236
356
|
raise LSPError(f"failed writing to LSP server: {e}") from e
|
|
237
357
|
|
|
238
|
-
def _read_loop(self) -> None:
|
|
358
|
+
def _read_loop(self, gen: int | None = None) -> None:
|
|
359
|
+
proc = self.proc
|
|
239
360
|
try:
|
|
240
|
-
proc = self.proc
|
|
241
361
|
if proc is None or proc.stdout is None:
|
|
242
362
|
return
|
|
243
363
|
stream = proc.stdout
|
|
244
364
|
while not self._closed:
|
|
245
365
|
headers: dict[str, str] = {}
|
|
246
366
|
while True:
|
|
247
|
-
|
|
367
|
+
try:
|
|
368
|
+
line = stream.readline()
|
|
369
|
+
except (ValueError, OSError):
|
|
370
|
+
# close() may release the pipes while this thread
|
|
371
|
+
# is parked in a read; that surfaces here as a
|
|
372
|
+
# closed-file/most-unblocked error. Treat it as
|
|
373
|
+
# EOF instead of letting a traceback escape a
|
|
374
|
+
# daemon thread.
|
|
375
|
+
return
|
|
248
376
|
if not line:
|
|
249
377
|
return
|
|
250
378
|
line = line.rstrip(b"\r\n")
|
|
@@ -259,8 +387,14 @@ class LSPClient:
|
|
|
259
387
|
length = int(headers.get("content-length", "0"))
|
|
260
388
|
except ValueError:
|
|
261
389
|
continue
|
|
262
|
-
|
|
263
|
-
|
|
390
|
+
if length < 0 or length > MAX_MESSAGE_BYTES:
|
|
391
|
+
# Unusable framing: a bogus/huge header must not make
|
|
392
|
+
# read() reserve gigabytes, and a negative one can
|
|
393
|
+
# never be satisfied. Stop reading; the finally block
|
|
394
|
+
# marks the transport dead so the server is replaced.
|
|
395
|
+
return
|
|
396
|
+
payload = self._read_exactly(stream, length)
|
|
397
|
+
if payload is None:
|
|
264
398
|
return
|
|
265
399
|
try:
|
|
266
400
|
message = json.loads(payload.decode("utf-8"))
|
|
@@ -268,12 +402,57 @@ class LSPClient:
|
|
|
268
402
|
continue
|
|
269
403
|
self._handle_message(message)
|
|
270
404
|
finally:
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
405
|
+
# Mark the transport dead for every exit path except an
|
|
406
|
+
# explicit close(): `alive` must go False so the manager
|
|
407
|
+
# replaces this client instead of sending requests into a
|
|
408
|
+
# pipe nobody reads (which only produced timeouts). Both
|
|
409
|
+
# guards matter: a superseded reader (start() installed a
|
|
410
|
+
# replacement) must not kill the fresh transport, and a
|
|
411
|
+
# concurrent close() already owns the flag.
|
|
412
|
+
current = (gen is None or gen == self._reader_gen) and self.proc is proc
|
|
413
|
+
if current:
|
|
414
|
+
self._reader_dead = True
|
|
415
|
+
# Only the CURRENT reader owns this client's waiters: a
|
|
416
|
+
# superseded reader exiting during a restart must not fail
|
|
417
|
+
# requests that belong to the replacement transport.
|
|
418
|
+
with self._state_lock:
|
|
419
|
+
pending = list(self._pending.values())
|
|
420
|
+
self._pending.clear()
|
|
275
421
|
for waiter in pending:
|
|
276
|
-
waiter
|
|
422
|
+
# Per waiter: one already-filled queue must not stop
|
|
423
|
+
# the rest from being notified.
|
|
424
|
+
with contextlib.suppress(queue.Full):
|
|
425
|
+
waiter.put_nowait(("error", {"message": "LSP server exited"}))
|
|
426
|
+
|
|
427
|
+
@staticmethod
|
|
428
|
+
def _read_exactly(stream: Any, length: int) -> bytes | None:
|
|
429
|
+
"""Read exactly LENGTH bytes from STREAM; None on EOF first.
|
|
430
|
+
|
|
431
|
+
``read(n)`` on a pipe-backed raw stream returns UP TO n bytes: it
|
|
432
|
+
reports what a single ``read(2)`` delivered, which for a message
|
|
433
|
+
larger than the pipe buffer is a partial message. Treating that
|
|
434
|
+
short read as EOF killed the reader thread permanently -- one
|
|
435
|
+
large documentSymbol/hover/findReferences result silently
|
|
436
|
+
disabled LSP for the rest of the session. Accumulate until the
|
|
437
|
+
framed length is satisfied; only a genuinely empty read (every
|
|
438
|
+
writer gone) is EOF.
|
|
439
|
+
"""
|
|
440
|
+
if length == 0:
|
|
441
|
+
return b""
|
|
442
|
+
chunks: list[bytes] = []
|
|
443
|
+
remaining = length
|
|
444
|
+
while remaining:
|
|
445
|
+
try:
|
|
446
|
+
chunk = stream.read(remaining)
|
|
447
|
+
except (ValueError, OSError):
|
|
448
|
+
return None # pipe closed under us (see the readline guard)
|
|
449
|
+
if not chunk:
|
|
450
|
+
return None
|
|
451
|
+
chunks.append(chunk)
|
|
452
|
+
# Clamp: a stream that over-delivers must not make `remaining`
|
|
453
|
+
# negative (a negative read() means "until EOF", i.e. a loop).
|
|
454
|
+
remaining = max(0, remaining - len(chunk))
|
|
455
|
+
return b"".join(chunks)
|
|
277
456
|
|
|
278
457
|
def _handle_message(self, message: dict[str, Any]) -> None:
|
|
279
458
|
if "id" in message and ("result" in message or "error" in message):
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/agent.md
RENAMED
|
@@ -56,9 +56,9 @@ The user will primarily request you perform software engineering tasks. This inc
|
|
|
56
56
|
- Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially.
|
|
57
57
|
- Implement the solution using all tools available to you
|
|
58
58
|
- Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
|
|
59
|
-
- VERY IMPORTANT:
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
- VERY IMPORTANT:
|
|
60
|
+
- When you have completed a task, you MUST run the lint and typecheck commands (e.g. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time.
|
|
61
|
+
- NEVER commit changes unless the user explicitly asks you to.
|
|
62
62
|
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
|
|
63
63
|
|
|
64
64
|
# Tool usage policy
|
|
@@ -698,7 +698,11 @@ class Session:
|
|
|
698
698
|
self.store.save(text)
|
|
699
699
|
self._save_error = None
|
|
700
700
|
return
|
|
701
|
-
except OSError as e:
|
|
701
|
+
except (OSError, UnicodeError) as e:
|
|
702
|
+
# UnicodeError too: a save failure must never escape into
|
|
703
|
+
# the agent loop (it unwound the run and lost the turn).
|
|
704
|
+
# The writer uses surrogateescape now, so this is a
|
|
705
|
+
# defensive net rather than the primary fix.
|
|
702
706
|
self._save_error = str(e)
|
|
703
707
|
if attempt == 1:
|
|
704
708
|
time.sleep(0.2)
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/edit.py
RENAMED
|
@@ -65,14 +65,16 @@ class Edit(Tool):
|
|
|
65
65
|
|
|
66
66
|
**When NOT to use `Edit`:**
|
|
67
67
|
- Creating brand new files → use `Write`
|
|
68
|
-
- You haven't read the file yet → must `Read` first
|
|
69
|
-
- The old_string is not unique and you want to replace all occurrences →
|
|
68
|
+
- You haven't read the file yet → must `Read` first
|
|
69
|
+
- The old_string is not unique and you want to replace all occurrences → pass `replace_all: true`
|
|
70
70
|
|
|
71
71
|
**How to use `Edit`:**
|
|
72
|
-
- MUST `Read` the file first
|
|
73
|
-
- Provide exact `old_string` to match (including proper
|
|
72
|
+
- MUST `Read` the file first
|
|
73
|
+
- Provide exact `old_string` to match (including proper indentation from the file content, not line number prefixes)
|
|
74
74
|
- Provide `new_string` as replacement (must be different from old_string)
|
|
75
|
-
- The edit will FAIL if old_string is not unique
|
|
75
|
+
- The edit will FAIL if old_string is not unique — add context, or set `replace_all: true`
|
|
76
|
+
- `replace_all: true` replaces EVERY occurrence (use it deliberately: it is not
|
|
77
|
+
scoped to one region)
|
|
76
78
|
- Preserve exact indentation from the file content (ignore line number prefixes from `Read` output)
|
|
77
79
|
- Always prefer editing existing files over creating new ones
|
|
78
80
|
"""
|
|
@@ -107,6 +109,13 @@ class Edit(Tool):
|
|
|
107
109
|
"description": "Original string to replace. If providing a unified diff, this should be false",
|
|
108
110
|
},
|
|
109
111
|
"new_str": {"type": "string", "description": "Replacement string OR unified diff text"},
|
|
112
|
+
"replace_all": {
|
|
113
|
+
"type": "boolean",
|
|
114
|
+
"description": (
|
|
115
|
+
"Replace EVERY occurrence of old_str instead of failing when it "
|
|
116
|
+
"is not unique. Default false (a non-unique match is an error)."
|
|
117
|
+
),
|
|
118
|
+
},
|
|
110
119
|
"diff": {
|
|
111
120
|
"type": "boolean",
|
|
112
121
|
"description": "Whether the replacement is a string or a diff. `true` for a diff, `false` otherwise.",
|
|
@@ -127,7 +136,13 @@ class Edit(Tool):
|
|
|
127
136
|
diffp = args.get("diff")
|
|
128
137
|
# gptel: string mode when `diff` is false OR `old_str` is provided.
|
|
129
138
|
if diffp is False or old is not None:
|
|
130
|
-
|
|
139
|
+
# `is True`, not bool(): a model that sends the string "false"
|
|
140
|
+
# (or 0) must not be read as "replace every occurrence" --
|
|
141
|
+
# bool("false") is True, which would silently mass-replace.
|
|
142
|
+
# Anything unrecognised fails closed, like `diffp is False`.
|
|
143
|
+
return self._string_replace(
|
|
144
|
+
path, old, new_str, ctx, replace_all=args.get("replace_all") is True
|
|
145
|
+
)
|
|
131
146
|
# Diff mode runs `patch` in Emacs `file-name-directory' of the path
|
|
132
147
|
# (see _patch_cwd): a trailing-slash directory path -> that
|
|
133
148
|
# directory itself, so a multi-file diff applies to files within
|
|
@@ -135,7 +150,15 @@ class Edit(Tool):
|
|
|
135
150
|
cwd = _patch_cwd(raw, path)
|
|
136
151
|
return self._apply_patch(path, cwd, new_str, ctx)
|
|
137
152
|
|
|
138
|
-
def _string_replace(
|
|
153
|
+
def _string_replace(
|
|
154
|
+
self,
|
|
155
|
+
path: str,
|
|
156
|
+
old: str | None,
|
|
157
|
+
new_str: str,
|
|
158
|
+
ctx: ToolContext,
|
|
159
|
+
*,
|
|
160
|
+
replace_all: bool = False,
|
|
161
|
+
) -> str:
|
|
139
162
|
if os.path.isdir(path):
|
|
140
163
|
return (
|
|
141
164
|
f"Error: String replacement is intended for single files, not directories ({path})"
|
|
@@ -144,6 +167,10 @@ class Edit(Tool):
|
|
|
144
167
|
return "Error: old_str is required for non-diff edits"
|
|
145
168
|
if not isinstance(old, str) or not isinstance(new_str, str):
|
|
146
169
|
return "Error: old_str and new_str must be strings"
|
|
170
|
+
if not old:
|
|
171
|
+
# `str.count("")` is len+1 and `replace("", x)` splices x
|
|
172
|
+
# between every character — never a meaningful edit.
|
|
173
|
+
return "Error: old_str must not be empty"
|
|
147
174
|
# surrogateescape: invalid UTF-8 bytes (Latin-1, GBK, ...) survive
|
|
148
175
|
# the read/rewrite round trip — errors="replace" would silently
|
|
149
176
|
# persist U+FFFD for every such byte. newline="": the file's own
|
|
@@ -162,12 +189,12 @@ class Edit(Tool):
|
|
|
162
189
|
count = content.count(old)
|
|
163
190
|
if count == 0:
|
|
164
191
|
return f'Error: Could not find old_str "{old[:20]}" in file {path}'
|
|
165
|
-
if count > 1:
|
|
192
|
+
if count > 1 and not replace_all:
|
|
166
193
|
return (
|
|
167
|
-
"Error: Match is not unique. Consider providing more
|
|
168
|
-
"for the replacement, or a unified diff"
|
|
194
|
+
f"Error: Match is not unique ({count} occurrences). Consider providing more "
|
|
195
|
+
"context for the replacement, passing replace_all: true, or using a unified diff"
|
|
169
196
|
)
|
|
170
|
-
new = content.replace(old, new_str, 1)
|
|
197
|
+
new = content.replace(old, new_str) if replace_all else content.replace(old, new_str, 1)
|
|
171
198
|
from .filesystem import atomic_write_text
|
|
172
199
|
|
|
173
200
|
try:
|
|
@@ -177,6 +204,11 @@ class Edit(Tool):
|
|
|
177
204
|
diff_text = unified_diff(content, new, path)
|
|
178
205
|
if diff_text:
|
|
179
206
|
ctx.record_diff(diff_text)
|
|
207
|
+
if replace_all and count > 1:
|
|
208
|
+
return (
|
|
209
|
+
f"Successfully replaced all {count} occurrences of {old[:20]} (truncated) "
|
|
210
|
+
f"with {new_str[:20]} (truncated)"
|
|
211
|
+
)
|
|
180
212
|
return f"Successfully replaced {old[:20]} (truncated) with {new_str[:20]} (truncated)"
|
|
181
213
|
|
|
182
214
|
def _apply_patch(self, path: str, cwd: str, diff: str, ctx: ToolContext) -> str:
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tui/commands.py
RENAMED
|
@@ -628,7 +628,8 @@ class CommandMixin:
|
|
|
628
628
|
return
|
|
629
629
|
for f in files:
|
|
630
630
|
try:
|
|
631
|
-
|
|
631
|
+
# surrogateescape mirrors SessionPersistence.save (see there)
|
|
632
|
+
with open(f, encoding="utf-8", errors="surrogateescape") as fh:
|
|
632
633
|
text = fh.read()
|
|
633
634
|
except OSError:
|
|
634
635
|
continue
|
|
@@ -672,7 +673,8 @@ class CommandMixin:
|
|
|
672
673
|
self.console.print(f"[red]file not found: {safe_markup(path)}[/red]")
|
|
673
674
|
return
|
|
674
675
|
try:
|
|
675
|
-
|
|
676
|
+
# surrogateescape mirrors SessionPersistence.save (see there)
|
|
677
|
+
with open(path, encoding="utf-8", errors="surrogateescape") as fh:
|
|
676
678
|
text = fh.read()
|
|
677
679
|
except OSError as e:
|
|
678
680
|
self.console.print(f"[red]cannot read {safe_markup(path)}: {safe_markup(e)}[/red]")
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tui/render.py
RENAMED
|
@@ -20,7 +20,7 @@ from rich.panel import Panel
|
|
|
20
20
|
from rich.table import Table
|
|
21
21
|
from rich.text import Text
|
|
22
22
|
|
|
23
|
-
from ..core.models import ImagePart, display_args
|
|
23
|
+
from ..core.models import ImagePart, api_safe, display_args
|
|
24
24
|
from ..io.diffrender import render_diff
|
|
25
25
|
from ..io.text_filter import strip_final_check, strip_reasoning
|
|
26
26
|
from ..prompts import _is_mode_reminder_text
|
|
@@ -66,8 +66,13 @@ def sanitize_terminal(text: str) -> str:
|
|
|
66
66
|
what actually ran), spoof hyperlinks, or -- on terminals that honor
|
|
67
67
|
OSC 52 -- write the user's clipboard. Tabs and newlines survive;
|
|
68
68
|
colors in command output are dropped along with everything else.
|
|
69
|
+
|
|
70
|
+
Lone surrogates (a non-UTF-8 file read with ``surrogateescape``)
|
|
71
|
+
become U+FFFD: Rich would hand them to the terminal verbatim and the
|
|
72
|
+
UTF-8 encoder raised ``UnicodeEncodeError`` — killing the TUI while
|
|
73
|
+
rendering a restored session.
|
|
69
74
|
"""
|
|
70
|
-
return _TERMINAL_CONTROL.sub("", text)
|
|
75
|
+
return _TERMINAL_CONTROL.sub("", api_safe(text))
|
|
71
76
|
|
|
72
77
|
|
|
73
78
|
def safe_markup(value: object) -> str:
|
|
@@ -465,14 +470,39 @@ class RenderMixin:
|
|
|
465
470
|
|
|
466
471
|
@staticmethod
|
|
467
472
|
def _est_lines(row: Any, width: int) -> int:
|
|
468
|
-
"""
|
|
473
|
+
"""Estimated wrapped-line count for ROW at WIDTH (budget math).
|
|
474
|
+
|
|
475
|
+
The estimate must never be a LOWER bound: ``_apply_budget`` keeps
|
|
476
|
+
the newest rows by subtracting estimates from a fixed budget, so
|
|
477
|
+
under-estimating a tall row lets it through, the frame overflows,
|
|
478
|
+
and rich's ``Live`` crops it from the BOTTOM -- hiding exactly the
|
|
479
|
+
newest rows the budget exists to protect.
|
|
480
|
+
|
|
481
|
+
Two shapes made the old per-type estimate collapse to 1 line:
|
|
482
|
+
|
|
483
|
+
- multi-line ``Text`` (the old formula divided the total length by
|
|
484
|
+
the width, so 20 short lines counted as ~2 and were billed as 1);
|
|
485
|
+
- ``Group`` (a rendered diff is one ``Text`` per diff line, so a
|
|
486
|
+
300-line Edit diff was counted as a single line and disabled the
|
|
487
|
+
budget entirely).
|
|
488
|
+
|
|
489
|
+
Both now count real newlines, and ``Group`` recurses into its
|
|
490
|
+
renderables.
|
|
491
|
+
"""
|
|
492
|
+
width = max(1, width)
|
|
493
|
+
|
|
494
|
+
def _wrapped(text: str) -> int:
|
|
495
|
+
return sum(max(1, math.ceil(cell_len(line) / width)) for line in text.split("\n"))
|
|
496
|
+
|
|
469
497
|
if isinstance(row, Text):
|
|
470
|
-
return
|
|
498
|
+
return _wrapped(row.plain)
|
|
471
499
|
if isinstance(row, Markdown):
|
|
472
|
-
return
|
|
500
|
+
return _wrapped(row.markup)
|
|
473
501
|
if isinstance(row, Panel):
|
|
474
502
|
inner = getattr(row.renderable, "renderables", None)
|
|
475
503
|
return 3 + (len(inner) if isinstance(inner, (list, tuple)) else 1)
|
|
504
|
+
if isinstance(row, Group):
|
|
505
|
+
return sum(RenderMixin._est_lines(child, width) for child in row.renderables)
|
|
476
506
|
return 1
|
|
477
507
|
|
|
478
508
|
def _render_frame(self) -> Group:
|
|
File without changes
|
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/__main__.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/core/__init__.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/core/agent.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/core/planmode.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/core/subagent.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/entry/__init__.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/entry/cli.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/entry/view.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/io/__init__.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/io/attachments.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/io/diffrender.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/io/text_filter.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/llm/__init__.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/llm/client.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/lsp/__init__.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/lsp/config.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/lsp/manager.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/mcp/__init__.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/mcp/client.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/mcp/config.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/mcp/manager.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/core.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/plan.md
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/review.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/title.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/session/config.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/base.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/bash.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/edit_mac.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/glob.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/glob_mac.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/grep.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/grep_mac.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/insert.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/lsp.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/mcp.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/mkdir.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/planexit.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/question.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/read.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/skill.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/todo.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/write.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tui/__init__.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tui/core.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tui/input.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|