From 54de30aa0851681ab9c9964aa9f3db26cd77f2b1 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:09:50 +0000 Subject: [PATCH 1/6] Port FileMemoryProvider to python and integrate it and FileAccessProvider into the harness --- python/packages/core/AGENTS.md | 8 + .../packages/core/agent_framework/__init__.py | 8 + .../core/agent_framework/_harness/_agent.py | 51 ++- .../agent_framework/_harness/_file_memory.py | 343 ++++++++++++++++++ .../core/tests/core/test_harness_agent.py | 109 +++--- .../tests/core/test_harness_file_memory.py | 284 +++++++++++++++ .../02-agents/harness/console/formatters.py | 70 ++-- .../harness/harness_data_processing.py | 125 +++++++ .../02-agents/harness/working/sales.csv | 50 +++ 9 files changed, 956 insertions(+), 92 deletions(-) create mode 100644 python/packages/core/agent_framework/_harness/_file_memory.py create mode 100644 python/packages/core/tests/core/test_harness_file_memory.py create mode 100644 python/samples/02-agents/harness/harness_data_processing.py create mode 100644 python/samples/02-agents/harness/working/sales.csv diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index 9e0b9dca59..f5fc15a3d7 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -100,6 +100,14 @@ agent_framework/ - **`FileSearchResult`** / **`FileSearchMatch`** - `SerializationMixin` DTOs returned by `search_files`, carrying the matching file name, a context snippet, and the matching lines with 1-based line numbers. - **`FileAccessProvider`** - `ContextProvider` that adds shared file-access tools (`file_access_save_file`, `file_access_read_file`, `file_access_delete_file`, `file_access_list_files`, `file_access_list_subdirectories`, `file_access_search_files`) plus default usage instructions to each invocation. `file_access_list_files`/`file_access_list_subdirectories` enumerate direct children (files / subdirectories) so the agent can walk the tree level by level; `file_access_search_files` searches recursively from the store root and returns store-root-relative `file_name` paths, scoped via an `fnmatch` glob (where `*` crosses `/`, e.g. `*.md`, `reports/*`). Unlike `MemoryContextProvider`, the store is intentionally shared across sessions and agents. +### File Memory Harness (`_harness/_file_memory.py`) + +- **`FileMemoryProvider`** - `ContextProvider` that gives an agent a session-scoped, file-based memory backed by the same `AgentFileStore` abstraction. Adds five tools (`file_memory_save_file`, `file_memory_read_file`, `file_memory_delete_file`, `file_memory_list_files`, `file_memory_search_files`) plus default usage instructions. Port of the .NET `FileMemoryProvider`. +- **Scoping** - Memories are isolated per session by default: each session writes under a working folder derived from `context.session_id`. Pass an explicit `scope` (e.g. a user id) to group memories across sessions, mirroring `FoundryMemoryProvider`'s `scope` arg. +- **Descriptions & index** - `file_memory_save_file` accepts an optional `description`, stored in a companion `_description.md` sidecar. After each save/delete the provider rebuilds a capped (50-entry) `memories.md` index, and `before_run` injects that index as a `user` context message so the model knows what memories exist. Sidecars and the index are internal files hidden from `file_memory_list_files`/`file_memory_search_files` and rejected as save targets. +- **`DEFAULT_FILE_MEMORY_SOURCE_ID`** / **`DEFAULT_FILE_MEMORY_INSTRUCTIONS`** - Public defaults for the provider's source id and instruction banner. +- **Harness wiring** - `create_harness_agent` includes both `FileMemoryProvider` and `FileAccessProvider` by default. Disable via `disable_file_memory` / `disable_file_access`; override the backing store via `file_memory_store` / `file_access_store`. When no store is supplied, defaults are `FileSystemAgentFileStore` rooted at `{cwd}/agent-file-memory` (memory) and `{cwd}/working` (access), mirroring the .NET `HarnessAgent`. + ### Tool Approval Harness (`_harness/_tool_approval.py`) - **`ToolApprovalMiddleware`** - Experimental opt-in agent middleware that coordinates session-backed approval diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index d4407e432f..07516ad36b 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -102,6 +102,11 @@ FileSystemAgentFileStore, InMemoryAgentFileStore, ) +from ._harness._file_memory import ( + DEFAULT_FILE_MEMORY_INSTRUCTIONS, + DEFAULT_FILE_MEMORY_SOURCE_ID, + FileMemoryProvider, +) from ._harness._loop import ( AgentLoopMiddleware, JudgeVerdict, @@ -341,6 +346,8 @@ "DEFAULT_BACKGROUND_AGENTS_SOURCE_ID", "DEFAULT_FILE_ACCESS_INSTRUCTIONS", "DEFAULT_FILE_ACCESS_SOURCE_ID", + "DEFAULT_FILE_MEMORY_INSTRUCTIONS", + "DEFAULT_FILE_MEMORY_SOURCE_ID", "DEFAULT_HARNESS_INSTRUCTIONS", "DEFAULT_MAX_ITERATIONS", "DEFAULT_MEMORY_SOURCE_ID", @@ -431,6 +438,7 @@ "FileAccessProvider", "FileCheckpointStorage", "FileHistoryProvider", + "FileMemoryProvider", "FileSearchMatch", "FileSearchResult", "FileSkill", diff --git a/python/packages/core/agent_framework/_harness/_agent.py b/python/packages/core/agent_framework/_harness/_agent.py index 1a6178b54d..b98c4d5df4 100644 --- a/python/packages/core/agent_framework/_harness/_agent.py +++ b/python/packages/core/agent_framework/_harness/_agent.py @@ -12,6 +12,7 @@ import logging from collections.abc import Callable, Sequence +from pathlib import Path from typing import TYPE_CHECKING, Any from .._agents import Agent, SupportsAgentRun @@ -21,7 +22,8 @@ from .._sessions import ContextProvider, HistoryProvider, InMemoryHistoryProvider from .._skills import SkillsProvider from ._background_agents import BackgroundAgentsProvider -from ._memory import MemoryContextProvider, MemoryStore +from ._file_access import AgentFileStore, FileAccessProvider, FileSystemAgentFileStore +from ._file_memory import FileMemoryProvider from ._mode import AgentModeProvider from ._todo import TodoProvider @@ -124,8 +126,10 @@ def _assemble_context_providers( todo_provider: TodoProvider | None, disable_mode: bool, mode_provider: AgentModeProvider | None, - disable_memory: bool, - memory_store: MemoryStore | None, + disable_file_memory: bool, + file_memory_store: AgentFileStore | None, + disable_file_access: bool, + file_access_store: AgentFileStore | None, skills_provider: SkillsProvider | None, skills_paths: Sequence[str] | None, background_agents: Sequence[SupportsAgentRun] | None, @@ -149,8 +153,17 @@ def _assemble_context_providers( if not disable_mode: providers.append(mode_provider or AgentModeProvider()) - if not disable_memory and memory_store is not None: - providers.append(MemoryContextProvider(store=memory_store)) + # File-based session memory (on by default). Default store is rooted at + # ``{cwd}/agent-file-memory``; the provider isolates memories per session + # via its default ``scope=session_id``. + if not disable_file_memory: + memory_store = file_memory_store or FileSystemAgentFileStore(Path.cwd() / "agent-file-memory") + providers.append(FileMemoryProvider(memory_store)) + + # Shared file access (on by default). Default store is rooted at ``{cwd}/working``. + if not disable_file_access: + access_store = file_access_store or FileSystemAgentFileStore(Path.cwd() / "working") + providers.append(FileAccessProvider(access_store)) # Skills are opt-in: only added when skills_provider or skills_paths is provided. if skills_provider: @@ -241,8 +254,10 @@ def create_harness_agent( todo_provider: TodoProvider | None = None, disable_mode: bool = False, mode_provider: AgentModeProvider | None = None, - disable_memory: bool = False, - memory_store: MemoryStore | None = None, + disable_file_memory: bool = False, + file_memory_store: AgentFileStore | None = None, + disable_file_access: bool = False, + file_access_store: AgentFileStore | None = None, skills_provider: SkillsProvider | None = None, skills_paths: Sequence[str] | None = None, background_agents: Sequence[SupportsAgentRun] | None = None, @@ -264,7 +279,8 @@ def create_harness_agent( - **Compaction** — context-window compaction before/after each run - **TodoProvider** — todo list management - **AgentModeProvider** — plan/execute mode tracking - - **MemoryContextProvider** — file-based durable memory (when ``memory_store`` provided) + - **FileMemoryProvider** — file-based session memory (on by default) + - **FileAccessProvider** — shared file read/write tools (on by default) - **SkillsProvider** — skill discovery and progressive loading - **BackgroundAgentsProvider** — delegate work to background sub-agents - **OpenTelemetry** — observability via ``AgentTelemetryLayer`` @@ -336,9 +352,16 @@ def create_harness_agent( todo_provider: Custom TodoProvider instance. Ignored when disable_todo is True. disable_mode: When True, skip the AgentModeProvider. mode_provider: Custom AgentModeProvider instance. Ignored when disable_mode is True. - disable_memory: When True, skip the MemoryContextProvider. - memory_store: Memory store instance. When provided (and disable_memory is False), - a MemoryContextProvider is added. + disable_file_memory: When True, skip the FileMemoryProvider. When False (default), + a FileMemoryProvider is added, giving the agent session-scoped, file-based memory. + file_memory_store: Custom AgentFileStore backing the FileMemoryProvider. When None + (and disable_file_memory is False), a FileSystemAgentFileStore rooted at + ``{cwd}/agent-file-memory`` is created. Ignored when disable_file_memory is True. + disable_file_access: When True, skip the FileAccessProvider. When False (default), + a FileAccessProvider is added, giving the agent shared read/write file tools. + file_access_store: Custom AgentFileStore backing the FileAccessProvider. When None + (and disable_file_access is False), a FileSystemAgentFileStore rooted at + ``{cwd}/working`` is created. Ignored when disable_file_access is True. skills_provider: Custom SkillsProvider instance for code-defined skills. Can be combined with ``skills_paths`` to aggregate file and code-based skills. skills_paths: Paths for file-based skill discovery (looks for SKILL.md files). @@ -417,8 +440,10 @@ def create_harness_agent( todo_provider=todo_provider, disable_mode=disable_mode, mode_provider=mode_provider, - disable_memory=disable_memory, - memory_store=memory_store, + disable_file_memory=disable_file_memory, + file_memory_store=file_memory_store, + disable_file_access=disable_file_access, + file_access_store=file_access_store, skills_provider=skills_provider, skills_paths=skills_paths, background_agents=background_agents, diff --git a/python/packages/core/agent_framework/_harness/_file_memory.py b/python/packages/core/agent_framework/_harness/_file_memory.py new file mode 100644 index 0000000000..e8d89bfdcd --- /dev/null +++ b/python/packages/core/agent_framework/_harness/_file_memory.py @@ -0,0 +1,343 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""File-based memory harness provider backed by an ``AgentFileStore``. + +:class:`FileMemoryProvider` gives an agent a session-scoped, file-based memory +system. Each memory is stored as an individual file with a meaningful name, and +large files can carry a companion description file (suffixed with +``_description.md``) that provides a short summary used for discovery. A +``memories.md`` index file is maintained automatically and injected into the +agent's context so the model knows what memories already exist. + +File access is mediated through the :class:`~agent_framework.AgentFileStore` +abstraction (shared with :class:`~agent_framework.FileAccessProvider`), so the +same in-memory, local-disk, or remote-blob backends can be reused here. + +Unlike :class:`~agent_framework.FileAccessProvider`, which exposes a *shared* +store visible across sessions and agents, :class:`FileMemoryProvider` isolates +memories per session by default: every session writes under its own working +folder (derived from the session id). Pass an explicit ``scope`` to group +memories differently, for example by user id. + +The provider exposes the following tools to the agent (registered on the +per-invocation :class:`~agent_framework.SessionContext` in +:meth:`FileMemoryProvider.before_run`): + +* ``file_memory_save_file`` — Save a memory file (with an optional description). +* ``file_memory_read_file`` — Read the content of a memory file by name. +* ``file_memory_delete_file`` — Delete a memory file (and its description). +* ``file_memory_list_files`` — List memory files with their descriptions. +* ``file_memory_search_files`` — Search memory file contents with a regex. +""" + +from __future__ import annotations + +import asyncio +import weakref +from typing import Any, ClassVar + +from .._feature_stage import ExperimentalFeature, experimental +from .._sessions import AgentSession, ContextProvider, SessionContext +from .._tools import tool +from .._types import Message +from ._file_access import AgentFileStore, _normalize_relative_path # pyright: ignore[reportPrivateUsage] + +DEFAULT_FILE_MEMORY_SOURCE_ID = "file_memory" + +DEFAULT_FILE_MEMORY_INSTRUCTIONS = ( + "## File Based Memory\n" + "You have access to a session-scoped, file-based memory system via the `file_memory_*` tools " + "for storing and retrieving information across interactions. " + "These files act as your working memory for the current session and are isolated from other sessions. " + "Use these tools to store plans, memories, processing results, or downloaded data.\n\n" + '- Use descriptive file names (e.g., "projectarchitecture.md", "userpreferences.md").\n' + "- Include a description when saving a file to help with future discovery.\n" + "- Before starting new tasks, use file_memory_list_files and file_memory_search_files to check for " + "relevant existing memories to avoid duplicate work.\n" + "- Keep memories up-to-date by overwriting files when information changes.\n" + "- When you receive large amounts of data (e.g., downloaded web pages, API responses, research results), " + "save them to files if they will be required later, so that they are not lost when older context is " + "compacted or truncated. This ensures important data remains accessible across long-running sessions." +) + +_DESCRIPTION_SUFFIX = "_description.md" +_MEMORY_INDEX_FILE_NAME = "memories.md" +_MAX_INDEX_ENTRIES = 50 + + +def _description_file_name(file_name: str) -> str: + """Return the companion description file name for ``file_name``. + + The suffix replaces the original extension when present (so ``notes.md`` + becomes ``notes_description.md``); otherwise it is appended. + """ + dot_index = file_name.rfind(".") + if dot_index > 0: + return f"{file_name[:dot_index]}{_DESCRIPTION_SUFFIX}" + return f"{file_name}{_DESCRIPTION_SUFFIX}" + + +def _is_internal_file(file_name: str) -> bool: + """Return whether ``file_name`` is an internal file hidden from the agent. + + Internal files are the description sidecars and the ``memories.md`` index. + """ + lowered = file_name.lower() + return lowered.endswith(_DESCRIPTION_SUFFIX) or lowered == _MEMORY_INDEX_FILE_NAME + + +def _combine_paths(base_path: str, relative_path: str) -> str: + """Join a working-folder path with a relative path using forward slashes.""" + if not base_path: + return relative_path + if not relative_path: + return base_path + return f"{base_path.rstrip('/')}/{relative_path.lstrip('/')}" + + +@experimental(feature_id=ExperimentalFeature.HARNESS) +class FileMemoryProvider(ContextProvider): + """Context provider that gives an agent session-scoped, file-based memory. + + The provider exposes five tools to the agent via the per-invocation + :class:`~agent_framework.SessionContext`: + + - ``file_memory_save_file`` — Save a memory file with an optional description. + - ``file_memory_read_file`` — Read the content of a memory file by name. + - ``file_memory_delete_file`` — Delete a memory file and its description. + - ``file_memory_list_files`` — List memory files with their descriptions. + - ``file_memory_search_files`` — Search memory file contents with a regex. + + Memories are isolated per session: each session reads and writes under a + working folder derived from its session id. Pass an explicit ``scope`` to + group memories differently (for example, per user id) across sessions. + """ + + _WRITE_LOCKS_BY_LOOP: ClassVar[weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, dict[str, asyncio.Lock]]] = ( + weakref.WeakKeyDictionary() + ) + + def __init__( + self, + store: AgentFileStore, + *, + source_id: str = DEFAULT_FILE_MEMORY_SOURCE_ID, + scope: str | None = None, + instructions: str | None = None, + ) -> None: + """Initialize the file memory provider. + + Args: + store: The file store implementation used for storage operations. + + Keyword Args: + source_id: Unique source ID for the provider. + scope: The namespace that logically groups and isolates memories + (for example, a user ID). Used as the working folder within the + store. When ``None`` (the default), the active session's + ``session_id`` is used, isolating memories per session. + instructions: Optional instruction override. When ``None`` the + default file-memory instructions are used. + """ + super().__init__(source_id) + self.store = store + self.scope = scope + self.instructions = instructions or DEFAULT_FILE_MEMORY_INSTRUCTIONS + + def _resolve_working_folder(self, context: SessionContext) -> str: + """Resolve the working folder for the current invocation. + + Uses the configured ``scope`` when set, otherwise the session id. The + result is normalized as a relative directory path so it cannot escape + the store root. + """ + raw_scope = self.scope or context.session_id or "" + return _normalize_relative_path(raw_scope, is_directory=True) + + def _write_lock(self, working_folder: str) -> asyncio.Lock: + """Return a per-event-loop, per-working-folder write lock. + + Serializes save/delete operations (and their index rebuilds) so the + ``memories.md`` index stays consistent. Locks are keyed by the running + loop so the provider can be shared across loops. + """ + loop = asyncio.get_running_loop() + lock_key = f"{self.source_id}:{working_folder}" + locks_for_loop = self._WRITE_LOCKS_BY_LOOP.get(loop) + if locks_for_loop is None: + locks_for_loop = {} + self._WRITE_LOCKS_BY_LOOP[loop] = locks_for_loop + lock = locks_for_loop.get(lock_key) + if lock is None: + lock = asyncio.Lock() + locks_for_loop[lock_key] = lock + return lock + + async def _rebuild_index(self, working_folder: str) -> None: + """Rebuild the ``memories.md`` index for ``working_folder``. + + Lists the non-internal files, sorts them deterministically, reads any + companion descriptions, and writes a capped markdown summary. + """ + file_names = await self.store.list_files(working_folder) + sorted_files = sorted((name for name in file_names if not _is_internal_file(name)), key=str.lower) + + lines = ["# Memory Index", ""] + for file_name in sorted_files[:_MAX_INDEX_ENTRIES]: + description = await self.store.read_file(_combine_paths(working_folder, _description_file_name(file_name))) + if description and description.strip(): + lines.append(f"- **{file_name}**: {description.strip()}") + else: + lines.append(f"- **{file_name}**") + + index_path = _combine_paths(working_folder, _MEMORY_INDEX_FILE_NAME) + await self.store.write_file(index_path, "\n".join(lines) + "\n") + + async def before_run( + self, + *, + agent: Any, + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + """Inject file-memory tools, instructions, and the memory index.""" + del agent, session, state + + working_folder = self._resolve_working_folder(context) + + if working_folder: + await self.store.create_directory(working_folder) + + @tool(name="file_memory_save_file", approval_mode="never_require") + async def file_memory_save_file(file_name: str, content: str, description: str | None = None) -> str: + """Save a memory file with the given name and content. Overwrites the file if it already exists. Include a description for large files to provide a summary that helps with future discovery.""" # noqa: E501 + try: + normalized = _normalize_relative_path(file_name) + except ValueError as exc: + return f"Could not save file '{file_name}': {exc}" + if _is_internal_file(normalized): + return ( + f"Could not save file '{file_name}': the file name is reserved for internal use. " + "Please choose a different file name." + ) + + path = _combine_paths(working_folder, normalized) + desc_path = _combine_paths(working_folder, _description_file_name(normalized)) + async with self._write_lock(working_folder): + try: + await self.store.write_file(path, content) + if description and description.strip(): + await self.store.write_file(desc_path, description) + else: + await self.store.delete_file(desc_path) + await self._rebuild_index(working_folder) + except OSError as exc: + return f"Could not save file '{file_name}': {exc.strerror or exc}" + if description and description.strip(): + return f"File '{file_name}' saved with description." + return f"File '{file_name}' saved." + + @tool(name="file_memory_read_file", approval_mode="never_require") + async def file_memory_read_file(file_name: str) -> str: + """Read the content of a memory file by name. Returns the file content or a message indicating the file was not found.""" # noqa: E501 + try: + normalized = _normalize_relative_path(file_name) + except ValueError as exc: + return f"Could not read file '{file_name}': {exc}" + try: + content = await self.store.read_file(_combine_paths(working_folder, normalized)) + except OSError as exc: + return f"Could not read file '{file_name}': {exc.strerror or exc}" + return content if content is not None else f"File '{file_name}' not found." + + @tool(name="file_memory_delete_file", approval_mode="never_require") + async def file_memory_delete_file(file_name: str) -> str: + """Delete a memory file by name. Also removes its companion description file if one exists.""" + try: + normalized = _normalize_relative_path(file_name) + except ValueError as exc: + return f"Could not delete file '{file_name}': {exc}" + + path = _combine_paths(working_folder, normalized) + desc_path = _combine_paths(working_folder, _description_file_name(normalized)) + async with self._write_lock(working_folder): + try: + deleted = await self.store.delete_file(path) + await self.store.delete_file(desc_path) + await self._rebuild_index(working_folder) + except OSError as exc: + return f"Could not delete file '{file_name}': {exc.strerror or exc}" + return f"File '{file_name}' deleted." if deleted else f"File '{file_name}' not found." + + @tool(name="file_memory_list_files", approval_mode="never_require") + async def file_memory_list_files() -> list[dict[str, Any]] | str: + """List all memory files with their descriptions (if available). Internal files (description sidecars and the memory index) are not shown.""" # noqa: E501 + try: + file_names = await self.store.list_files(working_folder) + except OSError as exc: + return f"Could not list memory files: {exc.strerror or exc}" + + available = set(file_names) + entries: list[dict[str, Any]] = [] + for file_name in file_names: + if _is_internal_file(file_name): + continue + description: str | None = None + desc_file_name = _description_file_name(file_name) + if desc_file_name in available: + description = await self.store.read_file(_combine_paths(working_folder, desc_file_name)) + entries.append({"file_name": file_name, "description": description}) + return entries + + @tool(name="file_memory_search_files", approval_mode="never_require") + async def file_memory_search_files( + regex_pattern: str, + file_pattern: str | None = None, + ) -> list[dict[str, Any]] | str: + """Search memory file contents using a case-insensitive regular expression. Optionally filter which files to search using a glob pattern (e.g., "*.md", "research*"). Returns matching file names, content snippets, and matching lines with line numbers. The regex_pattern must be 256 characters or fewer.""" # noqa: E501 + pattern = file_pattern if file_pattern and file_pattern.strip() else None + try: + results = await self.store.search_files(working_folder, regex_pattern, pattern, recursive=False) + except ValueError as exc: + return f"Could not search memory files: {exc}" + except OSError as exc: + return f"Could not search memory files: {exc.strerror or exc}" + return [result.to_dict() for result in results if not _is_internal_file(result.file_name)] + + context.extend_instructions(self.source_id, [self.instructions]) + context.extend_tools( + self.source_id, + [ + file_memory_save_file, + file_memory_read_file, + file_memory_delete_file, + file_memory_list_files, + file_memory_search_files, + ], + ) + + index_content = await self.store.read_file(_combine_paths(working_folder, _MEMORY_INDEX_FILE_NAME)) + if index_content and index_content.strip(): + context.extend_messages( + self.source_id, + [ + Message( + role="user", + contents=[ + ( + "The following is your memory index — a list of files you have previously saved. " + "You can read any of these files using the file_memory_read_file tool.\n\n" + f"{index_content}" + ) + ], + ) + ], + ) + + +__all__ = [ + "DEFAULT_FILE_MEMORY_INSTRUCTIONS", + "DEFAULT_FILE_MEMORY_SOURCE_ID", + "FileMemoryProvider", +] diff --git a/python/packages/core/tests/core/test_harness_agent.py b/python/packages/core/tests/core/test_harness_agent.py index 0a280e87ed..05edf52a48 100644 --- a/python/packages/core/tests/core/test_harness_agent.py +++ b/python/packages/core/tests/core/test_harness_agent.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import AsyncIterator, Mapping +from pathlib import Path from typing import Any import pytest @@ -11,6 +12,10 @@ AgentSession, ChatResponse, CompactionProvider, + FileAccessProvider, + FileMemoryProvider, + FileSystemAgentFileStore, + InMemoryAgentFileStore, InMemoryHistoryProvider, Message, SkillsProvider, @@ -49,6 +54,12 @@ async def get_streaming_response( # --- Assembly Tests --- +@pytest.fixture(autouse=True) +def _isolate_cwd(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Run every test in a temp directory so default file stores don't write into the repo.""" + monkeypatch.chdir(tmp_path) + + def test_create_harness_agent_with_defaults() -> None: """create_harness_agent should assemble successfully with default options.""" agent = create_harness_agent( @@ -59,8 +70,9 @@ def test_create_harness_agent_with_defaults() -> None: assert agent.id is not None -def test_create_harness_agent_includes_all_default_providers() -> None: - """Default assembly should include history, compaction, todo, mode (no skills by default).""" +def test_create_harness_agent_includes_all_default_providers(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Default assembly should include history, compaction, todo, mode, file memory, and file access.""" + monkeypatch.chdir(tmp_path) agent = create_harness_agent( client=_FakeChatClient(), # type: ignore[arg-type] max_context_window_tokens=128_000, @@ -73,6 +85,8 @@ def test_create_harness_agent_includes_all_default_providers() -> None: assert CompactionProvider in provider_types assert TodoProvider in provider_types assert AgentModeProvider in provider_types + assert FileMemoryProvider in provider_types + assert FileAccessProvider in provider_types assert SkillsProvider not in provider_types @@ -100,62 +114,67 @@ def test_create_harness_agent_disable_mode() -> None: assert AgentModeProvider not in provider_types -def test_create_harness_agent_disable_memory() -> None: - """disable_memory=True should exclude MemoryContextProvider even when memory_store is provided.""" - from agent_framework import MemoryContextProvider - from agent_framework._harness._memory import MemoryStore - - class _FakeMemoryStore(MemoryStore): - def list_topics(self, session, *, source_id): - return [] - - def get_topic(self, session, *, source_id, topic): - raise NotImplementedError - - def write_topic(self, session, record, *, source_id): - pass - - def delete_topic(self, session, *, source_id, topic): - pass - - def get_index_text(self, session, *, source_id): - return "" - - def get_transcripts_directory(self, session, *, source_id): - return "" - - def read_state(self, session, *, source_id): - return {} +def test_create_harness_agent_disable_file_memory() -> None: + """disable_file_memory=True should exclude the FileMemoryProvider.""" + agent = create_harness_agent( + client=_FakeChatClient(), # type: ignore[arg-type] + max_context_window_tokens=128_000, + max_output_tokens=16_384, + disable_file_memory=True, + disable_file_access=True, + ) + provider_types = [type(p) for p in agent.context_providers] + assert FileMemoryProvider not in provider_types - def rebuild_index(self, session, *, source_id): - pass - def search_transcripts(self, session, *, source_id, query): - return [] +def test_create_harness_agent_disable_file_access() -> None: + """disable_file_access=True should exclude the FileAccessProvider.""" + agent = create_harness_agent( + client=_FakeChatClient(), # type: ignore[arg-type] + max_context_window_tokens=128_000, + max_output_tokens=16_384, + disable_file_memory=True, + disable_file_access=True, + ) + provider_types = [type(p) for p in agent.context_providers] + assert FileAccessProvider not in provider_types - def write_state(self, session, state, *, source_id): - pass - # With memory_store provided and disable_memory=False, MemoryContextProvider should be present. - agent_with_memory = create_harness_agent( +def test_create_harness_agent_uses_custom_file_stores() -> None: + """Custom file stores should be used by the file memory and file access providers.""" + memory_store = InMemoryAgentFileStore() + access_store = InMemoryAgentFileStore() + agent = create_harness_agent( client=_FakeChatClient(), # type: ignore[arg-type] max_context_window_tokens=128_000, max_output_tokens=16_384, - memory_store=_FakeMemoryStore(), + file_memory_store=memory_store, + file_access_store=access_store, ) - provider_types = [type(p) for p in agent_with_memory.context_providers] - assert MemoryContextProvider in provider_types - # With memory_store provided and disable_memory=True, MemoryContextProvider should be absent. - agent_disabled = create_harness_agent( + memory_provider = next(p for p in agent.context_providers if isinstance(p, FileMemoryProvider)) + access_provider = next(p for p in agent.context_providers if isinstance(p, FileAccessProvider)) + assert memory_provider.store is memory_store + assert access_provider.store is access_store + + +def test_create_harness_agent_default_file_stores_are_filesystem( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Without custom stores, the providers default to FileSystemAgentFileStore rooted in cwd.""" + monkeypatch.chdir(tmp_path) + agent = create_harness_agent( client=_FakeChatClient(), # type: ignore[arg-type] max_context_window_tokens=128_000, max_output_tokens=16_384, - memory_store=_FakeMemoryStore(), - disable_memory=True, ) - provider_types = [type(p) for p in agent_disabled.context_providers] - assert MemoryContextProvider not in provider_types + + memory_provider = next(p for p in agent.context_providers if isinstance(p, FileMemoryProvider)) + access_provider = next(p for p in agent.context_providers if isinstance(p, FileAccessProvider)) + assert isinstance(memory_provider.store, FileSystemAgentFileStore) + assert isinstance(access_provider.store, FileSystemAgentFileStore) + assert memory_provider.store.root_path == (tmp_path / "agent-file-memory").resolve() + assert access_provider.store.root_path == (tmp_path / "working").resolve() def test_create_harness_agent_skills_paths_adds_provider() -> None: diff --git a/python/packages/core/tests/core/test_harness_file_memory.py b/python/packages/core/tests/core/test_harness_file_memory.py new file mode 100644 index 0000000000..ac1f7b5df2 --- /dev/null +++ b/python/packages/core/tests/core/test_harness_file_memory.py @@ -0,0 +1,284 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import json + +from agent_framework import ( + AgentSession, + FileMemoryProvider, + InMemoryAgentFileStore, +) +from agent_framework._harness._file_memory import ( + _MEMORY_INDEX_FILE_NAME, + DEFAULT_FILE_MEMORY_INSTRUCTIONS, + DEFAULT_FILE_MEMORY_SOURCE_ID, + _combine_paths, + _description_file_name, + _is_internal_file, +) +from agent_framework._sessions import SessionContext + + +def _tool_by_name(tools: list[object], name: str) -> object: + """Return the tool with the requested name from a prepared tool list.""" + for tool in tools: + if getattr(tool, "name", None) == name: + return tool + raise AssertionError(f"Tool {name!r} was not found.") + + +async def _prepare( + provider: FileMemoryProvider, *, session_id: str = "session-1" +) -> tuple[SessionContext, dict[str, object]]: + """Run ``before_run`` against a fresh session context and return tools by name.""" + session = AgentSession(session_id=session_id) + context = SessionContext(session_id=session_id, input_messages=[]) + await provider.before_run(agent=None, session=session, context=context, state={}) + tools = {getattr(t, "name", None): t for t in context.tools} + return context, tools + + +def test_description_file_name_replaces_extension() -> None: + """The description sidecar replaces a known extension and appends otherwise.""" + assert _description_file_name("notes.md") == "notes_description.md" + assert _description_file_name("data.json") == "data_description.md" + assert _description_file_name("noext") == "noext_description.md" + # Leading-dot files have no stem, so the suffix is appended. + assert _description_file_name(".hidden") == ".hidden_description.md" + + +def test_is_internal_file_detects_sidecars_and_index() -> None: + """Internal files are description sidecars and the memory index, case-insensitively.""" + assert _is_internal_file("notes_description.md") + assert _is_internal_file("NOTES_DESCRIPTION.MD") + assert _is_internal_file(_MEMORY_INDEX_FILE_NAME) + assert _is_internal_file("Memories.md") + assert not _is_internal_file("notes.md") + assert not _is_internal_file("description.md") + + +def test_combine_paths_joins_with_forward_slash() -> None: + """Working-folder paths join with a single forward slash and tolerate empties.""" + assert _combine_paths("session-1", "notes.md") == "session-1/notes.md" + assert _combine_paths("session-1/", "/notes.md") == "session-1/notes.md" + assert _combine_paths("", "notes.md") == "notes.md" + assert _combine_paths("session-1", "") == "session-1" + + +async def test_provider_registers_tools_and_instructions() -> None: + """``before_run`` should register the five tools and the default instructions.""" + provider = FileMemoryProvider(store=InMemoryAgentFileStore()) + context, tools = await _prepare(provider) + + expected = { + "file_memory_save_file", + "file_memory_read_file", + "file_memory_delete_file", + "file_memory_list_files", + "file_memory_search_files", + } + assert set(tools) >= expected + assert all(t.approval_mode == "never_require" for t in context.tools) # type: ignore[attr-defined] + assert any(DEFAULT_FILE_MEMORY_INSTRUCTIONS in chunk for chunk in context.instructions) + + +async def test_provider_uses_default_source_id() -> None: + """The default source id should match the public constant.""" + provider = FileMemoryProvider(store=InMemoryAgentFileStore()) + assert provider.source_id == DEFAULT_FILE_MEMORY_SOURCE_ID + + +async def test_save_read_delete_round_trip() -> None: + """The tools should drive a save/read/list/delete flow with index maintenance.""" + store = InMemoryAgentFileStore() + provider = FileMemoryProvider(store=store) + _, tools = await _prepare(provider) + + save = tools["file_memory_save_file"] + read = tools["file_memory_read_file"] + delete = tools["file_memory_delete_file"] + list_files = tools["file_memory_list_files"] + + saved = await save.invoke(arguments={"file_name": "plan.md", "content": "step 1"}) + assert "plan.md" in saved[0].text and "saved" in saved[0].text + + read_back = await read.invoke(arguments={"file_name": "plan.md"}) + assert read_back[0].text == "step 1" + + # Overwrite is allowed (no overwrite flag needed). + await save.invoke(arguments={"file_name": "plan.md", "content": "step 2"}) + assert (await read.invoke(arguments={"file_name": "plan.md"}))[0].text == "step 2" + + listed = json.loads((await list_files.invoke())[0].text) + assert listed == [{"file_name": "plan.md", "description": None}] + + deleted = await delete.invoke(arguments={"file_name": "plan.md"}) + assert "deleted" in deleted[0].text + missing = await read.invoke(arguments={"file_name": "plan.md"}) + assert "not found" in missing[0].text + missing_delete = await delete.invoke(arguments={"file_name": "plan.md"}) + assert "not found" in missing_delete[0].text + + +async def test_description_sidecar_is_written_and_listed() -> None: + """Saving with a description writes a sidecar and surfaces it in listings.""" + store = InMemoryAgentFileStore() + provider = FileMemoryProvider(store=store, scope="user-1") + _, tools = await _prepare(provider) + save = tools["file_memory_save_file"] + list_files = tools["file_memory_list_files"] + + result = await save.invoke( + arguments={"file_name": "arch.md", "content": "big content", "description": "system architecture"} + ) + assert "with description" in result[0].text + + sidecar = await store.read_file(_combine_paths("user-1", "arch_description.md")) + assert sidecar == "system architecture" + + listed = json.loads((await list_files.invoke())[0].text) + assert listed == [{"file_name": "arch.md", "description": "system architecture"}] + + # Re-saving without a description removes the sidecar. + await save.invoke(arguments={"file_name": "arch.md", "content": "big content"}) + assert await store.read_file(_combine_paths("user-1", "arch_description.md")) is None + listed_again = json.loads((await list_files.invoke())[0].text) + assert listed_again == [{"file_name": "arch.md", "description": None}] + + +async def test_delete_removes_sidecar() -> None: + """Deleting a file also removes its companion description sidecar.""" + store = InMemoryAgentFileStore() + provider = FileMemoryProvider(store=store, scope="user-1") + _, tools = await _prepare(provider) + + await tools["file_memory_save_file"].invoke( + arguments={"file_name": "arch.md", "content": "x", "description": "desc"} + ) + assert await store.read_file(_combine_paths("user-1", "arch_description.md")) == "desc" + + await tools["file_memory_delete_file"].invoke(arguments={"file_name": "arch.md"}) + assert await store.read_file(_combine_paths("user-1", "arch_description.md")) is None + + +async def test_index_is_rebuilt_and_injected_on_next_run() -> None: + """Saved memories should be summarized in the index and injected as a context message.""" + store = InMemoryAgentFileStore() + provider = FileMemoryProvider(store=store, scope="user-1") + _, tools = await _prepare(provider) + + await tools["file_memory_save_file"].invoke( + arguments={"file_name": "arch.md", "content": "x", "description": "architecture"} + ) + await tools["file_memory_save_file"].invoke(arguments={"file_name": "todo.md", "content": "y"}) + + index = await store.read_file(_combine_paths("user-1", _MEMORY_INDEX_FILE_NAME)) + assert index is not None + assert "# Memory Index" in index + assert "- **arch.md**: architecture" in index + assert "- **todo.md**" in index + + # A subsequent run injects the index as a user context message. + session = AgentSession(session_id="ignored") + context = SessionContext(session_id="ignored", input_messages=[]) + await provider.before_run(agent=None, session=session, context=context, state={}) + injected = context.context_messages.get(DEFAULT_FILE_MEMORY_SOURCE_ID, []) + assert len(injected) == 1 + assert injected[0].role == "user" + assert "arch.md" in injected[0].text + + +async def test_list_and_search_hide_internal_files() -> None: + """Listing and search must hide description sidecars and the memory index.""" + store = InMemoryAgentFileStore() + provider = FileMemoryProvider(store=store, scope="user-1") + _, tools = await _prepare(provider) + + await tools["file_memory_save_file"].invoke( + arguments={"file_name": "arch.md", "content": "architecture text", "description": "architecture"} + ) + + listed = json.loads((await tools["file_memory_list_files"].invoke())[0].text) + assert [e["file_name"] for e in listed] == ["arch.md"] + + # The description text lives in an internal sidecar, so a regex matching it + # must not return the sidecar (only the memory file itself). + found = json.loads( + (await tools["file_memory_search_files"].invoke(arguments={"regex_pattern": "architecture"}))[0].text + ) + names = [e["file_name"] for e in found] + assert "arch.md" in names + assert all(not _is_internal_file(name) for name in names) + + +async def test_scope_isolates_memories_across_sessions() -> None: + """Two sessions sharing a store should not see each other's memories by default.""" + store = InMemoryAgentFileStore() + provider = FileMemoryProvider(store=store) + + _, tools_a = await _prepare(provider, session_id="session-a") + await tools_a["file_memory_save_file"].invoke(arguments={"file_name": "a.md", "content": "from a"}) + + _, tools_b = await _prepare(provider, session_id="session-b") + listed_b = json.loads((await tools_b["file_memory_list_files"].invoke())[0].text) + assert listed_b == [] + + # The original session still sees its own memory. + _, tools_a2 = await _prepare(provider, session_id="session-a") + listed_a = json.loads((await tools_a2["file_memory_list_files"].invoke())[0].text) + assert [e["file_name"] for e in listed_a] == ["a.md"] + + +async def test_explicit_scope_shares_memories_across_sessions() -> None: + """An explicit scope groups memories regardless of session id.""" + store = InMemoryAgentFileStore() + provider = FileMemoryProvider(store=store, scope="shared") + + _, tools_a = await _prepare(provider, session_id="session-a") + await tools_a["file_memory_save_file"].invoke(arguments={"file_name": "shared.md", "content": "v"}) + + _, tools_b = await _prepare(provider, session_id="session-b") + listed_b = json.loads((await tools_b["file_memory_list_files"].invoke())[0].text) + assert [e["file_name"] for e in listed_b] == ["shared.md"] + + +async def test_save_rejects_reserved_internal_names() -> None: + """Saving a file whose name collides with an internal file must be rejected.""" + provider = FileMemoryProvider(store=InMemoryAgentFileStore()) + _, tools = await _prepare(provider) + save = tools["file_memory_save_file"] + + reserved = await save.invoke(arguments={"file_name": _MEMORY_INDEX_FILE_NAME, "content": "x"}) + assert "reserved" in reserved[0].text + + sidecar = await save.invoke(arguments={"file_name": "notes_description.md", "content": "x"}) + assert "reserved" in sidecar[0].text + + +async def test_tools_surface_path_validation_errors() -> None: + """Path traversal and rooted paths should be reported as tool messages, not raised.""" + provider = FileMemoryProvider(store=InMemoryAgentFileStore()) + _, tools = await _prepare(provider) + + bad_save = await tools["file_memory_save_file"].invoke(arguments={"file_name": "../escape.md", "content": "x"}) + assert "Could not save" in bad_save[0].text + + bad_read = await tools["file_memory_read_file"].invoke(arguments={"file_name": "/rooted.md"}) + assert "Could not read" in bad_read[0].text + + bad_delete = await tools["file_memory_delete_file"].invoke(arguments={"file_name": "../escape.md"}) + assert "Could not delete" in bad_delete[0].text + + +async def test_provider_accepts_custom_instructions() -> None: + """Custom instructions override the default banner.""" + provider = FileMemoryProvider(store=InMemoryAgentFileStore(), instructions="custom memory banner") + context, _ = await _prepare(provider) + assert "custom memory banner" in context.instructions + assert all(DEFAULT_FILE_MEMORY_INSTRUCTIONS not in chunk for chunk in context.instructions) + + +def test_file_memory_provider_is_experimental() -> None: + """The provider should be marked experimental under the harness feature.""" + assert getattr(FileMemoryProvider, "__feature_stage__", None) == "experimental" diff --git a/python/samples/02-agents/harness/console/formatters.py b/python/samples/02-agents/harness/console/formatters.py index 47327c6637..07a1d2e3ec 100644 --- a/python/samples/02-agents/harness/console/formatters.py +++ b/python/samples/02-agents/harness/console/formatters.py @@ -182,6 +182,8 @@ def format_detail(self, call: Content) -> str | None: args_dict = json.loads(call.arguments) except (json.JSONDecodeError, TypeError): return None + if not isinstance(args_dict, dict): + return None elif isinstance(call.arguments, dict): args_dict = call.arguments else: @@ -314,46 +316,46 @@ def _format_id_list(self, call: Content, param_name: str, verb: str) -> str | No class ModeToolFormatter(ToolCallFormatter): - """Formats AgentMode_* tool calls, showing the target mode for Set operations.""" + """Formats mode_* tool calls, showing the target mode for set operations.""" def can_format(self, call: Content) -> bool: - """Match AgentMode_* tool calls.""" - return call.name is not None and call.name.startswith("AgentMode_") + """Match mode_* tool calls.""" + return call.name is not None and call.name.startswith("mode_") def format_detail(self, call: Content) -> str | None: - """Format based on the specific AgentMode operation.""" - if call.name == "AgentMode_Set": + """Format based on the specific mode operation.""" + if call.name == "mode_set": value = get_argument_value(call, "mode") return f"({value})" if value else None return None class BackgroundAgentToolFormatter(ToolCallFormatter): - """Formats BackgroundAgents_* tool calls with human-readable details + """Formats background_agents_* tool calls with human-readable details for task start, continue, wait, and result retrieval operations. """ def can_format(self, call: Content) -> bool: - """Match BackgroundAgents_* tool calls.""" - return call.name is not None and call.name.startswith("BackgroundAgents_") + """Match background_agents_* tool calls.""" + return call.name is not None and call.name.startswith("background_agents_") def format_detail(self, call: Content) -> str | None: - """Format based on the specific BackgroundAgents operation.""" - if call.name == "BackgroundAgents_StartTask": + """Format based on the specific background_agents operation.""" + if call.name == "background_agents_start_task": return self._format_start_background_task(call) - if call.name == "BackgroundAgents_WaitForFirstCompletion": - return self._format_id_list(call, "taskIds", "Wait for") - if call.name == "BackgroundAgents_GetTaskResults": - return self._format_single_id(call, "taskId") - if call.name == "BackgroundAgents_ContinueTask": + if call.name == "background_agents_wait_for_first_completion": + return self._format_id_list(call, "task_ids", "Wait for") + if call.name == "background_agents_get_task_results": + return self._format_single_id(call, "task_id") + if call.name == "background_agents_continue_task": return self._format_continue_task(call) - if call.name == "BackgroundAgents_ClearCompletedTask": - return self._format_single_id(call, "taskId") + if call.name == "background_agents_clear_completed_task": + return self._format_single_id(call, "task_id") return None def _format_start_background_task(self, call: Content) -> str | None: - """Format StartTask with agent name and description.""" - agent_name = get_argument_value(call, "agentName") + """Format start_task with agent name and description.""" + agent_name = get_argument_value(call, "agent_name") description = get_argument_value(call, "description") if agent_name is None and description is None: @@ -392,8 +394,8 @@ def _format_single_id(self, call: Content, param_name: str) -> str | None: return None def _format_continue_task(self, call: Content) -> str | None: - """Format ContinueTask with task ID and optional text.""" - task_id = get_argument_value(call, "taskId") + """Format continue_task with task ID and optional text.""" + task_id = get_argument_value(call, "task_id") text = get_argument_value(call, "text") if not isinstance(task_id, int): @@ -410,28 +412,28 @@ def _format_continue_task(self, call: Content) -> str | None: class FileMemoryToolFormatter(ToolCallFormatter): - """Formats FileMemory_* tool calls, showing file names and search patterns + """Formats file_memory_* tool calls, showing file names and search patterns with tree-view corners for save operations. """ def can_format(self, call: Content) -> bool: - """Match FileMemory_* tool calls.""" - return call.name is not None and call.name.startswith("FileMemory_") + """Match file_memory_* tool calls.""" + return call.name is not None and call.name.startswith("file_memory_") def format_detail(self, call: Content) -> str | None: - """Format based on the specific FileMemory operation.""" - if call.name == "FileMemory_SaveFile": + """Format based on the specific file_memory operation.""" + if call.name == "file_memory_save_file": return self._format_save_file(call) - if call.name in ("FileMemory_ReadFile", "FileMemory_DeleteFile"): - value = get_argument_value(call, "fileName") + if call.name in ("file_memory_read_file", "file_memory_delete_file"): + value = get_argument_value(call, "file_name") return f"({value})" if value else None - if call.name == "FileMemory_SearchFiles": + if call.name == "file_memory_search_files": return self._format_search_files(call) return None def _format_save_file(self, call: Content) -> str | None: - """Format SaveFile with file name and description indicator.""" - file_name = get_argument_value(call, "fileName") + """Format save_file with file name and description indicator.""" + file_name = get_argument_value(call, "file_name") description = get_argument_value(call, "description") if not file_name: @@ -442,9 +444,9 @@ def _format_save_file(self, call: Content) -> str | None: return f"\n └─ {file_name}" def _format_search_files(self, call: Content) -> str | None: - """Format SearchFiles with regex pattern and optional file pattern.""" - pattern = get_argument_value(call, "regexPattern") - file_pattern = get_argument_value(call, "filePattern") + """Format search_files with regex pattern and optional file pattern.""" + pattern = get_argument_value(call, "regex_pattern") + file_pattern = get_argument_value(call, "file_pattern") if not pattern: return None diff --git a/python/samples/02-agents/harness/harness_data_processing.py b/python/samples/02-agents/harness/harness_data_processing.py new file mode 100644 index 0000000000..14c3d0fddf --- /dev/null +++ b/python/samples/02-agents/harness/harness_data_processing.py @@ -0,0 +1,125 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework", +# "textual>=6.2.1", +# "rich>=13.7.1", +# "azure-identity", +# "python-dotenv", +# ] +# /// +# Run with any PEP 723 compatible runner, e.g.: +# uv run samples/02-agents/harness/harness_data_processing.py + +# Copyright (c) Microsoft. All rights reserved. + +"""Harness Data Processing Assistant with Console UI. + +Demonstrates ``create_harness_agent`` configured with the default +``FileAccessProvider`` to give an agent access to a folder of CSV data files. +The agent can read, analyze, and extract information from the data, then write +results back as new files via the ``file_access_*`` tools. + +The sample includes a pre-populated ``working/`` folder with sales transaction +data. The ``file_access_store`` is set explicitly to that folder (resolved +relative to this script) so it works regardless of the current working +directory. Ask the agent to analyze the data, produce summaries, or create new +output files. For example:: + + Please process the sales.csv file by first filtering it to only North region + sales, and then calculating the sum of sales by person. I'd like to write the + results of the processing to north_region_totals.csv + +Unused harness features (file memory, todos, plan/execute mode, web search) are +disabled to keep this a simple, conversational data-interaction sample. + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint URL + FOUNDRY_MODEL — Model deployment name + +Authentication: + Run ``az login`` before running this sample. +""" + +import asyncio +from pathlib import Path + +from agent_framework import FileSystemAgentFileStore, create_harness_agent +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential +from console import build_default_observers, run_agent_async +from dotenv import load_dotenv + +DATA_ANALYST_INSTRUCTIONS = """\ +You are a data analyst assistant. You have access to a folder of data files via the file_access_* tools. + +## Getting started +- Start by listing available files with file_access_list_files to see what data is available. +- Read the files to understand their structure and contents. + +## Working with data +- When asked to analyze data, read the relevant files first, then perform the analysis. +- Show your analysis clearly with tables, summaries, and key insights. +- When calculations are needed, work through them step by step and show your reasoning. + +## Writing output +- When asked to produce output files (e.g., reports, summaries, filtered data), use file_access_save_file to write them. +- Use appropriate file formats: CSV for tabular data, Markdown for reports. +- Confirm what you wrote and where. + +## Important +- Never modify or delete the original input data files unless explicitly asked to do so. +- If asked about data you haven't read yet, read it first before answering. +- Always explain your reasoning and thought process as you work through tasks. +- Always explain what you learned and what you are going to do next between tool calls, so the user can + follow along with your thought process. +""" + +MAX_CONTEXT_WINDOW_TOKENS = 1_050_000 +MAX_OUTPUT_TOKENS = 128_000 + + +async def main() -> None: + load_dotenv() + + # Resolve the working/ folder bundled alongside this script. The agent reads + # the seed data from here and writes any output files back into it. + working_dir = Path(__file__).parent / "working" + + # Create the chat client. + # For authentication, run `az login` in terminal or replace AzureCliCredential + # with your preferred authentication option. + client = FoundryChatClient(credential=AzureCliCredential()) + + # Create a harness agent with data-analyst instructions. The FileAccessProvider + # is explicitly pointed at the sample's working/ folder so it works regardless + # of the current working directory. Unused features are disabled. + agent = create_harness_agent( + client=client, + max_context_window_tokens=MAX_CONTEXT_WINDOW_TOKENS, + max_output_tokens=MAX_OUTPUT_TOKENS, + name="DataAnalyst", + description="A data analyst assistant that reads, analyzes, and processes data files.", + agent_instructions=DATA_ANALYST_INSTRUCTIONS, + file_access_store=FileSystemAgentFileStore(working_dir), + disable_file_memory=True, + disable_todo=True, + disable_mode=True, + disable_web_search=True, + ) + + # Run the harness console. This sample has no plan/execute mode, so it uses + # the default observers (no planning observer) and no initial mode. + await run_agent_async( + agent, + session=agent.create_session(), + observers=build_default_observers(), + title="📊 Data Analyst", + placeholder="Ask me to analyze the data files, produce summaries, or create output files...", + max_context_window_tokens=MAX_CONTEXT_WINDOW_TOKENS, + max_output_tokens=MAX_OUTPUT_TOKENS, + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/harness/working/sales.csv b/python/samples/02-agents/harness/working/sales.csv new file mode 100644 index 0000000000..50a2369942 --- /dev/null +++ b/python/samples/02-agents/harness/working/sales.csv @@ -0,0 +1,50 @@ +date,product,category,quantity,unit_price,region,salesperson +2025-01-03,Laptop Pro 15,Electronics,2,1299.99,North,Alice +2025-01-05,Ergonomic Chair,Furniture,5,349.50,South,Bob +2025-01-07,Wireless Mouse,Electronics,12,24.99,North,Alice +2025-01-08,Standing Desk,Furniture,1,599.00,West,Carol +2025-01-10,USB-C Hub,Electronics,8,45.99,North,David +2025-01-12,Monitor 27in,Electronics,3,429.00,South,Bob +2025-01-14,Desk Lamp,Furniture,6,79.95,West,Carol +2025-01-15,Keyboard Mech,Electronics,4,149.99,North,Alice +2025-01-17,Filing Cabinet,Furniture,2,189.00,South,David +2025-01-20,Webcam HD,Electronics,10,89.99,West,Bob +2025-01-22,Laptop Pro 15,Electronics,1,1299.99,South,Carol +2025-01-24,Ergonomic Chair,Furniture,3,349.50,North,Alice +2025-01-25,Notebook Pack,Stationery,20,12.99,South,David +2025-01-27,Wireless Mouse,Electronics,15,24.99,West,Carol +2025-01-28,Whiteboard,Stationery,4,129.00,North,Bob +2025-01-30,Standing Desk,Furniture,2,599.00,South,Alice +2025-02-02,USB-C Hub,Electronics,6,45.99,West,David +2025-02-04,Monitor 27in,Electronics,2,429.00,North,Carol +2025-02-05,Desk Lamp,Furniture,8,79.95,South,Bob +2025-02-07,Keyboard Mech,Electronics,5,149.99,West,Alice +2025-02-09,Filing Cabinet,Furniture,1,189.00,North,David +2025-02-11,Webcam HD,Electronics,7,89.99,South,Carol +2025-02-13,Laptop Pro 15,Electronics,3,1299.99,West,Bob +2025-02-15,Notebook Pack,Stationery,30,12.99,North,Alice +2025-02-17,Ergonomic Chair,Furniture,4,349.50,South,David +2025-02-19,Wireless Mouse,Electronics,20,24.99,North,Carol +2025-02-20,Whiteboard,Stationery,2,129.00,West,Bob +2025-02-22,Standing Desk,Furniture,1,599.00,North,Alice +2025-02-24,USB-C Hub,Electronics,10,45.99,South,David +2025-02-26,Monitor 27in,Electronics,4,429.00,West,Carol +2025-02-28,Desk Lamp,Furniture,3,79.95,North,Bob +2025-03-02,Keyboard Mech,Electronics,6,149.99,South,Alice +2025-03-04,Filing Cabinet,Furniture,3,189.00,West,David +2025-03-06,Webcam HD,Electronics,9,89.99,North,Carol +2025-03-08,Laptop Pro 15,Electronics,2,1299.99,South,Bob +2025-03-10,Notebook Pack,Stationery,25,12.99,West,Alice +2025-03-12,Ergonomic Chair,Furniture,6,349.50,North,David +2025-03-14,Wireless Mouse,Electronics,18,24.99,South,Carol +2025-03-15,Whiteboard,Stationery,5,129.00,North,Bob +2025-03-17,Standing Desk,Furniture,3,599.00,West,Alice +2025-03-19,USB-C Hub,Electronics,7,45.99,North,David +2025-03-21,Monitor 27in,Electronics,5,429.00,South,Carol +2025-03-23,Desk Lamp,Furniture,4,79.95,West,Bob +2025-03-25,Keyboard Mech,Electronics,3,149.99,North,Alice +2025-03-27,Filing Cabinet,Furniture,2,189.00,South,David +2025-03-28,Webcam HD,Electronics,11,89.99,West,Carol +2025-03-29,Laptop Pro 15,Electronics,1,1299.99,North,Bob +2025-03-30,Notebook Pack,Stationery,15,12.99,South,Alice +2025-03-31,Ergonomic Chair,Furniture,2,349.50,West,David From 292c0a1e8d7bfcd1800be717e375af595ea8939a Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:44:48 +0000 Subject: [PATCH 2/6] Address PR comments --- .../agent_framework/_harness/_file_access.py | 9 +- .../agent_framework/_harness/_file_memory.py | 41 +++++++- .../core/tests/core/test_harness_agent.py | 10 +- .../tests/core/test_harness_file_access.py | 7 +- .../tests/core/test_harness_file_memory.py | 98 +++++++++++++++++++ 5 files changed, 155 insertions(+), 10 deletions(-) diff --git a/python/packages/core/agent_framework/_harness/_file_access.py b/python/packages/core/agent_framework/_harness/_file_access.py index 98024b2cdf..938da2a81f 100644 --- a/python/packages/core/agent_framework/_harness/_file_access.py +++ b/python/packages/core/agent_framework/_harness/_file_access.py @@ -82,15 +82,18 @@ def _compile_search_regex(pattern: str) -> re.Pattern[str]: """Compile a case-insensitive search regex, enforcing the length cap. Raises: - ValueError: When ``pattern`` exceeds ``_MAX_SEARCH_PATTERN_LENGTH`` characters. - re.error: When ``pattern`` is not a valid regular expression. + ValueError: When ``pattern`` exceeds ``_MAX_SEARCH_PATTERN_LENGTH`` + characters, or when ``pattern`` is not a valid regular expression. """ if len(pattern) > _MAX_SEARCH_PATTERN_LENGTH: raise ValueError( f"Regex pattern is too long ({len(pattern)} characters). " f"Maximum supported length is {_MAX_SEARCH_PATTERN_LENGTH} characters." ) - return re.compile(pattern, flags=re.IGNORECASE) + try: + return re.compile(pattern, flags=re.IGNORECASE) + except re.error as exc: + raise ValueError(f"Invalid regular expression: {exc}") from exc async def _run_search_with_timeout( diff --git a/python/packages/core/agent_framework/_harness/_file_memory.py b/python/packages/core/agent_framework/_harness/_file_memory.py index e8d89bfdcd..1a427f911f 100644 --- a/python/packages/core/agent_framework/_harness/_file_memory.py +++ b/python/packages/core/agent_framework/_harness/_file_memory.py @@ -33,6 +33,7 @@ from __future__ import annotations import asyncio +import logging import weakref from typing import Any, ClassVar @@ -42,6 +43,8 @@ from .._types import Message from ._file_access import AgentFileStore, _normalize_relative_path # pyright: ignore[reportPrivateUsage] +logger = logging.getLogger(__name__) + DEFAULT_FILE_MEMORY_SOURCE_ID = "file_memory" DEFAULT_FILE_MEMORY_INSTRUCTIONS = ( @@ -95,6 +98,20 @@ def _combine_paths(base_path: str, relative_path: str) -> str: return f"{base_path.rstrip('/')}/{relative_path.lstrip('/')}" +def _is_nested_path(normalized_file_name: str) -> bool: + """Return whether a normalized file name points into a subdirectory. + + File memory is a flat, session-scoped space: every discovery surface + (the ``memories.md`` index, ``file_memory_list_files``, and non-recursive + ``file_memory_search_files``) only enumerates direct children of the + working folder. A nested name such as ``"notes/plan.md"`` would therefore + be saved but never surface again, so such names are rejected up front. + ``_normalize_relative_path`` already converts backslashes to forward + slashes, so checking for ``/`` covers both separators. + """ + return "/" in normalized_file_name + + @experimental(feature_id=ExperimentalFeature.HARNESS) class FileMemoryProvider(ContextProvider): """Context provider that gives an agent session-scoped, file-based memory. @@ -216,6 +233,11 @@ async def file_memory_save_file(file_name: str, content: str, description: str | normalized = _normalize_relative_path(file_name) except ValueError as exc: return f"Could not save file '{file_name}': {exc}" + if _is_nested_path(normalized): + return ( + f"Could not save file '{file_name}': memory files must not be saved into a " + "subdirectory. Please choose a flat file name without path separators." + ) if _is_internal_file(normalized): return ( f"Could not save file '{file_name}': the file name is reserved for internal use. " @@ -232,6 +254,8 @@ async def file_memory_save_file(file_name: str, content: str, description: str | else: await self.store.delete_file(desc_path) await self._rebuild_index(working_folder) + except ValueError as exc: + return f"Could not save file '{file_name}': {exc}" except OSError as exc: return f"Could not save file '{file_name}': {exc.strerror or exc}" if description and description.strip(): @@ -245,8 +269,12 @@ async def file_memory_read_file(file_name: str) -> str: normalized = _normalize_relative_path(file_name) except ValueError as exc: return f"Could not read file '{file_name}': {exc}" + if _is_nested_path(normalized): + return f"File '{file_name}' not found." try: content = await self.store.read_file(_combine_paths(working_folder, normalized)) + except ValueError as exc: + return f"Could not read file '{file_name}': {exc}" except OSError as exc: return f"Could not read file '{file_name}': {exc.strerror or exc}" return content if content is not None else f"File '{file_name}' not found." @@ -258,6 +286,8 @@ async def file_memory_delete_file(file_name: str) -> str: normalized = _normalize_relative_path(file_name) except ValueError as exc: return f"Could not delete file '{file_name}': {exc}" + if _is_nested_path(normalized): + return f"File '{file_name}' not found." path = _combine_paths(working_folder, normalized) desc_path = _combine_paths(working_folder, _description_file_name(normalized)) @@ -266,6 +296,8 @@ async def file_memory_delete_file(file_name: str) -> str: deleted = await self.store.delete_file(path) await self.store.delete_file(desc_path) await self._rebuild_index(working_folder) + except ValueError as exc: + return f"Could not delete file '{file_name}': {exc}" except OSError as exc: return f"Could not delete file '{file_name}': {exc.strerror or exc}" return f"File '{file_name}' deleted." if deleted else f"File '{file_name}' not found." @@ -317,7 +349,14 @@ async def file_memory_search_files( ], ) - index_content = await self.store.read_file(_combine_paths(working_folder, _MEMORY_INDEX_FILE_NAME)) + try: + index_content = await self.store.read_file(_combine_paths(working_folder, _MEMORY_INDEX_FILE_NAME)) + except (OSError, ValueError) as exc: + # A corrupt/unavailable index (e.g. non-UTF8 bytes on disk or a store + # error) must not block the run. Skip index injection for this run; it + # self-heals on the next successful save/delete that rebuilds the index. + logger.warning("Could not read memory index; skipping index injection: %s", exc) + index_content = None if index_content and index_content.strip(): context.extend_messages( self.source_id, diff --git a/python/packages/core/tests/core/test_harness_agent.py b/python/packages/core/tests/core/test_harness_agent.py index f23d5e8fe5..eaf58c53d7 100644 --- a/python/packages/core/tests/core/test_harness_agent.py +++ b/python/packages/core/tests/core/test_harness_agent.py @@ -115,29 +115,31 @@ def test_create_harness_agent_disable_mode() -> None: def test_create_harness_agent_disable_file_memory() -> None: - """disable_file_memory=True should exclude the FileMemoryProvider.""" + """disable_file_memory=True should exclude only the FileMemoryProvider.""" agent = create_harness_agent( client=_FakeChatClient(), # type: ignore[arg-type] max_context_window_tokens=128_000, max_output_tokens=16_384, disable_file_memory=True, - disable_file_access=True, ) provider_types = [type(p) for p in agent.context_providers] assert FileMemoryProvider not in provider_types + # The file access provider should remain active. + assert FileAccessProvider in provider_types def test_create_harness_agent_disable_file_access() -> None: - """disable_file_access=True should exclude the FileAccessProvider.""" + """disable_file_access=True should exclude only the FileAccessProvider.""" agent = create_harness_agent( client=_FakeChatClient(), # type: ignore[arg-type] max_context_window_tokens=128_000, max_output_tokens=16_384, - disable_file_memory=True, disable_file_access=True, ) provider_types = [type(p) for p in agent.context_providers] assert FileAccessProvider not in provider_types + # The file memory provider should remain active. + assert FileMemoryProvider in provider_types def test_create_harness_agent_uses_custom_file_stores() -> None: diff --git a/python/packages/core/tests/core/test_harness_file_access.py b/python/packages/core/tests/core/test_harness_file_access.py index a873d7c93d..2c74d9f962 100644 --- a/python/packages/core/tests/core/test_harness_file_access.py +++ b/python/packages/core/tests/core/test_harness_file_access.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import re import time from pathlib import Path @@ -226,7 +225,7 @@ async def test_in_memory_store_search_rejects_invalid_and_oversize_regex() -> No store = InMemoryAgentFileStore() await store.write_file("a.md", "hello") - with pytest.raises(re.error): + with pytest.raises(ValueError, match="Invalid regular expression"): await store.search_files("", "[unclosed") with pytest.raises(ValueError, match="too long"): @@ -769,6 +768,10 @@ async def test_file_access_tool_wrappers_surface_value_error_as_message( searched = await search_files.invoke(arguments={"regex_pattern": too_long}) assert "Could not search files" in searched[0].text + # An invalid regex should also be surfaced as text rather than raised. + invalid = await search_files.invoke(arguments={"regex_pattern": "[unclosed"}) + assert "Could not search files" in invalid[0].text + async def test_file_access_tool_read_file_wrapper_surfaces_non_utf8( tmp_path: Path, chat_client_base: SupportsChatGetResponse diff --git a/python/packages/core/tests/core/test_harness_file_memory.py b/python/packages/core/tests/core/test_harness_file_memory.py index ac1f7b5df2..5814d7211f 100644 --- a/python/packages/core/tests/core/test_harness_file_memory.py +++ b/python/packages/core/tests/core/test_harness_file_memory.py @@ -10,6 +10,7 @@ InMemoryAgentFileStore, ) from agent_framework._harness._file_memory import ( + _MAX_INDEX_ENTRIES, _MEMORY_INDEX_FILE_NAME, DEFAULT_FILE_MEMORY_INSTRUCTIONS, DEFAULT_FILE_MEMORY_SOURCE_ID, @@ -282,3 +283,100 @@ async def test_provider_accepts_custom_instructions() -> None: def test_file_memory_provider_is_experimental() -> None: """The provider should be marked experimental under the harness feature.""" assert getattr(FileMemoryProvider, "__feature_stage__", None) == "experimental" + + +async def test_tools_reject_nested_paths() -> None: + """Memory files must stay flat; nested names are rejected/undiscoverable.""" + store = InMemoryAgentFileStore() + provider = FileMemoryProvider(store=store) + _, tools = await _prepare(provider) + + saved = await tools["file_memory_save_file"].invoke(arguments={"file_name": "notes/plan.md", "content": "x"}) + assert "subdirectory" in saved[0].text + # Nothing should have been written for the nested name. + assert await store.list_files("") == [] + + # Backslash separators are normalized to "/" and rejected the same way. + saved_backslash = await tools["file_memory_save_file"].invoke( + arguments={"file_name": "notes\\plan.md", "content": "x"} + ) + assert "subdirectory" in saved_backslash[0].text + + # Reading/deleting a nested name reports a clean "not found" message. + read_back = await tools["file_memory_read_file"].invoke(arguments={"file_name": "notes/plan.md"}) + assert "not found" in read_back[0].text + deleted = await tools["file_memory_delete_file"].invoke(arguments={"file_name": "notes/plan.md"}) + assert "not found" in deleted[0].text + + +async def test_index_caps_entries_at_max() -> None: + """The rebuilt ``memories.md`` index lists at most ``_MAX_INDEX_ENTRIES`` files.""" + store = InMemoryAgentFileStore() + provider = FileMemoryProvider(store=store, scope="user-1") + _, tools = await _prepare(provider) + save = tools["file_memory_save_file"] + + total = _MAX_INDEX_ENTRIES + 5 + for i in range(total): + await save.invoke(arguments={"file_name": f"memory-{i:03d}.md", "content": "x"}) + + index = await store.read_file(_combine_paths("user-1", _MEMORY_INDEX_FILE_NAME)) + assert index is not None + entry_lines = [line for line in index.splitlines() if line.startswith("- ")] + assert len(entry_lines) == _MAX_INDEX_ENTRIES + + +async def test_tools_surface_store_value_errors() -> None: + """``ValueError`` raised by the store is returned as a tool message, not raised.""" + + class _ValueErrorStore(InMemoryAgentFileStore): + async def write_file(self, path: str, content: str, *, overwrite: bool = True) -> None: + raise ValueError("boom-write") + + async def read_file(self, path: str) -> str | None: + raise ValueError("boom-read") + + async def delete_file(self, path: str) -> bool: + raise ValueError("boom-delete") + + provider = FileMemoryProvider(store=_ValueErrorStore()) + _, tools = await _prepare(provider) + + saved = await tools["file_memory_save_file"].invoke(arguments={"file_name": "plan.md", "content": "x"}) + assert "Could not save" in saved[0].text and "boom-write" in saved[0].text + + read_back = await tools["file_memory_read_file"].invoke(arguments={"file_name": "plan.md"}) + assert "Could not read" in read_back[0].text and "boom-read" in read_back[0].text + + deleted = await tools["file_memory_delete_file"].invoke(arguments={"file_name": "plan.md"}) + assert "Could not delete" in deleted[0].text and "boom-delete" in deleted[0].text + + +async def test_before_run_skips_injection_when_index_unreadable() -> None: + """A failing index read must not crash the run; injection is simply skipped.""" + + class _UnreadableIndexStore(InMemoryAgentFileStore): + async def read_file(self, path: str) -> str | None: + if path.endswith(_MEMORY_INDEX_FILE_NAME): + raise ValueError("corrupt index") + return await super().read_file(path) + + store = _UnreadableIndexStore() + # Seed an index so before_run attempts to read it. + await store.write_file(_combine_paths("user-1", _MEMORY_INDEX_FILE_NAME), "# Memory Index\n") + provider = FileMemoryProvider(store=store, scope="user-1") + + session = AgentSession(session_id="s-1") + context = SessionContext(session_id="s-1", input_messages=[]) + # Should not raise despite the unreadable index. + await provider.before_run(agent=None, session=session, context=context, state={}) + assert context.context_messages.get(DEFAULT_FILE_MEMORY_SOURCE_ID, []) == [] + + +async def test_search_reports_invalid_regex() -> None: + """An invalid regex from the model is surfaced as a clean tool message.""" + provider = FileMemoryProvider(store=InMemoryAgentFileStore()) + _, tools = await _prepare(provider) + + result = await tools["file_memory_search_files"].invoke(arguments={"regex_pattern": "[unclosed"}) + assert "Could not search memory files" in result[0].text From 111f8653ecd5c673c2779c17838fbf1d40dc431b Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:45:19 +0000 Subject: [PATCH 3/6] Address PR comments --- .../agent_framework/_harness/_file_access.py | 86 +++++++++++++++--- .../agent_framework/_harness/_file_memory.py | 88 ++++++++++++------- .../tests/core/test_harness_file_access.py | 10 ++- .../tests/core/test_harness_file_memory.py | 11 ++- 4 files changed, 141 insertions(+), 54 deletions(-) diff --git a/python/packages/core/agent_framework/_harness/_file_access.py b/python/packages/core/agent_framework/_harness/_file_access.py index 938da2a81f..74f151fb41 100644 --- a/python/packages/core/agent_framework/_harness/_file_access.py +++ b/python/packages/core/agent_framework/_harness/_file_access.py @@ -30,7 +30,9 @@ from abc import ABC, abstractmethod from collections.abc import Callable, Mapping, MutableMapping from pathlib import Path -from typing import Any, cast +from typing import Annotated, Any, cast + +from pydantic import BaseModel, Field from .._feature_stage import ExperimentalFeature, experimental from .._serialization import SerializationMixin @@ -81,19 +83,21 @@ def _compile_search_regex(pattern: str) -> re.Pattern[str]: """Compile a case-insensitive search regex, enforcing the length cap. + An invalid ``pattern`` raises :class:`re.error` unchanged so the search + tools surface it to the calling model, which can correct the pattern and + retry. + Raises: ValueError: When ``pattern`` exceeds ``_MAX_SEARCH_PATTERN_LENGTH`` - characters, or when ``pattern`` is not a valid regular expression. + characters. + re.error: When ``pattern`` is not a valid regular expression. """ if len(pattern) > _MAX_SEARCH_PATTERN_LENGTH: raise ValueError( f"Regex pattern is too long ({len(pattern)} characters). " f"Maximum supported length is {_MAX_SEARCH_PATTERN_LENGTH} characters." ) - try: - return re.compile(pattern, flags=re.IGNORECASE) - except re.error as exc: - raise ValueError(f"Invalid regular expression: {exc}") from exc + return re.compile(pattern, flags=re.IGNORECASE) async def _run_search_with_timeout( @@ -984,6 +988,63 @@ async def create_directory(self, path: str) -> None: await asyncio.to_thread(lambda: full_path.mkdir(parents=True, exist_ok=True)) +class _SaveFileInput(BaseModel): + """Input schema for ``file_access_save_file``.""" + + file_name: Annotated[str, Field(description="Name (relative path) of the file to save.")] + content: Annotated[str, Field(description="Full text content to write to the file.")] + overwrite: Annotated[ + bool, + Field(default=False, description="When true, replace an existing file; otherwise saving fails if it exists."), + ] = False + + +class _ReadFileInput(BaseModel): + """Input schema for ``file_access_read_file``.""" + + file_name: Annotated[str, Field(description="Name (relative path) of the file to read.")] + + +class _DeleteFileInput(BaseModel): + """Input schema for ``file_access_delete_file``.""" + + file_name: Annotated[str, Field(description="Name (relative path) of the file to delete.")] + + +class _ListFilesInput(BaseModel): + """Input schema for ``file_access_list_files``.""" + + directory: Annotated[ + str | None, + Field(default=None, description="Relative directory to list; omit or pass empty to list the root."), + ] = None + + +class _ListSubdirectoriesInput(BaseModel): + """Input schema for ``file_access_list_subdirectories``.""" + + directory: Annotated[ + str | None, + Field(default=None, description="Relative directory to list; omit or pass empty to list the root."), + ] = None + + +class _SearchFilesInput(BaseModel): + """Input schema for ``file_access_search_files``.""" + + regex_pattern: Annotated[ + str, + Field(description="Case-insensitive regex matched against file contents; 256 characters or fewer."), + ] + file_pattern: Annotated[ + str | None, + Field( + default=None, + description='Optional glob to filter which files are searched (e.g. "*.md", "reports/*").', + ), + ] = None + + @experimental(feature_id=ExperimentalFeature.HARNESS) class FileAccessProvider(ContextProvider): """Context provider that gives an agent CRUD/search access to a shared file store. @@ -1049,9 +1110,8 @@ async def before_run( state: dict[str, Any], ) -> None: """Inject file-access tools and instructions before the model runs.""" - del agent, session, state - @tool(name="file_access_save_file", approval_mode="never_require") + @tool(name="file_access_save_file", schema=_SaveFileInput, approval_mode="never_require") async def file_access_save_file(file_name: str, content: str, overwrite: bool = False) -> str: """Save a file with the given name and content. By default, does not overwrite an existing file unless overwrite is set to true.""" # noqa: E501 try: @@ -1065,7 +1125,7 @@ async def file_access_save_file(file_name: str, content: str, overwrite: bool = return f"Could not save file '{file_name}': {exc.strerror or exc}" return f"File '{file_name}' saved." - @tool(name="file_access_read_file", approval_mode="never_require") + @tool(name="file_access_read_file", schema=_ReadFileInput, approval_mode="never_require") async def file_access_read_file(file_name: str) -> str: """Read the content of a file by name. Returns the file content or a message indicating the file could not be read.""" # noqa: E501 try: @@ -1079,7 +1139,7 @@ async def file_access_read_file(file_name: str) -> str: delete_approval_mode: ApprovalMode = "always_require" if self.require_delete_approval else "never_require" - @tool(name="file_access_delete_file", approval_mode=delete_approval_mode) + @tool(name="file_access_delete_file", schema=_DeleteFileInput, approval_mode=delete_approval_mode) async def file_access_delete_file(file_name: str) -> str: """Delete a file by name.""" try: @@ -1091,7 +1151,7 @@ async def file_access_delete_file(file_name: str) -> str: return f"Could not delete file '{file_name}': {exc.strerror or exc}" return f"File '{file_name}' deleted." if deleted else f"File '{file_name}' not found." - @tool(name="file_access_list_files", approval_mode="never_require") + @tool(name="file_access_list_files", schema=_ListFilesInput, approval_mode="never_require") async def file_access_list_files(directory: str | None = None) -> list[str] | str: """List the direct child file names of a directory. Omit ``directory`` (or pass an empty string) to list the root. To enumerate files in a subdirectory, pass its relative path, for example ``"reports"`` or ``"reports/2024"``.""" # noqa: E501 target = directory if directory and directory.strip() else "" @@ -1102,7 +1162,7 @@ async def file_access_list_files(directory: str | None = None) -> list[str] | st except OSError as exc: return f"Could not list directory '{directory or ''}': {exc.strerror or exc}" - @tool(name="file_access_list_subdirectories", approval_mode="never_require") + @tool(name="file_access_list_subdirectories", schema=_ListSubdirectoriesInput, approval_mode="never_require") async def file_access_list_subdirectories(directory: str | None = None) -> list[str] | str: """List the direct child subdirectory names of a directory. @@ -1119,7 +1179,7 @@ async def file_access_list_subdirectories(directory: str | None = None) -> list[ except OSError as exc: return f"Could not list directory '{directory or ''}': {exc.strerror or exc}" - @tool(name="file_access_search_files", approval_mode="never_require") + @tool(name="file_access_search_files", schema=_SearchFilesInput, approval_mode="never_require") async def file_access_search_files( regex_pattern: str, file_pattern: str | None = None, diff --git a/python/packages/core/agent_framework/_harness/_file_memory.py b/python/packages/core/agent_framework/_harness/_file_memory.py index 1a427f911f..293b2b5947 100644 --- a/python/packages/core/agent_framework/_harness/_file_memory.py +++ b/python/packages/core/agent_framework/_harness/_file_memory.py @@ -34,8 +34,9 @@ import asyncio import logging -import weakref -from typing import Any, ClassVar +from typing import Annotated, Any + +from pydantic import BaseModel, Field from .._feature_stage import ExperimentalFeature, experimental from .._sessions import AgentSession, ContextProvider, SessionContext @@ -112,6 +113,48 @@ def _is_nested_path(normalized_file_name: str) -> bool: return "/" in normalized_file_name +class _SaveFileInput(BaseModel): + """Input schema for ``file_memory_save_file``.""" + + file_name: Annotated[str, Field(description="Flat file name to save under; must not contain path separators.")] + content: Annotated[str, Field(description="Full text content to write to the file.")] + description: Annotated[ + str | None, + Field( + default=None, + description="Optional summary used to aid future discovery; recommended for large files.", + ), + ] = None + + +class _ReadFileInput(BaseModel): + """Input schema for ``file_memory_read_file``.""" + + file_name: Annotated[str, Field(description="Name of the memory file to read.")] + + +class _DeleteFileInput(BaseModel): + """Input schema for ``file_memory_delete_file``.""" + + file_name: Annotated[str, Field(description="Name of the memory file to delete.")] + + +class _SearchFilesInput(BaseModel): + """Input schema for ``file_memory_search_files``.""" + + regex_pattern: Annotated[ + str, + Field(description="Case-insensitive regex matched against file contents; 256 characters or fewer."), + ] + file_pattern: Annotated[ + str | None, + Field( + default=None, + description='Optional glob to filter which files are searched (e.g. "*.md", "research*").', + ), + ] = None + + @experimental(feature_id=ExperimentalFeature.HARNESS) class FileMemoryProvider(ContextProvider): """Context provider that gives an agent session-scoped, file-based memory. @@ -130,10 +173,6 @@ class FileMemoryProvider(ContextProvider): group memories differently (for example, per user id) across sessions. """ - _WRITE_LOCKS_BY_LOOP: ClassVar[weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, dict[str, asyncio.Lock]]] = ( - weakref.WeakKeyDictionary() - ) - def __init__( self, store: AgentFileStore, @@ -160,6 +199,10 @@ def __init__( self.store = store self.scope = scope self.instructions = instructions or DEFAULT_FILE_MEMORY_INSTRUCTIONS + # Serializes save/delete operations (and their index rebuilds) so the + # ``memories.md`` index stays consistent. A single per-instance lock is + # sufficient for v1; concurrent writes across scopes are rare in practice. + self._write_lock = asyncio.Lock() def _resolve_working_folder(self, context: SessionContext) -> str: """Resolve the working folder for the current invocation. @@ -171,25 +214,6 @@ def _resolve_working_folder(self, context: SessionContext) -> str: raw_scope = self.scope or context.session_id or "" return _normalize_relative_path(raw_scope, is_directory=True) - def _write_lock(self, working_folder: str) -> asyncio.Lock: - """Return a per-event-loop, per-working-folder write lock. - - Serializes save/delete operations (and their index rebuilds) so the - ``memories.md`` index stays consistent. Locks are keyed by the running - loop so the provider can be shared across loops. - """ - loop = asyncio.get_running_loop() - lock_key = f"{self.source_id}:{working_folder}" - locks_for_loop = self._WRITE_LOCKS_BY_LOOP.get(loop) - if locks_for_loop is None: - locks_for_loop = {} - self._WRITE_LOCKS_BY_LOOP[loop] = locks_for_loop - lock = locks_for_loop.get(lock_key) - if lock is None: - lock = asyncio.Lock() - locks_for_loop[lock_key] = lock - return lock - async def _rebuild_index(self, working_folder: str) -> None: """Rebuild the ``memories.md`` index for ``working_folder``. @@ -219,14 +243,12 @@ async def before_run( state: dict[str, Any], ) -> None: """Inject file-memory tools, instructions, and the memory index.""" - del agent, session, state - working_folder = self._resolve_working_folder(context) if working_folder: await self.store.create_directory(working_folder) - @tool(name="file_memory_save_file", approval_mode="never_require") + @tool(name="file_memory_save_file", schema=_SaveFileInput, approval_mode="never_require") async def file_memory_save_file(file_name: str, content: str, description: str | None = None) -> str: """Save a memory file with the given name and content. Overwrites the file if it already exists. Include a description for large files to provide a summary that helps with future discovery.""" # noqa: E501 try: @@ -246,7 +268,7 @@ async def file_memory_save_file(file_name: str, content: str, description: str | path = _combine_paths(working_folder, normalized) desc_path = _combine_paths(working_folder, _description_file_name(normalized)) - async with self._write_lock(working_folder): + async with self._write_lock: try: await self.store.write_file(path, content) if description and description.strip(): @@ -262,7 +284,7 @@ async def file_memory_save_file(file_name: str, content: str, description: str | return f"File '{file_name}' saved with description." return f"File '{file_name}' saved." - @tool(name="file_memory_read_file", approval_mode="never_require") + @tool(name="file_memory_read_file", schema=_ReadFileInput, approval_mode="never_require") async def file_memory_read_file(file_name: str) -> str: """Read the content of a memory file by name. Returns the file content or a message indicating the file was not found.""" # noqa: E501 try: @@ -279,7 +301,7 @@ async def file_memory_read_file(file_name: str) -> str: return f"Could not read file '{file_name}': {exc.strerror or exc}" return content if content is not None else f"File '{file_name}' not found." - @tool(name="file_memory_delete_file", approval_mode="never_require") + @tool(name="file_memory_delete_file", schema=_DeleteFileInput, approval_mode="never_require") async def file_memory_delete_file(file_name: str) -> str: """Delete a memory file by name. Also removes its companion description file if one exists.""" try: @@ -291,7 +313,7 @@ async def file_memory_delete_file(file_name: str) -> str: path = _combine_paths(working_folder, normalized) desc_path = _combine_paths(working_folder, _description_file_name(normalized)) - async with self._write_lock(working_folder): + async with self._write_lock: try: deleted = await self.store.delete_file(path) await self.store.delete_file(desc_path) @@ -322,7 +344,7 @@ async def file_memory_list_files() -> list[dict[str, Any]] | str: entries.append({"file_name": file_name, "description": description}) return entries - @tool(name="file_memory_search_files", approval_mode="never_require") + @tool(name="file_memory_search_files", schema=_SearchFilesInput, approval_mode="never_require") async def file_memory_search_files( regex_pattern: str, file_pattern: str | None = None, diff --git a/python/packages/core/tests/core/test_harness_file_access.py b/python/packages/core/tests/core/test_harness_file_access.py index 2c74d9f962..db41ef9c51 100644 --- a/python/packages/core/tests/core/test_harness_file_access.py +++ b/python/packages/core/tests/core/test_harness_file_access.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import re import time from pathlib import Path @@ -225,7 +226,7 @@ async def test_in_memory_store_search_rejects_invalid_and_oversize_regex() -> No store = InMemoryAgentFileStore() await store.write_file("a.md", "hello") - with pytest.raises(ValueError, match="Invalid regular expression"): + with pytest.raises(re.error): await store.search_files("", "[unclosed") with pytest.raises(ValueError, match="too long"): @@ -768,9 +769,10 @@ async def test_file_access_tool_wrappers_surface_value_error_as_message( searched = await search_files.invoke(arguments={"regex_pattern": too_long}) assert "Could not search files" in searched[0].text - # An invalid regex should also be surfaced as text rather than raised. - invalid = await search_files.invoke(arguments={"regex_pattern": "[unclosed"}) - assert "Could not search files" in invalid[0].text + # An invalid regex is surfaced to the caller (the model) as a raised error + # so it can correct the pattern and retry. + with pytest.raises(re.error): + await search_files.invoke(arguments={"regex_pattern": "[unclosed"}) async def test_file_access_tool_read_file_wrapper_surfaces_non_utf8( diff --git a/python/packages/core/tests/core/test_harness_file_memory.py b/python/packages/core/tests/core/test_harness_file_memory.py index 5814d7211f..a87c7a231d 100644 --- a/python/packages/core/tests/core/test_harness_file_memory.py +++ b/python/packages/core/tests/core/test_harness_file_memory.py @@ -3,6 +3,9 @@ from __future__ import annotations import json +import re + +import pytest from agent_framework import ( AgentSession, @@ -373,10 +376,10 @@ async def read_file(self, path: str) -> str | None: assert context.context_messages.get(DEFAULT_FILE_MEMORY_SOURCE_ID, []) == [] -async def test_search_reports_invalid_regex() -> None: - """An invalid regex from the model is surfaced as a clean tool message.""" +async def test_search_propagates_invalid_regex() -> None: + """An invalid regex from the model is surfaced as a raised error so it can retry.""" provider = FileMemoryProvider(store=InMemoryAgentFileStore()) _, tools = await _prepare(provider) - result = await tools["file_memory_search_files"].invoke(arguments={"regex_pattern": "[unclosed"}) - assert "Could not search memory files" in result[0].text + with pytest.raises(re.error): + await tools["file_memory_search_files"].invoke(arguments={"regex_pattern": "[unclosed"}) From f6e436528b415f0c9680638a9edd49404390d328 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:10:29 +0000 Subject: [PATCH 4/6] Create FileSystemAgentFileStore root lazily on first write Construction no longer calls mkdir, so building a store (and therefore a default create_harness_agent, which wires default file-memory and file-access stores under the CWD) performs no filesystem writes and does not fail in read-only working directories. The root directory is created on the first write_file / create_directory call; all read/list/search operations already tolerate a missing root. Updates docstrings and adds a regression test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agent_framework/_harness/_file_access.py | 13 ++++++++--- .../tests/core/test_harness_file_access.py | 22 +++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/python/packages/core/agent_framework/_harness/_file_access.py b/python/packages/core/agent_framework/_harness/_file_access.py index 74f151fb41..a78209d442 100644 --- a/python/packages/core/agent_framework/_harness/_file_access.py +++ b/python/packages/core/agent_framework/_harness/_file_access.py @@ -664,7 +664,9 @@ class FileSystemAgentFileStore(AgentFileStore): All paths are resolved relative to the root directory provided at construction time. Lexical path traversal attempts (for example, via ``..`` segments or absolute paths) are rejected with :class:`ValueError`. The root - directory is created automatically if it does not already exist. + directory is created lazily on the first write (or ``create_directory``) + rather than at construction, so constructing a store never touches the + filesystem and is safe in read-only working directories. Symbolic links and reparse points anywhere along the resolved path are rejected on read, write, delete, list, and existence checks. The check is @@ -681,15 +683,20 @@ class FileSystemAgentFileStore(AgentFileStore): def __init__(self, root_directory: str | os.PathLike[str]) -> None: """Initialize the file-system store. + The root directory is **not** created here; construction performs no + filesystem writes. The directory is created lazily on the first + ``write_file`` (or ``create_directory``) call, so a store can be + constructed in a read-only working directory and only fails if a write + is actually attempted. + Args: root_directory: The directory under which all files are stored. - Created if it does not exist. + Created lazily on first write if it does not exist. """ raw_root = os.fspath(root_directory) if not raw_root or not raw_root.strip(): raise ValueError("root_directory must not be empty or whitespace-only.") root_path = Path(raw_root).resolve() - root_path.mkdir(parents=True, exist_ok=True) self._root_path = root_path @property diff --git a/python/packages/core/tests/core/test_harness_file_access.py b/python/packages/core/tests/core/test_harness_file_access.py index db41ef9c51..6531e40f30 100644 --- a/python/packages/core/tests/core/test_harness_file_access.py +++ b/python/packages/core/tests/core/test_harness_file_access.py @@ -435,6 +435,28 @@ def test_filesystem_store_requires_non_empty_root() -> None: FileSystemAgentFileStore(" ") +async def test_filesystem_store_does_not_create_root_until_write(tmp_path: Path) -> None: + """Constructing a store must not touch the filesystem; the root is created lazily on first write.""" + root = tmp_path / "does-not-exist-yet" + + # Construction performs no filesystem writes (safe in read-only CWDs). + store = FileSystemAgentFileStore(root) + assert not root.exists() + + # Read-only operations tolerate the missing root without creating it. + assert await store.read_file("a.txt") is None + assert await store.file_exists("a.txt") is False + assert await store.list_files() == [] + assert await store.list_directories() == [] + assert await store.search_files("", ".") == [] + assert not root.exists() + + # The first write creates the root directory lazily. + await store.write_file("a.txt", "alpha") + assert root.is_dir() + assert await store.read_file("a.txt") == "alpha" + + async def test_file_access_provider_registers_tools_and_instructions( chat_client_base: SupportsChatGetResponse, ) -> None: From c82942857ed755d2e0dc40d3f18b9708bd78e0c2 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:04:11 +0000 Subject: [PATCH 5/6] Fix typing --- python/packages/core/tests/core/test_harness_file_memory.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/python/packages/core/tests/core/test_harness_file_memory.py b/python/packages/core/tests/core/test_harness_file_memory.py index a87c7a231d..09cf9171da 100644 --- a/python/packages/core/tests/core/test_harness_file_memory.py +++ b/python/packages/core/tests/core/test_harness_file_memory.py @@ -10,6 +10,7 @@ from agent_framework import ( AgentSession, FileMemoryProvider, + FunctionTool, InMemoryAgentFileStore, ) from agent_framework._harness._file_memory import ( @@ -34,12 +35,12 @@ def _tool_by_name(tools: list[object], name: str) -> object: async def _prepare( provider: FileMemoryProvider, *, session_id: str = "session-1" -) -> tuple[SessionContext, dict[str, object]]: +) -> tuple[SessionContext, dict[str, FunctionTool]]: """Run ``before_run`` against a fresh session context and return tools by name.""" session = AgentSession(session_id=session_id) context = SessionContext(session_id=session_id, input_messages=[]) await provider.before_run(agent=None, session=session, context=context, state={}) - tools = {getattr(t, "name", None): t for t in context.tools} + tools: dict[str, FunctionTool] = {tool.name: tool for tool in context.tools} return context, tools From d5dad94a51536e38235eb61002d9515996173401 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:12:43 +0000 Subject: [PATCH 6/6] Fixing typing errors --- .../tests/core/test_harness_file_memory.py | 60 ++++++++++--------- 1 file changed, 33 insertions(+), 27 deletions(-) diff --git a/python/packages/core/tests/core/test_harness_file_memory.py b/python/packages/core/tests/core/test_harness_file_memory.py index 09cf9171da..dd262c6158 100644 --- a/python/packages/core/tests/core/test_harness_file_memory.py +++ b/python/packages/core/tests/core/test_harness_file_memory.py @@ -9,6 +9,7 @@ from agent_framework import ( AgentSession, + Content, FileMemoryProvider, FunctionTool, InMemoryAgentFileStore, @@ -33,6 +34,11 @@ def _tool_by_name(tools: list[object], name: str) -> object: raise AssertionError(f"Tool {name!r} was not found.") +def _text(result: list[Content]) -> str: + """Return the first content item's text (memory tools always emit text).""" + return result[0].text or "" + + async def _prepare( provider: FileMemoryProvider, *, session_id: str = "session-1" ) -> tuple[SessionContext, dict[str, FunctionTool]]: @@ -106,24 +112,24 @@ async def test_save_read_delete_round_trip() -> None: list_files = tools["file_memory_list_files"] saved = await save.invoke(arguments={"file_name": "plan.md", "content": "step 1"}) - assert "plan.md" in saved[0].text and "saved" in saved[0].text + assert "plan.md" in _text(saved) and "saved" in _text(saved) read_back = await read.invoke(arguments={"file_name": "plan.md"}) - assert read_back[0].text == "step 1" + assert _text(read_back) == "step 1" # Overwrite is allowed (no overwrite flag needed). await save.invoke(arguments={"file_name": "plan.md", "content": "step 2"}) - assert (await read.invoke(arguments={"file_name": "plan.md"}))[0].text == "step 2" + assert _text(await read.invoke(arguments={"file_name": "plan.md"})) == "step 2" - listed = json.loads((await list_files.invoke())[0].text) + listed = json.loads(_text(await list_files.invoke())) assert listed == [{"file_name": "plan.md", "description": None}] deleted = await delete.invoke(arguments={"file_name": "plan.md"}) - assert "deleted" in deleted[0].text + assert "deleted" in _text(deleted) missing = await read.invoke(arguments={"file_name": "plan.md"}) - assert "not found" in missing[0].text + assert "not found" in _text(missing) missing_delete = await delete.invoke(arguments={"file_name": "plan.md"}) - assert "not found" in missing_delete[0].text + assert "not found" in _text(missing_delete) async def test_description_sidecar_is_written_and_listed() -> None: @@ -137,18 +143,18 @@ async def test_description_sidecar_is_written_and_listed() -> None: result = await save.invoke( arguments={"file_name": "arch.md", "content": "big content", "description": "system architecture"} ) - assert "with description" in result[0].text + assert "with description" in _text(result) sidecar = await store.read_file(_combine_paths("user-1", "arch_description.md")) assert sidecar == "system architecture" - listed = json.loads((await list_files.invoke())[0].text) + listed = json.loads(_text(await list_files.invoke())) assert listed == [{"file_name": "arch.md", "description": "system architecture"}] # Re-saving without a description removes the sidecar. await save.invoke(arguments={"file_name": "arch.md", "content": "big content"}) assert await store.read_file(_combine_paths("user-1", "arch_description.md")) is None - listed_again = json.loads((await list_files.invoke())[0].text) + listed_again = json.loads(_text(await list_files.invoke())) assert listed_again == [{"file_name": "arch.md", "description": None}] @@ -204,13 +210,13 @@ async def test_list_and_search_hide_internal_files() -> None: arguments={"file_name": "arch.md", "content": "architecture text", "description": "architecture"} ) - listed = json.loads((await tools["file_memory_list_files"].invoke())[0].text) + listed = json.loads(_text(await tools["file_memory_list_files"].invoke())) assert [e["file_name"] for e in listed] == ["arch.md"] # The description text lives in an internal sidecar, so a regex matching it # must not return the sidecar (only the memory file itself). found = json.loads( - (await tools["file_memory_search_files"].invoke(arguments={"regex_pattern": "architecture"}))[0].text + _text(await tools["file_memory_search_files"].invoke(arguments={"regex_pattern": "architecture"})) ) names = [e["file_name"] for e in found] assert "arch.md" in names @@ -226,12 +232,12 @@ async def test_scope_isolates_memories_across_sessions() -> None: await tools_a["file_memory_save_file"].invoke(arguments={"file_name": "a.md", "content": "from a"}) _, tools_b = await _prepare(provider, session_id="session-b") - listed_b = json.loads((await tools_b["file_memory_list_files"].invoke())[0].text) + listed_b = json.loads(_text(await tools_b["file_memory_list_files"].invoke())) assert listed_b == [] # The original session still sees its own memory. _, tools_a2 = await _prepare(provider, session_id="session-a") - listed_a = json.loads((await tools_a2["file_memory_list_files"].invoke())[0].text) + listed_a = json.loads(_text(await tools_a2["file_memory_list_files"].invoke())) assert [e["file_name"] for e in listed_a] == ["a.md"] @@ -244,7 +250,7 @@ async def test_explicit_scope_shares_memories_across_sessions() -> None: await tools_a["file_memory_save_file"].invoke(arguments={"file_name": "shared.md", "content": "v"}) _, tools_b = await _prepare(provider, session_id="session-b") - listed_b = json.loads((await tools_b["file_memory_list_files"].invoke())[0].text) + listed_b = json.loads(_text(await tools_b["file_memory_list_files"].invoke())) assert [e["file_name"] for e in listed_b] == ["shared.md"] @@ -255,10 +261,10 @@ async def test_save_rejects_reserved_internal_names() -> None: save = tools["file_memory_save_file"] reserved = await save.invoke(arguments={"file_name": _MEMORY_INDEX_FILE_NAME, "content": "x"}) - assert "reserved" in reserved[0].text + assert "reserved" in _text(reserved) sidecar = await save.invoke(arguments={"file_name": "notes_description.md", "content": "x"}) - assert "reserved" in sidecar[0].text + assert "reserved" in _text(sidecar) async def test_tools_surface_path_validation_errors() -> None: @@ -267,13 +273,13 @@ async def test_tools_surface_path_validation_errors() -> None: _, tools = await _prepare(provider) bad_save = await tools["file_memory_save_file"].invoke(arguments={"file_name": "../escape.md", "content": "x"}) - assert "Could not save" in bad_save[0].text + assert "Could not save" in _text(bad_save) bad_read = await tools["file_memory_read_file"].invoke(arguments={"file_name": "/rooted.md"}) - assert "Could not read" in bad_read[0].text + assert "Could not read" in _text(bad_read) bad_delete = await tools["file_memory_delete_file"].invoke(arguments={"file_name": "../escape.md"}) - assert "Could not delete" in bad_delete[0].text + assert "Could not delete" in _text(bad_delete) async def test_provider_accepts_custom_instructions() -> None: @@ -296,7 +302,7 @@ async def test_tools_reject_nested_paths() -> None: _, tools = await _prepare(provider) saved = await tools["file_memory_save_file"].invoke(arguments={"file_name": "notes/plan.md", "content": "x"}) - assert "subdirectory" in saved[0].text + assert "subdirectory" in _text(saved) # Nothing should have been written for the nested name. assert await store.list_files("") == [] @@ -304,13 +310,13 @@ async def test_tools_reject_nested_paths() -> None: saved_backslash = await tools["file_memory_save_file"].invoke( arguments={"file_name": "notes\\plan.md", "content": "x"} ) - assert "subdirectory" in saved_backslash[0].text + assert "subdirectory" in _text(saved_backslash) # Reading/deleting a nested name reports a clean "not found" message. read_back = await tools["file_memory_read_file"].invoke(arguments={"file_name": "notes/plan.md"}) - assert "not found" in read_back[0].text + assert "not found" in _text(read_back) deleted = await tools["file_memory_delete_file"].invoke(arguments={"file_name": "notes/plan.md"}) - assert "not found" in deleted[0].text + assert "not found" in _text(deleted) async def test_index_caps_entries_at_max() -> None: @@ -347,13 +353,13 @@ async def delete_file(self, path: str) -> bool: _, tools = await _prepare(provider) saved = await tools["file_memory_save_file"].invoke(arguments={"file_name": "plan.md", "content": "x"}) - assert "Could not save" in saved[0].text and "boom-write" in saved[0].text + assert "Could not save" in _text(saved) and "boom-write" in _text(saved) read_back = await tools["file_memory_read_file"].invoke(arguments={"file_name": "plan.md"}) - assert "Could not read" in read_back[0].text and "boom-read" in read_back[0].text + assert "Could not read" in _text(read_back) and "boom-read" in _text(read_back) deleted = await tools["file_memory_delete_file"].invoke(arguments={"file_name": "plan.md"}) - assert "Could not delete" in deleted[0].text and "boom-delete" in deleted[0].text + assert "Could not delete" in _text(deleted) and "boom-delete" in _text(deleted) async def test_before_run_skips_injection_when_index_unreadable() -> None: