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)