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:
@@ -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
|
||||
|
Reference in New Issue
Block a user