From f17198e126bcd5a562bfabcc27e3066f2ca11aaa Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Mon, 11 May 2026 05:13:51 +0200 Subject: [PATCH] fix: tighten tool guidance and editor workflows --- helpers/document_query.py | 42 +++++-- helpers/task_scheduler.py | 6 +- .../agent.system.tool.text_editor_remote.md | 3 +- .../skills/host-file-editing/SKILL.md | 4 +- .../_a0_connector/tools/text_editor_remote.py | 19 +++- .../prompts/agent.system.promptinclude.md | 3 +- plugins/_text_editor/helpers/file_ops.py | 54 +++++++++ plugins/_text_editor/helpers/patch_request.py | 39 ++++++- .../prompts/agent.system.tool.text_editor.md | 10 +- plugins/_text_editor/tools/text_editor.py | 85 +++++++++++++- prompts/agent.system.tool.behaviour.md | 3 + prompts/agent.system.tool.call_sub.md | 2 + prompts/behaviour.merge.sys.md | 4 +- tests/test_document_query_fallback.py | 72 ++++++++++++ tests/test_model_search.py | 4 +- tests/test_task_scheduler_timezone.py | 96 +++++++++++++++- tests/test_text_editor_context_patch.py | 103 ++++++++++++++++- tests/test_tool_action_contracts.py | 83 ++++++++++++++ tools/a2a_chat.py | 107 +++++++++++++++--- 19 files changed, 694 insertions(+), 45 deletions(-) create mode 100644 tests/test_document_query_fallback.py diff --git a/helpers/document_query.py b/helpers/document_query.py index fe98ee988..4bc8052a6 100644 --- a/helpers/document_query.py +++ b/helpers/document_query.py @@ -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: diff --git a/helpers/task_scheduler.py b/helpers/task_scheduler.py index f77b457d1..94a36f378 100644 --- a/helpers/task_scheduler.py +++ b/helpers/task_scheduler.py @@ -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): diff --git a/plugins/_a0_connector/prompts/agent.system.tool.text_editor_remote.md b/plugins/_a0_connector/prompts/agent.system.tool.text_editor_remote.md index fe902444b..514108b8b 100644 --- a/plugins/_a0_connector/prompts/agent.system.tool.text_editor_remote.md +++ b/plugins/_a0_connector/prompts/agent.system.tool.text_editor_remote.md @@ -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 diff --git a/plugins/_a0_connector/skills/host-file-editing/SKILL.md b/plugins/_a0_connector/skills/host-file-editing/SKILL.md index 59f8de4f4..27e6d6e08 100644 --- a/plugins/_a0_connector/skills/host-file-editing/SKILL.md +++ b/plugins/_a0_connector/skills/host-file-editing/SKILL.md @@ -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. diff --git a/plugins/_a0_connector/tools/text_editor_remote.py b/plugins/_a0_connector/tools/text_editor_remote.py index f4140ca3d..1db409c66 100644 --- a/plugins/_a0_connector/tools/text_editor_remote.py +++ b/plugins/_a0_connector/tools/text_editor_remote.py @@ -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, diff --git a/plugins/_promptinclude/prompts/agent.system.promptinclude.md b/plugins/_promptinclude/prompts/agent.system.promptinclude.md index 225fdb422..673996d1b 100644 --- a/plugins/_promptinclude/prompts/agent.system.promptinclude.md +++ b/plugins/_promptinclude/prompts/agent.system.promptinclude.md @@ -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 diff --git a/plugins/_text_editor/helpers/file_ops.py b/plugins/_text_editor/helpers/file_ops.py index 10da4f323..972bf9da2 100644 --- a/plugins/_text_editor/helpers/file_ops.py +++ b/plugins/_text_editor/helpers/file_ops.py @@ -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 # ------------------------------------------------------------------ diff --git a/plugins/_text_editor/helpers/patch_request.py b/plugins/_text_editor/helpers/patch_request.py index b8c4ae0c8..9364a91ef 100644 --- a/plugins/_text_editor/helpers/patch_request.py +++ b/plugins/_text_editor/helpers/patch_request.py @@ -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) diff --git a/plugins/_text_editor/prompts/agent.system.tool.text_editor.md b/plugins/_text_editor/prompts/agent.system.tool.text_editor.md index 0d0741ce8..1bea90fa9 100644 --- a/plugins/_text_editor/prompts/agent.system.tool.text_editor.md +++ b/plugins/_text_editor/prompts/agent.system.tool.text_editor.md @@ -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'" } } ~~~ diff --git a/plugins/_text_editor/tools/text_editor.py b/plugins/_text_editor/tools/text_editor.py index a943f2a04..6c9258059 100644 --- a/plugins/_text_editor/tools/text_editor.py +++ b/plugins/_text_editor/tools/text_editor.py @@ -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" diff --git a/prompts/agent.system.tool.behaviour.md b/prompts/agent.system.tool.behaviour.md index 662f8c650..d078ab181 100644 --- a/prompts/agent.system.tool.behaviour.md +++ b/prompts/agent.system.tool.behaviour.md @@ -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 diff --git a/prompts/agent.system.tool.call_sub.md b/prompts/agent.system.tool.call_sub.md index b25d95443..3cd630fce 100644 --- a/prompts/agent.system.tool.call_sub.md +++ b/prompts/agent.system.tool.call_sub.md @@ -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 { diff --git a/prompts/behaviour.merge.sys.md b/prompts/behaviour.merge.sys.md index 97c60f2c4..e56ac038f 100644 --- a/prompts/behaviour.merge.sys.md +++ b/prompts/behaviour.merge.sys.md @@ -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 newline at end of file +- No level 1 headings (#), only level 2 headings (##) and bullet points (*) diff --git a/tests/test_document_query_fallback.py b/tests/test_document_query_fallback.py new file mode 100644 index 000000000..3473fd0a2 --- /dev/null +++ b/tests/test_document_query_fallback.py @@ -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 == "" diff --git a/tests/test_model_search.py b/tests/test_model_search.py index 2ea6b4c9e..7540b7342 100644 --- a/tests/test_model_search.py +++ b/tests/test_model_search.py @@ -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", ] diff --git a/tests/test_task_scheduler_timezone.py b/tests/test_task_scheduler_timezone.py index 6cde12dd0..3d55780b8 100644 --- a/tests/test_task_scheduler_timezone.py +++ b/tests/test_task_scheduler_timezone.py @@ -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 diff --git a/tests/test_text_editor_context_patch.py b/tests/test_text_editor_context_patch.py index a2f015b56..68778788a 100644 --- a/tests/test_text_editor_context_patch.py +++ b/tests/test_text_editor_context_patch.py @@ -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" diff --git a/tests/test_tool_action_contracts.py b/tests/test_tool_action_contracts.py index c2fb9dc16..cc896ccdf 100644 --- a/tests/test_tool_action_contracts.py +++ b/tests/test_tool_action_contracts.py @@ -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" diff --git a/tools/a2a_chat.py b/tools/a2a_chat.py index 40f0b8898..2f4188546 100644 --- a/tools/a2a_chat.py +++ b/tools/a2a_chat.py @@ -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)