Skip to content
Open
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
2 changes: 1 addition & 1 deletion python/packages/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ The vector store API is experimental under the shared `VECTOR_STORES` feature ID

- **`FileMemoryProvider`** - `ContextProvider` that gives an agent a session-scoped, file-based memory backed by the same `AgentFileStore` abstraction. Adds tools (`file_memory_write`, `file_memory_read`, `file_memory_delete`, `file_memory_ls`, `file_memory_grep`, `file_memory_replace`, `file_memory_replace_lines`) 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. The scope (or session id) is an **opaque namespace key, not a path**: it is mapped to exactly one folder via the shared `agent_framework._filesystem._storage_key_segment` derivation, which is **injective** (except for a collision-resistant digest fallback past a length cap) — two byte-distinct values do not share a working folder, so a caller authorized for one identifier cannot reach another's memories. A multi-segment value such as `"tenants/alice"` therefore becomes a single encoded folder rather than a nested directory. The provider **fails closed** (`ValueError`) when neither a `scope` nor a `session_id` is available, because an empty working folder would be the shared store root. Applications should still canonicalize and authorize externally supplied identifiers themselves; the storage mapping is the storage-layer guarantee, not a substitute for that check.
- **Descriptions & index** - `file_memory_write` accepts an optional `description`, stored in a companion `<stem>_description.md` sidecar. After each write/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_ls`/`file_memory_grep` and rejected as write targets.
- **Descriptions & index** - `file_memory_write` accepts an optional `description`, stored in a companion sidecar named `<file name>_description.md` (the whole memory file name is kept, so `notes.md` and `notes` get distinct sidecars). After each write/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_ls`/`file_memory_grep` and rejected as write targets. Sidecars written by earlier versions used the extension-less stem (`notes_description.md`) and are not migrated.
- **`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 the `FileMemoryProvider` by default; the `FileAccessProvider` is opt-in and added only when a `file_access_store` is supplied (no implicit `{cwd}/working` store is created). Disable file memory via `disable_file_memory`; override its backing store via `file_memory_store`. When no file-memory store is supplied, the default is `FileSystemAgentFileStore` rooted at `{cwd}/agent-file-memory`. `create_harness_agent` also wires in `MessageInjectionMiddleware` by default (mirroring the .NET harness's `UseMessageInjection`); it is always on with no opt-out because it is a no-op when no messages are queued for the session.
- **Experimental-feature gating** - `create_harness_agent` itself is released (no longer `@experimental`), but a few features it can wire in remain experimental or pre-release: **background agents** (`background_agents`), **file access** (`file_access_store`), **looping** (`loop_should_continue`), and the **shell tooling** (`shell_executor`, from the pre-release `agent-framework-tools` package). Enabling any of them emits a single `ExperimentalWarning` (naming the responsible parameter). The three experimental harness providers share one `HARNESS` dedup key so the downstream experimental provider does not warn a second time; the shell tooling uses a separate dedup key (it is not a `HARNESS`-decorated feature).
Expand Down
44 changes: 36 additions & 8 deletions python/packages/core/agent_framework/_harness/_file_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@

: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
large files can carry a companion description file (the memory file name with
``_description.md`` appended, so the sidecar of ``notes.md`` is
``notes.md_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.

Expand Down Expand Up @@ -42,6 +43,7 @@

from __future__ import annotations

import hashlib
import logging
from typing import Annotated, Any

Expand Down Expand Up @@ -86,22 +88,48 @@
_DESCRIPTION_SUFFIX = "_description.md"
_MEMORY_INDEX_FILE_NAME = "memories.md"
_MAX_INDEX_ENTRIES = 50
# Filesystem name limits are commonly 255 bytes; the sidecar derivation must stay
# within that budget so every valid memory file name also gets a writable sidecar.
_MAX_DESCRIPTION_FILE_NAME_BYTES = 255
# Prefix for scopes that are not usable verbatim as a folder name. Kept distinct
# from the other harness prefixes so the same value under a different component
# never lands in the same directory.
_ENCODED_SCOPE_PREFIX = "~scope-"
# Prefix for description sidecars of memory file names too long to carry the
# suffix verbatim. Distinct from the other prefixes for the same reason.
# Memory file names that already start with this prefix also take the digest
# branch, so the verbatim namespace never contains the prefix and the two
# branches cannot produce the same sidecar.
_DIGEST_DESCRIPTION_PREFIX = "~memory-desc-"


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.
The suffix is appended to the *whole* memory file name, so ``notes.md``
becomes ``notes.md_description.md`` while ``notes`` becomes
``notes_description.md``. Keeping the extension in the derivation makes
the mapping injective: distinct memory files always get distinct sidecars,
so writes and deletes can never clobber another memory's description.

A memory file name long enough to push the appended sidecar past the
filesystem's name limit cannot carry the suffix verbatim, so such names
fall back to a SHA-256 digest (still suffixed, still injective in
practice). Names that already start with the reserved digest prefix take
the digest branch too: otherwise a short memory named after another name's
digest stem would share that name's sidecar without any hash collision.
The prefix is matched case-insensitively because the default APFS and NTFS
filesystems are case-insensitive, so an uppercase or mixed-case reserved
prefix would otherwise alias a long name's digest sidecar on disk.
"""
dot_index = file_name.rfind(".")
if dot_index > 0:
return f"{file_name[:dot_index]}{_DESCRIPTION_SUFFIX}"
return f"{file_name}{_DESCRIPTION_SUFFIX}"
sidecar = f"{file_name}{_DESCRIPTION_SUFFIX}"
if (
not file_name.lower().startswith(_DIGEST_DESCRIPTION_PREFIX)
and len(sidecar.encode("utf-8")) <= _MAX_DESCRIPTION_FILE_NAME_BYTES
):
return sidecar
digest = hashlib.sha256(file_name.encode("utf-8")).hexdigest()
return f"{_DIGEST_DESCRIPTION_PREFIX}{digest}{_DESCRIPTION_SUFFIX}"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The digest and verbatim branches still share an output namespace. For any long filename L, a valid short memory named ~memory-desc-<sha256(L)> produces the same description sidecar, so writing, rewriting, or deleting either memory can overwrite or remove the other memory’s description without any SHA-256 collision. Please make the two namespaces disjoint—for example, also encode or hash input names that start with the reserved digest prefix—and add a regression covering a long filename and its matching digest-stem short filename.



def _is_internal_file(file_name: str) -> bool:
Expand Down
Loading
Loading