fix: tighten tool guidance and editor workflows

This commit is contained in:
Alessandro 2026-05-11 05:13:51 +02:00
parent 6ba1f30dca
commit f17198e126
19 changed files with 694 additions and 45 deletions

View file

@ -29,6 +29,7 @@ from langchain.text_splitter import RecursiveCharacterTextSplitter
DEFAULT_SEARCH_THRESHOLD = 0.5
MAX_REMOTE_DOCUMENT_BYTES = 50 * 1024 * 1024
SMALL_DOCUMENT_QA_FALLBACK_CHARS = 12_000
class DocumentQueryStore:
@ -369,7 +370,7 @@ class DocumentQueryHelper:
await self.agent.handle_intervention()
# index documents
await asyncio.gather(
document_contents = await asyncio.gather(
*[self.document_get_content(uri, True) for uri in document_uris]
)
await self.agent.handle_intervention()
@ -409,19 +410,27 @@ class DocumentQueryHelper:
selected_chunks[chunk.metadata["id"]] = chunk
if not selected_chunks:
self.progress_callback("No relevant content found in the documents")
content = f"!!! No content found for documents: {json.dumps(document_uris)} matching queries: {json.dumps(questions)}"
return False, content
content = self._small_document_fallback_content(
document_uris, document_contents
)
if not content:
self.progress_callback("No relevant content found in the documents")
content = f"!!! No content found for documents: {json.dumps(document_uris)} matching queries: {json.dumps(questions)}"
return False, content
self.progress_callback(
"No matching chunks found; using complete small-document content"
)
else:
content = "\n\n----\n\n".join(
[chunk.page_content for chunk in selected_chunks.values()]
)
self.progress_callback(
f"Processing {len(questions)} questions in context of {len(selected_chunks)} chunks"
f"Processing {len(questions)} questions in document context"
)
await self.agent.handle_intervention()
questions_str = "\n".join([f" * {question}" for question in questions])
content = "\n\n----\n\n".join(
[chunk.page_content for chunk in selected_chunks.values()]
)
qa_system_message = self.agent.parse_prompt(
"fw.document_query.system_prompt.md"
@ -440,6 +449,23 @@ class DocumentQueryHelper:
return True, str(ai_response)
@staticmethod
def _small_document_fallback_content(
document_uris: Sequence[str], document_contents: Sequence[str]
) -> str:
total_chars = 0
sections = []
for uri, content in zip(document_uris, document_contents):
if not isinstance(content, str) or not content.strip():
continue
total_chars += len(content)
if total_chars > SMALL_DOCUMENT_QA_FALLBACK_CHARS:
return ""
sections.append(f"## {uri}\n\n{content.strip()}")
return "\n\n----\n\n".join(sections)
async def document_get_content(
self, document_uri: str, add_to_db: bool = False
) -> str:

View file

@ -820,9 +820,13 @@ class TaskScheduler:
save_tmp_chat(context)
return context
else:
PrintStyle.warning(
message = (
f"Scheduler Task {task.name} loaded from task {task.uuid} but context not found"
)
if task.is_dedicated():
PrintStyle.info(f"{message}; creating dedicated context")
else:
PrintStyle.warning(message)
return await self.__new_context(task)
async def _persist_chat(self, task: Union[ScheduledTask, AdHocTask, PlannedTask], context: AgentContext):

View file

@ -14,11 +14,12 @@ report that to the user instead of falling back to server-side file tools.
- `path`: file path on the CLI host filesystem
- `read`: optional `line_from`, `line_to`
- `write`: requires `content`
- `patch`: requires either `patch_text` or `edits`
- `patch`: requires one of `old_text` + `new_text`, `patch_text`, or `edits`
## Notes
- Prefer `read` before line-number edits.
- If the user says patch, change without rewriting, or don't rewrite, use `action: "patch"` instead of `write`.
- For simple "change X to Y" requests, prefer exact replace with `old_text` and `new_text`; `old_text` must match one exact current span.
- Prefer `patch_text` for context-anchored changes and `edits` only for fresh, surgical line ranges.
- If freshness checks reject a line patch, reread the file and retry with updated ranges.
- Relative paths are relative to the CLI host filesystem. Do not rewrite them to

View file

@ -20,12 +20,14 @@ If the task belongs inside Agent Zero's own runtime, use the normal server-side
- Start with `read` when inspecting a file or preparing line-based edits.
- Use `write` only when replacing or creating the whole file is truly the right operation.
- Prefer `patch` with `patch_text` for context-anchored edits, especially after inserts/deletes or when line numbers may have shifted.
- Prefer `patch` with `old_text` and `new_text` for simple exact replacements.
- Use `patch_text` for context-anchored edits, especially after inserts/deletes or when line numbers may have shifted.
- Use `patch` with `edits` only for small line-range edits based on the latest remote read.
- If freshness-aware line patching rejects an edit as stale, reread the file and retry with updated ranges.
## Patch Text Rules
- Exact replace requires `old_text` to match one exact current span; use a longer span if it matches multiple places.
- `patch_text` supports update hunks for one file.
- Use one `@@ existing line` anchor, then `+new line` entries for insertion.
- For replacement, use `@@ before target` followed by `-old` and `+new`, or use `@@ old target` followed by the same replacement pair.

View file

@ -23,7 +23,10 @@ from plugins._a0_connector.helpers.ws_runtime import (
select_remote_file_target_sid,
store_pending_file_op,
)
from plugins._text_editor.helpers.patch_request import parse_patch_request
from plugins._text_editor.helpers.patch_request import (
exact_replace_to_patch_text,
parse_patch_request,
)
FILE_OP_TIMEOUT = 30.0
@ -83,7 +86,12 @@ class TextEditorRemote(Tool):
patch_request, err = parse_patch_request(
self.args.get("edits"),
self.args.get("patch_text"),
both_error="provide either edits or patch_text for patch, not both",
self.args.get("old_text"),
self.args.get("new_text"),
both_error=(
"provide exactly one patch form: edits, patch_text, "
"or old_text/new_text"
),
)
if err:
return Response(
@ -94,6 +102,13 @@ class TextEditorRemote(Tool):
result = await self._execute_context_patch(
path, patch_request.patch_text
)
elif patch_request and patch_request.mode == "replace":
result = await self._execute_context_patch(
path,
exact_replace_to_patch_text(
path, patch_request.old_text, patch_request.new_text
),
)
else:
result = await self._execute_patch(
path,

View file

@ -3,8 +3,9 @@
create/edit/delete persist across conversations
preference changes, instruction files, project notes, and prompt includes > persist via text_editor before responding
explicit memory requests like "remember this", "what did I ask you to remember", or "forget this" > use memory tools, not promptinclude files, unless the user asks to edit a file
explicit durable behavior, personality, style, greeting, or exact-response rule requests > use behaviour_adjustment, not promptinclude files, unless the user asks to edit a file
never just acknowledge durable project/instruction changes verbally; persist them to file when the user asks for a file/instruction/preference change
use promptinclude files for persistent project context and behavioral instructions
use promptinclude files for persistent project context, reference instructions, and user-authored prompt include files
recursive search alphabetical by full path
{{if includes}}
### includes

View file

@ -218,6 +218,13 @@ class ContextPatchFileResult(TypedDict):
line_to: int
class ExactReplaceFileResult(TypedDict):
total_lines: int
replacement_count: int
line_from: int
line_to: int
def validate_edits(edits: list | None) -> tuple[list[dict], str]:
"""
Normalise and validate an edits array.
@ -391,6 +398,53 @@ def apply_context_patch_file(path: str, patch_text: str) -> ContextPatchFileResu
)
def apply_exact_replace_file(
path: str, old_text: str, new_text: str
) -> ExactReplaceFileResult:
"""Replace exactly one text span in an existing text file."""
path = os.path.expanduser(path)
if not os.path.isfile(path):
raise FileNotFoundError("file not found")
if not old_text:
raise ValueError("old_text is required for exact replace")
with open(path, "r", encoding="utf-8", errors="replace") as src:
content = src.read()
match_count = content.count(old_text)
if match_count == 0:
raise ValueError("old_text not found")
if match_count > 1:
raise ValueError(
f"old_text matched {match_count} times; provide a longer exact span"
)
if old_text == new_text:
raise ValueError("old_text and new_text are identical")
start = content.index(old_text)
line_from = content[:start].count("\n") + 1
line_to = line_from + max(_count_content_lines(old_text) - 1, 0)
new_content = content.replace(old_text, new_text, 1)
dir_name = os.path.dirname(path) or "."
fd, tmp_path = tempfile.mkstemp(dir=dir_name, suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as dst:
dst.write(new_content)
shutil.move(tmp_path, path)
except Exception:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
raise
return ExactReplaceFileResult(
total_lines=_count_content_lines(new_content),
replacement_count=1,
line_from=line_from,
line_to=line_to,
)
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------

View file

@ -4,7 +4,7 @@ from dataclasses import dataclass
from typing import Any, Literal
PatchMode = Literal["edits", "patch_text"]
PatchMode = Literal["edits", "patch_text", "replace"]
@dataclass(frozen=True)
@ -12,20 +12,37 @@ class PatchRequest:
mode: PatchMode
edits: Any = None
patch_text: str = ""
old_text: str = ""
new_text: str = ""
def parse_patch_request(
edits: Any,
patch_text: Any,
old_text: Any = None,
new_text: Any = None,
*,
both_error: str = "provide either edits or patch_text, not both",
missing_error: str = "edits or patch_text is required for patch",
both_error: str = "provide exactly one patch form: edits, patch_text, or old_text/new_text",
missing_error: str = "edits, patch_text, or old_text/new_text is required for patch",
) -> tuple[PatchRequest | None, str]:
"""Validate the mutually-exclusive patch request shape."""
if edits is not None and patch_text is not None:
has_edits = edits is not None
has_patch_text = patch_text is not None
has_replace = old_text is not None or new_text is not None
if sum([has_edits, has_patch_text, has_replace]) > 1:
return None, both_error
if patch_text is not None:
if has_replace:
old = str(old_text or "")
if not old:
return None, "old_text is required for exact replace"
return PatchRequest(
mode="replace",
old_text=old,
new_text=str(new_text or ""),
), ""
if has_patch_text:
text = str(patch_text)
if not text.strip():
return None, "patch_text must not be empty"
@ -35,3 +52,15 @@ def parse_patch_request(
return None, missing_error
return PatchRequest(mode="edits", edits=edits), ""
def exact_replace_to_patch_text(path: str, old_text: str, new_text: str) -> str:
"""Represent one exact text replacement as a context patch."""
lines = [
"*** Begin Patch",
f"*** Update File: {path}",
]
lines.extend(f"-{line}" for line in old_text.split("\n"))
lines.extend(f"+{line}" for line in new_text.split("\n"))
lines.append("*** End Patch")
return "\n".join(lines)

View file

@ -44,9 +44,10 @@ usage:
~~~
#### patch
edit existing file. prefer patch_text; use edits only right after read for tiny line edits
edit existing file. prefer exact replace for simple "change X to Y"; use patch_text for context changes; use edits only right after read for tiny line edits
if the user says patch, change without rewriting, or don't rewrite, use action patch instead of write
args path plus exactly one of: patch_text string OR edits [{from to content}]
args path plus exactly one of: old_text+new_text OR patch_text string OR edits [{from to content}]
exact replace: `old_text` must be the exact current text span and must match once; `new_text` is the replacement
patch_text uses current file content, no prior read required
patch_text update-only forms:
- insert after anchor: @@ exact existing line then +new lines
@ -61,13 +62,14 @@ ensure valid syntax in content (all braces brackets tags closed)
usage:
~~~json
{
"thoughts": ["A context patch is safer than line-number surgery here."],
"thoughts": ["I can replace one exact current string without rewriting the whole file."],
"headline": "Patching file",
"tool_name": "text_editor",
"tool_args": {
"action": "patch",
"path": "/path/file.py",
"patch_text": "*** Begin Patch\n*** Update File: file.py\n@@ def run():\n+ print('ready')\n*** End Patch"
"old_text": "status = 'draft'",
"new_text": "status = 'ready'"
}
}
~~~

View file

@ -8,6 +8,7 @@ from plugins._text_editor.helpers.file_ops import (
validate_edits,
apply_patch,
apply_context_patch_file,
apply_exact_replace_file,
file_info,
)
from plugins._text_editor.helpers.patch_request import parse_patch_request
@ -152,13 +153,15 @@ class TextEditor(Tool):
# PATCH
# ------------------------------------------------------------------
async def _patch(
self, path: str = "", edits=None, patch_text=None, **kwargs
self, path: str = "", edits=None, patch_text=None, old_text=None, new_text=None, **kwargs
) -> Response:
if not path:
return self._error("patch", path, "path is required")
patch_request, err = parse_patch_request(
edits,
patch_text,
old_text,
new_text,
missing_error="",
)
if err:
@ -174,6 +177,10 @@ class TextEditor(Tool):
return await self._patch_context(
path, expanded, patch_request.patch_text
)
if patch_request and patch_request.mode == "replace":
return await self._patch_replace(
path, expanded, patch_request.old_text, patch_request.new_text
)
return await self._patch_edits(
path,
@ -241,6 +248,61 @@ class TextEditor(Tool):
)
return Response(message=msg, break_loop=False)
async def _patch_replace(
self, path: str, expanded: str, old_text: str, new_text: str
) -> Response:
# Extension point
ext_data = {
"path": expanded,
"old_text": old_text,
"new_text": new_text,
"edits": [],
"mode": "replace",
}
await call_extensions_async(
"text_editor_patch_before", agent=self.agent, data=ext_data
)
try:
result = await runtime.call_development_function(
apply_exact_replace_file,
ext_data["path"],
ext_data["old_text"],
ext_data["new_text"],
)
except Exception as exc:
return self._error("patch", path, str(exc))
total_lines = result["total_lines"]
await call_extensions_async(
"text_editor_patch_after", agent=self.agent,
data={
"path": ext_data["path"],
"total_lines": total_lines,
"replacement_count": result["replacement_count"],
"mode": "replace",
},
)
post_info = await runtime.call_development_function(
file_info, ext_data["path"]
)
mark_file_state_stale(self.agent, post_info, key=_MTIME_KEY)
patch_content = await _read_exact_replace_region(
ext_data["path"], result, _get_config(self.agent)
)
msg = self.agent.read_prompt(
"fw.text_editor.patch_ok.md",
path=ext_data["path"],
edit_count=str(result["replacement_count"]),
total_lines=str(total_lines),
content=patch_content,
)
return Response(message=msg, break_loop=False)
async def _patch_context(
self, path: str, expanded: str, patch_text
) -> Response:
@ -364,6 +426,27 @@ async def _read_context_patch_region(
return read_result["content"]
async def _read_exact_replace_region(
path: str, result: dict, cfg: dict
) -> str:
total_lines = int(result["total_lines"])
if total_lines <= 0:
return ""
line_from = min(max(int(result["line_from"]), 1), total_lines)
line_to = min(max(int(result["line_to"]), line_from) + 3, total_lines)
read_result = await runtime.call_development_function(
read_file,
path,
line_from=max(line_from - 1, 1),
line_to=line_to,
max_line_tokens=cfg["max_line_tokens"],
max_total_read_tokens=cfg["max_total_read_tokens"],
)
return read_result["content"]
def _freshness_error_message(agent, info: FileInfo, code: str) -> str:
prompt = (
"fw.text_editor.patch_stale_read.md"

View file

@ -2,3 +2,6 @@
exact tool name uses british spelling: `behaviour_adjustment`
update persistent behavioral rules
arg: `adjustments` text describing what to add or remove
use for durable behavior, personality, style, response-format, greeting, and exact-response rules
when the user asks for an exact word, phrase, token, or casing, preserve it verbatim in `adjustments`
do not edit promptinclude files for behavioral rules unless the user explicitly asks for a file change

View file

@ -4,6 +4,8 @@ args: `message`, optional `profile`, `reset`
- `profile`: optional prompt profile name for the subordinate; leave empty for the default profile
- `reset`: use json boolean `true` for the first message or when changing profile; use `false` to continue
- `message`: define role, goal, and the concrete task
after the subordinate returns, answer from its result directly when it satisfies the user request
do not repeat the same solving work or call extra tools after a sufficient subordinate result
example:
~~~json
{

View file

@ -2,7 +2,9 @@
1. The assistant receives a markdown ruleset of AGENT's behaviour and text of adjustments to be implemented
2. Assistant merges the ruleset with the instructions into a new markdown ruleset
3. Assistant keeps the ruleset short, removing any duplicates or redundant information
4. Assistant preserves exact words, phrases, tokens, capitalization, punctuation, and quoted/code-spanned text from the adjustments verbatim
5. If an adjustment says to respond exactly with a phrase, the resulting rule must include that full exact phrase unchanged
# Format
- The response format is a markdown format of instructions for AI AGENT explaining how the AGENT is supposed to behave
- No level 1 headings (#), only level 2 headings (##) and bullet points (*)
- No level 1 headings (#), only level 2 headings (##) and bullet points (*)

View file

@ -0,0 +1,72 @@
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from helpers.document_query import DocumentQueryHelper
class FakeStore:
@staticmethod
def normalize_uri(uri: str) -> str:
return uri
async def search_documents(self, **_kwargs):
return []
class FakeAgent:
def __init__(self):
self.chat_messages = None
async def handle_intervention(self):
return None
def parse_prompt(self, name: str) -> str:
return name
async def call_utility_model(self, **_kwargs) -> str:
return "codename"
async def call_chat_model(self, messages, explicit_caching=False):
self.chat_messages = messages
return "The project codename is Atlas.", None
def test_document_qa_uses_small_document_content_when_search_finds_no_chunks():
agent = FakeAgent()
progress = []
helper = object.__new__(DocumentQueryHelper)
helper.agent = agent
helper.store = FakeStore()
helper.progress_callback = progress.append
async def document_get_content(uri, add_to_db=False):
assert uri == "/tmp/project.md"
assert add_to_db is True
return "# Project\n\nCodename: Atlas\n"
helper.document_get_content = document_get_content
ok, content = asyncio.run(
helper.document_qa(["/tmp/project.md"], ["What is the codename?"])
)
assert ok is True
assert content == "The project codename is Atlas."
assert "No matching chunks found" in "\n".join(progress)
assert agent.chat_messages is not None
assert "Codename: Atlas" in agent.chat_messages[1].content
def test_small_document_fallback_refuses_large_content():
content = DocumentQueryHelper._small_document_fallback_content(
["/tmp/large.md"], ["x" * 12_001]
)
assert content == ""

View file

@ -22,9 +22,9 @@ def _handler():
def test_model_search_parses_openai_style_data():
handler = _handler()
assert handler._parse({"data": [{"id": "gpt-4.1"}, {"id": "gpt-4.1-mini"}]}, "openai") == [
assert handler._parse({"data": [{"id": "gpt-4.1"}, {"id": "gpt-4o-mini"}]}, "openai") == [
"gpt-4.1",
"gpt-4.1-mini",
"gpt-4o-mini",
]

View file

@ -1,3 +1,4 @@
import asyncio
from datetime import datetime, timezone
from pathlib import Path
import sys
@ -8,7 +9,7 @@ if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from helpers import task_scheduler
from helpers.task_scheduler import ScheduledTask, TaskSchedule
from helpers.task_scheduler import AdHocTask, ScheduledTask, TaskSchedule
class FixedDateTime(datetime):
@ -63,3 +64,96 @@ def test_scheduled_task_normalizes_legacy_local_timezone(monkeypatch):
assert task.schedule.timezone == "Europe/Rome"
assert task.get_next_run() == datetime(2026, 5, 10, 7, 30, tzinfo=timezone.utc)
def test_scheduler_missing_dedicated_context_logs_info(monkeypatch):
calls = []
class FakePrintStyle:
@staticmethod
def info(message):
calls.append(("info", message))
@staticmethod
def warning(message):
calls.append(("warning", message))
class FakeAgentContext:
@staticmethod
def get(_context_id):
return None
def __init__(self, _config, id, name):
self.id = id
self.name = name
monkeypatch.setattr(task_scheduler, "PrintStyle", FakePrintStyle)
monkeypatch.setattr(task_scheduler, "AgentContext", FakeAgentContext)
monkeypatch.setattr(task_scheduler, "initialize_agent", lambda: object())
monkeypatch.setattr(task_scheduler, "save_tmp_chat", lambda _context: None)
monkeypatch.setattr(
task_scheduler.projects, "activate_project", lambda *_args, **_kwargs: None
)
task = AdHocTask.create(
name="dedicated",
system_prompt="",
prompt="run this",
token="123",
)
scheduler = object.__new__(task_scheduler.TaskScheduler)
context = asyncio.run(scheduler._get_chat_context(task))
assert context.id == task.context_id
assert len(calls) == 1
level, message = calls[0]
assert level == "info"
assert "creating dedicated context" in message
def test_scheduler_missing_shared_context_still_logs_warning(monkeypatch):
calls = []
class FakePrintStyle:
@staticmethod
def info(message):
calls.append(("info", message))
@staticmethod
def warning(message):
calls.append(("warning", message))
class FakeAgentContext:
@staticmethod
def get(_context_id):
return None
def __init__(self, _config, id, name):
self.id = id
self.name = name
monkeypatch.setattr(task_scheduler, "PrintStyle", FakePrintStyle)
monkeypatch.setattr(task_scheduler, "AgentContext", FakeAgentContext)
monkeypatch.setattr(task_scheduler, "initialize_agent", lambda: object())
monkeypatch.setattr(task_scheduler, "save_tmp_chat", lambda _context: None)
monkeypatch.setattr(
task_scheduler.projects, "activate_project", lambda *_args, **_kwargs: None
)
task = AdHocTask.create(
name="shared",
system_prompt="",
prompt="run this",
token="123",
context_id="shared-context",
)
scheduler = object.__new__(task_scheduler.TaskScheduler)
context = asyncio.run(scheduler._get_chat_context(task))
assert context.id == task.context_id
assert len(calls) == 1
level, message = calls[0]
assert level == "warning"
assert "context not found" in message

View file

@ -14,8 +14,14 @@ if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from plugins._text_editor.helpers.context_patch import ContextPatchError
from plugins._text_editor.helpers.file_ops import apply_context_patch_file
from plugins._text_editor.helpers.patch_request import parse_patch_request
from plugins._text_editor.helpers.file_ops import (
apply_context_patch_file,
apply_exact_replace_file,
)
from plugins._text_editor.helpers.patch_request import (
exact_replace_to_patch_text,
parse_patch_request,
)
from plugins._text_editor.helpers.patch_state import (
LOCAL_FRESHNESS_KEY,
REMOTE_FRESHNESS_KEY,
@ -100,6 +106,41 @@ def test_context_patch_replaces_matching_context(tmp_path: Path) -> None:
assert target.read_text(encoding="utf-8") == "alpha\nbeta\ndelta\n"
def test_exact_replace_file_replaces_one_span(tmp_path: Path) -> None:
target = tmp_path / "sample.txt"
target.write_text("alpha\nstatus = draft\ngamma\n", encoding="utf-8")
result = apply_exact_replace_file(
str(target), "status = draft", "status = ready"
)
assert result["replacement_count"] == 1
assert result["line_from"] == 2
assert target.read_text(encoding="utf-8") == "alpha\nstatus = ready\ngamma\n"
def test_exact_replace_file_rejects_ambiguous_match(tmp_path: Path) -> None:
target = tmp_path / "sample.txt"
target.write_text("alpha\nalpha\n", encoding="utf-8")
with pytest.raises(ValueError, match="matched 2 times"):
apply_exact_replace_file(str(target), "alpha", "beta")
assert target.read_text(encoding="utf-8") == "alpha\nalpha\n"
def test_exact_replace_to_patch_text_works_with_context_patch(tmp_path: Path) -> None:
target = tmp_path / "sample.txt"
target.write_text("alpha\nstatus = draft\ngamma\n", encoding="utf-8")
patch_text = exact_replace_to_patch_text(
"sample.txt", "status = draft", "status = ready"
)
apply_context_patch_file(str(target), patch_text)
assert target.read_text(encoding="utf-8") == "alpha\nstatus = ready\ngamma\n"
def test_context_patch_replaces_when_anchor_is_target_line(
tmp_path: Path,
) -> None:
@ -211,7 +252,7 @@ def test_patch_request_rejects_edits_and_patch_text_together() -> None:
)
assert request is None
assert err == "provide either edits or patch_text, not both"
assert err == "provide exactly one patch form: edits, patch_text, or old_text/new_text"
def test_patch_request_rejects_empty_patch_text() -> None:
@ -221,6 +262,21 @@ def test_patch_request_rejects_empty_patch_text() -> None:
assert err == "patch_text must not be empty"
def test_patch_request_accepts_exact_replace() -> None:
request, err = parse_patch_request(
None,
None,
old_text="status = draft",
new_text="status = ready",
)
assert err == ""
assert request is not None
assert request.mode == "replace"
assert request.old_text == "status = draft"
assert request.new_text == "status = ready"
def test_patch_state_records_and_checks_fresh_file_state() -> None:
agent = _FakeAgent()
file_data = {"realpath": "/tmp/sample.txt", "mtime": 1.0, "total_lines": 3}
@ -435,6 +491,45 @@ def test_text_editor_patch_text_does_not_require_prior_read(
assert calls[1][1]["mode"] == "patch_text"
def test_text_editor_exact_replace_does_not_require_prior_read(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
module, calls = _load_text_editor_tool(monkeypatch)
target = tmp_path / "sample.txt"
target.write_text("line-1\nstatus = draft\nline-3\n", encoding="utf-8")
agent = _FakeAgent()
tool = module.TextEditor(agent, "text_editor", "patch", {}, "", None)
response = asyncio.run(
tool._patch(
path=str(target),
old_text="status = draft",
new_text="status = ready",
)
)
assert "patched 1 edits applied 3 lines now" in response.message
assert "status = ready" in response.message
assert target.read_text(encoding="utf-8") == "line-1\nstatus = ready\nline-3\n"
realpath = os.path.realpath(target)
assert agent.data[module._MTIME_KEY][realpath] == {
"mtime": 0,
"total_lines": 0,
}
assert calls[0] == (
"text_editor_patch_before",
{
"path": str(target),
"old_text": "status = draft",
"new_text": "status = ready",
"edits": [],
"mode": "replace",
},
)
assert calls[1][0] == "text_editor_patch_after"
assert calls[1][1]["mode"] == "replace"
def test_text_editor_execute_accepts_action_alias_for_read(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
@ -472,7 +567,7 @@ def test_text_editor_patch_text_rejects_simultaneous_edits(
)
)
assert "provide either edits or patch_text" in response.message
assert "provide exactly one patch form" in response.message
assert target.read_text(encoding="utf-8") == "line-1\n"

View file

@ -209,6 +209,89 @@ def test_behaviour_adjustment_normalizes_duplicate_rules(monkeypatch):
assert rules == "## Behavioral rules\n* Favor Linux commands.\n* Token rule.\n"
def test_behaviour_prompts_preserve_exact_rules_and_avoid_promptinclude():
behaviour_prompt = Path("prompts/agent.system.tool.behaviour.md").read_text(
encoding="utf-8"
)
merge_prompt = Path("prompts/behaviour.merge.sys.md").read_text(
encoding="utf-8"
)
promptinclude_prompt = Path(
"plugins/_promptinclude/prompts/agent.system.promptinclude.md"
).read_text(encoding="utf-8")
assert "exact-response rules" in behaviour_prompt
assert "preserve it verbatim" in behaviour_prompt
assert "respond exactly with a phrase" in merge_prompt
assert "use behaviour_adjustment, not promptinclude files" in promptinclude_prompt
def _load_a2a_chat_tool(monkeypatch):
_install_tool_stub(monkeypatch)
sys.modules.pop("tools.a2a_chat", None)
return importlib.import_module("tools.a2a_chat")
def test_a2a_extracts_latest_assistant_text_from_history(monkeypatch):
module = _load_a2a_chat_tool(monkeypatch)
final = {
"result": {
"history": [
{
"role": "user",
"parts": [{"kind": "text", "text": "what is 2+2?"}],
},
{
"role": "assistant",
"parts": [{"kind": "text", "text": "4"}],
},
]
}
}
assert module._extract_latest_assistant_text(final) == "4"
def test_a2a_extracts_status_or_artifact_text_when_history_is_empty(monkeypatch):
module = _load_a2a_chat_tool(monkeypatch)
status_final = {
"result": {
"status": {
"message": {
"parts": [{"kind": "text", "text": "status answer"}]
}
}
}
}
artifact_final = {
"result": {
"artifacts": [
{"parts": [{"kind": "text", "text": "artifact answer"}]}
]
}
}
assert module._extract_latest_assistant_text(status_final) == "status answer"
assert module._extract_latest_assistant_text(artifact_final) == "artifact answer"
def test_a2a_session_key_normalizes_explicit_a2a_path(monkeypatch):
module = _load_a2a_chat_tool(monkeypatch)
assert module._session_key("http://localhost:32080/a2a") == "http://localhost:32080"
assert module._session_key("http://localhost:32080") == "http://localhost:32080"
def test_a2a_empty_response_message_is_explicit_failure(monkeypatch):
module = _load_a2a_chat_tool(monkeypatch)
assert module._extract_latest_assistant_text({"result": {"history": []}}) == ""
assert "failed" in module.A2A_EMPTY_RESPONSE_ERROR
assert "not success" in module.A2A_EMPTY_RESPONSE_ERROR
def test_notify_user_prompt_documents_numeric_priority_values():
prompt = Path("prompts/agent.system.tool.notify_user.md").read_text(
encoding="utf-8"

View file

@ -1,8 +1,90 @@
from typing import Any
from helpers.tool import Tool, Response
from helpers.print_style import PrintStyle
from helpers.fasta2a_client import connect_to_agent, is_client_available
A2A_EMPTY_RESPONSE_ERROR = (
"A2A chat failed: the remote task completed but no assistant text was found. "
"Expected final.result.history to include an assistant message with a text "
"part, or a text artifact/status message. Treat this as a failed remote "
"response, not success."
)
def _session_key(agent_url: str) -> str:
"""Keep root and explicit /a2a URLs in the same conversation cache."""
normalized = agent_url.rstrip("/")
if normalized.endswith("/a2a"):
return normalized[:-4].rstrip("/")
return normalized
def _text_from_part(part: Any) -> str:
if not isinstance(part, dict):
return ""
for key in ("text", "content"):
value = part.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def _text_from_message(message: Any) -> str:
if isinstance(message, str):
return message.strip()
if not isinstance(message, dict):
return ""
parts = message.get("parts")
if isinstance(parts, list):
texts = [_text_from_part(part) for part in parts]
text = "\n".join(text for text in texts if text)
if text:
return text
for key in ("text", "content", "message", "output"):
value = message.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def _extract_latest_assistant_text(task_response: Any) -> str:
if not isinstance(task_response, dict):
return ""
result = task_response.get("result", task_response)
if not isinstance(result, dict):
return ""
history = result.get("history")
if isinstance(history, list):
for message in reversed(history):
if isinstance(message, dict) and message.get("role") == "user":
continue
text = _text_from_message(message)
if text:
return text
status = result.get("status")
if isinstance(status, dict):
text = _text_from_message(status.get("message"))
if text:
return text
artifacts = result.get("artifacts")
if isinstance(artifacts, list):
for artifact in reversed(artifacts):
text = _text_from_message(artifact)
if text:
return text
return _text_from_message(result)
class A2AChatTool(Tool):
"""Communicate with another FastA2A-compatible agent."""
@ -21,12 +103,13 @@ class A2AChatTool(Tool):
# Retrieve or create session cache on the Agent instance
sessions: dict[str, str] = self.agent.get_data("_a2a_sessions") or {}
cache_key = _session_key(agent_url)
# Handle reset flag start fresh conversation
if reset and agent_url in sessions:
sessions.pop(agent_url, None)
# Handle reset flag: start fresh conversation
if reset and cache_key in sessions:
sessions.pop(cache_key, None)
context_id = None if reset else sessions.get(agent_url)
context_id = None if reset else sessions.get(cache_key)
try:
async with await connect_to_agent(agent_url) as conn:
task_resp = await conn.send_message(user_message, attachments=attachments, context_id=context_id)
@ -36,18 +119,16 @@ class A2AChatTool(Tool):
final = await conn.wait_for_completion(task_id)
new_context_id = final["result"].get("context_id") # type: ignore[index]
if isinstance(new_context_id, str):
sessions[agent_url] = new_context_id
sessions[cache_key] = new_context_id
# persist back to agent data
self.agent.set_data("_a2a_sessions", sessions)
# Extract latest assistant text
history = final["result"].get("history", [])
assistant_text = ""
if history:
last_parts = history[-1].get("parts", [])
assistant_text = "\n".join(
p.get("text", "") for p in last_parts if p.get("kind") == "text"
assistant_text = _extract_latest_assistant_text(final)
if not assistant_text:
return Response(
message=A2A_EMPTY_RESPONSE_ERROR,
break_loop=False,
)
return Response(message=assistant_text or "(no response)", break_loop=False)
return Response(message=assistant_text, break_loop=False)
except Exception as e:
PrintStyle.error(f"A2A chat error: {e}")
return Response(message=f"A2A chat error: {e}", break_loop=False)