fix: tighten tool guidance and editor workflows

Alessandro committed May 11, 2026 at 05:13 UTC f17198e126bcd5a562bfabcc27e3066f2ca11aaa
19 files changed +694 -45
helpers/document_query.py
+34 -8
@@ -29,6 +29,7 @@ from langchain.text_splitter import RecursiveCharacterTextSplitter
29
30 DEFAULT_SEARCH_THRESHOLD = 0.5
31 MAX_REMOTE_DOCUMENT_BYTES = 50 * 1024 * 1024
32 +SMALL_DOCUMENT_QA_FALLBACK_CHARS = 12_000
33
34
35 class DocumentQueryStore:
@@ -369,7 +370,7 @@ class DocumentQueryHelper:
370 await self.agent.handle_intervention()
371
372 # index documents
372 - await asyncio.gather(
373 + document_contents = await asyncio.gather(
374 *[self.document_get_content(uri, True) for uri in document_uris]
375 )
376 await self.agent.handle_intervention()
@@ -409,19 +410,27 @@ class DocumentQueryHelper:
410 selected_chunks[chunk.metadata["id"]] = chunk
411
412 if not selected_chunks:
412 - self.progress_callback("No relevant content found in the documents")
413 - content = f"!!! No content found for documents: {json.dumps(document_uris)} matching queries: {json.dumps(questions)}"
414 - return False, content
413 + content = self._small_document_fallback_content(
414 + document_uris, document_contents
415 + )
416 + if not content:
417 + self.progress_callback("No relevant content found in the documents")
418 + content = f"!!! No content found for documents: {json.dumps(document_uris)} matching queries: {json.dumps(questions)}"
419 + return False, content
420 + self.progress_callback(
421 + "No matching chunks found; using complete small-document content"
422 + )
423 + else:
424 + content = "\n\n----\n\n".join(
425 + [chunk.page_content for chunk in selected_chunks.values()]
426 + )
427
428 self.progress_callback(
417 - f"Processing {len(questions)} questions in context of {len(selected_chunks)} chunks"
429 + f"Processing {len(questions)} questions in document context"
430 )
431 await self.agent.handle_intervention()
432
433 questions_str = "\n".join([f" * {question}" for question in questions])
422 - content = "\n\n----\n\n".join(
423 - [chunk.page_content for chunk in selected_chunks.values()]
424 - )
434
435 qa_system_message = self.agent.parse_prompt(
436 "fw.document_query.system_prompt.md"
@@ -440,6 +449,23 @@ class DocumentQueryHelper:
449
450 return True, str(ai_response)
451
452 + @staticmethod
453 + def _small_document_fallback_content(
454 + document_uris: Sequence[str], document_contents: Sequence[str]
455 + ) -> str:
456 + total_chars = 0
457 + sections = []
458 +
459 + for uri, content in zip(document_uris, document_contents):
460 + if not isinstance(content, str) or not content.strip():
461 + continue
462 + total_chars += len(content)
463 + if total_chars > SMALL_DOCUMENT_QA_FALLBACK_CHARS:
464 + return ""
465 + sections.append(f"## {uri}\n\n{content.strip()}")
466 +
467 + return "\n\n----\n\n".join(sections)
468 +
469 async def document_get_content(
470 self, document_uri: str, add_to_db: bool = False
471 ) -> str:
helpers/task_scheduler.py
+5 -1
@@ -820,9 +820,13 @@ class TaskScheduler:
820 save_tmp_chat(context)
821 return context
822 else:
823 - PrintStyle.warning(
823 + message = (
824 f"Scheduler Task {task.name} loaded from task {task.uuid} but context not found"
825 )
826 + if task.is_dedicated():
827 + PrintStyle.info(f"{message}; creating dedicated context")
828 + else:
829 + PrintStyle.warning(message)
830 return await self.__new_context(task)
831
832 async def _persist_chat(self, task: Union[ScheduledTask, AdHocTask, PlannedTask], context: AgentContext):
plugins/_a0_connector/prompts/agent.system.tool.text_editor_remote.md
+2 -1
@@ -14,11 +14,12 @@ report that to the user instead of falling back to server-side file tools.
14 - `path`: file path on the CLI host filesystem
15 - `read`: optional `line_from`, `line_to`
16 - `write`: requires `content`
17 -- `patch`: requires either `patch_text` or `edits`
17 +- `patch`: requires one of `old_text` + `new_text`, `patch_text`, or `edits`
18
19 ## Notes
20 - Prefer `read` before line-number edits.
21 - If the user says patch, change without rewriting, or don't rewrite, use `action: "patch"` instead of `write`.
22 +- For simple "change X to Y" requests, prefer exact replace with `old_text` and `new_text`; `old_text` must match one exact current span.
23 - Prefer `patch_text` for context-anchored changes and `edits` only for fresh, surgical line ranges.
24 - If freshness checks reject a line patch, reread the file and retry with updated ranges.
25 - Relative paths are relative to the CLI host filesystem. Do not rewrite them to
plugins/_a0_connector/skills/host-file-editing/SKILL.md
+3 -1
@@ -20,12 +20,14 @@ If the task belongs inside Agent Zero's own runtime, use the normal server-side
20
21 - Start with `read` when inspecting a file or preparing line-based edits.
22 - Use `write` only when replacing or creating the whole file is truly the right operation.
23 -- Prefer `patch` with `patch_text` for context-anchored edits, especially after inserts/deletes or when line numbers may have shifted.
23 +- Prefer `patch` with `old_text` and `new_text` for simple exact replacements.
24 +- Use `patch_text` for context-anchored edits, especially after inserts/deletes or when line numbers may have shifted.
25 - Use `patch` with `edits` only for small line-range edits based on the latest remote read.
26 - If freshness-aware line patching rejects an edit as stale, reread the file and retry with updated ranges.
27
28 ## Patch Text Rules
29
30 +- Exact replace requires `old_text` to match one exact current span; use a longer span if it matches multiple places.
31 - `patch_text` supports update hunks for one file.
32 - Use one `@@ existing line` anchor, then `+new line` entries for insertion.
33 - For replacement, use `@@ before target` followed by `-old` and `+new`, or use `@@ old target` followed by the same replacement pair.
plugins/_a0_connector/tools/text_editor_remote.py
+17 -2
@@ -23,7 +23,10 @@ from plugins._a0_connector.helpers.ws_runtime import (
23 select_remote_file_target_sid,
24 store_pending_file_op,
25 )
26 -from plugins._text_editor.helpers.patch_request import parse_patch_request
26 +from plugins._text_editor.helpers.patch_request import (
27 + exact_replace_to_patch_text,
28 + parse_patch_request,
29 +)
30
31
32 FILE_OP_TIMEOUT = 30.0
@@ -83,7 +86,12 @@ class TextEditorRemote(Tool):
86 patch_request, err = parse_patch_request(
87 self.args.get("edits"),
88 self.args.get("patch_text"),
86 - both_error="provide either edits or patch_text for patch, not both",
89 + self.args.get("old_text"),
90 + self.args.get("new_text"),
91 + both_error=(
92 + "provide exactly one patch form: edits, patch_text, "
93 + "or old_text/new_text"
94 + ),
95 )
96 if err:
97 return Response(
@@ -94,6 +102,13 @@ class TextEditorRemote(Tool):
102 result = await self._execute_context_patch(
103 path, patch_request.patch_text
104 )
105 + elif patch_request and patch_request.mode == "replace":
106 + result = await self._execute_context_patch(
107 + path,
108 + exact_replace_to_patch_text(
109 + path, patch_request.old_text, patch_request.new_text
110 + ),
111 + )
112 else:
113 result = await self._execute_patch(
114 path,
plugins/_promptinclude/prompts/agent.system.promptinclude.md
+2 -1
@@ -3,8 +3,9 @@
3 create/edit/delete persist across conversations
4 preference changes, instruction files, project notes, and prompt includes > persist via text_editor before responding
5 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
6 +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
7 never just acknowledge durable project/instruction changes verbally; persist them to file when the user asks for a file/instruction/preference change
7 -use promptinclude files for persistent project context and behavioral instructions
8 +use promptinclude files for persistent project context, reference instructions, and user-authored prompt include files
9 recursive search alphabetical by full path
10 {{if includes}}
11 ### includes
plugins/_text_editor/helpers/file_ops.py
+54
@@ -218,6 +218,13 @@ class ContextPatchFileResult(TypedDict):
218 line_to: int
219
220
221 +class ExactReplaceFileResult(TypedDict):
222 + total_lines: int
223 + replacement_count: int
224 + line_from: int
225 + line_to: int
226 +
227 +
228 def validate_edits(edits: list | None) -> tuple[list[dict], str]:
229 """
230 Normalise and validate an edits array.
@@ -391,6 +398,53 @@ def apply_context_patch_file(path: str, patch_text: str) -> ContextPatchFileResu
398 )
399
400
401 +def apply_exact_replace_file(
402 + path: str, old_text: str, new_text: str
403 +) -> ExactReplaceFileResult:
404 + """Replace exactly one text span in an existing text file."""
405 + path = os.path.expanduser(path)
406 + if not os.path.isfile(path):
407 + raise FileNotFoundError("file not found")
408 + if not old_text:
409 + raise ValueError("old_text is required for exact replace")
410 +
411 + with open(path, "r", encoding="utf-8", errors="replace") as src:
412 + content = src.read()
413 +
414 + match_count = content.count(old_text)
415 + if match_count == 0:
416 + raise ValueError("old_text not found")
417 + if match_count > 1:
418 + raise ValueError(
419 + f"old_text matched {match_count} times; provide a longer exact span"
420 + )
421 + if old_text == new_text:
422 + raise ValueError("old_text and new_text are identical")
423 +
424 + start = content.index(old_text)
425 + line_from = content[:start].count("\n") + 1
426 + line_to = line_from + max(_count_content_lines(old_text) - 1, 0)
427 + new_content = content.replace(old_text, new_text, 1)
428 +
429 + dir_name = os.path.dirname(path) or "."
430 + fd, tmp_path = tempfile.mkstemp(dir=dir_name, suffix=".tmp")
431 + try:
432 + with os.fdopen(fd, "w", encoding="utf-8") as dst:
433 + dst.write(new_content)
434 + shutil.move(tmp_path, path)
435 + except Exception:
436 + if os.path.exists(tmp_path):
437 + os.unlink(tmp_path)
438 + raise
439 +
440 + return ExactReplaceFileResult(
441 + total_lines=_count_content_lines(new_content),
442 + replacement_count=1,
443 + line_from=line_from,
444 + line_to=line_to,
445 + )
446 +
447 +
448 # ------------------------------------------------------------------
449 # Internal
450 # ------------------------------------------------------------------
plugins/_text_editor/helpers/patch_request.py
+34 -5
@@ -4,7 +4,7 @@ from dataclasses import dataclass
4 from typing import Any, Literal
5
6
7 -PatchMode = Literal["edits", "patch_text"]
7 +PatchMode = Literal["edits", "patch_text", "replace"]
8
9
10 @dataclass(frozen=True)
@@ -12,20 +12,37 @@ class PatchRequest:
12 mode: PatchMode
13 edits: Any = None
14 patch_text: str = ""
15 + old_text: str = ""
16 + new_text: str = ""
17
18
19 def parse_patch_request(
20 edits: Any,
21 patch_text: Any,
22 + old_text: Any = None,
23 + new_text: Any = None,
24 *,
21 - both_error: str = "provide either edits or patch_text, not both",
22 - missing_error: str = "edits or patch_text is required for patch",
25 + both_error: str = "provide exactly one patch form: edits, patch_text, or old_text/new_text",
26 + missing_error: str = "edits, patch_text, or old_text/new_text is required for patch",
27 ) -> tuple[PatchRequest | None, str]:
28 """Validate the mutually-exclusive patch request shape."""
25 - if edits is not None and patch_text is not None:
29 + has_edits = edits is not None
30 + has_patch_text = patch_text is not None
31 + has_replace = old_text is not None or new_text is not None
32 + if sum([has_edits, has_patch_text, has_replace]) > 1:
33 return None, both_error
34
28 - if patch_text is not None:
35 + if has_replace:
36 + old = str(old_text or "")
37 + if not old:
38 + return None, "old_text is required for exact replace"
39 + return PatchRequest(
40 + mode="replace",
41 + old_text=old,
42 + new_text=str(new_text or ""),
43 + ), ""
44 +
45 + if has_patch_text:
46 text = str(patch_text)
47 if not text.strip():
48 return None, "patch_text must not be empty"
@@ -35,3 +52,15 @@ def parse_patch_request(
52 return None, missing_error
53
54 return PatchRequest(mode="edits", edits=edits), ""
55 +
56 +
57 +def exact_replace_to_patch_text(path: str, old_text: str, new_text: str) -> str:
58 + """Represent one exact text replacement as a context patch."""
59 + lines = [
60 + "*** Begin Patch",
61 + f"*** Update File: {path}",
62 + ]
63 + lines.extend(f"-{line}" for line in old_text.split("\n"))
64 + lines.extend(f"+{line}" for line in new_text.split("\n"))
65 + lines.append("*** End Patch")
66 + return "\n".join(lines)
plugins/_text_editor/prompts/agent.system.tool.text_editor.md
+6 -4
@@ -44,9 +44,10 @@ usage:
44 ~~~
45
46 #### patch
47 -edit existing file. prefer patch_text; use edits only right after read for tiny line edits
47 +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
48 if the user says patch, change without rewriting, or don't rewrite, use action patch instead of write
49 -args path plus exactly one of: patch_text string OR edits [{from to content}]
49 +args path plus exactly one of: old_text+new_text OR patch_text string OR edits [{from to content}]
50 +exact replace: `old_text` must be the exact current text span and must match once; `new_text` is the replacement
51 patch_text uses current file content, no prior read required
52 patch_text update-only forms:
53 - insert after anchor: @@ exact existing line then +new lines
@@ -61,13 +62,14 @@ ensure valid syntax in content (all braces brackets tags closed)
62 usage:
63 ~~~json
64 {
64 - "thoughts": ["A context patch is safer than line-number surgery here."],
65 + "thoughts": ["I can replace one exact current string without rewriting the whole file."],
66 "headline": "Patching file",
67 "tool_name": "text_editor",
68 "tool_args": {
69 "action": "patch",
70 "path": "/path/file.py",
70 - "patch_text": "*** Begin Patch\n*** Update File: file.py\n@@ def run():\n+ print('ready')\n*** End Patch"
71 + "old_text": "status = 'draft'",
72 + "new_text": "status = 'ready'"
73 }
74 }
75 ~~~
plugins/_text_editor/tools/text_editor.py
+84 -1
@@ -8,6 +8,7 @@ from plugins._text_editor.helpers.file_ops import (
8 validate_edits,
9 apply_patch,
10 apply_context_patch_file,
11 + apply_exact_replace_file,
12 file_info,
13 )
14 from plugins._text_editor.helpers.patch_request import parse_patch_request
@@ -152,13 +153,15 @@ class TextEditor(Tool):
153 # PATCH
154 # ------------------------------------------------------------------
155 async def _patch(
155 - self, path: str = "", edits=None, patch_text=None, **kwargs
156 + self, path: str = "", edits=None, patch_text=None, old_text=None, new_text=None, **kwargs
157 ) -> Response:
158 if not path:
159 return self._error("patch", path, "path is required")
160 patch_request, err = parse_patch_request(
161 edits,
162 patch_text,
163 + old_text,
164 + new_text,
165 missing_error="",
166 )
167 if err:
@@ -174,6 +177,10 @@ class TextEditor(Tool):
177 return await self._patch_context(
178 path, expanded, patch_request.patch_text
179 )
180 + if patch_request and patch_request.mode == "replace":
181 + return await self._patch_replace(
182 + path, expanded, patch_request.old_text, patch_request.new_text
183 + )
184
185 return await self._patch_edits(
186 path,
@@ -241,6 +248,61 @@ class TextEditor(Tool):
248 )
249 return Response(message=msg, break_loop=False)
250
251 + async def _patch_replace(
252 + self, path: str, expanded: str, old_text: str, new_text: str
253 + ) -> Response:
254 + # Extension point
255 + ext_data = {
256 + "path": expanded,
257 + "old_text": old_text,
258 + "new_text": new_text,
259 + "edits": [],
260 + "mode": "replace",
261 + }
262 + await call_extensions_async(
263 + "text_editor_patch_before", agent=self.agent, data=ext_data
264 + )
265 +
266 + try:
267 + result = await runtime.call_development_function(
268 + apply_exact_replace_file,
269 + ext_data["path"],
270 + ext_data["old_text"],
271 + ext_data["new_text"],
272 + )
273 + except Exception as exc:
274 + return self._error("patch", path, str(exc))
275 +
276 + total_lines = result["total_lines"]
277 +
278 + await call_extensions_async(
279 + "text_editor_patch_after", agent=self.agent,
280 + data={
281 + "path": ext_data["path"],
282 + "total_lines": total_lines,
283 + "replacement_count": result["replacement_count"],
284 + "mode": "replace",
285 + },
286 + )
287 +
288 + post_info = await runtime.call_development_function(
289 + file_info, ext_data["path"]
290 + )
291 + mark_file_state_stale(self.agent, post_info, key=_MTIME_KEY)
292 +
293 + patch_content = await _read_exact_replace_region(
294 + ext_data["path"], result, _get_config(self.agent)
295 + )
296 +
297 + msg = self.agent.read_prompt(
298 + "fw.text_editor.patch_ok.md",
299 + path=ext_data["path"],
300 + edit_count=str(result["replacement_count"]),
301 + total_lines=str(total_lines),
302 + content=patch_content,
303 + )
304 + return Response(message=msg, break_loop=False)
305 +
306 async def _patch_context(
307 self, path: str, expanded: str, patch_text
308 ) -> Response:
@@ -364,6 +426,27 @@ async def _read_context_patch_region(
426 return read_result["content"]
427
428
429 +async def _read_exact_replace_region(
430 + path: str, result: dict, cfg: dict
431 +) -> str:
432 + total_lines = int(result["total_lines"])
433 + if total_lines <= 0:
434 + return ""
435 +
436 + line_from = min(max(int(result["line_from"]), 1), total_lines)
437 + line_to = min(max(int(result["line_to"]), line_from) + 3, total_lines)
438 +
439 + read_result = await runtime.call_development_function(
440 + read_file,
441 + path,
442 + line_from=max(line_from - 1, 1),
443 + line_to=line_to,
444 + max_line_tokens=cfg["max_line_tokens"],
445 + max_total_read_tokens=cfg["max_total_read_tokens"],
446 + )
447 + return read_result["content"]
448 +
449 +
450 def _freshness_error_message(agent, info: FileInfo, code: str) -> str:
451 prompt = (
452 "fw.text_editor.patch_stale_read.md"
prompts/agent.system.tool.behaviour.md
+3
@@ -2,3 +2,6 @@
2 exact tool name uses british spelling: `behaviour_adjustment`
3 update persistent behavioral rules
4 arg: `adjustments` text describing what to add or remove
5 +use for durable behavior, personality, style, response-format, greeting, and exact-response rules
6 +when the user asks for an exact word, phrase, token, or casing, preserve it verbatim in `adjustments`
7 +do not edit promptinclude files for behavioral rules unless the user explicitly asks for a file change
prompts/agent.system.tool.call_sub.md
+2
@@ -4,6 +4,8 @@ args: `message`, optional `profile`, `reset`
4 - `profile`: optional prompt profile name for the subordinate; leave empty for the default profile
5 - `reset`: use json boolean `true` for the first message or when changing profile; use `false` to continue
6 - `message`: define role, goal, and the concrete task
7 +after the subordinate returns, answer from its result directly when it satisfies the user request
8 +do not repeat the same solving work or call extra tools after a sufficient subordinate result
9 example:
10 ~~~json
11 {
prompts/behaviour.merge.sys.md
+3 -1
@@ -2,7 +2,9 @@
2 1. The assistant receives a markdown ruleset of AGENT's behaviour and text of adjustments to be implemented
3 2. Assistant merges the ruleset with the instructions into a new markdown ruleset
4 3. Assistant keeps the ruleset short, removing any duplicates or redundant information
5 +4. Assistant preserves exact words, phrases, tokens, capitalization, punctuation, and quoted/code-spanned text from the adjustments verbatim
6 +5. If an adjustment says to respond exactly with a phrase, the resulting rule must include that full exact phrase unchanged
7
8 # Format
9 - The response format is a markdown format of instructions for AI AGENT explaining how the AGENT is supposed to behave
8 -- No level 1 headings (#), only level 2 headings (##) and bullet points (*)
\ No newline at end of file
10 +- No level 1 headings (#), only level 2 headings (##) and bullet points (*)
tests/test_document_query_fallback.py new
+72
@@ -0,0 +1,72 @@
1 +from __future__ import annotations
2 +
3 +import asyncio
4 +import sys
5 +from pathlib import Path
6 +
7 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
8 +if str(PROJECT_ROOT) not in sys.path:
9 + sys.path.insert(0, str(PROJECT_ROOT))
10 +
11 +from helpers.document_query import DocumentQueryHelper
12 +
13 +
14 +class FakeStore:
15 + @staticmethod
16 + def normalize_uri(uri: str) -> str:
17 + return uri
18 +
19 + async def search_documents(self, **_kwargs):
20 + return []
21 +
22 +
23 +class FakeAgent:
24 + def __init__(self):
25 + self.chat_messages = None
26 +
27 + async def handle_intervention(self):
28 + return None
29 +
30 + def parse_prompt(self, name: str) -> str:
31 + return name
32 +
33 + async def call_utility_model(self, **_kwargs) -> str:
34 + return "codename"
35 +
36 + async def call_chat_model(self, messages, explicit_caching=False):
37 + self.chat_messages = messages
38 + return "The project codename is Atlas.", None
39 +
40 +
41 +def test_document_qa_uses_small_document_content_when_search_finds_no_chunks():
42 + agent = FakeAgent()
43 + progress = []
44 + helper = object.__new__(DocumentQueryHelper)
45 + helper.agent = agent
46 + helper.store = FakeStore()
47 + helper.progress_callback = progress.append
48 +
49 + async def document_get_content(uri, add_to_db=False):
50 + assert uri == "/tmp/project.md"
51 + assert add_to_db is True
52 + return "# Project\n\nCodename: Atlas\n"
53 +
54 + helper.document_get_content = document_get_content
55 +
56 + ok, content = asyncio.run(
57 + helper.document_qa(["/tmp/project.md"], ["What is the codename?"])
58 + )
59 +
60 + assert ok is True
61 + assert content == "The project codename is Atlas."
62 + assert "No matching chunks found" in "\n".join(progress)
63 + assert agent.chat_messages is not None
64 + assert "Codename: Atlas" in agent.chat_messages[1].content
65 +
66 +
67 +def test_small_document_fallback_refuses_large_content():
68 + content = DocumentQueryHelper._small_document_fallback_content(
69 + ["/tmp/large.md"], ["x" * 12_001]
70 + )
71 +
72 + assert content == ""
tests/test_model_search.py
+2 -2
@@ -22,9 +22,9 @@ def _handler():
22 def test_model_search_parses_openai_style_data():
23 handler = _handler()
24
25 - assert handler._parse({"data": [{"id": "gpt-4.1"}, {"id": "gpt-4.1-mini"}]}, "openai") == [
25 + assert handler._parse({"data": [{"id": "gpt-4.1"}, {"id": "gpt-4o-mini"}]}, "openai") == [
26 "gpt-4.1",
27 - "gpt-4.1-mini",
27 + "gpt-4o-mini",
28 ]
29
30
tests/test_task_scheduler_timezone.py
+95 -1
@@ -1,3 +1,4 @@
1 +import asyncio
2 from datetime import datetime, timezone
3 from pathlib import Path
4 import sys
@@ -8,7 +9,7 @@ if str(PROJECT_ROOT) not in sys.path:
9 sys.path.insert(0, str(PROJECT_ROOT))
10
11 from helpers import task_scheduler
11 -from helpers.task_scheduler import ScheduledTask, TaskSchedule
12 +from helpers.task_scheduler import AdHocTask, ScheduledTask, TaskSchedule
13
14
15 class FixedDateTime(datetime):
@@ -63,3 +64,96 @@ def test_scheduled_task_normalizes_legacy_local_timezone(monkeypatch):
64
65 assert task.schedule.timezone == "Europe/Rome"
66 assert task.get_next_run() == datetime(2026, 5, 10, 7, 30, tzinfo=timezone.utc)
67 +
68 +
69 +def test_scheduler_missing_dedicated_context_logs_info(monkeypatch):
70 + calls = []
71 +
72 + class FakePrintStyle:
73 + @staticmethod
74 + def info(message):
75 + calls.append(("info", message))
76 +
77 + @staticmethod
78 + def warning(message):
79 + calls.append(("warning", message))
80 +
81 + class FakeAgentContext:
82 + @staticmethod
83 + def get(_context_id):
84 + return None
85 +
86 + def __init__(self, _config, id, name):
87 + self.id = id
88 + self.name = name
89 +
90 + monkeypatch.setattr(task_scheduler, "PrintStyle", FakePrintStyle)
91 + monkeypatch.setattr(task_scheduler, "AgentContext", FakeAgentContext)
92 + monkeypatch.setattr(task_scheduler, "initialize_agent", lambda: object())
93 + monkeypatch.setattr(task_scheduler, "save_tmp_chat", lambda _context: None)
94 + monkeypatch.setattr(
95 + task_scheduler.projects, "activate_project", lambda *_args, **_kwargs: None
96 + )
97 +
98 + task = AdHocTask.create(
99 + name="dedicated",
100 + system_prompt="",
101 + prompt="run this",
102 + token="123",
103 + )
104 + scheduler = object.__new__(task_scheduler.TaskScheduler)
105 +
106 + context = asyncio.run(scheduler._get_chat_context(task))
107 +
108 + assert context.id == task.context_id
109 + assert len(calls) == 1
110 + level, message = calls[0]
111 + assert level == "info"
112 + assert "creating dedicated context" in message
113 +
114 +
115 +def test_scheduler_missing_shared_context_still_logs_warning(monkeypatch):
116 + calls = []
117 +
118 + class FakePrintStyle:
119 + @staticmethod
120 + def info(message):
121 + calls.append(("info", message))
122 +
123 + @staticmethod
124 + def warning(message):
125 + calls.append(("warning", message))
126 +
127 + class FakeAgentContext:
128 + @staticmethod
129 + def get(_context_id):
130 + return None
131 +
132 + def __init__(self, _config, id, name):
133 + self.id = id
134 + self.name = name
135 +
136 + monkeypatch.setattr(task_scheduler, "PrintStyle", FakePrintStyle)
137 + monkeypatch.setattr(task_scheduler, "AgentContext", FakeAgentContext)
138 + monkeypatch.setattr(task_scheduler, "initialize_agent", lambda: object())
139 + monkeypatch.setattr(task_scheduler, "save_tmp_chat", lambda _context: None)
140 + monkeypatch.setattr(
141 + task_scheduler.projects, "activate_project", lambda *_args, **_kwargs: None
142 + )
143 +
144 + task = AdHocTask.create(
145 + name="shared",
146 + system_prompt="",
147 + prompt="run this",
148 + token="123",
149 + context_id="shared-context",
150 + )
151 + scheduler = object.__new__(task_scheduler.TaskScheduler)
152 +
153 + context = asyncio.run(scheduler._get_chat_context(task))
154 +
155 + assert context.id == task.context_id
156 + assert len(calls) == 1
157 + level, message = calls[0]
158 + assert level == "warning"
159 + assert "context not found" in message
tests/test_text_editor_context_patch.py
+99 -4
@@ -14,8 +14,14 @@ if str(PROJECT_ROOT) not in sys.path:
14 sys.path.insert(0, str(PROJECT_ROOT))
15
16 from plugins._text_editor.helpers.context_patch import ContextPatchError
17 -from plugins._text_editor.helpers.file_ops import apply_context_patch_file
18 -from plugins._text_editor.helpers.patch_request import parse_patch_request
17 +from plugins._text_editor.helpers.file_ops import (
18 + apply_context_patch_file,
19 + apply_exact_replace_file,
20 +)
21 +from plugins._text_editor.helpers.patch_request import (
22 + exact_replace_to_patch_text,
23 + parse_patch_request,
24 +)
25 from plugins._text_editor.helpers.patch_state import (
26 LOCAL_FRESHNESS_KEY,
27 REMOTE_FRESHNESS_KEY,
@@ -100,6 +106,41 @@ def test_context_patch_replaces_matching_context(tmp_path: Path) -> None:
106 assert target.read_text(encoding="utf-8") == "alpha\nbeta\ndelta\n"
107
108
109 +def test_exact_replace_file_replaces_one_span(tmp_path: Path) -> None:
110 + target = tmp_path / "sample.txt"
111 + target.write_text("alpha\nstatus = draft\ngamma\n", encoding="utf-8")
112 +
113 + result = apply_exact_replace_file(
114 + str(target), "status = draft", "status = ready"
115 + )
116 +
117 + assert result["replacement_count"] == 1
118 + assert result["line_from"] == 2
119 + assert target.read_text(encoding="utf-8") == "alpha\nstatus = ready\ngamma\n"
120 +
121 +
122 +def test_exact_replace_file_rejects_ambiguous_match(tmp_path: Path) -> None:
123 + target = tmp_path / "sample.txt"
124 + target.write_text("alpha\nalpha\n", encoding="utf-8")
125 +
126 + with pytest.raises(ValueError, match="matched 2 times"):
127 + apply_exact_replace_file(str(target), "alpha", "beta")
128 +
129 + assert target.read_text(encoding="utf-8") == "alpha\nalpha\n"
130 +
131 +
132 +def test_exact_replace_to_patch_text_works_with_context_patch(tmp_path: Path) -> None:
133 + target = tmp_path / "sample.txt"
134 + target.write_text("alpha\nstatus = draft\ngamma\n", encoding="utf-8")
135 +
136 + patch_text = exact_replace_to_patch_text(
137 + "sample.txt", "status = draft", "status = ready"
138 + )
139 + apply_context_patch_file(str(target), patch_text)
140 +
141 + assert target.read_text(encoding="utf-8") == "alpha\nstatus = ready\ngamma\n"
142 +
143 +
144 def test_context_patch_replaces_when_anchor_is_target_line(
145 tmp_path: Path,
146 ) -> None:
@@ -211,7 +252,7 @@ def test_patch_request_rejects_edits_and_patch_text_together() -> None:
252 )
253
254 assert request is None
214 - assert err == "provide either edits or patch_text, not both"
255 + assert err == "provide exactly one patch form: edits, patch_text, or old_text/new_text"
256
257
258 def test_patch_request_rejects_empty_patch_text() -> None:
@@ -221,6 +262,21 @@ def test_patch_request_rejects_empty_patch_text() -> None:
262 assert err == "patch_text must not be empty"
263
264
265 +def test_patch_request_accepts_exact_replace() -> None:
266 + request, err = parse_patch_request(
267 + None,
268 + None,
269 + old_text="status = draft",
270 + new_text="status = ready",
271 + )
272 +
273 + assert err == ""
274 + assert request is not None
275 + assert request.mode == "replace"
276 + assert request.old_text == "status = draft"
277 + assert request.new_text == "status = ready"
278 +
279 +
280 def test_patch_state_records_and_checks_fresh_file_state() -> None:
281 agent = _FakeAgent()
282 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(
491 assert calls[1][1]["mode"] == "patch_text"
492
493
494 +def test_text_editor_exact_replace_does_not_require_prior_read(
495 + tmp_path: Path, monkeypatch: pytest.MonkeyPatch
496 +) -> None:
497 + module, calls = _load_text_editor_tool(monkeypatch)
498 + target = tmp_path / "sample.txt"
499 + target.write_text("line-1\nstatus = draft\nline-3\n", encoding="utf-8")
500 + agent = _FakeAgent()
501 + tool = module.TextEditor(agent, "text_editor", "patch", {}, "", None)
502 +
503 + response = asyncio.run(
504 + tool._patch(
505 + path=str(target),
506 + old_text="status = draft",
507 + new_text="status = ready",
508 + )
509 + )
510 +
511 + assert "patched 1 edits applied 3 lines now" in response.message
512 + assert "status = ready" in response.message
513 + assert target.read_text(encoding="utf-8") == "line-1\nstatus = ready\nline-3\n"
514 + realpath = os.path.realpath(target)
515 + assert agent.data[module._MTIME_KEY][realpath] == {
516 + "mtime": 0,
517 + "total_lines": 0,
518 + }
519 + assert calls[0] == (
520 + "text_editor_patch_before",
521 + {
522 + "path": str(target),
523 + "old_text": "status = draft",
524 + "new_text": "status = ready",
525 + "edits": [],
526 + "mode": "replace",
527 + },
528 + )
529 + assert calls[1][0] == "text_editor_patch_after"
530 + assert calls[1][1]["mode"] == "replace"
531 +
532 +
533 def test_text_editor_execute_accepts_action_alias_for_read(
534 tmp_path: Path, monkeypatch: pytest.MonkeyPatch
535 ) -> None:
@@ -472,7 +567,7 @@ def test_text_editor_patch_text_rejects_simultaneous_edits(
567 )
568 )
569
475 - assert "provide either edits or patch_text" in response.message
570 + assert "provide exactly one patch form" in response.message
571 assert target.read_text(encoding="utf-8") == "line-1\n"
572
573
tests/test_tool_action_contracts.py
+83
@@ -209,6 +209,89 @@ def test_behaviour_adjustment_normalizes_duplicate_rules(monkeypatch):
209 assert rules == "## Behavioral rules\n* Favor Linux commands.\n* Token rule.\n"
210
211
212 +def test_behaviour_prompts_preserve_exact_rules_and_avoid_promptinclude():
213 + behaviour_prompt = Path("prompts/agent.system.tool.behaviour.md").read_text(
214 + encoding="utf-8"
215 + )
216 + merge_prompt = Path("prompts/behaviour.merge.sys.md").read_text(
217 + encoding="utf-8"
218 + )
219 + promptinclude_prompt = Path(
220 + "plugins/_promptinclude/prompts/agent.system.promptinclude.md"
221 + ).read_text(encoding="utf-8")
222 +
223 + assert "exact-response rules" in behaviour_prompt
224 + assert "preserve it verbatim" in behaviour_prompt
225 + assert "respond exactly with a phrase" in merge_prompt
226 + assert "use behaviour_adjustment, not promptinclude files" in promptinclude_prompt
227 +
228 +
229 +def _load_a2a_chat_tool(monkeypatch):
230 + _install_tool_stub(monkeypatch)
231 + sys.modules.pop("tools.a2a_chat", None)
232 + return importlib.import_module("tools.a2a_chat")
233 +
234 +
235 +def test_a2a_extracts_latest_assistant_text_from_history(monkeypatch):
236 + module = _load_a2a_chat_tool(monkeypatch)
237 +
238 + final = {
239 + "result": {
240 + "history": [
241 + {
242 + "role": "user",
243 + "parts": [{"kind": "text", "text": "what is 2+2?"}],
244 + },
245 + {
246 + "role": "assistant",
247 + "parts": [{"kind": "text", "text": "4"}],
248 + },
249 + ]
250 + }
251 + }
252 +
253 + assert module._extract_latest_assistant_text(final) == "4"
254 +
255 +
256 +def test_a2a_extracts_status_or_artifact_text_when_history_is_empty(monkeypatch):
257 + module = _load_a2a_chat_tool(monkeypatch)
258 +
259 + status_final = {
260 + "result": {
261 + "status": {
262 + "message": {
263 + "parts": [{"kind": "text", "text": "status answer"}]
264 + }
265 + }
266 + }
267 + }
268 + artifact_final = {
269 + "result": {
270 + "artifacts": [
271 + {"parts": [{"kind": "text", "text": "artifact answer"}]}
272 + ]
273 + }
274 + }
275 +
276 + assert module._extract_latest_assistant_text(status_final) == "status answer"
277 + assert module._extract_latest_assistant_text(artifact_final) == "artifact answer"
278 +
279 +
280 +def test_a2a_session_key_normalizes_explicit_a2a_path(monkeypatch):
281 + module = _load_a2a_chat_tool(monkeypatch)
282 +
283 + assert module._session_key("http://localhost:32080/a2a") == "http://localhost:32080"
284 + assert module._session_key("http://localhost:32080") == "http://localhost:32080"
285 +
286 +
287 +def test_a2a_empty_response_message_is_explicit_failure(monkeypatch):
288 + module = _load_a2a_chat_tool(monkeypatch)
289 +
290 + assert module._extract_latest_assistant_text({"result": {"history": []}}) == ""
291 + assert "failed" in module.A2A_EMPTY_RESPONSE_ERROR
292 + assert "not success" in module.A2A_EMPTY_RESPONSE_ERROR
293 +
294 +
295 def test_notify_user_prompt_documents_numeric_priority_values():
296 prompt = Path("prompts/agent.system.tool.notify_user.md").read_text(
297 encoding="utf-8"
tools/a2a_chat.py
+94 -13
@@ -1,8 +1,90 @@
1 +from typing import Any
2 +
3 from helpers.tool import Tool, Response
4 from helpers.print_style import PrintStyle
5 from helpers.fasta2a_client import connect_to_agent, is_client_available
6
7
8 +A2A_EMPTY_RESPONSE_ERROR = (
9 + "A2A chat failed: the remote task completed but no assistant text was found. "
10 + "Expected final.result.history to include an assistant message with a text "
11 + "part, or a text artifact/status message. Treat this as a failed remote "
12 + "response, not success."
13 +)
14 +
15 +
16 +def _session_key(agent_url: str) -> str:
17 + """Keep root and explicit /a2a URLs in the same conversation cache."""
18 + normalized = agent_url.rstrip("/")
19 + if normalized.endswith("/a2a"):
20 + return normalized[:-4].rstrip("/")
21 + return normalized
22 +
23 +
24 +def _text_from_part(part: Any) -> str:
25 + if not isinstance(part, dict):
26 + return ""
27 + for key in ("text", "content"):
28 + value = part.get(key)
29 + if isinstance(value, str) and value.strip():
30 + return value.strip()
31 + return ""
32 +
33 +
34 +def _text_from_message(message: Any) -> str:
35 + if isinstance(message, str):
36 + return message.strip()
37 + if not isinstance(message, dict):
38 + return ""
39 +
40 + parts = message.get("parts")
41 + if isinstance(parts, list):
42 + texts = [_text_from_part(part) for part in parts]
43 + text = "\n".join(text for text in texts if text)
44 + if text:
45 + return text
46 +
47 + for key in ("text", "content", "message", "output"):
48 + value = message.get(key)
49 + if isinstance(value, str) and value.strip():
50 + return value.strip()
51 +
52 + return ""
53 +
54 +
55 +def _extract_latest_assistant_text(task_response: Any) -> str:
56 + if not isinstance(task_response, dict):
57 + return ""
58 +
59 + result = task_response.get("result", task_response)
60 + if not isinstance(result, dict):
61 + return ""
62 +
63 + history = result.get("history")
64 + if isinstance(history, list):
65 + for message in reversed(history):
66 + if isinstance(message, dict) and message.get("role") == "user":
67 + continue
68 + text = _text_from_message(message)
69 + if text:
70 + return text
71 +
72 + status = result.get("status")
73 + if isinstance(status, dict):
74 + text = _text_from_message(status.get("message"))
75 + if text:
76 + return text
77 +
78 + artifacts = result.get("artifacts")
79 + if isinstance(artifacts, list):
80 + for artifact in reversed(artifacts):
81 + text = _text_from_message(artifact)
82 + if text:
83 + return text
84 +
85 + return _text_from_message(result)
86 +
87 +
88 class A2AChatTool(Tool):
89 """Communicate with another FastA2A-compatible agent."""
90
@@ -21,12 +103,13 @@ class A2AChatTool(Tool):
103
104 # Retrieve or create session cache on the Agent instance
105 sessions: dict[str, str] = self.agent.get_data("_a2a_sessions") or {}
106 + cache_key = _session_key(agent_url)
107
25 - # Handle reset flag – start fresh conversation
26 - if reset and agent_url in sessions:
27 - sessions.pop(agent_url, None)
108 + # Handle reset flag: start fresh conversation
109 + if reset and cache_key in sessions:
110 + sessions.pop(cache_key, None)
111
29 - context_id = None if reset else sessions.get(agent_url)
112 + context_id = None if reset else sessions.get(cache_key)
113 try:
114 async with await connect_to_agent(agent_url) as conn:
115 task_resp = await conn.send_message(user_message, attachments=attachments, context_id=context_id)
@@ -36,18 +119,16 @@ class A2AChatTool(Tool):
119 final = await conn.wait_for_completion(task_id)
120 new_context_id = final["result"].get("context_id") # type: ignore[index]
121 if isinstance(new_context_id, str):
39 - sessions[agent_url] = new_context_id
122 + sessions[cache_key] = new_context_id
123 # persist back to agent data
124 self.agent.set_data("_a2a_sessions", sessions)
42 - # Extract latest assistant text
43 - history = final["result"].get("history", [])
44 - assistant_text = ""
45 - if history:
46 - last_parts = history[-1].get("parts", [])
47 - assistant_text = "\n".join(
48 - p.get("text", "") for p in last_parts if p.get("kind") == "text"
125 + assistant_text = _extract_latest_assistant_text(final)
126 + if not assistant_text:
127 + return Response(
128 + message=A2A_EMPTY_RESPONSE_ERROR,
129 + break_loop=False,
130 )
50 - return Response(message=assistant_text or "(no response)", break_loop=False)
131 + return Response(message=assistant_text, break_loop=False)
132 except Exception as e:
133 PrintStyle.error(f"A2A chat error: {e}")
134 return Response(message=f"A2A chat error: {e}", break_loop=False)