diff --git a/extensions/python/hist_add_tool_result/AGENTS.md b/extensions/python/hist_add_tool_result/AGENTS.md index 0c92126a9..c1dd0ecf2 100644 --- a/extensions/python/hist_add_tool_result/AGENTS.md +++ b/extensions/python/hist_add_tool_result/AGENTS.md @@ -12,6 +12,7 @@ - Preserve tool result traceability without leaking secrets. - Keep file artifacts inside expected runtime/user-owned paths. +- Skip BACKGROUND contexts; background workers must remain ephemeral and must not create chat message files. ## Work Guidance diff --git a/extensions/python/hist_add_tool_result/_90_save_tool_call_file.py b/extensions/python/hist_add_tool_result/_90_save_tool_call_file.py index 21d6ea0ee..fd5a32854 100644 --- a/extensions/python/hist_add_tool_result/_90_save_tool_call_file.py +++ b/extensions/python/hist_add_tool_result/_90_save_tool_call_file.py @@ -1,4 +1,5 @@ from typing import Any +from agent import AgentContextType from helpers.extension import Extension from helpers import files, persist_chat import os, re @@ -9,6 +10,9 @@ class SaveToolCallFile(Extension): def execute(self, data: dict[str, Any] | None = None, **kwargs): if not self.agent: return + + if self.agent.context.type == AgentContextType.BACKGROUND: + return if not data: return diff --git a/helpers/parallel_tools.py b/helpers/parallel_tools.py index 7412e48cd..207806018 100644 --- a/helpers/parallel_tools.py +++ b/helpers/parallel_tools.py @@ -570,6 +570,7 @@ async def _remove_context(context_id: str | None) -> None: if not context_id: return from agent import AgentContext + from helpers import persist_chat context = AgentContext.get(context_id) if context: @@ -578,6 +579,7 @@ async def _remove_context(context_id: str | None) -> None: except Exception: pass AgentContext.remove(context_id) + persist_chat.remove_chat(context_id) def _log_parallel_child_started(agent: "Agent", job: ParallelJob) -> None: diff --git a/helpers/parallel_tools.py.dox.md b/helpers/parallel_tools.py.dox.md index cd73ee32f..1194cd081 100644 --- a/helpers/parallel_tools.py.dox.md +++ b/helpers/parallel_tools.py.dox.md @@ -27,6 +27,7 @@ - Normalization rejects `document_query` inside `parallel` because document parsing and Q&A fan out into heavier worker/model paths that must run sequentially. - `call_subordinate` jobs run in isolated child chat contexts tagged with parent-chat metadata; they must not be added to the scheduler task list and may use normal child-chat tools, including `parallel`. - Direct tool jobs run in isolated background contexts and are blocked from recursively invoking `parallel`. +- Direct tool background context cleanup removes both the in-memory context and any transient chat folder left on disk. - Parent-visible child log items are created for each wrapped call so the WebUI can inspect concurrent children separately while the wrapper result remains model-history-only. - Child tool logs mirror normal tool-call visible args; job ids remain available through wrapper results and prompt extras rather than visible process-step args. - Job IDs are stable handles for later await, collect, or cancel operations. diff --git a/helpers/persist_chat.py b/helpers/persist_chat.py index 2dd8e0663..2fbc1111c 100644 --- a/helpers/persist_chat.py +++ b/helpers/persist_chat.py @@ -71,7 +71,9 @@ def load_tmp_chats(): folders = files.list_files(CHATS_FOLDER, "*") json_files = [] for folder_name in folders: - json_files.append(_get_chat_file_path(folder_name)) + chat_file = _get_chat_file_path(folder_name) + if files.exists(chat_file): + json_files.append(chat_file) ctxids = [] for file in json_files: diff --git a/helpers/persist_chat.py.dox.md b/helpers/persist_chat.py.dox.md index 3cc103ee7..262013e9f 100644 --- a/helpers/persist_chat.py.dox.md +++ b/helpers/persist_chat.py.dox.md @@ -42,6 +42,7 @@ - Imported dependency areas include: `agent`, `collections`, `datetime`, `helpers`, `helpers.localization`, `helpers.log`, `initialize`, `json`, `typing`, `uuid`. - Serialized chats store `agent_profile` both at the context level for the main chat and on each serialized agent so subordinate profiles survive server restart. - Deserialization must rebuild each agent with its serialized profile when present, falling back to the context profile for older chat files. +- Chat loading skips directories that do not contain `chat.json`; malformed existing chat files still report load errors. ## Key Concepts diff --git a/tests/test_parallel_tool.py b/tests/test_parallel_tool.py index b23974d77..3bd2a35dd 100644 --- a/tests/test_parallel_tool.py +++ b/tests/test_parallel_tool.py @@ -262,6 +262,19 @@ async def test_parallel_cancel_still_stops_and_removes_running_jobs() -> None: assert job.id not in agent.context.get_data(parallel_tools.PARALLEL_JOBS_KEY) +@pytest.mark.asyncio +async def test_parallel_remove_context_deletes_persisted_worker_chat(monkeypatch) -> None: + removed = [] + + from helpers import persist_chat + + monkeypatch.setattr(persist_chat, "remove_chat", removed.append) + + await parallel_tools._remove_context("missing-worker") + + assert removed == ["missing-worker"] + + @pytest.mark.asyncio async def test_parallel_recursion_guard_allows_subordinate_children_but_blocks_tool_workers() -> None: from extensions.python.tool_execute_before._20_block_parallel_recursion import ( diff --git a/tests/test_persist_chat_log_ids.py b/tests/test_persist_chat_log_ids.py index 373ef63de..53120375f 100644 --- a/tests/test_persist_chat_log_ids.py +++ b/tests/test_persist_chat_log_ids.py @@ -1,5 +1,8 @@ from __future__ import annotations +import json +from types import SimpleNamespace + def test_deserialize_log_preserves_item_id() -> None: from helpers.log import Log @@ -16,3 +19,37 @@ def test_deserialize_log_preserves_item_id() -> None: assert restored.logs[0].id == "msg-123" assert restored.logs[1].type == "assistant" assert restored.logs[1].id is None + + +def test_load_tmp_chats_skips_directories_without_chat_json(monkeypatch, capsys) -> None: + from helpers import persist_chat + + monkeypatch.setattr(persist_chat, "_convert_v080_chats", lambda: None) + monkeypatch.setattr( + persist_chat.files, + "get_abs_path", + lambda *parts: "/" + "/".join(str(part).strip("/") for part in parts if part), + ) + monkeypatch.setattr( + persist_chat.files, + "list_files", + lambda folder, pattern="*": ["orphan", "valid"], + ) + monkeypatch.setattr( + persist_chat.files, + "exists", + lambda path: str(path).endswith("/valid/chat.json"), + ) + monkeypatch.setattr( + persist_chat.files, + "read_file", + lambda path: json.dumps({"id": "valid"}), + ) + monkeypatch.setattr( + persist_chat, + "_deserialize_context", + lambda data: SimpleNamespace(id=data["id"]), + ) + + assert persist_chat.load_tmp_chats() == ["valid"] + assert "Error loading chat" not in capsys.readouterr().out diff --git a/tests/test_tool_result_file_persistence.py b/tests/test_tool_result_file_persistence.py new file mode 100644 index 000000000..a48fb229b --- /dev/null +++ b/tests/test_tool_result_file_persistence.py @@ -0,0 +1,22 @@ +from types import SimpleNamespace + +from agent import AgentContextType +from extensions.python.hist_add_tool_result._90_save_tool_call_file import SaveToolCallFile + + +def test_tool_result_file_persistence_skips_background_context(tmp_path, monkeypatch) -> None: + target = tmp_path / "messages" + agent = SimpleNamespace( + context=SimpleNamespace(id="background-worker", type=AgentContextType.BACKGROUND) + ) + data = {"tool_result": "x" * 501} + + monkeypatch.setattr( + "extensions.python.hist_add_tool_result._90_save_tool_call_file.persist_chat.get_chat_msg_files_folder", + lambda _ctxid: str(target), + ) + + SaveToolCallFile(agent=agent).execute(data=data) + + assert "file" not in data + assert not target.exists()