Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions python/packages/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions python/packages/core/agent_framework/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@
FileSystemAgentFileStore,
InMemoryAgentFileStore,
)
from ._harness._file_memory import (
DEFAULT_FILE_MEMORY_INSTRUCTIONS,
DEFAULT_FILE_MEMORY_SOURCE_ID,
FileMemoryProvider,
)
from ._harness._loop import (
AgentLoopMiddleware,
JudgeVerdict,
Expand Down Expand Up @@ -341,6 +346,8 @@
"DEFAULT_BACKGROUND_AGENTS_SOURCE_ID",
"DEFAULT_FILE_ACCESS_INSTRUCTIONS",
"DEFAULT_FILE_ACCESS_SOURCE_ID",
"DEFAULT_FILE_MEMORY_INSTRUCTIONS",
"DEFAULT_FILE_MEMORY_SOURCE_ID",
"DEFAULT_HARNESS_INSTRUCTIONS",
"DEFAULT_MAX_ITERATIONS",
"DEFAULT_MEMORY_SOURCE_ID",
Expand Down Expand Up @@ -431,6 +438,7 @@
"FileAccessProvider",
"FileCheckpointStorage",
"FileHistoryProvider",
"FileMemoryProvider",
"FileSearchMatch",
"FileSearchResult",
"FileSkill",
Expand Down
51 changes: 38 additions & 13 deletions python/packages/core/agent_framework/_harness/_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import logging
from collections.abc import Callable, Sequence
from pathlib import Path
from typing import TYPE_CHECKING, Any

from .._agents import Agent, SupportsAgentRun
Expand All @@ -21,7 +22,8 @@
from .._sessions import ContextProvider, HistoryProvider, InMemoryHistoryProvider
from .._skills import SkillsProvider
from ._background_agents import BackgroundAgentsProvider
from ._memory import MemoryContextProvider, MemoryStore
from ._file_access import AgentFileStore, FileAccessProvider, FileSystemAgentFileStore
from ._file_memory import FileMemoryProvider
from ._mode import AgentModeProvider
from ._todo import TodoProvider
from ._tool_approval import ToolApprovalMiddleware
Expand Down Expand Up @@ -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,
Expand All @@ -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")
Comment thread
westey-m marked this conversation as resolved.
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:
Expand Down Expand Up @@ -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,
Comment thread
westey-m marked this conversation as resolved.
skills_provider: SkillsProvider | None = None,
skills_paths: Sequence[str] | None = None,
background_agents: Sequence[SupportsAgentRun] | None = None,
Expand All @@ -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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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,
Expand Down
94 changes: 82 additions & 12 deletions python/packages/core/agent_framework/_harness/_file_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@
from abc import ABC, abstractmethod
from collections.abc import Callable, Mapping, MutableMapping
from pathlib import Path
from typing import Any, cast
from typing import Annotated, Any, cast

from pydantic import BaseModel, Field

from .._feature_stage import ExperimentalFeature, experimental
from .._serialization import SerializationMixin
Expand Down Expand Up @@ -81,8 +83,13 @@
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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -981,6 +995,63 @@ async def create_directory(self, path: str) -> None:
await asyncio.to_thread(lambda: full_path.mkdir(parents=True, exist_ok=True))


class _SaveFileInput(BaseModel):
"""Input schema for ``file_access_save_file``."""

file_name: Annotated[str, Field(description="Name (relative path) of the file to save.")]
content: Annotated[str, Field(description="Full text content to write to the file.")]
overwrite: Annotated[
bool,
Field(default=False, description="When true, replace an existing file; otherwise saving fails if it exists."),
] = False


class _ReadFileInput(BaseModel):
"""Input schema for ``file_access_read_file``."""

file_name: Annotated[str, Field(description="Name (relative path) of the file to read.")]


class _DeleteFileInput(BaseModel):
"""Input schema for ``file_access_delete_file``."""

file_name: Annotated[str, Field(description="Name (relative path) of the file to delete.")]


class _ListFilesInput(BaseModel):
"""Input schema for ``file_access_list_files``."""

directory: Annotated[
str | None,
Field(default=None, description="Relative directory to list; omit or pass empty to list the root."),
] = None


class _ListSubdirectoriesInput(BaseModel):
"""Input schema for ``file_access_list_subdirectories``."""

directory: Annotated[
str | None,
Field(default=None, description="Relative directory to list; omit or pass empty to list the root."),
] = None


class _SearchFilesInput(BaseModel):
"""Input schema for ``file_access_search_files``."""

regex_pattern: Annotated[
str,
Field(description="Case-insensitive regex matched against file contents; 256 characters or fewer."),
]
file_pattern: Annotated[
str | None,
Field(
default=None,
description='Optional glob to filter which files are searched (e.g. "*.md", "reports/*").',
),
] = None


@experimental(feature_id=ExperimentalFeature.HARNESS)
class FileAccessProvider(ContextProvider):
"""Context provider that gives an agent CRUD/search access to a shared file store.
Expand Down Expand Up @@ -1046,9 +1117,8 @@ async def before_run(
state: dict[str, Any],
) -> None:
"""Inject file-access tools and instructions before the model runs."""
del agent, session, state

@tool(name="file_access_save_file", approval_mode="never_require")
@tool(name="file_access_save_file", schema=_SaveFileInput, approval_mode="never_require")
async def file_access_save_file(file_name: str, content: str, overwrite: bool = False) -> str:
"""Save a file with the given name and content. By default, does not overwrite an existing file unless overwrite is set to true.""" # noqa: E501
try:
Expand All @@ -1062,7 +1132,7 @@ async def file_access_save_file(file_name: str, content: str, overwrite: bool =
return f"Could not save file '{file_name}': {exc.strerror or exc}"
return f"File '{file_name}' saved."

@tool(name="file_access_read_file", approval_mode="never_require")
@tool(name="file_access_read_file", schema=_ReadFileInput, approval_mode="never_require")
async def file_access_read_file(file_name: str) -> str:
"""Read the content of a file by name. Returns the file content or a message indicating the file could not be read.""" # noqa: E501
try:
Expand All @@ -1076,7 +1146,7 @@ async def file_access_read_file(file_name: str) -> str:

delete_approval_mode: ApprovalMode = "always_require" if self.require_delete_approval else "never_require"

@tool(name="file_access_delete_file", approval_mode=delete_approval_mode)
@tool(name="file_access_delete_file", schema=_DeleteFileInput, approval_mode=delete_approval_mode)
async def file_access_delete_file(file_name: str) -> str:
"""Delete a file by name."""
try:
Expand All @@ -1088,7 +1158,7 @@ async def file_access_delete_file(file_name: str) -> str:
return f"Could not delete file '{file_name}': {exc.strerror or exc}"
return f"File '{file_name}' deleted." if deleted else f"File '{file_name}' not found."

@tool(name="file_access_list_files", approval_mode="never_require")
@tool(name="file_access_list_files", schema=_ListFilesInput, approval_mode="never_require")
async def file_access_list_files(directory: str | None = None) -> list[str] | str:
"""List the direct child file names of a directory. Omit ``directory`` (or pass an empty string) to list the root. To enumerate files in a subdirectory, pass its relative path, for example ``"reports"`` or ``"reports/2024"``.""" # noqa: E501
target = directory if directory and directory.strip() else ""
Expand All @@ -1099,7 +1169,7 @@ async def file_access_list_files(directory: str | None = None) -> list[str] | st
except OSError as exc:
return f"Could not list directory '{directory or ''}': {exc.strerror or exc}"

@tool(name="file_access_list_subdirectories", approval_mode="never_require")
@tool(name="file_access_list_subdirectories", schema=_ListSubdirectoriesInput, approval_mode="never_require")
async def file_access_list_subdirectories(directory: str | None = None) -> list[str] | str:
"""List the direct child subdirectory names of a directory.

Expand All @@ -1116,7 +1186,7 @@ async def file_access_list_subdirectories(directory: str | None = None) -> list[
except OSError as exc:
return f"Could not list directory '{directory or ''}': {exc.strerror or exc}"

@tool(name="file_access_search_files", approval_mode="never_require")
@tool(name="file_access_search_files", schema=_SearchFilesInput, approval_mode="never_require")
async def file_access_search_files(
regex_pattern: str,
file_pattern: str | None = None,
Expand Down
Loading
Loading