mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-05-16 19:50:43 +00:00
fix: tighten tool guidance and editor workflows
This commit is contained in:
parent
6ba1f30dca
commit
f17198e126
19 changed files with 694 additions and 45 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'"
|
||||
}
|
||||
}
|
||||
~~~
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
1. The assistant receives a markdown ruleset of AGENT's behaviour and text of adjustments to be implemented
|
||||
2. Assistant merges the ruleset with the instructions into a new markdown ruleset
|
||||
3. Assistant keeps the ruleset short, removing any duplicates or redundant information
|
||||
4. Assistant preserves exact words, phrases, tokens, capitalization, punctuation, and quoted/code-spanned text from the adjustments verbatim
|
||||
5. If an adjustment says to respond exactly with a phrase, the resulting rule must include that full exact phrase unchanged
|
||||
|
||||
# Format
|
||||
- The response format is a markdown format of instructions for AI AGENT explaining how the AGENT is supposed to behave
|
||||
- No level 1 headings (#), only level 2 headings (##) and bullet points (*)
|
||||
- No level 1 headings (#), only level 2 headings (##) and bullet points (*)
|
||||
|
|
|
|||
72
tests/test_document_query_fallback.py
Normal file
72
tests/test_document_query_fallback.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from helpers.document_query import DocumentQueryHelper
|
||||
|
||||
|
||||
class FakeStore:
|
||||
@staticmethod
|
||||
def normalize_uri(uri: str) -> str:
|
||||
return uri
|
||||
|
||||
async def search_documents(self, **_kwargs):
|
||||
return []
|
||||
|
||||
|
||||
class FakeAgent:
|
||||
def __init__(self):
|
||||
self.chat_messages = None
|
||||
|
||||
async def handle_intervention(self):
|
||||
return None
|
||||
|
||||
def parse_prompt(self, name: str) -> str:
|
||||
return name
|
||||
|
||||
async def call_utility_model(self, **_kwargs) -> str:
|
||||
return "codename"
|
||||
|
||||
async def call_chat_model(self, messages, explicit_caching=False):
|
||||
self.chat_messages = messages
|
||||
return "The project codename is Atlas.", None
|
||||
|
||||
|
||||
def test_document_qa_uses_small_document_content_when_search_finds_no_chunks():
|
||||
agent = FakeAgent()
|
||||
progress = []
|
||||
helper = object.__new__(DocumentQueryHelper)
|
||||
helper.agent = agent
|
||||
helper.store = FakeStore()
|
||||
helper.progress_callback = progress.append
|
||||
|
||||
async def document_get_content(uri, add_to_db=False):
|
||||
assert uri == "/tmp/project.md"
|
||||
assert add_to_db is True
|
||||
return "# Project\n\nCodename: Atlas\n"
|
||||
|
||||
helper.document_get_content = document_get_content
|
||||
|
||||
ok, content = asyncio.run(
|
||||
helper.document_qa(["/tmp/project.md"], ["What is the codename?"])
|
||||
)
|
||||
|
||||
assert ok is True
|
||||
assert content == "The project codename is Atlas."
|
||||
assert "No matching chunks found" in "\n".join(progress)
|
||||
assert agent.chat_messages is not None
|
||||
assert "Codename: Atlas" in agent.chat_messages[1].content
|
||||
|
||||
|
||||
def test_small_document_fallback_refuses_large_content():
|
||||
content = DocumentQueryHelper._small_document_fallback_content(
|
||||
["/tmp/large.md"], ["x" * 12_001]
|
||||
)
|
||||
|
||||
assert content == ""
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue