Python: [BREAKING] Port FileMemoryProvider and integrate FileMemoryProvider & FileAccess into the harness agent (#6547)

* Port FileMemoryProvider to python and integrate it and FileAccessProvider into the harness

* Address PR comments

* Address PR comments

* 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>

* Fix typing

* Fixing typing errors

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
westey
2026-06-18 21:15:46 +01:00
committed by GitHub
parent 92823e9e61
commit dc445592ed
11 changed files with 1240 additions and 110 deletions
+8
View File
@@ -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 `<stem>_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
@@ -102,6 +102,11 @@ from ._harness._file_access import (
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 @@ __all__ = [
"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 @@ __all__ = [
"FileAccessProvider",
"FileCheckpointStorage",
"FileHistoryProvider",
"FileMemoryProvider",
"FileSearchMatch",
"FileSearchResult",
"FileSkill",
@@ -12,6 +12,7 @@ from __future__ import annotations
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 .._feature_stage import ExperimentalFeature, experimental
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
from ._tool_approval import ToolApprovalMiddleware
@@ -126,8 +128,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,
@@ -151,8 +155,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:
@@ -243,8 +256,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,
@@ -268,7 +283,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
- **Tool approval** — "don't ask again" standing approval rules plus heuristic
@@ -342,9 +358,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).
@@ -433,8 +456,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,
@@ -30,7 +30,9 @@ import re
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,8 +83,13 @@ _ELOOP = errno.ELOOP
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.
ValueError: When ``pattern`` exceeds ``_MAX_SEARCH_PATTERN_LENGTH``
characters.
re.error: When ``pattern`` is not a valid regular expression.
"""
if len(pattern) > _MAX_SEARCH_PATTERN_LENGTH:
@@ -657,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
@@ -674,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
@@ -981,6 +995,63 @@ class FileSystemAgentFileStore(AgentFileStore):
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.
@@ -1046,9 +1117,8 @@ class FileAccessProvider(ContextProvider):
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:
@@ -1062,7 +1132,7 @@ class FileAccessProvider(ContextProvider):
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:
@@ -1076,7 +1146,7 @@ class FileAccessProvider(ContextProvider):
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:
@@ -1088,7 +1158,7 @@ class FileAccessProvider(ContextProvider):
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 ""
@@ -1099,7 +1169,7 @@ class FileAccessProvider(ContextProvider):
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.
@@ -1116,7 +1186,7 @@ class FileAccessProvider(ContextProvider):
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,
@@ -0,0 +1,404 @@
# 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 logging
from typing import Annotated, Any
from pydantic import BaseModel, Field
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]
logger = logging.getLogger(__name__)
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('/')}"
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
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.
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.
"""
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
# 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.
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)
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."""
working_folder = self._resolve_working_folder(context)
if working_folder:
await self.store.create_directory(working_folder)
@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:
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. "
"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:
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 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():
return f"File '{file_name}' saved with description."
return f"File '{file_name}' saved."
@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:
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."
@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:
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))
async with self._write_lock:
try:
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."
@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", schema=_SearchFilesInput, 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,
],
)
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,
[
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",
]
@@ -4,6 +4,7 @@ from __future__ import annotations
import importlib.util
from collections.abc import AsyncIterable, Awaitable, Mapping, Sequence
from pathlib import Path
from typing import Any
import pytest
@@ -17,6 +18,10 @@ from agent_framework import (
ChatResponseUpdate,
CompactionProvider,
Content,
FileAccessProvider,
FileMemoryProvider,
FileSystemAgentFileStore,
InMemoryAgentFileStore,
InMemoryHistoryProvider,
Message,
ResponseStream,
@@ -60,6 +65,12 @@ class _FakeChatClient(BaseChatClient[ChatOptions[Any]]):
# --- 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(
@@ -70,8 +81,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(),
max_context_window_tokens=128_000,
@@ -84,6 +96,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
@@ -111,62 +125,69 @@ 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): # type: ignore[override] # pyrefly: ignore[bad-override] # ty: ignore[invalid-method-override]
return ""
def get_transcripts_directory(self, session, *, source_id): # pyrefly: ignore[bad-override]
return ""
def read_state(self, session, *, source_id):
return {}
def rebuild_index(self, session, *, source_id): # type: ignore[override] # pyrefly: ignore[bad-override] # ty: ignore[invalid-method-override]
pass
def search_transcripts(self, session, *, source_id, query): # type: ignore[override] # pyrefly: ignore[bad-override] # ty: ignore[invalid-method-override]
return []
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(
client=_FakeChatClient(),
def test_create_harness_agent_disable_file_memory() -> None:
"""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,
memory_store=_FakeMemoryStore(),
disable_file_memory=True,
)
provider_types = [type(p) for p in agent_with_memory.context_providers]
assert MemoryContextProvider in provider_types
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
# With memory_store provided and disable_memory=True, MemoryContextProvider should be absent.
agent_disabled = create_harness_agent(
client=_FakeChatClient(),
def test_create_harness_agent_disable_file_access() -> None:
"""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,
memory_store=_FakeMemoryStore(),
disable_memory=True,
disable_file_access=True,
)
provider_types = [type(p) for p in agent_disabled.context_providers]
assert MemoryContextProvider not in provider_types
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:
"""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,
file_memory_store=memory_store,
file_access_store=access_store,
)
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_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:
@@ -442,6 +442,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:
@@ -778,6 +800,11 @@ 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 _text(searched[0])
# 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(
tmp_path: Path, chat_client_base: SupportsChatGetResponse
@@ -0,0 +1,392 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import json
import re
import pytest
from agent_framework import (
AgentSession,
Content,
FileMemoryProvider,
FunctionTool,
InMemoryAgentFileStore,
)
from agent_framework._harness._file_memory import (
_MAX_INDEX_ENTRIES,
_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.")
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]]:
"""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: dict[str, FunctionTool] = {tool.name: tool for tool 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 _text(saved) and "saved" in _text(saved)
read_back = await read.invoke(arguments={"file_name": "plan.md"})
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 _text(await read.invoke(arguments={"file_name": "plan.md"})) == "step 2"
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 _text(deleted)
missing = await read.invoke(arguments={"file_name": "plan.md"})
assert "not found" in _text(missing)
missing_delete = await delete.invoke(arguments={"file_name": "plan.md"})
assert "not found" in _text(missing_delete)
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 _text(result)
sidecar = await store.read_file(_combine_paths("user-1", "arch_description.md"))
assert sidecar == "system architecture"
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(_text(await list_files.invoke()))
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(_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(
_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
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(_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(_text(await tools_a2["file_memory_list_files"].invoke()))
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(_text(await tools_b["file_memory_list_files"].invoke()))
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 _text(reserved)
sidecar = await save.invoke(arguments={"file_name": "notes_description.md", "content": "x"})
assert "reserved" in _text(sidecar)
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 _text(bad_save)
bad_read = await tools["file_memory_read_file"].invoke(arguments={"file_name": "/rooted.md"})
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 _text(bad_delete)
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"
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 _text(saved)
# 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 _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 _text(read_back)
deleted = await tools["file_memory_delete_file"].invoke(arguments={"file_name": "notes/plan.md"})
assert "not found" in _text(deleted)
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 _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 _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 _text(deleted) and "boom-delete" in _text(deleted)
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_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)
with pytest.raises(re.error):
await tools["file_memory_search_files"].invoke(arguments={"regex_pattern": "[unclosed"})
@@ -316,46 +316,46 @@ class TodoToolFormatter(ToolCallFormatter):
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:
@@ -394,8 +394,8 @@ class BackgroundAgentToolFormatter(ToolCallFormatter):
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):
@@ -412,28 +412,28 @@ class BackgroundAgentToolFormatter(ToolCallFormatter):
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:
@@ -444,9 +444,9 @@ class FileMemoryToolFormatter(ToolCallFormatter):
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
@@ -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())
@@ -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
1 date product category quantity unit_price region salesperson
2 2025-01-03 Laptop Pro 15 Electronics 2 1299.99 North Alice
3 2025-01-05 Ergonomic Chair Furniture 5 349.50 South Bob
4 2025-01-07 Wireless Mouse Electronics 12 24.99 North Alice
5 2025-01-08 Standing Desk Furniture 1 599.00 West Carol
6 2025-01-10 USB-C Hub Electronics 8 45.99 North David
7 2025-01-12 Monitor 27in Electronics 3 429.00 South Bob
8 2025-01-14 Desk Lamp Furniture 6 79.95 West Carol
9 2025-01-15 Keyboard Mech Electronics 4 149.99 North Alice
10 2025-01-17 Filing Cabinet Furniture 2 189.00 South David
11 2025-01-20 Webcam HD Electronics 10 89.99 West Bob
12 2025-01-22 Laptop Pro 15 Electronics 1 1299.99 South Carol
13 2025-01-24 Ergonomic Chair Furniture 3 349.50 North Alice
14 2025-01-25 Notebook Pack Stationery 20 12.99 South David
15 2025-01-27 Wireless Mouse Electronics 15 24.99 West Carol
16 2025-01-28 Whiteboard Stationery 4 129.00 North Bob
17 2025-01-30 Standing Desk Furniture 2 599.00 South Alice
18 2025-02-02 USB-C Hub Electronics 6 45.99 West David
19 2025-02-04 Monitor 27in Electronics 2 429.00 North Carol
20 2025-02-05 Desk Lamp Furniture 8 79.95 South Bob
21 2025-02-07 Keyboard Mech Electronics 5 149.99 West Alice
22 2025-02-09 Filing Cabinet Furniture 1 189.00 North David
23 2025-02-11 Webcam HD Electronics 7 89.99 South Carol
24 2025-02-13 Laptop Pro 15 Electronics 3 1299.99 West Bob
25 2025-02-15 Notebook Pack Stationery 30 12.99 North Alice
26 2025-02-17 Ergonomic Chair Furniture 4 349.50 South David
27 2025-02-19 Wireless Mouse Electronics 20 24.99 North Carol
28 2025-02-20 Whiteboard Stationery 2 129.00 West Bob
29 2025-02-22 Standing Desk Furniture 1 599.00 North Alice
30 2025-02-24 USB-C Hub Electronics 10 45.99 South David
31 2025-02-26 Monitor 27in Electronics 4 429.00 West Carol
32 2025-02-28 Desk Lamp Furniture 3 79.95 North Bob
33 2025-03-02 Keyboard Mech Electronics 6 149.99 South Alice
34 2025-03-04 Filing Cabinet Furniture 3 189.00 West David
35 2025-03-06 Webcam HD Electronics 9 89.99 North Carol
36 2025-03-08 Laptop Pro 15 Electronics 2 1299.99 South Bob
37 2025-03-10 Notebook Pack Stationery 25 12.99 West Alice
38 2025-03-12 Ergonomic Chair Furniture 6 349.50 North David
39 2025-03-14 Wireless Mouse Electronics 18 24.99 South Carol
40 2025-03-15 Whiteboard Stationery 5 129.00 North Bob
41 2025-03-17 Standing Desk Furniture 3 599.00 West Alice
42 2025-03-19 USB-C Hub Electronics 7 45.99 North David
43 2025-03-21 Monitor 27in Electronics 5 429.00 South Carol
44 2025-03-23 Desk Lamp Furniture 4 79.95 West Bob
45 2025-03-25 Keyboard Mech Electronics 3 149.99 North Alice
46 2025-03-27 Filing Cabinet Furniture 2 189.00 South David
47 2025-03-28 Webcam HD Electronics 11 89.99 West Carol
48 2025-03-29 Laptop Pro 15 Electronics 1 1299.99 North Bob
49 2025-03-30 Notebook Pack Stationery 15 12.99 South Alice
50 2025-03-31 Ergonomic Chair Furniture 2 349.50 West David