feat: revamp entity memory for the second brain (#9177)

## Summary

**The second brain becomes buildable.** This PR makes entity memory real
— a brain that accumulates the people, projects and systems around the
user for six months without fragmenting, going stale, or forgetting how
to answer — and it ships the second-brain agent that proves it, live
over REST and MCP.

What a second brain can do after this PR that it could not before:

- **Remember by name.** `remember_about("Sarah Chen", ...)` upserts; the
store slugifies ids, resolves name/alias/type drift, and merges. The
model never invents an id and never touches a fact id.
- **Correct itself.** Stating "radar shipped" retires "radar is blocked
on review" (fact supersession: one judged model call in the write path,
threshold-gated, nothing deleted). Facts render with as-of dates, so
July's truth outranks March's.
- **Recall what the turn is about.** `recall(message)` injects the top-k
relevant entities plus an always-present entity directory — "not in the
directory" is a grounded no. The current message finally reaches the
stores (the single dropped parameter that made entity memory
write-only).
- **Search its whole self.** A real `search_learnings` query surface on
sqlite/postgres/async_postgres (JSONB `CAST` + `ILIKE`, slug-boundary
crossing, errors raised — never a silent `[]`), replacing the
15-most-recent-rows scan that made the brain degrade as it grew.
- **Point at its notes.** `remember_about(..., note="notes/radar.md")`
writes the index-to-content stitch the one-claim-one-home rule depends
on; links are reciprocal and recall shows linked entities by name.

### The tool surface, before and after

| | Before | After |
|---|---|---|
| Entity tools | 8 (two dead — `update_fact`/`delete_fact` needed ids
never shown to the model) | **4**: `remember_about` (incl. `note=`),
`link_entities`, `search_entities` (query optional = browse), `forget`
(fact retire / entity archive) |
| Capture | ALWAYS extraction pass (append-only, blind to existing
state, ≥1 LLM call/run) | **AGENTIC only** — recording is the agent's
job |
| Recall | keyed lookup that returned `None` in practice |
`recall(message)` → top-k + directory; `get()` keeps the keyed path |
| FileSystem default | 9 tools incl. `check_lines`/`delete_file`,
record-set instructions | **the notes seven**; `allow_delete=True` /
`include_tools` restore the rest; instructions are the notes contract |

### Breaking changes are confined to `EntityMemoryStore` and the
`FileSystem` defaults

That is deliberate and safe. Entity memory has no real users: every
cookbook TEST_LOG entry for it was PENDING, and on the default path it
never contributed anything to an agent — recall could not be keyed, so
it was write-only. Two of its eight tools were unusable even in
principle. `FileSystem` is weeks old with one behavior change already
shipped in 2.8.3; flipping its default now costs one release-note line.

**Migration for the old entity tools:**
`create_entity`/`update_entity`/`add_fact`/`add_event` → one
`remember_about` call; `add_relationship` → `link_entities`;
`update_fact`/`delete_fact` → state the new fact (supersession retires
the old one) or `forget(entity, fact=...)`; entity deletion →
`forget(entity)` archives (reversible), `store.delete()` hard-deletes.
**FileSystem:** `tools(allow_delete=True)` restores `delete_file`;
`include_tools` selects from the whole surface (incl. `check_lines`);
`FULL_TOOLS` stays exported.

Also in this PR: the LearningMachine guidance/data split
(`instructions()` on stores and the machine; `build_context()` is
data-only; manual door == automatic door, tested), the manual door with
`capture_hook()`, `RunContext` threading with signature-filtered
dispatch (narrow third-party stores keep working), a lossless config
round-trip (an AGENTIC machine no longer reloads as ALWAYS with zero
tools), the `decision_log` contentless-row fix, and two visibility
guards: a run with `user_id=None` while per-user stores are configured,
and a hand-placed machine with no `model=` (capture is a model call, so
without one the tools return "No model provided" and entity memory
retires nothing). Both latch and fire once. The `enable_user_memories`
deprecation and the `enable_agentic_memory` tool-name collision warning
were built, measured and removed: the first fired on every
`Agent.load()` and `deep_copy()` for a parameter the author never wrote,
the second on every run. The collision is a comment on the field
instead.

Spec: `specs/agno/2.8.4/README.md`. Build report (commit map, §8 test
mapping, seven adversarial review passes, deviations, live-proof
evidence): `specs/agno/2.8.4/notes/BUILD_REPORT.md`.

## Type of change

- [x] New feature
- [x] Breaking change
- [x] Bug fix

---

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [x] Self-review completed
- [x] Documentation updated (comments, docstrings)
- [x] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [x] Tested in clean environment
- [x] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [x] I have searched existing [open pull requests](../../pulls) and
confirmed that no other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

**Release notes (for the 2.8.4 changelog):**

- Entity memory revamped: AGENTIC-only capture and a four-tool surface
(`remember_about`, `link_entities`, `search_entities`, `forget`). Anyone
on the old eight tools migrates per the mapping above.
- FileSystem defaults flipped: `tools()` now returns the notes seven;
`allow_delete=True` restores `delete_file`, `include_tools` restores
`check_lines` or any of the full surface; the default `instructions()`
text was rewritten around the notes contract.
- The fact-supersession judge prompt wording changed from 2.8.3;
operators who overrode the three prompt strings on `EntityMemoryConfig`
should re-check their overrides against the new defaults.

- **Live proof:** the rebuilt second brain ran 8 REST turns (db verified
between turns: note pointer, reciprocal edges, supersession's
`superseded_by` id, archive flag, a private confidence landing in
per-user memory) and 4 fresh-session `/mcp` turns via an in-process
fastmcp client (pinned `user_id` carries one brain across channels).
`cookbook/examples/second_brain/TEST_LOG.md` records model behavior
honestly, including routing misses.
- **Out of scope, deliberately** (2.8.5+): the recap engine, tier-one
Memory fixes, `BaseLearningStore`, callable prompt hooks / render hook /
typed custom-schema tools, `search_summaries`, the remaining fifteen
`search_learnings` backend implementations, semantic retrieval over
notes, relationship traversal beyond one-hop names, `replace_text`,
`expected_version` on `write_file`, `learning="agentic"`.
- **Identity over `/mcp`, measured:** `run_agent` advertises an optional
`user_id`, and a host that fills it overrides a pinned
`Agent(user_id=...)` - that run's profile and user memory go to the name
the host sent. Entities are global, so the shared half of the brain is
unaffected. The second-brain cookbook says so and points at auth; a
code-level answer is 2.8.5-shaped.
- **No version bump** — the release PR is cut separately.
- Search folds separators and case on both sides: `multi-tenant` reaches
a stored `multi tenant` and the reverse, and a wrong-case non-ASCII
query (`CAFÉ`, `ΟΣ`) reaches its row on sqlite too - the JSON-escape
prefilter carries a wildcard per escaped character and the value-scoped
verification casefolds.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <[email protected]>
This commit is contained in:
Ashpreet
2026-07-26 18:02:20 +01:00
committed by GitHub
co-authored by Claude Fable 5
parent 5908093cad
commit 6aa13e8f85
78 changed files with 9457 additions and 3876 deletions
@@ -55,9 +55,7 @@ team = Team(
user_profile=UserProfileConfig(
mode=LearningMode.ALWAYS,
),
entity_memory=EntityMemoryConfig(
mode=LearningMode.ALWAYS,
),
entity_memory=EntityMemoryConfig(), # AGENTIC-only: the agent records through its four tools
),
markdown=True,
show_members_responses=True,
@@ -11,8 +11,8 @@ In agentic mode:
from agno.agent import Agent
from agno.db.postgres import PostgresDb
from agno.learn.config import LearningMode
from agno.learn.machine import LearningMachine
from agno.learn.mode import LearningMode
from agno.models.openai import OpenAIResponses
from agno.team import Team
@@ -0,0 +1,63 @@
"""
Entity Memory: The Four Tools
=============================
Entity memory is the agent's knowledge about the WORLD - the people,
projects, companies and systems around the user - as opposed to user
memory, which is about the user themselves.
It is AGENTIC-only: the agent records through four tools (remember_about,
link_entities, search_entities, forget), and the store does the librarian
work - ids are slugified from names, "Sarah Chen" and "sarah chen" resolve
to one person, and a correcting fact retires the stale one (supersession).
Deep dives: cookbook/08_learning/04_entity_memory/
Run:
.venvs/demo/bin/python cookbook/08_learning/01_basics/5_entity_memory.py
"""
from uuid import uuid4
from agno.agent import Agent
from agno.db.postgres import PostgresDb
from agno.learn import EntityMemoryConfig, LearningMachine
from agno.models.openai import OpenAIResponses
# ---------------------------------------------------------------------------
# Create Agent
# ---------------------------------------------------------------------------
db = PostgresDb(db_url="postgresql+psycopg://ai:ai@localhost:5532/ai")
# Fresh per-run namespace so the demo starts clean on every execution.
NAMESPACE = f"basics_{uuid4().hex[:6]}"
agent = Agent(
model=OpenAIResponses(id="gpt-5.5"),
db=db,
instructions="You are a sales assistant. Acknowledge notes briefly.",
learning=LearningMachine(
entity_memory=EntityMemoryConfig(namespace=NAMESPACE),
),
markdown=True,
)
# ---------------------------------------------------------------------------
# Run Demo
# ---------------------------------------------------------------------------
if __name__ == "__main__":
agent.print_response(
"Note on Acme Corp: fintech startup in SF, about 50 people. "
"Jane Smith is their CTO.",
session_id="s1",
stream=True,
)
# A fresh session: the entity directory plus relevance recall carry the
# context - no tool call needed to answer.
agent.print_response(
"What do we know about Acme?",
session_id="s2",
stream=True,
)
@@ -1,81 +0,0 @@
"""
Entity Memory: Always Mode
==========================
Entity Memory stores knowledge about external things:
- Companies, people, projects
- Facts, events, relationships
- Shared context across users
ALWAYS mode automatically extracts entity information from conversations.
No explicit tool calls - entities are discovered and saved behind the scenes.
Compare with: 5b_entity_memory_agentic.py for explicit tool-based management.
"""
from agno.agent import Agent
from agno.db.postgres import PostgresDb
from agno.learn import EntityMemoryConfig, LearningMachine, LearningMode
from agno.models.openai import OpenAIResponses
# ---------------------------------------------------------------------------
# Create Agent
# ---------------------------------------------------------------------------
db = PostgresDb(db_url="postgresql+psycopg://ai:ai@localhost:5532/ai")
# ALWAYS mode: Entities are extracted automatically after responses.
# The agent doesn't see memory tools - extraction happens invisibly.
agent = Agent(
model=OpenAIResponses(id="gpt-5.5"),
db=db,
instructions="You're a sales assistant. Acknowledge notes briefly.",
learning=LearningMachine(
entity_memory=EntityMemoryConfig(
mode=LearningMode.ALWAYS,
),
),
markdown=True,
)
# ---------------------------------------------------------------------------
# Run Demo
# ---------------------------------------------------------------------------
if __name__ == "__main__":
from rich.pretty import pprint
user_id = "[email protected]"
# Session 1: Mention entities naturally
print("\n" + "=" * 60)
print("SESSION 1: Discuss entities (extraction happens automatically)")
print("=" * 60 + "\n")
agent.print_response(
"Just met with Acme Corp. They're a fintech startup in SF, "
"50 employees. CTO is Jane Smith. They use Python and Postgres.",
user_id=user_id,
session_id="session_1",
stream=True,
)
print("\n--- Extracted Entities ---")
entities = agent.learning_machine.entity_memory_store.search(query="acme", limit=10)
pprint(entities)
# Session 2: Add more info about same entity
print("\n" + "=" * 60)
print("SESSION 2: Update same entity")
print("=" * 60 + "\n")
agent.print_response(
"Update on Acme Corp: they just raised $50M Series B from Sequoia. "
"Jane Smith mentioned they're hiring 20 engineers.",
user_id=user_id,
session_id="session_2",
stream=True,
)
print("\n--- Updated Entities ---")
entities = agent.learning_machine.entity_memory_store.search(query="acme", limit=10)
pprint(entities)
@@ -1,86 +0,0 @@
"""
Entity Memory: Agentic Mode
===========================
Entity Memory stores knowledge about external things:
- Companies, people, projects
- Facts, events, relationships
- Shared context across users
AGENTIC mode gives the agent explicit tools to manage entities:
- search_entities, create_entity
- add_fact, add_event, add_relationship
The agent decides when to store and retrieve information.
Compare with: 5a_entity_memory_always.py for automatic extraction.
"""
from agno.agent import Agent
from agno.db.postgres import PostgresDb
from agno.learn import EntityMemoryConfig, LearningMachine, LearningMode
from agno.models.openai import OpenAIResponses
# ---------------------------------------------------------------------------
# Create Agent
# ---------------------------------------------------------------------------
db = PostgresDb(db_url="postgresql+psycopg://ai:ai@localhost:5532/ai")
# AGENTIC mode: Agent gets entity tools and decides when to use them.
# You'll see tool calls like "create_entity", "add_fact" in responses.
agent = Agent(
model=OpenAIResponses(id="gpt-5.5"),
db=db,
instructions=(
"You're a sales assistant tracking companies and contacts. "
"Be concise. Always search for existing entities before creating new ones."
),
learning=LearningMachine(
entity_memory=EntityMemoryConfig(
mode=LearningMode.AGENTIC,
),
),
markdown=True,
)
# ---------------------------------------------------------------------------
# Run Demo
# ---------------------------------------------------------------------------
if __name__ == "__main__":
from rich.pretty import pprint
user_id = "[email protected]"
# Session 1: Create entity
print("\n" + "=" * 60)
print("SESSION 1: Create entity (watch for tool calls)")
print("=" * 60 + "\n")
agent.print_response(
"Track Acme Corp - fintech startup in SF, 50 employees, "
"uses Python and Postgres. CTO is Jane Smith.",
user_id=user_id,
session_id="session_1",
stream=True,
)
print("\n--- Created Entities ---")
entities = agent.learning_machine.entity_memory_store.search(query="acme", limit=10)
pprint(entities)
# Session 2: Update same entity
print("\n" + "=" * 60)
print("SESSION 2: Update existing entity")
print("=" * 60 + "\n")
agent.print_response(
"Acme Corp just raised $50M Series B from Sequoia.",
user_id=user_id,
session_id="session_2",
stream=True,
)
print("\n--- Updated Entities ---")
entities = agent.learning_machine.entity_memory_store.search(query="acme", limit=10)
pprint(entities)
@@ -15,7 +15,6 @@ max_updates_per_run caps tool executions:
from agno.agent import Agent
from agno.db.postgres import PostgresDb
from agno.learn import (
EntityMemoryConfig,
LearningMachine,
LearningMode,
UserMemoryConfig,
@@ -32,7 +31,6 @@ db = PostgresDb(db_url="postgresql+psycopg://ai:ai@localhost:5532/ai")
# Global max_updates_per_run=5 applies to all stores unless overridden.
# user_profile: inherits 5 from LearningMachine
# user_memory: explicit override to 3
# entity_memory: explicit override to 15 (dense entity info needs more)
agent = Agent(
model=OpenAIResponses(id="gpt-5.5"),
db=db,
@@ -40,9 +38,6 @@ agent = Agent(
max_updates_per_run=5,
user_profile=UserProfileConfig(mode=LearningMode.ALWAYS),
user_memory=UserMemoryConfig(mode=LearningMode.ALWAYS, max_updates_per_run=3),
entity_memory=EntityMemoryConfig(
mode=LearningMode.ALWAYS, max_updates_per_run=15
),
),
markdown=True,
debug_mode=True, # Shows "Tool call limit reached" logs
@@ -91,16 +86,3 @@ if __name__ == "__main__":
print("\n--- User Memory (limit: 3) ---")
lm.user_memory_store.print(user_id=user_id)
print("\n--- Entity Memory (limit: 15) ---")
from rich.pretty import pprint
entities = lm.entity_memory_store.search(query="techcorp", limit=20)
if entities:
pprint(entities)
else:
print("No entities found")
print("\n" + "=" * 70)
print("Check debug logs above for 'Tool call limit reached' messages")
print("=" * 70)
+1 -2
View File
@@ -11,5 +11,4 @@ Core learning primitives and default patterns.
- `3a_session_context_summary.py`: Session context summary tracking.
- `3b_session_context_planning.py`: Session context planning with goal and progress state.
- `4_learned_knowledge.py`: Learned knowledge retrieval and storage with vector search.
- `5a_entity_memory_always.py`: Entity memory extraction in ALWAYS mode.
- `5b_entity_memory_agentic.py`: Entity memory management in AGENTIC mode.
- `5_entity_memory.py`: The four entity tools (AGENTIC-only in 2.8.4).
+15 -8
View File
@@ -1,5 +1,11 @@
# Test Log: 01_basics
> 2026-07-25 (feat/entity-memory-revamp): 5a/5b replaced by 5_entity_memory.py -
> entity memory is AGENTIC-only on the four-tool surface. Ran live against gpt-5.5:
> PASS (Acme Corp captured with properties + CTO edge in s1; s2 answered from the
> injected block with no tool call). 6_extraction_limits.py no longer includes
> entity memory (it has no extraction pass to limit).
> Tests not yet run. Run each file and update this log.
### 1a_user_profile_always.py
@@ -72,23 +78,24 @@
---
### 5a_entity_memory_always.py
### 5_entity_memory.py
**Status:** PENDING
**Status:** PASS
**Description:** Run and validate `5a_entity_memory_always.py` example behavior.
**Description:** The four entity tools; capture in one session, recall in a fresh one.
**Result:** Not run yet.
**Result:** Run live 2026-07-25 (gpt-5.5): Acme Corp captured with properties and the CTO
edge in s1; s2 answered from the injected block with no tool call.
---
### 5b_entity_memory_agentic.py
### 6_extraction_limits.py
**Status:** PENDING
**Description:** Run and validate `5b_entity_memory_agentic.py` example behavior.
**Description:** max_updates_per_run caps for user_profile/user_memory (entity memory
removed - it has no extraction pass to limit).
**Result:** Not run yet.
**Result:** Not re-run in this pass.
---
@@ -1,103 +0,0 @@
"""
Entity Memory: Facts and Events (Deep Dive)
============================================
Semantic (facts) vs episodic (events) memory for entities.
Entity Memory stores knowledge about external entities:
- Facts: Timeless truths ("Acme uses PostgreSQL")
- Events: Time-bound occurrences ("Acme raised $30M on Jan 15")
AGENTIC mode gives the agent tools to create/update entities.
Compare with: 04_always_extraction.py for automatic extraction.
See also: 01_basics/5a_entity_memory_always.py for the basics.
"""
from agno.agent import Agent
from agno.db.postgres import PostgresDb
from agno.learn import EntityMemoryConfig, LearningMachine, LearningMode
from agno.models.openai import OpenAIResponses
# ---------------------------------------------------------------------------
# Create Agent
# ---------------------------------------------------------------------------
db = PostgresDb(db_url="postgresql+psycopg://ai:ai@localhost:5532/ai")
# Dense entity info (many facts/events) can trigger many updates per message.
# max_updates_per_run (default: 10) caps updates per extraction.
agent = Agent(
model=OpenAIResponses(id="gpt-5.5"),
db=db,
instructions=(
"Track information about companies and people. "
"Distinguish between facts (timeless) and events (time-bound)."
),
learning=LearningMachine(
entity_memory=EntityMemoryConfig(
mode=LearningMode.AGENTIC,
namespace="global",
),
),
markdown=True,
)
# ---------------------------------------------------------------------------
# Run Demo
# ---------------------------------------------------------------------------
if __name__ == "__main__":
from rich.pretty import pprint
user_id = "[email protected]"
session_id = "company_research"
# Share facts and events
print("\n" + "=" * 60)
print("MESSAGE 1: Share mixed facts and events")
print("=" * 60 + "\n")
agent.print_response(
"Notes from my meeting with DataPipe: "
"They're based in San Francisco. "
"They build real-time ETL infrastructure in Rust. "
"Their CTO is Marcus Chen. "
"They just hit 1000 customers last month. "
"Series B closed at $80M two weeks ago.",
user_id=user_id,
session_id=session_id,
stream=True,
)
print("\n--- Entities ---")
pprint(
agent.learning_machine.entity_memory_store.search(query="datapipe", limit=10)
)
# Query the entity
print("\n" + "=" * 60)
print("MESSAGE 2: Query the entity")
print("=" * 60 + "\n")
agent.print_response(
"What do we know about DataPipe?",
user_id=user_id,
session_id="session_2",
stream=True,
)
# Add more events
print("\n" + "=" * 60)
print("MESSAGE 3: Add more events")
print("=" * 60 + "\n")
agent.print_response(
"Update on DataPipe: They announced a partnership with BigCloud yesterday. "
"They're also opening a London office next quarter.",
user_id=user_id,
session_id="session_3",
stream=True,
)
print("\n--- Updated Entities ---")
pprint(
agent.learning_machine.entity_memory_store.search(query="datapipe", limit=10)
)
@@ -0,0 +1,86 @@
"""
Entity Memory: The Four Tools
=============================
Entity memory is the agent's knowledge base about the world: the people,
projects, companies and systems around the user. The agent records through
four tools:
- remember_about: upsert an entity by name - facts, events, a description,
and a note pointer. Resolution is the store's job (ids are slugified,
"Sarah Chen" and "sarah chen" are one person).
- link_entities: record a relationship; the edge is stored on both entities.
- search_entities: find entities, or list them by recency (no query).
- forget: retire a fact, or archive a whole entity.
Corrections are just new facts: stating "radar shipped" retires "radar is
blocked on review" automatically (fact supersession), and facts render with
as-of dates so newer truth outranks older.
Run:
.venvs/demo/bin/python cookbook/08_learning/04_entity_memory/01_the_four_tools.py
"""
from uuid import uuid4
from agno.agent import Agent
from agno.db.postgres import PostgresDb
from agno.learn import EntityMemoryConfig, LearningMachine
from agno.models.openai import OpenAIResponses
# ---------------------------------------------------------------------------
# Create Agent
# ---------------------------------------------------------------------------
db = PostgresDb(db_url="postgresql+psycopg://ai:ai@localhost:5532/ai")
# Fresh per-run namespace so the demo starts clean on every execution.
# A real deployment pins one namespace - that persistence is the point.
NAMESPACE = f"four_tools_{uuid4().hex[:6]}"
agent = Agent(
model=OpenAIResponses(id="gpt-5.5"),
db=db,
instructions="You are a project tracker. Record what you are told, briefly.",
learning=LearningMachine(
entity_memory=EntityMemoryConfig(namespace=NAMESPACE),
),
markdown=True,
)
# ---------------------------------------------------------------------------
# Run Demo
# ---------------------------------------------------------------------------
if __name__ == "__main__":
store = agent.learning_machine.entity_memory_store
print("=" * 60)
print("TURN 1: capture a project and a person")
print("=" * 60)
agent.print_response(
"Track the radar project: it is blocked on security review, and Sarah Chen is the designer.",
session_id="s1",
stream=True,
)
print("=" * 60)
print("TURN 2: a correction - supersession retires the stale fact")
print("=" * 60)
agent.print_response(
"Good news: radar shipped v1 to production today.",
session_id="s2",
stream=True,
)
print("\n--- radar, live facts only (the blocked fact is retired, not deleted) ---")
store.print(entity_id="radar", entity_type="project", namespace=NAMESPACE)
print("=" * 60)
print("TURN 3: recall in a fresh session - the entity directory plus")
print("relevance recall inject what this turn is about")
print("=" * 60)
agent.print_response(
"What do you know about radar?",
session_id="s3",
stream=True,
)
@@ -1,104 +0,0 @@
"""
Entity Memory: Relationships (Deep Dive)
========================================
Graph edges between entities.
Relationships connect entities to form a knowledge graph:
- "Bob is CTO of Acme"
- "Acme acquired StartupX"
- "API Gateway depends on Auth Service"
AGENTIC mode lets the agent create entities and add relationships.
Compare with: 01_facts_and_events.py for facts/events.
See also: 01_basics/5b_entity_memory_agentic.py for the basics.
"""
from agno.agent import Agent
from agno.db.postgres import PostgresDb
from agno.learn import EntityMemoryConfig, LearningMachine, LearningMode
from agno.models.openai import OpenAIResponses
# ---------------------------------------------------------------------------
# Create Agent
# ---------------------------------------------------------------------------
db = PostgresDb(db_url="postgresql+psycopg://ai:ai@localhost:5532/ai")
# Org charts and relationship-heavy prompts can trigger many entity updates.
# max_updates_per_run (default: 10) caps updates per extraction. Increase
# if your prompts contain many entities.
agent = Agent(
model=OpenAIResponses(id="gpt-5.5"),
db=db,
instructions=(
"Build a knowledge graph of entities and their relationships. "
"Use appropriate relation types: works_at, reports_to, acquired, depends_on, etc."
),
learning=LearningMachine(
entity_memory=EntityMemoryConfig(
mode=LearningMode.AGENTIC,
max_updates_per_run=15,
),
),
markdown=True,
)
# ---------------------------------------------------------------------------
# Run Demo
# ---------------------------------------------------------------------------
if __name__ == "__main__":
from rich.pretty import pprint
user_id = "[email protected]"
session_id = "org_session"
# Define org structure
print("\n" + "=" * 60)
print("MESSAGE 1: Define org structure")
print("=" * 60 + "\n")
agent.print_response(
"TechCorp's leadership: "
"Sarah Chen is the CEO and founder. "
"Bob Martinez is the CTO, reporting to Sarah. "
"Alice Kim leads Engineering under Bob. "
"DevOps and Backend teams report to Alice.",
user_id=user_id,
session_id=session_id,
stream=True,
)
print("\n--- Entities ---")
pprint(
agent.learning_machine.entity_memory_store.search(query="techcorp", limit=10)
)
# Query relationships
print("\n" + "=" * 60)
print("MESSAGE 2: Query relationships")
print("=" * 60 + "\n")
agent.print_response(
"Who reports to Bob Martinez?",
user_id=user_id,
session_id="session_2",
stream=True,
)
# Add more relationships
print("\n" + "=" * 60)
print("MESSAGE 3: Company relationships")
print("=" * 60 + "\n")
agent.print_response(
"TechCorp just acquired StartupAI for $50M. "
"They also partnered with CloudCo on infrastructure.",
user_id=user_id,
session_id="session_3",
stream=True,
)
print("\n--- Updated Entities ---")
pprint(
agent.learning_machine.entity_memory_store.search(query="techcorp", limit=10)
)
@@ -0,0 +1,86 @@
"""
Entity Memory: Links, Browse and Forget
=======================================
The graph half of entity memory:
- link_entities writes the edge on BOTH entities, so "who works on radar?"
is answerable from either side, and recall shows linked entities by NAME
(one-hop expansion): "radar - designed_by <- Sarah Chen".
- search_entities with no query lists entities by recency - the browse
surface ("who works on what" needs enumeration).
- forget archives an entity: it leaves recall and the directory, stays
findable by explicit search with an (archived) marker, and any later
remember_about revives it.
Run:
.venvs/demo/bin/python cookbook/08_learning/04_entity_memory/02_links_and_forget.py
"""
from uuid import uuid4
from agno.agent import Agent
from agno.db.postgres import PostgresDb
from agno.learn import EntityMemoryConfig, LearningMachine
from agno.models.openai import OpenAIResponses
# ---------------------------------------------------------------------------
# Create Agent
# ---------------------------------------------------------------------------
db = PostgresDb(db_url="postgresql+psycopg://ai:ai@localhost:5532/ai")
# Fresh per-run namespace so the demo starts clean on every execution.
# A real deployment pins one namespace - that persistence is the point.
NAMESPACE = f"links_{uuid4().hex[:6]}"
agent = Agent(
model=OpenAIResponses(id="gpt-5.5"),
db=db,
instructions="You are a team tracker. Record what you are told, briefly.",
learning=LearningMachine(
entity_memory=EntityMemoryConfig(namespace=NAMESPACE),
),
markdown=True,
)
# ---------------------------------------------------------------------------
# Run Demo
# ---------------------------------------------------------------------------
if __name__ == "__main__":
store = agent.learning_machine.entity_memory_store
print("=" * 60)
print("TURN 1: capture people, projects and their links")
print("=" * 60)
agent.print_response(
"Sarah Chen designs the radar project. Tom Alvarez runs the infra platform. "
"Radar depends on the infra platform.",
session_id="s1",
stream=True,
)
print("\n--- the edge is on BOTH rows (reciprocal, with the far end's type) ---")
store.print(entity_id="radar", entity_type="project", namespace=NAMESPACE)
store.print(entity_id="sarah_chen", entity_type="person", namespace=NAMESPACE)
print("=" * 60)
print("TURN 2: browse - no query lists entities by recency")
print("=" * 60)
agent.print_response(
"Who and what are you tracking right now? List everything.",
session_id="s2",
stream=True,
)
print("=" * 60)
print("TURN 3: archive - 'we killed radar' is a status change")
print("=" * 60)
agent.print_response(
"We cancelled the radar project. Archive it.",
session_id="s3",
stream=True,
)
print("\n--- archived: out of the directory, still searchable ---")
print(store.search_entities(query="radar", namespace=NAMESPACE))
@@ -1,8 +1,25 @@
# 04_entity_memory
Deep-dive examples for entity memory, facts, events, and relationships.
Deep-dive examples for entity memory: the agent's knowledge base about the
world (people, projects, companies, systems), revamped in 2.8.4 around four
tools with resolution, supersession and relevance recall in the store.
## Files
- `01_facts_and_events.py`: Distinguish timeless facts from time-bound events.
- `02_entity_relationships.py`: Capture and query entity relationships.
- `01_the_four_tools.py`: remember_about, a correction retiring a stale fact
via supersession, and relevance recall in a fresh session.
- `02_links_and_forget.py`: reciprocal links with one-hop names on recall,
browsing by recency, and archive/revive with forget.
## The four tools
| Tool | Job |
|---|---|
| `remember_about` | Upsert an entity by name: facts, events, description, `note=` pointer |
| `link_entities` | Record a relationship; the edge lands on both entities |
| `search_entities` | Find entities; with no query, list by recency (browse) |
| `forget` | Retire a fact, or archive a whole entity (reversible) |
There are no ids to invent and no fact ids to track: resolution (slugified
ids, aliases, normalized types) and correction (fact supersession with as-of
dates) are the store's job.
@@ -1,24 +1,40 @@
# Test Log: 04_entity_memory
> Tests not yet run. Run each file and update this log.
> Tested 2026-07-25 against gpt-5.5 (OpenAIResponses), branch feat/entity-memory-revamp,
> Postgres (pgvector container on 5532). Rebuilt on the four-tool surface.
### 01_facts_and_events.py
### 01_the_four_tools.py
**Status:** PENDING
**Status:** PASS
**Description:** Run and validate `01_facts_and_events.py` example behavior.
**Description:** Capture (facts + events + link via remember_about/link_entities), a
correction, and relevance recall in a fresh session.
**Result:** Not run yet.
**Result:** Turn 2's "radar shipped v1" retired the "blocked on security review" fact via
supersession (tool reply: "Superseded 1 earlier fact(s)"); the live-facts print shows only
"status: v1 shipped to production (as of 2026-07-25)" plus the reciprocal designer edge.
Turn 3 in a fresh session recalled radar through the message-driven path and answered from
the injected block. Model behavior note: the agent also chose to record the ship as both a
fact (status line) and an event - exactly the intended split.
Earlier phrasing note (kept for honesty): with turn 2 phrased "no longer using Postgres, we
switched to SQLite for the prototype", the judge did NOT supersede "uses Postgres" -
it read "for the prototype" as scoping that can coexist with prod Postgres. Defensible
conservatism; the demo now uses the unambiguous blocked->shipped correction.
---
### 02_entity_relationships.py
### 02_links_and_forget.py
**Status:** PENDING
**Status:** PASS
**Description:** Run and validate `02_entity_relationships.py` example behavior.
**Description:** Reciprocal links, one-hop names, browse-by-recency, archive via forget.
**Result:** Not run yet.
**Result:** The edge landed on both rows with the far end's type (radar: "designs <-
sarah_chen"; sarah_chen: outgoing twin). Browse turn listed all tracked entities with the
directory. The archive turn called forget; the explicit search then showed
"**radar project** (project) (archived)" while the entity left the directory. Model
behavior note: the agent named the entity "radar project" (slug radar_project) rather than
"radar" - consistent within the run, and resolution keeps later variants merged.
---
@@ -48,8 +48,7 @@ def create_personal_assistant(user_id: str, session_id: str) -> Agent:
session_context=SessionContextConfig(
enable_planning=True,
),
entity_memory=EntityMemoryConfig(
mode=LearningMode.ALWAYS,
entity_memory=EntityMemoryConfig( # AGENTIC-only: the agent records through its four tools
namespace=f"user:{user_id}:personal",
),
),
@@ -65,8 +65,7 @@ def create_support_agent(customer_id: str, ticket_id: str, org_id: str) -> Agent
session_context=SessionContextConfig(
enable_planning=True,
),
entity_memory=EntityMemoryConfig(
mode=LearningMode.ALWAYS,
entity_memory=EntityMemoryConfig( # AGENTIC-only: the agent records through its four tools
namespace=f"org:{org_id}:support",
),
learned_knowledge=LearnedKnowledgeConfig(
@@ -1,66 +0,0 @@
"""
Decision Logs: ALWAYS Mode (Automatic Logging)
===============================================
This example demonstrates automatic decision logging where
tool calls are automatically recorded as decisions.
In ALWAYS mode, DecisionLogStore extracts decisions from:
- Tool calls (which tool was used)
- Other significant choices the agent makes
Run:
.venvs/demo/bin/python cookbook/08_learning/09_decision_logs/02_decision_log_always.py
"""
from agno.agent import Agent
from agno.db.postgres import PostgresDb
from agno.learn import DecisionLogConfig, LearningMachine, LearningMode
from agno.models.openai import OpenAIResponses
from agno.tools.duckduckgo import DuckDuckGoTools
# ---------------------------------------------------------------------------
# Setup
# ---------------------------------------------------------------------------
# Database connection
db = PostgresDb(db_url="postgresql+psycopg://ai:ai@localhost:5532/ai")
# ---------------------------------------------------------------------------
# Create Agent
# ---------------------------------------------------------------------------
# Create an agent with automatic decision logging
# ALWAYS mode: Tool calls are automatically logged as decisions
agent = Agent(
id="auto-decision-logger",
name="Auto Decision Logger",
model=OpenAIResponses(id="gpt-5.5"),
db=db,
learning=LearningMachine(
decision_log=DecisionLogConfig(
mode=LearningMode.ALWAYS,
),
),
tools=[DuckDuckGoTools()],
instructions=[
"You are a helpful research assistant.",
"Use web search to find current information when needed.",
],
markdown=True,
)
# ---------------------------------------------------------------------------
# Run Agent
# ---------------------------------------------------------------------------
if __name__ == "__main__":
# Test: Agent uses a tool (will be logged automatically)
print("=== Test: Agent uses web search ===\n")
agent.print_response(
"What are the latest developments in AI agents?",
session_id="session-002",
)
# View auto-logged decisions
print("\n=== Auto-Logged Decisions ===\n")
decision_store = agent.learning_machine.decision_log_store
if decision_store:
decision_store.print(agent_id="auto-decision-logger", limit=10)
@@ -0,0 +1,62 @@
"""
Decision Logs: Recording Outcomes
=================================
The feedback half of the decision log: log_decision records the choice with
its reasoning, and record_outcome closes the loop later with what actually
happened. Decision logging is AGENTIC-only - the agent logs deliberately;
there is no automatic extraction pass.
Run:
.venvs/demo/bin/python cookbook/08_learning/09_decision_logs/02_record_outcomes.py
"""
from agno.agent import Agent
from agno.db.postgres import PostgresDb
from agno.learn import DecisionLogConfig, LearningMachine
from agno.models.openai import OpenAIResponses
# ---------------------------------------------------------------------------
# Create Agent
# ---------------------------------------------------------------------------
db = PostgresDb(db_url="postgresql+psycopg://ai:ai@localhost:5532/ai")
agent = Agent(
id="outcome-logger",
name="Outcome Logger",
model=OpenAIResponses(id="gpt-5.5"),
db=db,
learning=LearningMachine(
decision_log=DecisionLogConfig(),
),
instructions=[
"You are an engineering advisor.",
"When you make a recommendation, log it as a decision with your reasoning.",
"When told how a past recommendation worked out: first call "
"search_decisions to find that decision and its id, then call "
"record_outcome with that id. Never ask the user for a decision id.",
],
markdown=True,
)
# ---------------------------------------------------------------------------
# Run Demo
# ---------------------------------------------------------------------------
if __name__ == "__main__":
agent.print_response(
"Should we use Postgres or DynamoDB for the new billing service? "
"We need transactions and our team knows SQL.",
session_id="s1",
stream=True,
)
agent.print_response(
"Update: we went with your Postgres recommendation and the migration "
"went smoothly. Record that outcome.",
session_id="s2",
stream=True,
)
print("\n--- the decision log, with its outcome ---")
agent.learning_machine.decision_log_store.print(agent_id="outcome-logger")
@@ -5,4 +5,4 @@ Examples for capturing and reviewing agent decision logs.
## Files
- `01_basic_decision_log.py`: AGENTIC decision logging with explicit tool usage.
- `02_decision_log_always.py`: ALWAYS-mode automatic decision logging.
- `02_record_outcomes.py`: Close the loop - record how a logged decision worked out.
@@ -1,24 +1,30 @@
# Test Log: 09_decision_logs
> Tests not yet run. Run each file and update this log.
> Tested 2026-07-25 against gpt-5.5 (OpenAIResponses), branch feat/entity-memory-revamp.
> Decision logging is AGENTIC-only in 2.8.4; the old 02_decision_log_always.py showcased
> the deleted contentless-row extraction and is replaced by 02_record_outcomes.py.
### 01_basic_decision_log.py
**Status:** PENDING
**Description:** Run and validate `01_basic_decision_log.py` example behavior.
**Description:** Basic log_decision / search_decisions flow.
**Result:** Not run yet.
**Result:** Not re-run in this pass (unchanged API surface; the store default is now
AGENTIC, which this file already used implicitly through LearningMachine).
---
### 02_decision_log_always.py
### 02_record_outcomes.py
**Status:** PENDING
**Status:** PASS
**Description:** Run and validate `02_decision_log_always.py` example behavior.
**Description:** log_decision in one session, record_outcome in a later one.
**Result:** Not run yet.
**Result:** The Postgres-vs-DynamoDB recommendation was logged with full reasoning; the
follow-up session searched, found the decision id and recorded outcome "migration went
smoothly" with quality good. Honest note: on the first run the agent logged a meta-decision
asking for the id instead of searching - the instructions now say search first, never ask
for an id, and the re-run recorded the outcome correctly.
---
@@ -0,0 +1,37 @@
# 11_composition
The manual door. `learning=` is the automatic door: pass it and the framework
attaches context, instructions and tools at fixed positions. Don't pass it,
and the framework attaches nothing - you place the machine's three public
surfaces yourself, the way FileSystem composes. Read this folder next to
`00_quickstart` to see the two doors side by side.
## Files
- `basic.py`: tools=[*learning.get_tools()] + instructions=[learning.instructions()].
- `with_filesystem.py`: LearningMachine + FileSystem + your own system prompt,
in one deliberate order.
- `context_block.py`: build_context() placed via additional_context - data
without tools.
- `always_capture.py`: post_hooks=[learning.capture_hook()] - ALWAYS-mode
extraction through the manual door (the escape hatch).
## The three surfaces
| Surface | Returns | Place it in |
|---|---|---|
| `learning.get_tools(user_id=...)` | the capture tools | `tools=[...]` |
| `learning.instructions()` | the guidance block | `instructions=[...]` |
| `learning.build_context(user_id=..., message=...)` | the recalled data | `additional_context` / a dependency |
The manual door injects nothing - give the machine its `db` and its `model`
explicitly. Every capture path is a model call: without one, `update_profile`
and `update_user_memory` return "No model provided", `capture_hook`'s ALWAYS
extraction stores nothing, and entity memory keeps every stated fact instead of
retiring the ones it contradicts. `get_tools()` warns once when a store is in
that state.
The manual door is agentic by nature: with no `learning=` there is no
automatic post-run extraction, and the tools are the capture mechanism.
Passing the same machine to `learning=` AND placing its surfaces by hand
renders the blocks twice - the framework warns once when it detects that.
@@ -0,0 +1,70 @@
# Test Log: 11_composition
> Tested 2026-07-25 against gpt-5.5 (OpenAIResponses), branch feat/entity-memory-revamp,
> Postgres (pgvector container on 5532). Re-tested 2026-07-26 after the missing-model fix.
### basic.py
**Status:** PASS
**Result:** With no learning=, the hand-placed tools captured the preference (user memory)
and the Meridian project + Priya link (entity memory); the printed manual-door surfaces
show the guidance block and a data block whose relevance recall expanded Meridian for the
message "what about meridian?" with the one-hop "runs <- Priya" edge.
**2026-07-26 correction:** the first log was wrong about the user memory. The machine
carried no `model=`, so `update_user_memory` returned "No model provided for memories
extraction" and stored nothing - only the entity write (no model needed) landed. The
same hole `always_capture.py` hit below; the manual door injects nothing. Re-run with
`model=` on the machine: `update_user_memory` stored "Prefers sources with primary data",
verified in the learnings table.
---
### with_filesystem.py
**Status:** PASS
**Result:** One deliberate order: learning tools + fs tools + both instruction blocks. The
agent wrote notes/vector-db-comparison.md with the deadline. Model behavior note: it also
wrote the "conclusions first" preference INTO the note alongside saving it - the
one-claim-one-home discipline is exactly what the second-brain instructions add on top.
**2026-07-26 correction:** same missing `model=`, so the note was written but the
preference was not stored. Re-run with the model: `update_user_memory(task=User prefers
conclusions first in every summary.)` and `append_file(notes/tasks.md)` both fired, and
the memory is in the table.
---
### context_block.py
**Status:** PASS
**Result:** build_context() placed via additional_context, no tools: the read-only agent
answered from its own knowledge.
**2026-07-26 correction:** the earlier "despite the seeded memory in the context, the
summary put its conclusion last" reads a model failure into a store failure - the seeding
call needed a model too, so nothing was seeded and the context block was empty. With
`model=` on the machine the seed lands; the answer still leads with its list and closes
with the conclusion, so the original observation (a data-only block informs but does not
compel) holds on the re-run.
---
### always_capture.py
**Status:** PASS
**Result:** post_hooks=[learning.capture_hook()] ran ALWAYS extraction in the background:
profile (Name/Preferred Name: Dana) and one memory (data engineer, Lisbon, ClickHouse
pipelines) appeared without any tool call. First run FAILED with empty stores - the manual
door injects nothing, so the machine needed model= passed explicitly; the file and README
now say so.
**2026-07-26:** this was the only file that had learned the lesson. `LearningMachine.get_tools()`
now warns once when a store that captures through a model has none, so the next person
finds out at attach time instead of from an empty table.
---
@@ -0,0 +1,51 @@
"""
Composition: ALWAYS Capture Through the Manual Door
===================================================
The manual door has no automatic post-run extraction - the tools are the
capture mechanism. For hand-placed prompts AND ALWAYS-mode extraction,
capture_hook() returns a post_hooks-compatible callable around the machine's
capture pass (backgrounded on the agent's executor). An escape hatch, not a
third shape.
Run:
.venvs/demo/bin/python cookbook/08_learning/11_composition/always_capture.py
"""
import time
from agno.agent import Agent
from agno.db.postgres import PostgresDb
from agno.learn import LearningMachine
from agno.models.openai import OpenAIResponses
db = PostgresDb(db_url="postgresql+psycopg://ai:ai@localhost:5532/ai")
# ALWAYS-mode stores: extraction runs after the response, no agent tools needed.
# The manual door injects nothing: the machine needs its db AND its model
# given explicitly (extraction is a model call).
learning = LearningMachine(
db=db, model=OpenAIResponses(id="gpt-5.5"), user_profile=True, user_memory=True
)
USER_ID = "[email protected]"
agent = Agent(
model=OpenAIResponses(id="gpt-5.5"),
db=db,
instructions=["You are a helpful assistant."],
post_hooks=[learning.capture_hook()],
user_id=USER_ID,
markdown=True,
)
if __name__ == "__main__":
agent.print_response(
"I'm Dana, a data engineer in Lisbon. I mostly work on our ClickHouse pipelines.",
stream=True,
)
# The capture pass runs in the background; give it a moment before reading
time.sleep(10)
print("\n--- what ALWAYS capture extracted ---")
learning.user_profile_store.print(user_id=USER_ID)
learning.user_memory_store.print(user_id=USER_ID)
@@ -0,0 +1,67 @@
"""
Composition: The Manual Door
============================
learning= is the automatic door: the framework injects context, instructions
and tools for you. This folder is the other door - no learning= at all. You
place the three public surfaces yourself, the way FileSystem composes:
- learning.get_tools(...) the capture tools
- learning.instructions() the guidance block (how to use them)
- learning.build_context(...) the recalled-data block
An agent with no learning= has no automatic capture: the manual door is
agentic by nature - the agent captures by calling the tools you handed it.
Run:
.venvs/demo/bin/python cookbook/08_learning/11_composition/basic.py
"""
from agno.agent import Agent
from agno.db.postgres import PostgresDb
from agno.learn import LearningMachine, LearningMode, UserMemoryConfig
from agno.models.openai import OpenAIResponses
# ---------------------------------------------------------------------------
# Build the machine, place its surfaces by hand
# ---------------------------------------------------------------------------
db = PostgresDb(db_url="postgresql+psycopg://ai:ai@localhost:5532/ai")
# The manual door injects nothing: without learning= nobody hands the machine
# the agent's model, and capture is a model call.
learning = LearningMachine(
db=db,
model=OpenAIResponses(id="gpt-5.5"),
user_memory=UserMemoryConfig(mode=LearningMode.AGENTIC),
entity_memory=True,
)
USER_ID = "[email protected]"
agent = Agent(
model=OpenAIResponses(id="gpt-5.5"),
db=db,
tools=[*learning.get_tools(user_id=USER_ID)],
instructions=[
"You are a research assistant.",
learning.instructions(),
],
user_id=USER_ID,
markdown=True,
)
# ---------------------------------------------------------------------------
# Run
# ---------------------------------------------------------------------------
if __name__ == "__main__":
agent.print_response(
"Remember that I prefer sources with primary data, and track the "
"Meridian project - Priya runs it.",
stream=True,
)
print("\n--- what the manual door placed (guidance + data) ---")
print(learning.instructions()[:400])
print("...")
print(learning.build_context(user_id=USER_ID, message="what about meridian?"))
@@ -0,0 +1,41 @@
"""
Composition: The Data Block via additional_context
==================================================
build_context() returns the recalled-data block on its own, for when you
want the data injected but not the tools - a read-only view of what the
machine knows, placed exactly where you choose.
Run:
.venvs/demo/bin/python cookbook/08_learning/11_composition/context_block.py
"""
from agno.agent import Agent
from agno.db.postgres import PostgresDb
from agno.learn import LearningMachine, LearningMode, UserMemoryConfig
from agno.models.openai import OpenAIResponses
db = PostgresDb(db_url="postgresql+psycopg://ai:ai@localhost:5532/ai")
learning = LearningMachine(
db=db,
model=OpenAIResponses(id="gpt-5.5"), # the manual door injects nothing
user_memory=UserMemoryConfig(mode=LearningMode.AGENTIC),
)
USER_ID = "[email protected]"
# Seed a memory through the data API so the read-only agent has something to see
store = learning.user_memory_store
store.get_tools(user_id=USER_ID)[0]("Prefers conclusions first, then supporting detail")
# No learning=, no tools: just the data block, placed as additional context
agent = Agent(
model=OpenAIResponses(id="gpt-5.5"),
db=db,
additional_context=learning.build_context(user_id=USER_ID),
user_id=USER_ID,
markdown=True,
)
if __name__ == "__main__":
agent.print_response("Summarize why teams adopt vector databases.", stream=True)
@@ -0,0 +1,50 @@
"""
Composition: LearningMachine + FileSystem, One Deliberate Order
===============================================================
The point of the manual door: LearningMachine, FileSystem and your own
system prompt compose in one order you can read off the page. Nothing is
attached behind your back.
Run:
.venvs/demo/bin/python cookbook/08_learning/11_composition/with_filesystem.py
"""
from agno.agent import Agent
from agno.db.postgres import PostgresDb
from agno.fs import FileSystem
from agno.learn import LearningMachine, LearningMode, UserMemoryConfig
from agno.models.openai import OpenAIResponses
db = PostgresDb(db_url="postgresql+psycopg://ai:ai@localhost:5532/ai")
learning = LearningMachine(
db=db,
model=OpenAIResponses(id="gpt-5.5"), # the manual door injects nothing
user_memory=UserMemoryConfig(mode=LearningMode.AGENTIC),
)
fs = FileSystem(db, namespace="composition-notes")
USER_ID = "[email protected]"
agent = Agent(
model=OpenAIResponses(id="gpt-5.5"),
db=db,
tools=[*learning.get_tools(user_id=USER_ID), fs.tools()],
instructions=[
"You are a research assistant. Keep running notes on topics you research.",
learning.instructions(),
fs.instructions(),
],
user_id=USER_ID,
markdown=True,
)
if __name__ == "__main__":
agent.print_response(
"Note down: the vector-db comparison is due Friday. And remember that "
"I want conclusions first in every summary.",
stream=True,
)
print("\n--- files ---")
for f in fs.list():
print(f.path)
+22 -13
View File
@@ -57,8 +57,8 @@ cookbook/08_learning/
│ ├── 3a_session_context_summary.py
│ ├── 3b_session_context_planning.py
│ ├── 4_learned_knowledge.py
│ ├── 5a_entity_memory_always.py
│ └── 5b_entity_memory_agentic.py
│ ├── 5_entity_memory.py
│ └── 6_extraction_limits.py
│
├── 02_user_profile/ # Deep dives into user profiles
│ ├── 01_always_extraction.py
@@ -69,9 +69,9 @@ cookbook/08_learning/
│ ├── 01_summary_mode.py
│ └── 02_planning_mode.py
│
├── 04_entity_memory/ # Deep dives into entity memory
│ ├── 01_facts_and_events.py
│ └── 02_entity_relationships.py
├── 04_entity_memory/ # Deep dives into entity memory (the four tools)
│ ├── 01_the_four_tools.py
│ └── 02_links_and_forget.py
│
├── 05_learned_knowledge/ # Deep dives into learned knowledge
│ ├── 01_agentic_mode.py
@@ -88,14 +88,20 @@ cookbook/08_learning/
│ ├── 01_minimal_custom_store.py
│ └── 02_custom_store_with_db.py
│
├── 09_decision_logs/ # Decision logging and auditing
├── 09_decision_logs/ # Decision logging and auditing (AGENTIC-only)
│ ├── 01_basic_decision_log.py
│ └── 02_decision_log_always.py
│ └── 02_record_outcomes.py
│
└── 10_demo/ # AgentOS demo: browse learnings in the UI
├── agents.py
├── seed.py
└── run.py
├── 10_demo/ # AgentOS demo: browse learnings in the UI
│ ├── agents.py
│ ├── seed.py
│ └── run.py
│
└── 11_composition/ # The manual door: place the surfaces yourself
├── basic.py
├── with_filesystem.py
├── context_block.py
└── always_capture.py
```
## Running the Cookbooks
@@ -388,7 +394,9 @@ agent = Agent(
Captures knowledge about external entities: companies, projects, people, products, systems.
**Supported modes:** ALWAYS, AGENTIC
**Supported modes:** AGENTIC only. The agent records through four tools
(`remember_about`, `link_entities`, `search_entities`, `forget`); there is no
extraction pass, and any other mode raises.
**Three types of entity data:**
- **Facts** (semantic memory): Timeless truths - "Uses PostgreSQL"
@@ -420,7 +428,8 @@ agent.run("What database does Acme use?")
Records decisions the agent makes, with reasoning and alternatives considered. Useful for auditing agent behavior and building feedback loops.
**Supported modes:** ALWAYS, AGENTIC
**Supported modes:** AGENTIC. The decision is the agent's to record, so it
records it with `log_decision`.
**Scope:** Per agent - stored and retrieved by `agent_id`.
@@ -5,6 +5,10 @@ Deduplicate work with a record log. The agent calls check_lines before it
acts and append_file after. The log matches on exact lines and outlives the
process, so a recurring job never repeats work it has already done.
check_lines is not in the default toolset (the default is the notes seven);
a feed-processing agent requests it explicitly via include_tools, and carries
the record-set conventions in its own instructions.
This example processes two overlapping ticket batches. The second pass acts
only on the genuinely new ticket.
"""
@@ -24,15 +28,29 @@ DB_FILE = f"tmp/agent_fs_records_{uuid4().hex}.db"
fs = FileSystem(SqliteDb(db_file=DB_FILE))
# The record-set conventions, carried by the agents that use the pattern.
RECORD_SET_INSTRUCTIONS = (
"To keep a record set (items already processed): store one record per line "
"in the seen/ directory, one file per date (seen/2026-07-24.md). Call "
"check_lines(lines, directory='seen') BEFORE acting, then record the new "
"ones with append_file(..., unique=True). check_lines matches exact whole "
"lines, and unique=True keeps the log free of duplicates if a run overlaps "
"another one."
)
# ---------------------------------------------------------------------------
# Create Agent
# ---------------------------------------------------------------------------
agent = Agent(
model=OpenAIResponses(id="gpt-5.5"),
tools=[fs.tools()],
tools=[
fs.tools(
include_tools=["check_lines", "append_file", "read_file", "list_files"]
)
],
instructions=[
"You triage support tickets.",
fs.instructions(),
RECORD_SET_INSTRUCTIONS,
],
)
@@ -48,15 +48,20 @@ fs = FileSystem(SqliteDb(db_file=DB_FILE))
# ---------------------------------------------------------------------------
agent = Agent(
model=OpenAIResponses(id="gpt-5.5"),
tools=[fs.tools()],
# check_lines is not in the default toolset; a feed agent asks for it by name.
tools=[
fs.tools(
include_tools=["check_lines", "append_file", "read_file", "list_files"]
)
],
instructions=[
"You are Radar, a news-brief agent that runs on a schedule. Each run you "
"receive the current feed as 'id|headline' lines. Report ONLY stories you "
"have never reported before: call check_lines on the ids with "
"directory='seen' first, brief the missing ones (one bullet each), then "
f"record the newly reported ids with append_file to seen/{TODAY}.md, one "
"id per line. If nothing is new, say so.",
fs.instructions(),
"id per line, with unique=True. If nothing is new, say so. check_lines "
"matches exact whole lines, so pass ids exactly as you will store them.",
],
)
@@ -10,7 +10,7 @@ The main reason to reach for one is per-user (and per-team) file stores from a s
- `basic.py`: the declarative common case, `namespace="assistant/{user_id}"`. One agent serves alice and bob with fully isolated files, and an anonymous run fails closed instead of collapsing into a shared store.
- `custom_factory.py`: the escape hatch for arbitrary policy. A callable tool factory builds the FileSystem from `run_context`, and here VIP users get their own tier of namespaces.
- `shared_namespace.py`: two agents share files by attaching the same namespace name. The producer writes, and the consumer attaches with `tools(read_only=True)` plus `instructions(read_only=True)`, which gives it four read tools and no way to write.
- `shared_namespace.py`: two agents share files by attaching the same namespace name. The producer writes, and the consumer attaches with `tools(read_only=True)` plus `instructions(read_only=True)`, which gives it three read tools and no way to write.
## When to use
@@ -44,3 +44,13 @@ folder now passes `fs.instructions()` in its own instructions list. Re-tested ag
**Result:** Consumer tool surface printed exactly `['read_file', 'list_files', 'search_content', 'check_lines']`, with no write, append, move or delete. The recorder appended both decisions in one `append_file` call. The consumer answered via `list_files(directory=., ...)` then `read_file(path=decisions/2026-07.md, ...)`, finding the file without being told its path, and reported pgvector as approved for production. Note that read_only constrains the tool surface only: the same FileSystem object still exposes `write()` from Python.
---
## 2026-07-26 — corrections after the default flip
`read_only=True` is now three tools, not four: `check_lines` left every default
set and is reachable only through `include_tools`. Both entries above record a
consumer surface of `['read_file', 'list_files', 'search_content', 'check_lines']`,
which is what the pre-flip code printed; `shared_namespace.py` itself was updated
and says "three read tools". Re-verified against the current code:
`FileSystem(db, namespace=...).tools(read_only=True)` yields exactly
`['read_file', 'list_files', 'search_content']`.
@@ -3,7 +3,7 @@ Namespaces - Sharing One Store
==============================
Two agents share files by attaching the same namespace by name. The producer
gets the full tool surface. The consumer gets tools(read_only=True) and the
matching instructions(read_only=True), which is four read tools, so it can
matching instructions(read_only=True), which is three read tools, so it can
consult the records but holds no tool that could change them.
This example has a recorder agent write decisions and an answering agent look
@@ -3,8 +3,9 @@ Operations - Quota Recovery
===========================
FileSystem caps the size of every file and every namespace, and nothing is
ever evicted silently. This example hits both caps on purpose, shows the
guidance the agent gets back, and then recovers the way that guidance
suggests, by starting a new partition and deleting old ones.
guidance the agent gets back, and then recovers programmatically: starting
a new partition and deleting old ones through the Python API (the delete
tool itself is opt-in via tools(allow_delete=True)).
The caps are set very small here so the numbers stay readable. No model, no
API keys.
+90 -33
View File
@@ -1,50 +1,107 @@
# Test Log - second_brain
# Test Log: second_brain
Tested 2026-07-25 against `gpt-5.6` (via `model="openai:gpt-5.6"`, which resolves to OpenAIResponses),
agno 2.8.2 (source tree at 5e6185ea9). Entries quote tool calls and printed state.
Model prose varies run to run and is paraphrased.
> Tested 2026-07-25 against gpt-5.6 (via model="openai:gpt-5.6"), branch
> feat/entity-memory-revamp. Rebuilt on the LearningMachine: entity memory (four tools,
> shared "global" namespace) + AGENTIC user profile/memory + shared "brain" notes
> namespace + the one-claim-one-home rule, with identity pinned (Agent(user_id="owner")).
> The pre-2.8.4 store (enable_agentic_memory era) was set aside as
> tmp/second_brain_pre284.db.bak; this brain starts fresh on the learnings table.
### test.py
## second_brain.py - REST live proof (8 turns, DB verified between turns)
**Status:** PASS
**Description:** Durable, owned memory across a session boundary. The driver captures a decision in one session, then asks for it back in a brand new session that shares no history, and finally lists the files in that user's namespace.
Served with uvicorn on 7777; turns driven via POST /agents/second-brain/runs, each in a
fresh session; every claim below checked in tmp/second_brain.db between turns.
**Result:** Cold run (`rm -rf tmp` first), exit 0. The capture session wrote the memory and the note in the same turn:
1. Capture (project + decision): harbor filed with the index line
"database: Postgres, over DynamoDB - see note", the reasoning ONLY in notes/harbor.md,
and the note pointer on the entity (properties.note = notes/harbor.md). The agent also
linked postgres/dynamodb, created as minimal entity_type="unknown" targets - the
designed link-first-describe-later shape.
2. Capture (person): Sarah Chen recorded; the edge landed on BOTH rows with the far end's
type (harbor: designs <- sarah_chen, entity_type person).
3. Correction: "not blocked anymore, v0.1 shipped" retired
"status: blocked on the auth review" (superseded_by = the new fact's id, verified in
the fact record) and appended a dated entry to the note.
4. Why-question (fresh session): answered with the note's reasoning verbatim shape
(multi-row transactions, team knows SQL, single-table modeling) - the note pointer
round-trip works.
5. Negative: "Have we ever discussed the Acme contract?" -> "No - I searched the entity
directory and all notes for Acme and contract and found nothing." A grounded no.
6. Archive: legacy-sync archived (archived_at set in DB; all other entities live), and
the subsequent index turn did NOT list it.
7. Browse: the full index listed the four live entities from the directory.
8. Private confidence: "keep it between us" about the harbor budget went to user_memory
(user_id=owner), explicitly NOT to the shared entity, and the brain said so.
```
• update_user_memory(task=Remember that Alice is building Harbor, a Postgres-backed
job queue in Rust ... prefers terse answers with no bullet lists.)
• append_file(path=notes/harbor.md, content=Project: Harbor - a Postgres-backed job
queue in Rust. Decision: Use PostgreSQL advisory locks instead of SELECT FOR UPDATE
SKIP LOCKED because workers are long-lived., unique=True)
```
Model behavior notes (findings, not failures):
- The note template included a literal "**Date recorded:** Not provided" line in turn 1 -
harmless, but the model narrates missing dates rather than omitting them.
- "Numbers before prose" (stored as a user memory in turn 8) visibly shaped later answers
across channels, including MCP - the preference survived and applied.
- The profile store was not used in this run (name-shaped facts never came up); the
preference went to user_memory, which is the right home for it.
The recall session, a fresh session id with nothing in context, answered: "You chose PostgreSQL advisory locks instead of `SELECT FOR UPDATE SKIP LOCKED` because Harbor's workers are long-lived." Final print: `notes/harbor.md (170 bytes)`.
The note body is model-written, so byte counts and wording vary run to run. What holds is the shape: the capture session writes, the recall session answers from the store, and a second process sees both.
### second_brain.py
## /mcp live proof (4 turns, in-process fastmcp client)
**Status:** PASS
**Description:** Serving run. `python second_brain.py` serves REST on `/` and MCP on `/mcp`. Verified over REST with three callers, and over MCP with a real `fastmcp.Client`.
The client saw the eight built-in AgentOS tools. Each run_agent call was a fresh session
(no session echo - exactly the MCP shape), and the pinned user_id carried the brain:
**Result:** `GET /config` reports `agents: [('second-brain', 'Second Brain')]`, the id MCP clients address. Three runs against `POST /agents/second-brain/runs`:
1. "Where does harbor stand?" recalled the REST-era state (v0.1 shipped, Postgres over
DynamoDB) across the channel boundary.
2. A write from MCP: Tom Alvarez / load tests, recorded with a note pointer
("load testing: Tom Alvarez will run the tests next week - see note").
3. A THIRD fresh session recalled that write.
4. The why-question over MCP answered from the note's reasoning.
```
[email protected] -> "You're building Harbor, a Postgres-backed job queue in Rust,
using advisory locks for its long-lived workers."
user_id=riya -> "I don't have any projects recorded for you yet."
no user_id -> • list_files -> "Error: this agent's files require user_id for
this run and none was provided."
"I couldn't access your note files because no user_id was provided."
```
## test.py
The second and third are the point: notes are per user, and the templated namespace `brain/{user_id}` fails closed rather than falling back to a shared store.
**Status:** PASS
Over MCP the client sees the eight built-in AgentOS tools, and `run_agent` reaches the same brain. `run_agent(agent_id="second-brain", user_id="[email protected]", ...)` both read the store and wrote a new decision that a later REST call read back. `user_id` is the load-bearing argument here: an MCP client that omits it gets the fail-closed error above rather than someone else's notes.
Capture session filed quill (advisory locks over SELECT FOR UPDATE SKIP LOCKED) with the
reasoning in notes/quill.md; a fresh recall session answered tersely and correctly
("advisory locks ... because Quill's workers are long-lived"). The shared brain listing
shows both notes/harbor.md (with its dated updates) and notes/quill.md.
`db_file` is relative, so run the CLI and the server from the example folder or they land in different stores.
## 2026-07-26 — add_datetime_to_context, cold-start proof
---
**Status:** PASS
The "**Date recorded:** Not provided" line above was the agent having no clock: the
instructions ask for dated notes, and until an entity fact renders its "(as of ...)" date
there is nothing in the prompt to date them with. Two prior runs produced headings like
"## Date not provided - architecture".
With `add_datetime_to_context=True` on the agent, a fresh db and one turn ("we picked
Postgres over Dynamo for the radar ingest queue") produced `notes/radar-ingest-queue.md`
with the heading `## 2026-07-26 - Postgres over Dynamo`. Dated on the first turn, with no
entity in the store to borrow a date from.
## 2026-07-26 — the pinned user_id does NOT survive an MCP client that fills it
**Status:** FAIL (identity, deployment-shaped - see the note in second_brain.py)
Measured in-process against the real `/mcp` app: `run_agent` advertises an optional
`user_id`, and a value supplied there overrides `Agent(user_id="owner")`. One call with
`user_id="claude-desktop"` wrote its session and its user memory under `claude-desktop`,
not `owner`. Entities are global so they stay shared; profile and user memory fork per
host. Unauthenticated `/mcp` is exactly where host models fill the field.
## 2026-07-26 — four turns after the review fixes (fresh db, gpt-5.6)
**Status:** PASS
| Turn | Expected | Result |
|---|---|---|
| Decision + people + systems in one message | note written dated, entity indexed with a pointer, both links reciprocal | `write_file notes/quill.md` (`## 2026-07-26 - Concurrency control decision`), `remember_about(Quill, facts=["concurrency control: Postgres advisory locks... - see note"])`, `link_entities` twice |
| "Priya has moved off Quill" | the old relationship retired, the new one recorded | `forget("Priya Raman", "runs -> Quill")` **removed the edge on both rows**, then `link_entities(Priya, works_on, Billing rewrite)`. Quill's row keeps only `backed_by -> postgresql` |
| "Between us: the Nimbus renewal is shaky" | private memory, no shared entity | `update_user_memory(...)` only; no Nimbus entity exists in the store |
| "What is the state of Quill, and why advisory locks?" | read the note, answer from it | `search_entities` → `read_file(notes/quill.md)` → answered with the reasoning, and volunteered that no replacement lead is recorded |
The retirement turn is the one that mattered: on the pre-fix code the same call
matched nothing it could act on, or matched two candidates it printed
identically. Model behavior note: turn 2 rewrote the whole note with
`write_file` rather than `replace_lines` - allowed, and the note stayed correct.
+74 -8
View File
@@ -2,8 +2,26 @@
Second Brain - Memory You Own, Behind Your Own MCP Server
=========================================================
A private agent that remembers what you are building: durable notes in its own
filesystem, plus what it learns about how you work. It is also an MCP server, so
your AI apps (claude, chatgpt, claude code) can read and write the same brain.
filesystem, an entity graph over the people and projects around you, and what
it learns about how you work. It is also an MCP server, so your AI apps
(claude, chatgpt, claude code) can read and write the same brain.
The stores split the work:
- Notes (FileSystem) hold the content: decisions with their reasoning, running
documents, anything longer than a line.
- Entities index the world: people, projects, systems - one-line current
values, links, and a note pointer to where the detail lives.
- Profile and memory hold the self: who you are and how you like to work.
Identity is pinned (user_id below): sessions do not thread over MCP and an
unauthenticated /mcp call carries no user, so the personal brain names its
owner once and calls that name nobody land on the same brain.
One caveat, measured rather than assumed: /mcp's run_agent takes an optional
user_id, and a host that fills it wins over the pin - that run's profile and
user memory go to whatever it sent. Entities are global, so the world half of
the brain is shared either way. Run /mcp behind auth (the JWT subject then wins
over both) if your client volunteers a user_id.
Running this file serves the AgentOS on http://localhost:7777
MCP Server on http://localhost:7777/mcp
@@ -12,13 +30,29 @@ MCP Server on http://localhost:7777/mcp
from agno.agent import Agent
from agno.db.sqlite import SqliteDb
from agno.fs import FileSystem
from agno.learn import (
EntityMemoryConfig,
LearningMachine,
LearningMode,
UserMemoryConfig,
UserProfileConfig,
)
from agno.os import AgentOS
# ---------------------------------------------------------------------------
# One database for agent sessions, learning, notes, traces, metrics, etc.
# Shared world, private self: notes live in the same shared namespace as the
# entities they document; profile and memory stay per-user.
# ---------------------------------------------------------------------------
db = SqliteDb(db_file="tmp/second_brain.db")
notes = FileSystem(db, namespace="brain/{user_id}")
notes = FileSystem(db, namespace="brain")
brain = LearningMachine(
db=db,
user_profile=UserProfileConfig(mode=LearningMode.AGENTIC), # private to each person
user_memory=UserMemoryConfig(mode=LearningMode.AGENTIC), # private to each person
entity_memory=EntityMemoryConfig(namespace="global"), # shared by the team
)
# ---------------------------------------------------------------------------
# Create the Agent
@@ -28,16 +62,48 @@ second_brain = Agent(
id="second-brain",
name="Second Brain",
model="openai:gpt-5.6",
learning=brain,
tools=[notes.tools()],
instructions=[
"You're a second brain for: {user_id}.",
"Remember what they're building. Keep one note per project in "
"notes/<project>.md and append decisions as they are made.",
"Answer according to their taste. In under 3 sentences unless they ask for more.",
"You are a second brain: you hold what your owner is building and thinking, "
"and you answer from what you hold.",
# One claim, one home. Notes hold the content; entities are the index over it.
"One claim, one home. Notes hold the content; entities are the index over it:",
"- Reasoning, wording, anything longer than a line goes in the note "
"(notes/<topic>.md), dated, and only in the note.",
"- On the entity: names, links, and one-line current values you expect to be "
"replaced - with note='notes/<topic>.md' whenever the detail lives there. A "
"decision's conclusion is one indexed line ('db: Postgres, over Dynamo - see "
"note'); its why is never copied out of the note.",
"- It happened on a date and next month it is history: that is an event. "
"Positions and opinions are events, not facts.",
"- Corrections replace, they never accumulate: state the new fact (the stale "
"one is retired automatically), and fix the note line with replace_lines in "
"the same turn. Never append a contradiction.",
"- Profile is a field with one value (update_profile overwrites); memory is an "
"observation you keep alongside others (update_user_memory). Standing "
"instructions are rules to obey, not observations to narrate.",
"- Confidences stay private: something shared in confidence about the world "
"goes to user memory, never to a shared entity - and say so when you file one.",
"- What you file about other people is your judgement, and the test is whether "
"your owner would file it: what they told you to remember, and what bears on "
"the work. Not a colleague's health, pay, or family, mentioned in passing and "
"never asked to be kept - those you use in the conversation and let go.",
"Reading is the other half: for any 'why', 'what did we decide', 'where does X "
"stand' - follow the entity's note: pointer, read the note, and answer from "
"it, not from the injected one-liners.",
"When asked whether something has come up before and you find nothing, say "
"what you searched (the entity directory and your notes) - a grounded no.",
"Answer in under 3 sentences unless asked for more.",
notes.instructions(),
],
enable_agentic_memory=True,
# The personal brain pins identity: every channel, MCP included, lands here.
user_id="owner",
add_history_to_context=True,
# A brain that cannot date its notes cannot tell July's truth from March's,
# and the instructions above ask for dated notes. Without this the agent has
# no clock and writes "Date not provided" until the first fact gives it one.
add_datetime_to_context=True,
)
# ---------------------------------------------------------------------------
+8 -15
View File
@@ -1,8 +1,8 @@
"""
Second Brain - CLI
====================================
Runs the second_brain without starting the server: capture a
decision in one session, then recall in a new session.
Runs the second_brain without starting the server: capture a decision in one
session, then recall it in a new session that shares nothing but the brain.
"""
from uuid import uuid4
@@ -10,10 +10,8 @@ from uuid import uuid4
from second_brain import notes, second_brain
# ---------------------------------------------------------------------------
# Create the run: one user, two sessions that share nothing
# Two sessions that share nothing; identity is pinned on the agent itself.
# ---------------------------------------------------------------------------
# Notes live under brain/{user_id}, so every run needs a user_id.
USER_ID = "[email protected]"
CAPTURE_SESSION = f"capture-{uuid4().hex[:8]}"
RECALL_SESSION = f"recall-{uuid4().hex[:8]}"
@@ -21,28 +19,23 @@ RECALL_SESSION = f"recall-{uuid4().hex[:8]}"
# Run: capture a decision, then ask for it back in the other session
# ---------------------------------------------------------------------------
if __name__ == "__main__":
print(f"User: {USER_ID}")
print("\n--- Session 1: capture a decision ---\n")
second_brain.print_response(
"I am building Harbor, a Postgres-backed job queue in Rust. I picked "
"I am building quill, a Postgres-backed job queue in Rust. I picked "
"advisory locks over SELECT FOR UPDATE SKIP LOCKED because our workers "
"are long-lived. I want terse answers, no bullet lists.",
user_id=USER_ID,
session_id=CAPTURE_SESSION,
stream=True,
)
print("\n--- Session 2: a new session, nothing in context ---\n")
second_brain.print_response(
"What did I decide about locking in Harbor, and why?",
user_id=USER_ID,
"What did I decide about locking in quill, and why?",
session_id=RECALL_SESSION,
stream=True,
)
print("\n--- Files in this user's brain ---\n")
brain = notes.resolve(user_id=USER_ID)
for meta in brain.list():
print("\n--- Files in the brain ---\n")
for meta in notes.list():
print(f" {meta.path} ({meta.size_bytes} bytes)\n")
print(brain.read(meta.path))
print(notes.read(meta.path))
+6
View File
@@ -170,6 +170,12 @@ def set_learning_machine(agent: Agent) -> None:
agent.learning.db = agent.db
if agent.learning.model is None:
agent.learning.model = agent.model
if (
agent.learning.learned_knowledge
and agent.learning.knowledge is None
and getattr(agent, "knowledge", None) is not None
):
agent.learning.knowledge = agent.knowledge
agent._learning = agent.learning
# PROPOSE/HITL modes need chat history for multi-turn confirmation
+15
View File
@@ -6,6 +6,7 @@ from asyncio import CancelledError, Task, create_task
from concurrent.futures import Future
from typing import (
TYPE_CHECKING,
Any,
List,
Optional,
)
@@ -393,6 +394,7 @@ def process_learnings(
run_messages: RunMessages,
session: AgentSession,
user_id: Optional[str],
run_context: Optional[Any] = None,
) -> Optional[RunMetrics]:
"""Process learnings from conversation (runs in background thread)."""
if agent._learning is None:
@@ -412,6 +414,10 @@ def process_learnings(
agent_id=agent.id,
team_id=agent.team_id,
run_metrics=collector,
run_context=run_context,
metadata=getattr(run_context, "metadata", None),
dependencies=getattr(run_context, "dependencies", None),
session_state=getattr(run_context, "session_state", None),
)
log_debug("Learning extraction completed.")
except Exception as e:
@@ -424,6 +430,7 @@ async def aprocess_learnings(
run_messages: RunMessages,
session: AgentSession,
user_id: Optional[str],
run_context: Optional[Any] = None,
) -> Optional[RunMetrics]:
"""Async process learnings from conversation."""
if agent._learning is None:
@@ -442,6 +449,10 @@ async def aprocess_learnings(
agent_id=agent.id,
team_id=agent.team_id,
run_metrics=collector,
run_context=run_context,
metadata=getattr(run_context, "metadata", None),
dependencies=getattr(run_context, "dependencies", None),
session_state=getattr(run_context, "session_state", None),
)
log_debug("Learning extraction completed.")
except Exception as e:
@@ -455,6 +466,7 @@ async def astart_learning_task(
session: AgentSession,
user_id: Optional[str],
existing_task: Optional[Task] = None,
run_context: Optional[Any] = None,
) -> Optional[Task]:
"""Start learning extraction as async task.
@@ -485,6 +497,7 @@ async def astart_learning_task(
run_messages=run_messages,
session=session,
user_id=user_id,
run_context=run_context,
)
)
@@ -497,6 +510,7 @@ def start_learning_future(
session: AgentSession,
user_id: Optional[str],
existing_future: Optional[Future] = None,
run_context: Optional[Any] = None,
) -> Optional[Future]:
"""Start learning extraction in background thread.
@@ -523,6 +537,7 @@ def start_learning_future(
run_messages=run_messages,
session=session,
user_id=user_id,
run_context=run_context,
)
return None
+55 -6
View File
@@ -103,12 +103,41 @@ def format_message_with_state_variables(
# ---------------------------------------------------------------------------
def _learning_message_text(input: Any) -> Optional[str]:
"""Flatten the run input to text for message-scoped learning recall.
Textual parts only: multimodal content parts contribute their "text"
values, and non-text parts are skipped rather than leaking Python reprs
into the recall terms.
"""
if input is None:
return None
if isinstance(input, str):
return input or None
if isinstance(input, Message):
return _learning_message_text(input.content)
if isinstance(input, list):
parts = [_learning_message_text(item) for item in input]
return "\n".join(part for part in parts if part) or None
if isinstance(input, dict):
# A multimodal content part or a payload dict: keep its string values.
parts = [value for value in input.values() if isinstance(value, str)]
return "\n".join(part for part in parts if part) or None
if isinstance(input, BaseModel):
try:
return _learning_message_text(input.model_dump())
except Exception:
return None
return str(input)
def get_system_message(
agent: Agent,
session: AgentSession,
run_context: Optional[RunContext] = None,
tools: Optional[List[Union[Function, dict]]] = None,
add_session_state_to_context: Optional[bool] = None,
input: Optional[Any] = None,
) -> Optional[Message]:
"""Return the system message for the Agent.
@@ -396,15 +425,25 @@ def get_system_message(
"You should ALWAYS prefer information from this conversation over the past summary.\n\n"
)
# 3.3.12 then add learnings to the system prompt
# 3.3.12 then add learnings to the system prompt: guidance (how to use the
# tools) plus data (what was recalled), concatenated here so the automatic
# door renders exactly what the manual door's instructions() + build_context()
# would.
if agent._learning is not None and agent.add_learnings_to_context:
learning_guidance = agent._learning._framework_instructions()
learning_context = agent._learning.build_context(
user_id=user_id,
session_id=session.session_id if session else None,
agent_id=agent.id,
message=_learning_message_text(input),
run_context=run_context,
metadata=run_context.metadata if run_context else None,
dependencies=run_context.dependencies if run_context else None,
session_state=session_state,
)
if learning_context:
system_message_content += learning_context + "\n"
learning_block = "\n".join(part for part in (learning_guidance, learning_context) if part)
if learning_block:
system_message_content += learning_block + "\n"
# 3.3.13 then add search_knowledge instructions to the system prompt
_resolved_knowledge = _get_resolved_knowledge(agent, run_context)
@@ -456,6 +495,7 @@ async def aget_system_message(
run_context: Optional[RunContext] = None,
tools: Optional[List[Union[Function, dict]]] = None,
add_session_state_to_context: Optional[bool] = None,
input: Optional[Any] = None,
) -> Optional[Message]:
"""Return the system message for the Agent.
@@ -747,15 +787,22 @@ async def aget_system_message(
"You should ALWAYS prefer information from this conversation over the past summary.\n\n"
)
# 3.3.12 then add learnings to the system prompt
# 3.3.12 then add learnings to the system prompt (see the sync twin)
if agent._learning is not None and agent.add_learnings_to_context:
learning_guidance = agent._learning._framework_instructions()
learning_context = await agent._learning.abuild_context(
user_id=user_id,
session_id=session.session_id if session else None,
agent_id=agent.id,
message=_learning_message_text(input),
run_context=run_context,
metadata=run_context.metadata if run_context else None,
dependencies=run_context.dependencies if run_context else None,
session_state=session_state,
)
if learning_context:
system_message_content += learning_context + "\n"
learning_block = "\n".join(part for part in (learning_guidance, learning_context) if part)
if learning_block:
system_message_content += learning_block + "\n"
# 3.3.13 then add search_knowledge instructions to the system prompt
_resolved_knowledge = _get_resolved_knowledge(agent, run_context)
@@ -1205,6 +1252,7 @@ def get_run_messages(
run_context=run_context,
tools=tools,
add_session_state_to_context=add_session_state_to_context,
input=input,
)
if system_message is not None:
run_messages.system_message = system_message
@@ -1410,6 +1458,7 @@ async def aget_run_messages(
run_context=run_context,
tools=tools,
add_session_state_to_context=add_session_state_to_context,
input=input,
)
if system_message is not None:
run_messages.system_message = system_message
+4
View File
@@ -509,6 +509,7 @@ def _run(
session=agent_session,
user_id=user_id,
existing_future=learning_future,
run_context=run_context,
)
# Start cultural knowledge creation in background thread
@@ -921,6 +922,7 @@ def _run_stream(
session=agent_session,
user_id=user_id,
existing_future=learning_future,
run_context=run_context,
)
# Start cultural knowledge creation in background thread
@@ -1647,6 +1649,7 @@ async def _arun(
session=agent_session,
user_id=user_id,
existing_task=learning_task,
run_context=run_context,
)
# Start cultural knowledge creation as a background task (runs concurrently with the main execution)
@@ -2329,6 +2332,7 @@ async def _arun_stream(
session=agent_session,
user_id=user_id,
existing_task=learning_task,
run_context=run_context,
)
# Start cultural knowledge creation as a background task (runs concurrently with the main execution)
+2
View File
@@ -170,6 +170,7 @@ def get_tools(
user_id=user_id,
session_id=session.session_id if session else None,
agent_id=agent.id,
run_context=run_context,
)
agent_tools.extend(learning_tools)
@@ -302,6 +303,7 @@ async def aget_tools(
user_id=user_id,
session_id=session.session_id if session else None,
agent_id=agent.id,
run_context=run_context,
)
agent_tools.extend(learning_tools)
+8 -1
View File
@@ -116,7 +116,10 @@ class Agent:
# --- Agent Memory ---
# Memory manager to use for this agent
memory_manager: Optional[MemoryManager] = None
# Enable the agent to manage memories of the user
# Enable the agent to manage memories of the user.
# Do not combine with a LearningMachine that has a user_memory store: both
# register a tool named update_user_memory, tool parsing keeps the first
# name it sees, and the learning store's tool is dropped without a word.
enable_agentic_memory: bool = False
# If True, the agent creates/updates user memories at the end of runs
update_memory_on_run: bool = False
@@ -872,6 +875,7 @@ class Agent:
run_context: Optional[RunContext] = None,
tools: Optional[List[Union[Function, dict]]] = None,
add_session_state_to_context: Optional[bool] = None,
input: Optional[Any] = None,
) -> Optional[Message]:
return _messages.get_system_message(
self,
@@ -879,6 +883,7 @@ class Agent:
run_context=run_context,
tools=tools,
add_session_state_to_context=add_session_state_to_context,
input=input,
)
async def aget_system_message(
@@ -887,6 +892,7 @@ class Agent:
run_context: Optional[RunContext] = None,
tools: Optional[List[Union[Function, dict]]] = None,
add_session_state_to_context: Optional[bool] = None,
input: Optional[Any] = None,
) -> Optional[Message]:
return await _messages.aget_system_message(
self,
@@ -894,6 +900,7 @@ class Agent:
run_context=run_context,
tools=tools,
add_session_state_to_context=add_session_state_to_context,
input=input,
)
def get_relevant_docs_from_knowledge(
+92
View File
@@ -1032,6 +1032,52 @@ class BaseDb(ABC):
"""
raise NotImplementedError
def search_learnings(
self,
query: str,
learning_type: Optional[str] = None,
user_id: Optional[str] = None,
agent_id: Optional[str] = None,
team_id: Optional[str] = None,
workflow_id: Optional[str] = None,
session_id: Optional[str] = None,
namespace: Optional[str] = None,
entity_id: Optional[str] = None,
entity_type: Optional[str] = None,
limit: Optional[int] = None,
) -> List[Dict[str, Any]]:
"""Search learning records by text query, filtered server-side.
The query is matched case-insensitively against the stored content, in
both its space and underscore forms ("sarah chen" finds "sarah_chen").
Results are ordered by updated_at descending.
Backends without a server-side search implementation keep this default,
which raises NotImplementedError - callers fall back to their
client-side scan then. Implementations RAISE on database errors rather
than returning an empty list: a dialect-wrong query must never present
as an empty store.
Args:
query: Text to search for in the stored content.
learning_type: Filter by learning type.
user_id: Filter by user ID.
agent_id: Filter by agent ID.
team_id: Filter by team ID.
workflow_id: Filter by workflow ID. Note: upsert_learning does not
currently populate the workflow_id column, so this filter only
matches rows written by an external writer that sets it.
session_id: Filter by session ID.
namespace: Filter by namespace ('user', 'global', or custom).
entity_id: Filter by entity ID.
entity_type: Filter by entity type.
limit: Maximum number of records to return.
Returns:
List of matching learning records, most recently updated first.
"""
raise NotImplementedError
def get_learning_by_id(self, id: str) -> Optional[Dict[str, Any]]:
"""Retrieve a single learning record by its primary key.
@@ -2001,6 +2047,52 @@ class AsyncBaseDb(ABC):
"""
raise NotImplementedError
async def search_learnings(
self,
query: str,
learning_type: Optional[str] = None,
user_id: Optional[str] = None,
agent_id: Optional[str] = None,
team_id: Optional[str] = None,
workflow_id: Optional[str] = None,
session_id: Optional[str] = None,
namespace: Optional[str] = None,
entity_id: Optional[str] = None,
entity_type: Optional[str] = None,
limit: Optional[int] = None,
) -> List[Dict[str, Any]]:
"""Async search learning records by text query, filtered server-side.
The query is matched case-insensitively against the stored content, in
both its space and underscore forms ("sarah chen" finds "sarah_chen").
Results are ordered by updated_at descending.
Backends without a server-side search implementation keep this default,
which raises NotImplementedError - callers fall back to their
client-side scan then. Implementations RAISE on database errors rather
than returning an empty list: a dialect-wrong query must never present
as an empty store.
Args:
query: Text to search for in the stored content.
learning_type: Filter by learning type.
user_id: Filter by user ID.
agent_id: Filter by agent ID.
team_id: Filter by team ID.
workflow_id: Filter by workflow ID. Note: upsert_learning does not
currently populate the workflow_id column, so this filter only
matches rows written by an external writer that sets it.
session_id: Filter by session ID.
namespace: Filter by namespace ('user', 'global', or custom).
entity_id: Filter by entity ID.
entity_type: Filter by entity type.
limit: Maximum number of records to return.
Returns:
List of matching learning records, most recently updated first.
"""
raise NotImplementedError
async def get_learning_by_id(self, id: str) -> Optional[Dict[str, Any]]:
"""Async retrieve a single learning record by its primary key.
+68 -1
View File
@@ -29,7 +29,7 @@ from agno.db.schemas.service_accounts import (
resolve_service_account_sort_column,
validate_service_account_update,
)
from agno.db.utils import deserialize_session, deserialize_sessions, json_serializer
from agno.db.utils import deserialize_session, deserialize_sessions, json_serializer, learning_search_patterns
from agno.run.base import RunStatus
from agno.session import AgentSession, Session, TeamSession, WorkflowSession
from agno.utils.log import log_debug, log_error, log_info, log_warning
@@ -3178,6 +3178,73 @@ class AsyncPostgresDb(AsyncBaseDb):
log_debug(f"Error getting learnings: {e}")
return []
async def search_learnings(
self,
query: str,
learning_type: Optional[str] = None,
user_id: Optional[str] = None,
agent_id: Optional[str] = None,
team_id: Optional[str] = None,
workflow_id: Optional[str] = None,
session_id: Optional[str] = None,
namespace: Optional[str] = None,
entity_id: Optional[str] = None,
entity_type: Optional[str] = None,
limit: Optional[int] = None,
) -> List[Dict[str, Any]]:
"""Async search learning records by text query. See AsyncBaseDb.search_learnings.
The content column is JSONB, which has no ILIKE operator - it is cast
to TEXT first (the same shape get_user_memories uses for
search_content). The query matches in both its space and underscore
forms. Errors are raised, never swallowed.
"""
try:
table = await self._get_table(table_type="learnings")
if table is None:
return []
patterns = learning_search_patterns(query)
if not patterns:
return []
async with self.async_session_factory() as sess:
stmt = select(table)
if learning_type is not None:
stmt = stmt.where(table.c.learning_type == learning_type)
if user_id is not None:
stmt = stmt.where(table.c.user_id == user_id)
if agent_id is not None:
stmt = stmt.where(table.c.agent_id == agent_id)
if team_id is not None:
stmt = stmt.where(table.c.team_id == team_id)
if workflow_id is not None:
stmt = stmt.where(table.c.workflow_id == workflow_id)
if session_id is not None:
stmt = stmt.where(table.c.session_id == session_id)
if namespace is not None:
stmt = stmt.where(table.c.namespace == namespace)
if entity_id is not None:
stmt = stmt.where(table.c.entity_id == entity_id)
if entity_type is not None:
stmt = stmt.where(table.c.entity_type == entity_type)
content_text = func.cast(table.c.content, postgresql.TEXT)
stmt = stmt.where(or_(*[content_text.ilike(pattern, escape="\\") for pattern in patterns]))
stmt = stmt.order_by(table.c.updated_at.desc().nulls_last())
if limit is not None:
stmt = stmt.limit(limit)
result = await sess.execute(stmt)
rows = result.fetchall()
return [dict(row._mapping) for row in rows]
except Exception as e:
log_error(f"Error searching learnings: {e}")
raise e
async def get_learning_by_id(self, id: str) -> Optional[Dict[str, Any]]:
try:
table = await self._get_table(table_type="learnings")
+67 -1
View File
@@ -38,7 +38,7 @@ from agno.db.schemas.service_accounts import (
resolve_service_account_sort_column,
validate_service_account_update,
)
from agno.db.utils import deserialize_session, deserialize_sessions, json_serializer
from agno.db.utils import deserialize_session, deserialize_sessions, json_serializer, learning_search_patterns
from agno.run.base import RunStatus
from agno.session import AgentSession, Session, TeamSession, WorkflowSession
from agno.utils.log import log_debug, log_error, log_info, log_warning
@@ -4703,6 +4703,72 @@ class PostgresDb(BaseDb):
log_debug(f"Error getting learnings: {e}")
return []
def search_learnings(
self,
query: str,
learning_type: Optional[str] = None,
user_id: Optional[str] = None,
agent_id: Optional[str] = None,
team_id: Optional[str] = None,
workflow_id: Optional[str] = None,
session_id: Optional[str] = None,
namespace: Optional[str] = None,
entity_id: Optional[str] = None,
entity_type: Optional[str] = None,
limit: Optional[int] = None,
) -> List[Dict[str, Any]]:
"""Search learning records by text query. See BaseDb.search_learnings.
The content column is JSONB, which has no ILIKE operator - it is cast
to TEXT first (the same shape get_user_memories uses for
search_content). The query matches in both its space and underscore
forms. Errors are raised, never swallowed.
"""
try:
table = self._get_table(table_type="learnings")
if table is None:
return []
patterns = learning_search_patterns(query)
if not patterns:
return []
with self.Session() as sess:
stmt = select(table)
if learning_type is not None:
stmt = stmt.where(table.c.learning_type == learning_type)
if user_id is not None:
stmt = stmt.where(table.c.user_id == user_id)
if agent_id is not None:
stmt = stmt.where(table.c.agent_id == agent_id)
if team_id is not None:
stmt = stmt.where(table.c.team_id == team_id)
if workflow_id is not None:
stmt = stmt.where(table.c.workflow_id == workflow_id)
if session_id is not None:
stmt = stmt.where(table.c.session_id == session_id)
if namespace is not None:
stmt = stmt.where(table.c.namespace == namespace)
if entity_id is not None:
stmt = stmt.where(table.c.entity_id == entity_id)
if entity_type is not None:
stmt = stmt.where(table.c.entity_type == entity_type)
content_text = func.cast(table.c.content, postgresql.TEXT)
stmt = stmt.where(or_(*[content_text.ilike(pattern, escape="\\") for pattern in patterns]))
stmt = stmt.order_by(table.c.updated_at.desc().nulls_last())
if limit is not None:
stmt = stmt.limit(limit)
result = sess.execute(stmt).fetchall()
return [dict(row._mapping) for row in result]
except Exception as e:
log_error(f"Error searching learnings: {e}")
raise e
def get_learning_by_id(self, id: str) -> Optional[Dict[str, Any]]:
try:
table = self._get_table(table_type="learnings")
+64
View File
@@ -43,6 +43,7 @@ from agno.db.utils import (
deserialize_session,
deserialize_session_json_fields,
deserialize_sessions,
learning_search_patterns,
serialize_session_json_fields,
)
from agno.run.base import RunStatus
@@ -4547,6 +4548,69 @@ class SqliteDb(BaseDb):
log_debug(f"Error getting learnings: {e}")
return []
def search_learnings(
self,
query: str,
learning_type: Optional[str] = None,
user_id: Optional[str] = None,
agent_id: Optional[str] = None,
team_id: Optional[str] = None,
workflow_id: Optional[str] = None,
session_id: Optional[str] = None,
namespace: Optional[str] = None,
entity_id: Optional[str] = None,
entity_type: Optional[str] = None,
limit: Optional[int] = None,
) -> List[Dict[str, Any]]:
"""Search learning records by text query. See BaseDb.search_learnings.
The query matches the content column case-insensitively in both its
space and underscore forms. Errors are raised, never swallowed.
"""
try:
table = self._get_table(table_type="learnings")
if table is None:
return []
patterns = learning_search_patterns(query)
if not patterns:
return []
with self.Session() as sess:
stmt = select(table)
if learning_type is not None:
stmt = stmt.where(table.c.learning_type == learning_type)
if user_id is not None:
stmt = stmt.where(table.c.user_id == user_id)
if agent_id is not None:
stmt = stmt.where(table.c.agent_id == agent_id)
if team_id is not None:
stmt = stmt.where(table.c.team_id == team_id)
if workflow_id is not None:
stmt = stmt.where(table.c.workflow_id == workflow_id)
if session_id is not None:
stmt = stmt.where(table.c.session_id == session_id)
if namespace is not None:
stmt = stmt.where(table.c.namespace == namespace)
if entity_id is not None:
stmt = stmt.where(table.c.entity_id == entity_id)
if entity_type is not None:
stmt = stmt.where(table.c.entity_type == entity_type)
stmt = stmt.where(or_(*[table.c.content.ilike(pattern, escape="\\") for pattern in patterns]))
stmt = stmt.order_by(table.c.updated_at.desc().nulls_last())
if limit is not None:
stmt = stmt.limit(limit)
results = sess.execute(stmt).fetchall()
return [dict(row._mapping) for row in results]
except Exception as e:
log_error(f"Error searching learnings: {e}")
raise e
def get_learning_by_id(self, id: str) -> Optional[Dict[str, Any]]:
try:
table = self._get_table(table_type="learnings")
+62
View File
@@ -189,6 +189,68 @@ def get_sort_value(record: Dict[str, Any], sort_by: str) -> Any:
return value
def learning_search_patterns(query: str) -> List[str]:
"""Build the ILIKE patterns for a learnings text search.
Three properties, each load-bearing:
- The stored content mixes display names ("Sarah Chen") with slugs
("sarah_chen"), so runs of spaces and underscores in the query become the
single-char LIKE wildcard ``_`` - one pattern crosses both forms.
- ``%`` and ``\\`` in the query are escaped (callers pass the pattern with
``escape="\\\\"``), so a model-authored query containing ``%`` cannot
collapse search into match-everything-by-recency.
- SQLite stores JSON with ``ensure_ascii`` escapes (``café`` is stored as
``caf\\u00e9``), so a non-ASCII query also gets its JSON-escaped variant;
on Postgres, where ``::text`` renders real characters, that extra pattern
simply never matches.
A query with no content beyond wildcards and whitespace yields no patterns.
Args:
query: The text to search for.
Returns:
Deduplicated '%...%' patterns to OR together with ILIKE (escape '\\').
"""
import re
stripped = query.strip()
if not re.sub(r"[%_\s]+", "", stripped):
return []
variants = [stripped]
# SQLite stores JSON with ensure_ascii escapes, and LIKE folds ASCII only,
# so an escape sequence never case-matches: a stored "Ος" is unreachable
# from "ΟΣ", "ος" or "οσ". No set of pre-cased whole-string variants covers
# the mixed forms, so the escape carries a wildcard per character instead -
# \\uXXXX is six characters wide - and the caller's value-scoped Python
# check (which casefolds) rejects whatever that lets through. Loose
# prefilter, precise verification, which is what this pair is for.
#
# The wildcards are carried as a sentinel until after the separator
# collapse below, which would otherwise fold a run of them into one.
wildcard = "\x00"
json_form = json.dumps(stripped, ensure_ascii=True)[1:-1]
if json_form != stripped:
variants.append(re.sub(r"\\u[0-9a-fA-F]{4}", wildcard * 6, json_form))
patterns: List[str] = []
for variant in variants:
escaped = variant.replace("\\", "\\\\").replace("%", "\\%")
# Runs of separators collapse to the single-char wildcard, so one
# pattern crosses the display-name/slug boundary in both directions
# ("sarah chen", "sarah_chen", "sarah__chen"). The hyphen is one of
# them: without it "multi-tenant" could never reach a stored "multi
# tenant", and the client-side verifier - which does fold hyphens -
# was already accepting what this pattern refused to fetch.
crossed = re.sub(r"[\s_\-]+", "_", escaped).replace(wildcard, "_")
pattern = f"%{crossed}%"
if pattern not in patterns:
patterns.append(pattern)
return patterns
class CustomJSONEncoder(json.JSONEncoder):
"""Custom encoder to handle non JSON serializable types."""
+6
View File
@@ -541,6 +541,12 @@ def _writes_off_learning_config(config: Any) -> Any:
for flag in vars(config_copy):
if flag.startswith("agent_can_") and not flag.startswith("agent_can_search"):
setattr(config_copy, flag, False)
# Entity memory's 2.8.4 revamp replaced its per-op agent_can_* flags with a
# single enable_agent_tools gating all four tools (three of which write), so
# severing writes means severing the toolset. Entity data still reaches the
# attempt through the injected context block.
if type(config_copy).__name__ == "EntityMemoryConfig" and hasattr(config_copy, "enable_agent_tools"):
config_copy.enable_agent_tools = False
config_model = getattr(config_copy, "model", None)
if config_model is not None and hasattr(config_model, "cache_response"):
config_copy.model = _cache_off_copy(config_model)
+9 -4
View File
@@ -446,10 +446,13 @@ class DbFileSystem(BaseFS):
return found
def search(self, namespace: str, query: str, directory: str = "", limit: int = 10) -> List[SearchMatch]:
"""Case-insensitive substring search. Correctness is owned by Python; on
Postgres ILIKE prefilters candidate rows, on SQLite every in-scope row is
scanned (SQLite's LIKE folds ASCII only, which would miss non-ASCII case
variants)."""
"""Case-insensitive substring search. Correctness is owned by Python; the
SQL predicate only prefilters candidate rows. On Postgres ILIKE folds
every query; on SQLite, LIKE folds ASCII only, so the prefilter applies
to pure-ASCII queries and a non-ASCII query still scans every in-scope
row. One known gap remains on SQLite: content containing a non-ASCII
uppercase form whose lowercase is ASCII (the Kelvin sign, U+212A) is
excluded by the ASCII prefilter for the matching ASCII query."""
self._ensure_table()
if not query:
return []
@@ -457,6 +460,8 @@ class DbFileSystem(BaseFS):
conditions = [t.c.namespace == namespace, self._directory_predicate(directory)]
if self.dialect == "postgresql":
conditions.append(t.c.content.icontains(query, autoescape=True))
elif query.isascii():
conditions.append(t.c.content.icontains(query, autoescape=True))
stmt = select(t.c.path, t.c.size_bytes, t.c.content).where(and_(*conditions))
with self.db_engine.begin() as conn:
rows = conn.execute(stmt).all()
+37 -35
View File
@@ -1,9 +1,9 @@
"""FileSystem: a durable, private filesystem for agents.
To the agent it looks exactly like a normal filesystem toolkit; underneath it is
a pluggable ``BaseFS`` backend, database by default. Use it for the agent's
own working state: records of items already processed, decisions, progress
checkpoints.
a pluggable ``BaseFS`` backend, database by default. Use it for the agent's own
durable notes: decisions with their reasoning, running documents, working state
it will need again.
Attach the tools, and compose its instructions into your own:
@@ -47,40 +47,35 @@ backend with no namespace share this one store, which is the intended behavior
but is rarely what a multi-tenant app wants (see the templated namespaces above).
"""
_DEFAULT_INSTRUCTIONS = """You have your own private, durable filesystem you can use to persist files across \
sessions and runs. Use it for your working state: records of items you have processed, decisions, progress \
checkpoints, notes to your future self.
For storing facts and memories about the user, prefer the user memory if provided and fallback to the \
filesystem if not.
_DEFAULT_INSTRUCTIONS = """You have your own private, durable filesystem for notes that persist across \
sessions and runs. Use it to write, maintain and re-read prose that matters later: decisions with their \
reasoning, running documents on a topic, notes to your future self.
Conventions:
- Paths are relative, like notes/decisions.md or seen/2026-07-24.md. Group related files in directories.
- To keep a record set (items already processed): store one record per line in the seen/ directory, one \
file per date (seen/2026-07-24.md). Call check_lines(lines, directory="seen") BEFORE acting, then record the \
new ones with append_file(..., unique=True). check_lines matches exact whole lines, and unique=True keeps \
the log free of duplicates if a run overlaps another one.
- Paths are relative, like notes/decisions.md. Group related files in directories.
- One topic, one file. Add dated entries as things develop; a note is a living document you maintain, not \
a log you only append to.
- To correct or update part of a file, read it, then call replace_lines with the line numbers you saw. \
Rewriting a whole file with write_file to change a few lines wastes effort and risks losing the rest.
- To find something in a large file, use search_content first: it reports the line number of each match, \
which you can pass to read_file as start_line.
- Store extracted facts and identifiers, not raw fetched payloads.
Never append a contradiction of what a note already says: fix the wording in place, in the same turn you \
learn it was wrong.
- To find something, use search_content first: it reports the file and first-match line, which you can \
pass to read_file as start_line. Then answer from what the note says, not from memory.
- To retire a note that is no longer current, move_file it into an archive/ directory. Do not blank it and \
do not overwrite it: its history may still be needed.
- Store distilled content, not raw fetched payloads.
- Never store secrets, passwords, or API keys.
- Files have size limits. If a write is refused, start a new file in your partition scheme (e.g. a new \
dated file), or delete only files you are certain are obsolete, like a date partition older than you still \
need. Never overwrite or delete a file of records to make room if you might still need those records: \
dropping them means you will repeat work you already did. If nothing is safely disposable, stop and report \
that storage is full rather than evicting history."""
- Files have size limits. If a write is refused, split the topic into smaller files or archive what is \
finished. Never overwrite a note to make room if you might still need it."""
_READ_ONLY_INSTRUCTIONS = """You have read access to a durable filesystem. The files persist across sessions \
and runs.
Use it to look up what you have recorded: items you have processed, decisions you made, notes you left. You \
cannot change these files - you have no tool to write, append, move, or delete.
Use it to look up what has been recorded: decisions, running documents, notes. You cannot change these \
files - you have no tool to write, append, move, or delete.
Conventions:
- Paths are relative, like notes/decisions.md or seen/2026-07-24.md.
- Use check_lines to see what is already recorded before acting."""
- Paths are relative, like notes/decisions.md.
- Use search_content to find where something is recorded, then read_file to read it."""
def _as_backend(source: Any) -> BaseFS:
@@ -445,7 +440,7 @@ class FileSystem:
# Agent surface
# ------------------------------------------------------------------
def tools(self, *, read_only: bool = False, **kwargs) -> "Toolkit":
def tools(self, *, read_only: bool = False, allow_delete: bool = False, **kwargs) -> "Toolkit":
"""Build the toolkit for this file store.
``Agent(tools=[fs.tools()], instructions=[..., fs.instructions()])`` is the
@@ -453,16 +448,23 @@ class FileSystem:
system prompt stays the developer's to order and edit. Pass
``add_instructions=True`` to have the toolkit carry them instead.
``read_only=True`` registers only ``read_file``, ``list_files``,
``search_content`` and ``check_lines``; pair it with
``instructions(read_only=True)``. That is the surface for a consumer agent
that consults another agent's namespace by shared name. ``**kwargs``
forwards to ``Toolkit`` (e.g. ``include_tools``,
``requires_confirmation_tools``).
The default surface is the notes seven: ``read_file``, ``write_file``,
``append_file``, ``replace_lines``, ``list_files``, ``search_content``,
``move_file``. ``allow_delete=True`` adds ``delete_file`` - destructive
is opt-in; archiving with ``move_file`` is the blessed retirement flow.
``check_lines`` (the batched record-set membership test feed agents use)
is requested by name via ``include_tools``, which selects from the whole
surface when passed.
``read_only=True`` registers only ``read_file``, ``list_files`` and
``search_content``; pair it with ``instructions(read_only=True)``. That
is the surface for a consumer agent that consults another agent's
namespace by shared name. ``**kwargs`` forwards to ``Toolkit`` (e.g.
``include_tools``, ``requires_confirmation_tools``).
"""
from agno.fs.toolkit import FileSystemTools
return FileSystemTools(fs=self, read_only=read_only, **kwargs)
return FileSystemTools(fs=self, read_only=read_only, allow_delete=allow_delete, **kwargs)
@staticmethod
def instructions(read_only: bool = False) -> str:
+125 -28
View File
@@ -23,9 +23,11 @@ genuinely needs both FileSystem and a local workspace, wrap one in a sub-agent.
import asyncio
import json
from contextlib import AsyncExitStack, asynccontextmanager
from datetime import datetime, timezone
from fnmatch import fnmatch
from typing import Dict, List, Optional, Union
from typing import Any, Dict, List, Optional, Union
from weakref import WeakKeyDictionary
# Real module-level imports, never TYPE_CHECKING-only: with postponed annotations a
# deferred import does not fail loudly. get_type_hints raises during schema
@@ -37,7 +39,7 @@ from agno.fs.fs import FileSystem
from agno.run import RunContext
from agno.team.team import Team
from agno.tools.toolkit import Toolkit
from agno.utils.log import log_debug, log_error
from agno.utils.log import log_debug, log_error, log_warning
_MAX_DIR_ENTRIES = 200
@@ -91,8 +93,9 @@ def _format_with_line_numbers(text: str, start_line: int = 1) -> str:
class FileSystemTools(Toolkit):
"""Toolkit over one ``FileSystem`` file store. Build it with ``FileSystem.tools()``.
Registers the full nine-tool surface, or the four read tools when
``read_only=True`` (the surface for a consumer agent that consults another
Registers the notes seven by default (``allow_delete=True`` adds
``delete_file``; ``include_tools`` selects explicitly from the whole
nine-tool surface), or the three read tools when ``read_only=True`` (the surface for a consumer agent that consults another
agent's namespace by shared name). Holds the matching FileSystem instructions,
which reach the system prompt only under ``add_instructions=True``; otherwise
compose ``FileSystem.instructions()`` into the agent's own instructions, and
@@ -109,6 +112,7 @@ class FileSystemTools(Toolkit):
agent genuinely needs both, wrap one in a sub-agent.
"""
# The whole surface, exported for callers that want everything explicitly.
FULL_TOOLS: List[str] = [
"read_file",
"write_file",
@@ -120,22 +124,69 @@ class FileSystemTools(Toolkit):
"move_file",
"delete_file",
]
READ_ONLY_TOOLS: List[str] = ["read_file", "list_files", "search_content", "check_lines"]
# The default surface: the notes seven. check_lines (the batched record-set
# membership test) is requested by name via include_tools; delete_file (the
# one tool that can lose work) sits behind allow_delete=True.
DEFAULT_TOOLS: List[str] = [
"read_file",
"write_file",
"append_file",
"replace_lines",
"list_files",
"search_content",
"move_file",
]
READ_ONLY_TOOLS: List[str] = ["read_file", "list_files", "search_content"]
# What a read-only toolkit may select from via include_tools.
_READ_CAPABLE_TOOLS: List[str] = ["read_file", "list_files", "search_content", "check_lines"]
def __init__(
self,
fs: FileSystem,
read_only: bool = False,
allow_delete: bool = False,
instructions: Optional[str] = None,
add_instructions: bool = False,
**kwargs,
):
self.fs = fs
self.read_only = read_only
# Every mutating tool is a read-modify-write over one row, and a model's
# tool calls for one turn are gathered concurrently (models/base.py) -
# the path AgentOS REST and /mcp take. Two replace_lines on one note
# both read the pre-edit content and the second write wins, with both
# tool results reporting success. Same guard entity memory takes for
# its rows, and the same honesty: single process only.
self._write_locks: Any = WeakKeyDictionary()
if read_only and allow_delete:
raise ValueError("allow_delete=True contradicts read_only=True; pick one.")
if instructions is None:
instructions = FileSystem.instructions(read_only=read_only)
registered = self.READ_ONLY_TOOLS if read_only else self.FULL_TOOLS
if kwargs.get("include_tools") is not None:
# include_tools is a fully explicit whitelist over the whole
# (read-capable) surface: naming check_lines or delete_file there IS
# the opt-in for them.
registered = self._READ_CAPABLE_TOOLS if read_only else self.FULL_TOOLS
elif read_only:
registered = self.READ_ONLY_TOOLS
else:
registered = self.DEFAULT_TOOLS + (["delete_file"] if allow_delete else [])
if kwargs.get("exclude_tools"):
# Excluding a tool that exists but is not in this set (delete_file,
# which left the default) is a no-op, not an error - upgraders used
# exclude_tools=["delete_file"] as the safety idiom. A name that is
# no tool at all is still a typo, and swallowing it silently leaves
# a tool registered that the caller believed was gone - say so
# without breaking a call that otherwise resolves fine.
unknown = [name for name in kwargs["exclude_tools"] if name not in self.FULL_TOOLS]
if unknown:
log_warning(
f"FileSystem.tools: exclude_tools names {unknown}, which are not FileSystem "
f"tools - check the spelling, because nothing was excluded for them. "
f"Available: {', '.join(self.FULL_TOOLS)}."
)
kwargs["exclude_tools"] = [name for name in kwargs["exclude_tools"] if name in registered]
sync_tools = [getattr(self, name) for name in registered]
async_tools = [(getattr(self, "a" + name), name) for name in registered]
@@ -166,14 +217,13 @@ class FileSystemTools(Toolkit):
if e.scope == "file":
return (
f"Error: {path} would be {e.current} bytes (limit {e.limit} per file). "
"Start a new file (for record logs, partition by date, e.g. seen/2026-07-24.md) "
"or delete files you no longer need."
"Split the topic into smaller files (or partition by date) and retry."
)
return (
f"Error: storage is full ({e.current} of {e.limit} bytes). "
"Delete only files you are certain are obsolete (see list_files), such as an old date "
"partition, then retry. Do not overwrite or delete records you might still need to "
"make room; if nothing is safely disposable, stop and report that storage is full."
"Free space only if you have a tool for it and only from files you are certain are "
"obsolete (see list_files). Never overwrite or discard records you might still need "
"to make room; if nothing is safely disposable, stop and report that storage is full."
)
# ------------------------------------------------------------------
@@ -216,7 +266,7 @@ class FileSystemTools(Toolkit):
return (
f"Error: file too long to read whole ({len(contents)} chars, {total} lines; "
f"limit {_MAX_READ_CHARS} chars). Read a range with start_line/end_line, or "
"use search_content first: it reports the line number of each match."
"use search_content first: it reports each matching file's first-match line."
)
return _format_with_line_numbers(contents, start_line=1)
start = start_line if start_line is not None else 1
@@ -363,8 +413,7 @@ class FileSystemTools(Toolkit):
Each result gives the line number of the first match, so you can follow up with
read_file(path, start_line=..., end_line=...) instead of reading the whole file.
``matches`` counts every occurrence in that file, while ``snippet`` shows only
the first. To check whether exact records are already stored, use check_lines
instead, since substring matches can mislead there.
the first.
:param query: Substring to search for.
:param directory: Directory to scope the search (default "." = everything).
@@ -480,7 +529,7 @@ class FileSystemTools(Toolkit):
"""Append lines to a file, creating it if needed.
Line-oriented: appended content always starts on a fresh line and ends with a
newline. Keep one record per line (one URL, one ID) so check_lines can match
newline. Keep one record per line (one URL, one ID) so exact-line checks can match
records exactly.
:param path: File path, e.g. "seen/2026-07-24.md". Parent folders are implicit.
@@ -678,6 +727,42 @@ class FileSystemTools(Toolkit):
self.check_lines, lines, directory, run_context=run_context, agent=agent, team=team
)
def _lock_for(self, key: str) -> "asyncio.Lock":
"""The lock for one file, per running event loop.
An asyncio.Lock binds to the loop that first awaits it, so the cache is
keyed weakly by loop: a second loop (a fresh asyncio.run, a worker
thread) gets its own locks instead of one bound to a closed loop.
"""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop is None:
return asyncio.Lock()
per_loop = self._write_locks.get(loop)
if per_loop is None:
per_loop = {}
self._write_locks[loop] = per_loop
lock = per_loop.get(key)
if lock is None:
lock = asyncio.Lock()
per_loop[key] = lock
return lock
@asynccontextmanager
async def _locked(self, *paths: str):
"""Hold the write lock for each named file, acquired in a stable order.
Sorted so two tool calls naming the same pair (move_file src/dst) cannot
deadlock by taking them in opposite orders.
"""
namespace = getattr(self.fs, "namespace", "")
async with AsyncExitStack() as stack:
for key in sorted({f"{namespace}:{path}" for path in paths if path}):
await stack.enter_async_context(self._lock_for(key))
yield
async def awrite_file(
self,
path: str,
@@ -689,9 +774,10 @@ class FileSystemTools(Toolkit):
team: Optional[Team] = None,
) -> str:
"""Async variant of ``write_file``."""
return await asyncio.to_thread(
self.write_file, path, content, overwrite, run_context=run_context, agent=agent, team=team
)
async with self._locked(path):
return await asyncio.to_thread(
self.write_file, path, content, overwrite, run_context=run_context, agent=agent, team=team
)
async def aappend_file(
self,
@@ -704,9 +790,10 @@ class FileSystemTools(Toolkit):
team: Optional[Team] = None,
) -> str:
"""Async variant of ``append_file``."""
return await asyncio.to_thread(
self.append_file, path, content, unique, run_context=run_context, agent=agent, team=team
)
async with self._locked(path):
return await asyncio.to_thread(
self.append_file, path, content, unique, run_context=run_context, agent=agent, team=team
)
async def areplace_lines(
self,
@@ -720,9 +807,17 @@ class FileSystemTools(Toolkit):
team: Optional[Team] = None,
) -> str:
"""Async variant of ``replace_lines``."""
return await asyncio.to_thread(
self.replace_lines, path, start_line, end_line, content, run_context=run_context, agent=agent, team=team
)
async with self._locked(path):
return await asyncio.to_thread(
self.replace_lines,
path,
start_line,
end_line,
content,
run_context=run_context,
agent=agent,
team=team,
)
async def amove_file(
self,
@@ -735,9 +830,10 @@ class FileSystemTools(Toolkit):
team: Optional[Team] = None,
) -> str:
"""Async variant of ``move_file``."""
return await asyncio.to_thread(
self.move_file, src, dst, overwrite, run_context=run_context, agent=agent, team=team
)
async with self._locked(src, dst):
return await asyncio.to_thread(
self.move_file, src, dst, overwrite, run_context=run_context, agent=agent, team=team
)
async def adelete_file(
self,
@@ -748,7 +844,8 @@ class FileSystemTools(Toolkit):
team: Optional[Team] = None,
) -> str:
"""Async variant of ``delete_file``."""
return await asyncio.to_thread(self.delete_file, path, run_context=run_context, agent=agent, team=team)
async with self._locked(path):
return await asyncio.to_thread(self.delete_file, path, run_context=run_context, agent=agent, team=team)
# The async twins delegate to their sync counterparts via asyncio.to_thread, so they
+47 -98
View File
@@ -13,6 +13,10 @@ Configurations:
- SessionContextConfig: Config for session context learning
- LearnedKnowledgeConfig: Config for learned knowledge
- EntityMemoryConfig: Config for entity memory
Custom `schema` classes serialize by import path; define them in an
importable module, not `__main__`, or they will not survive a
to_dict/from_dict round-trip across processes.
"""
from dataclasses import dataclass
@@ -34,8 +38,8 @@ class LearningMode(Enum):
ALWAYS: Automatic extraction after each response.
AGENTIC: Agent decides when to learn via tools.
PROPOSE: Agent proposes, human confirms.
HITL (Human-in-the-Loop): Reserved for future use.
PROPOSE: Agent proposes, human confirms (learned_knowledge only).
HITL: Deprecated, unsupported by every store; removed in 3.0.
"""
ALWAYS = "always"
@@ -330,73 +334,75 @@ class EntityMemoryConfig:
- "global": Shared with everyone (default)
- Custom string: Explicit grouping (e.g., "sales_west")
Mode: AGENTIC only. The agent records entities through its four tools
(remember_about, link_entities, search_entities, forget); there is no
extraction pass.
Attributes:
db: Database backend for storage.
model: Model for extraction (required for ALWAYS mode).
mode: How learning is extracted. Default: ALWAYS.
model: Model for the fact-supersession judgment on writes.
mode: AGENTIC (the only supported mode).
schema: Custom schema for entity memory data. Default: EntityMemory.
# Sharing boundary
namespace: Sharing boundary ("user", "global", or custom).
# Extraction operations
enable_create_entity: Allow creating new entities.
enable_update_entity: Allow updating entity properties.
enable_add_fact: Allow adding facts to entities.
enable_update_fact: Allow updating existing facts.
enable_delete_fact: Allow deleting facts.
enable_add_event: Allow adding events to entities.
enable_add_relationship: Allow adding relationships.
# Limits
max_updates_per_run: Max updates per extraction run. Default: 10.
# Agent tools
enable_agent_tools: Expose tools to the agent.
agent_can_create_entity: If agent_tools enabled, provide create_entity tool.
agent_can_update_entity: If agent_tools enabled, provide update_entity tool.
agent_can_search_entities: If agent_tools enabled, provide search_entities tool.
max_updates_per_run: Retained for LearningMachine limit propagation.
# Prompt customization
instructions: Custom instructions for entity extraction.
instructions: Custom instructions for the supersession judgment.
additional_instructions: Extra instructions appended to default.
system_message: Full override for extraction system message.
system_message: Full override for the supersession system message.
"""
# Required fields
db: Optional[Union["BaseDb", "AsyncBaseDb"]] = None
model: Optional["Model"] = None
# Mode and extraction
mode: LearningMode = LearningMode.ALWAYS
# Mode (AGENTIC only)
mode: LearningMode = LearningMode.AGENTIC
schema: Optional[Type[Any]] = None
# Sharing boundary
namespace: str = "global"
# Extraction operations
enable_create_entity: bool = True
enable_update_entity: bool = True
enable_add_fact: bool = True
enable_update_fact: bool = True
enable_delete_fact: bool = True
enable_add_event: bool = True
enable_add_relationship: bool = True
# Limits
max_updates_per_run: Optional[int] = None
# Fact supersession: a new fact retires a contradicted live fact when the
# judge's confidence is at or above this threshold. Conservative by design -
# a wrong supersession hides information the user gave us.
supersession_threshold: float = 0.8
# Rendering bounds. The injected block always starts with a one-line-per-
# entity directory (name + type, newest first, archived excluded), followed
# by at most max_entities_in_context expanded entities, each rendering at
# most max_facts_per_entity live facts (with as-of dates) and the last
# max_events_per_entity events - with visible truncation markers.
max_entities_in_context: int = 5
max_entities_in_directory: int = 50
max_facts_per_entity: int = 10
max_events_per_entity: int = 5
# Agent tools
enable_agent_tools: bool = False
agent_can_create_entity: bool = True
agent_can_update_entity: bool = True
agent_can_search_entities: bool = True
enable_agent_tools: bool = True
# Prompt customization
instructions: Optional[str] = None
additional_instructions: Optional[str] = None
system_message: Optional[str] = None
def __post_init__(self) -> None:
# Fail where the mistake is made: a store built from this config would
# raise the same error, but only on first use inside a run.
if self.mode != LearningMode.AGENTIC:
raise ValueError(
f"Entity memory is AGENTIC-only: the agent records entities through its tools "
f"and there is no extraction pass. Remove mode={self.mode.value!r} or set "
f"LearningMode.AGENTIC."
)
def __repr__(self) -> str:
return f"EntityMemoryConfig(mode={self.mode.value}, namespace={self.namespace}, enable_agent_tools={self.enable_agent_tools})"
@@ -413,6 +419,8 @@ class DecisionLogConfig:
Decision Logs record decisions made by the agent with reasoning
and context. Useful for auditing and learning from past decisions.
Mode: AGENTIC only - the agent logs decisions via tools.
Scope: AGENT (fixed) - Stored and retrieved by agent_id.
"""
@@ -420,8 +428,8 @@ class DecisionLogConfig:
db: Optional[Union["BaseDb", "AsyncBaseDb"]] = None
model: Optional["Model"] = None
# Mode and extraction
mode: LearningMode = LearningMode.ALWAYS
# Mode (AGENTIC only)
mode: LearningMode = LearningMode.AGENTIC
schema: Optional[Type[Any]] = None
# Extraction limits
@@ -439,62 +447,3 @@ class DecisionLogConfig:
def __repr__(self) -> str:
return f"DecisionLogConfig(mode={self.mode.value})"
# =============================================================================
# Placeholder Configurations (Not yet implemented)
# =============================================================================
@dataclass
class FeedbackConfig:
"""Configuration for Behavioral Feedback learning type.
Behavioral Feedback captures signals about what worked and what
didn't: thumbs up/down, corrections, regeneration requests.
Scope: AGENT (fixed) - Stored and retrieved by agent_id.
Note: Deferred to Phase 2.
"""
# Required fields
db: Optional[Union["BaseDb", "AsyncBaseDb"]] = None
model: Optional["Model"] = None
# Mode and extraction
mode: LearningMode = LearningMode.ALWAYS
schema: Optional[Type[Any]] = None
# Prompt customization
instructions: Optional[str] = None
def __repr__(self) -> str:
return "FeedbackConfig(mode=ALWAYS)"
@dataclass
class SelfImprovementConfig:
"""Configuration for Self-Improvement learning type.
Self-Improvement proposes updates to agent instructions based
on feedback patterns and successful interactions.
Scope: AGENT (fixed) - Stored and retrieved by agent_id.
Note: Deferred to Phase 3.
"""
# Required fields
db: Optional[Union["BaseDb", "AsyncBaseDb"]] = None
model: Optional["Model"] = None
# Mode and extraction
mode: LearningMode = LearningMode.HITL
schema: Optional[Type[Any]] = None
# Prompt customization
instructions: Optional[str] = None
def __repr__(self) -> str:
return "SelfImprovementConfig(mode=HITL)"
+416 -42
View File
@@ -14,7 +14,7 @@ Plus maintenance via the Curator for keeping memories healthy.
from dataclasses import dataclass, field
from os import getenv
from typing import Any, Callable, Dict, List, Optional, Union
from typing import Any, Callable, ClassVar, Dict, List, Optional, Union
from agno.learn.config import (
DecisionLogConfig,
@@ -40,6 +40,33 @@ try:
except ImportError:
pass
def _filter_store_kwargs(callee: Callable, kwargs: Dict[str, Any]) -> Dict[str, Any]:
"""Filter kwargs to what the callee accepts (signature-aware dispatch).
Third-party custom_stores satisfy a Protocol, and a store written as
``def recall(self, user_id=None)`` with no ``**kwargs`` would raise
TypeError the moment new context kwargs (message, run_context, ...) arrive.
Built-in stores take ``**kwargs`` and see everything; narrow custom stores
keep working untouched. Same inspect.signature approach as
invoke_callable_factory.
"""
import inspect
try:
parameters = inspect.signature(callee).parameters
except (TypeError, ValueError):
return kwargs
kinds = {p.kind for p in parameters.values()}
if inspect.Parameter.VAR_KEYWORD in kinds:
return kwargs
if inspect.Parameter.VAR_POSITIONAL in kinds:
# A *args-only callee cannot take these as keywords; pass everything
# through so the mismatch fails loudly instead of silently dropping data.
return kwargs
return {key: value for key, value in kwargs.items() if key in parameters}
# Type aliases for cleaner signatures
UserProfileInput = Union[bool, UserProfileConfig, LearningStore, None]
UserMemoryInput = Union[bool, UserMemoryConfig, LearningStore, None]
@@ -99,6 +126,16 @@ class LearningMachine:
# Internal state (lazy initialization)
_stores: Optional[Dict[str, LearningStore]] = field(default=None, init=False)
_curator: Optional[Any] = field(default=None, init=False)
# Manual-door tracking: instructions() called by the developer marks the
# machine as hand-placed; if the framework then also injects it (learning=),
# the blocks would render twice - warn once.
_placed_by_hand: bool = field(default=False, init=False)
_double_render_warned: bool = field(default=False, init=False)
_missing_user_id_warned: bool = field(default=False, init=False)
_missing_model_warned: bool = field(default=False, init=False)
# Strong refs to fire-and-forget capture tasks (acapture_hook), so the event
# loop cannot garbage-collect them mid-flight.
_capture_tasks: set = field(default_factory=set, init=False)
# =========================================================================
# Initialization (Lazy)
@@ -106,9 +143,23 @@ class LearningMachine:
@property
def stores(self) -> Dict[str, LearningStore]:
"""All registered stores, keyed by name. Lazily initialized."""
"""All registered stores, keyed by name. Lazily initialized.
Stores take the machine's model at construction, but the model usually
arrives *after* them: ``learning=`` hands over the agent's model during
Agent init, which is later than any earlier read of this property — a
test, an inspection, or the manual door. Backfill on access so a store
built before the model was bound picks it up, instead of keeping
``model=None`` for the life of the process and silently declining to
capture.
"""
if self._stores is None:
self._initialize_stores()
if self.model is not None:
for store in self._stores.values(): # type: ignore[union-attr]
config = getattr(store, "config", None)
if config is not None and getattr(config, "model", "unused") is None:
config.model = self.model
return self._stores # type: ignore
def _initialize_stores(self) -> None:
@@ -178,9 +229,15 @@ class LearningMachine:
Returns:
Initialized store instance.
"""
# Already a store instance
# Already a store instance. The Protocol gained instructions() in 2.8.4;
# the duck-typed fallback keeps third-party stores written before that
# working (they simply contribute no guidance text).
if isinstance(input_value, LearningStore):
return input_value
if not isinstance(input_value, bool) and all(
callable(getattr(input_value, method, None)) for method in ("recall", "process", "build_context")
):
return input_value
# Create store based on type
if store_type == "user_profile":
@@ -272,12 +329,16 @@ class LearningMachine:
config.model = self.model
if config.max_updates_per_run is None:
config.max_updates_per_run = self.max_updates_per_run
# A config still on the class default inherits the machine namespace;
# an explicit config namespace wins over it at every call site.
if config.namespace == "global" and self.namespace != "global":
config.namespace = self.namespace
else:
config = EntityMemoryConfig(
db=self.db,
model=self.model,
namespace=self.namespace,
mode=LearningMode.ALWAYS,
mode=LearningMode.AGENTIC,
max_updates_per_run=self.max_updates_per_run,
)
@@ -294,6 +355,8 @@ class LearningMachine:
config.knowledge = self.knowledge
if config.max_updates_per_run is None:
config.max_updates_per_run = self.max_updates_per_run
if config.namespace == "global" and self.namespace != "global":
config.namespace = self.namespace
else:
config = LearnedKnowledgeConfig(
model=self.model,
@@ -420,7 +483,7 @@ class LearningMachine:
message=message,
entity_id=entity_id,
entity_type=entity_type,
namespace=namespace or self.namespace,
namespace=namespace,
agent_id=agent_id,
team_id=team_id,
**kwargs,
@@ -447,7 +510,7 @@ class LearningMachine:
message=message,
entity_id=entity_id,
entity_type=entity_type,
namespace=namespace or self.namespace,
namespace=namespace,
agent_id=agent_id,
team_id=team_id,
**kwargs,
@@ -455,6 +518,165 @@ class LearningMachine:
return self._format_results(results=results)
def instructions(self) -> str:
"""The guidance block: how and when to use the learning tools.
Mode-aware and aggregated across enabled stores, which is why this is
an instance method and not a static string - the text depends on which
stores are enabled and what mode each is in. Pairs with build_context()
(the data block): the automatic path concatenates the two at the
injection site, and the manual door places each by hand.
Calling this marks the machine as hand-placed; if the same machine is
ALSO passed to Agent(learning=...), the framework warns once about the
double render.
"""
self._placed_by_hand = True
return self._instructions_text()
def _warn_if_user_id_missing(self, user_id: Optional[str]) -> None:
"""Per-user stores silently drop their tools and capture without a
user_id (an unauthenticated /mcp run is the common way to get here).
Make the degradation visible, once per machine."""
if user_id or self._missing_user_id_warned:
return
per_user = [name for name in ("user_profile", "user_memory") if self.stores.get(name) is not None]
if per_user:
self._missing_user_id_warned = True
log_warning(
f"This run has no user_id, but per-user learning stores are configured "
f"({', '.join(per_user)}). Their tools and capture are disabled for this run. "
f"Pin Agent(user_id=...) or authenticate the request so a user id reaches the stores."
)
def _warn_if_model_missing(self) -> None:
"""Capture is a model call, and the manual door injects nothing.
``learning=`` hands the agent's model to the machine; a hand-placed
machine keeps whatever it was constructed with. Without one, the
capture tools return "No model provided" and entity memory keeps every
stated fact, both without saying so at the point of use.
"""
if self._missing_model_warned:
return
# Ask the stores, not the machine: `self.model is not None` was the
# earlier guard here, and it short-circuited exactly the case this
# check exists to catch — a machine whose model arrived after its
# stores did. `stores` now backfills those, so a store still without
# one means the machine genuinely has none.
without = [name for name, store in self.stores.items() if getattr(store, "model", "unused") is None]
if not without:
return
self._missing_model_warned = True
log_warning(
f"LearningMachine has no model, so these stores cannot capture: {', '.join(sorted(without))}. "
f"Their tools return 'No model provided', and entity memory records every stated fact without "
f"retiring the ones it contradicts. Pass model= to LearningMachine (the manual door injects "
f"nothing), or attach the machine with learning= so the agent's model is used."
)
def _framework_instructions(self) -> str:
"""The guidance block, fetched by the automatic injection path.
Detects the double-render case: the same machine placed by hand
(instructions() was called) AND attached via learning=.
"""
if self._placed_by_hand and not self._double_render_warned:
self._double_render_warned = True
log_warning(
"This LearningMachine is attached via learning= AND its surfaces are placed by "
"hand (instructions()/build_context()); its context will render twice. Pick one "
"door: pass learning= for the automatic path, or place the surfaces yourself "
"without learning=."
)
return self._instructions_text()
def _instructions_text(self) -> str:
parts: List[str] = []
for name, store in self.stores.items():
instructions_fn = getattr(store, "instructions", None)
if not callable(instructions_fn):
continue
try:
text = instructions_fn()
if text:
parts.append(text)
except Exception as e:
log_warning(f"Error getting instructions from {name}: {str(e)}")
return "\n\n".join(parts)
def capture_hook(self) -> Callable:
"""A post_hooks-compatible callable that runs this machine's capture pass.
The manual door is agentic by nature: with no learning= there is no
automatic post-run extraction, and the tools are the capture mechanism.
For the developer who wants hand-placed prompts AND ALWAYS-mode
extraction, add this to Agent(post_hooks=[...]). An escape hatch, not a
third shape.
Extraction is backgrounded on the agent's background executor when one
is available (the same place learning= runs it), so it stays off the
response path; without an executor it runs inline. Works on both sync
and async runs (async post-hook execution calls sync hooks directly).
"""
machine = self
def learning_capture(run_output=None, agent=None, session=None, user_id=None, run_context=None) -> None:
messages = list(getattr(run_output, "messages", None) or [])
if not messages:
return
kwargs = dict(
messages=messages,
user_id=user_id,
session_id=getattr(session, "session_id", None) if session is not None else None,
agent_id=getattr(agent, "id", None) if agent is not None else None,
team_id=getattr(agent, "team_id", None) if agent is not None else None,
run_context=run_context,
metadata=getattr(run_context, "metadata", None),
dependencies=getattr(run_context, "dependencies", None),
session_state=getattr(run_context, "session_state", None),
)
executor = getattr(agent, "background_executor", None) if agent is not None else None
if executor is not None:
executor.submit(machine.process, **kwargs)
else:
machine.process(**kwargs)
return learning_capture
def acapture_hook(self) -> Callable:
"""Async version of capture_hook: schedules aprocess as a background task.
Only for async runs - a sync run() skips async hooks with a warning, so
pass capture_hook() there instead. The task is fire-and-forget off the
response path; failures are logged, never raised into the run.
"""
machine = self
async def alearning_capture(run_output=None, agent=None, session=None, user_id=None, run_context=None) -> None:
import asyncio
messages = list(getattr(run_output, "messages", None) or [])
if not messages:
return
task = asyncio.create_task(
machine.aprocess(
messages=messages,
user_id=user_id,
session_id=getattr(session, "session_id", None) if session is not None else None,
agent_id=getattr(agent, "id", None) if agent is not None else None,
team_id=getattr(agent, "team_id", None) if agent is not None else None,
run_context=run_context,
metadata=getattr(run_context, "metadata", None),
dependencies=getattr(run_context, "dependencies", None),
session_state=getattr(run_context, "session_state", None),
)
)
machine._capture_tasks.add(task)
task.add_done_callback(machine._capture_tasks.discard)
return alearning_capture
def get_tools(
self,
user_id: Optional[str] = None,
@@ -467,8 +689,9 @@ class LearningMachine:
"""Get learning tools to expose to the agent.
Returns tools based on which stores are enabled:
- user_profile: update_user_memory
- entity_memory: search_entities, create_entity, update_entity, add_fact, etc.
- user_profile: update_profile
- user_memory: update_user_memory
- entity_memory: remember_about, link_entities, search_entities, forget
- learned_knowledge: search_learnings, save_learning
Args:
@@ -482,10 +705,12 @@ class LearningMachine:
List of callable tools.
"""
tools = []
self._warn_if_user_id_missing(user_id)
self._warn_if_model_missing()
context = {
"user_id": user_id,
"session_id": session_id,
"namespace": namespace or self.namespace,
"namespace": namespace,
"agent_id": agent_id,
"team_id": team_id,
**kwargs,
@@ -493,7 +718,7 @@ class LearningMachine:
for name, store in self.stores.items():
try:
store_tools = store.get_tools(**context)
store_tools = store.get_tools(**_filter_store_kwargs(store.get_tools, context))
if store_tools:
tools.extend(store_tools)
log_debug(f"Got {len(store_tools)} tools from {name}")
@@ -513,10 +738,12 @@ class LearningMachine:
) -> List[Callable]:
"""Async version of get_tools."""
tools = []
self._warn_if_user_id_missing(user_id)
self._warn_if_model_missing()
context = {
"user_id": user_id,
"session_id": session_id,
"namespace": namespace or self.namespace,
"namespace": namespace,
"agent_id": agent_id,
"team_id": team_id,
**kwargs,
@@ -524,7 +751,7 @@ class LearningMachine:
for name, store in self.stores.items():
try:
store_tools = await store.aget_tools(**context)
store_tools = await store.aget_tools(**_filter_store_kwargs(store.aget_tools, context))
if store_tools:
tools.extend(store_tools)
log_debug(f"Got {len(store_tools)} tools from {name}")
@@ -560,7 +787,7 @@ class LearningMachine:
"messages": messages,
"user_id": user_id,
"session_id": session_id,
"namespace": namespace or self.namespace,
"namespace": namespace,
"agent_id": agent_id,
"team_id": team_id,
**kwargs,
@@ -568,7 +795,7 @@ class LearningMachine:
for name, store in self.stores.items():
try:
store.process(**context)
store.process(**_filter_store_kwargs(store.process, context))
if getattr(store, "was_updated", False):
log_debug(f"Store {name} was updated")
except Exception as e:
@@ -589,7 +816,7 @@ class LearningMachine:
"messages": messages,
"user_id": user_id,
"session_id": session_id,
"namespace": namespace or self.namespace,
"namespace": namespace,
"agent_id": agent_id,
"team_id": team_id,
**kwargs,
@@ -597,7 +824,7 @@ class LearningMachine:
for name, store in self.stores.items():
try:
await store.aprocess(**context)
await store.aprocess(**_filter_store_kwargs(store.aprocess, context))
if getattr(store, "was_updated", False):
log_debug(f"Store {name} was updated")
except Exception as e:
@@ -634,7 +861,7 @@ class LearningMachine:
"query": message, # For learned_knowledge
"entity_id": entity_id,
"entity_type": entity_type,
"namespace": namespace or self.namespace,
"namespace": namespace,
"agent_id": agent_id,
"team_id": team_id,
**kwargs,
@@ -642,7 +869,7 @@ class LearningMachine:
for name, store in self.stores.items():
try:
result = store.recall(**context)
result = store.recall(**_filter_store_kwargs(store.recall, context))
results[name] = result
try:
log_debug(f"Recalled from {name}: {result}")
@@ -674,7 +901,7 @@ class LearningMachine:
"query": message,
"entity_id": entity_id,
"entity_type": entity_type,
"namespace": namespace or self.namespace,
"namespace": namespace,
"agent_id": agent_id,
"team_id": team_id,
**kwargs,
@@ -682,7 +909,7 @@ class LearningMachine:
for name, store in self.stores.items():
try:
result = await store.arecall(**context)
result = await store.arecall(**_filter_store_kwargs(store.arecall, context))
results[name] = result
try:
log_debug(f"Recalled from {name}: {result}")
@@ -745,49 +972,196 @@ class LearningMachine:
# Serialization
# =========================================================================
_STORE_CONFIG_CLASSES: ClassVar[Dict[str, Any]] = {
"user_profile": UserProfileConfig,
"user_memory": UserMemoryConfig,
"session_context": SessionContextConfig,
"entity_memory": EntityMemoryConfig,
"learned_knowledge": LearnedKnowledgeConfig,
"decision_log": DecisionLogConfig,
}
def to_dict(self) -> Dict[str, Any]:
"""Serialize the LearningMachine configuration to a dictionary.
Preserves which stores are enabled and the namespace so that
from_dict() can reconstruct an equivalent instance. Does not
serialize db, model, or knowledge (those are injected at init).
Lossless for what a platform rebuild needs: per-store mode,
namespace, enable_* toggles, limits, prompt strings and
schema-by-import-path all round-trip. db, model and knowledge are
never serialized (they are injected at init), and callables cannot
be - a config carrying one round-trips as present-but-not-restorable
and from_dict logs once. A schema class defined in a __main__ script
serializes as a __main__ path and only resolves inside the same
process; define custom schemas in an importable module for a
cross-process rebuild. Store INSTANCES serialize their config;
custom_stores serialize as import-path refs (informational - the
instances cannot be rebuilt from a dict).
"""
d: Dict[str, Any] = {}
if self.user_profile:
d["user_profile"] = True
if self.user_memory:
d["user_memory"] = True
if self.session_context:
d["session_context"] = True
if self.entity_memory:
d["entity_memory"] = True
if self.learned_knowledge:
d["learned_knowledge"] = True
if self.decision_log:
d["decision_log"] = True
for store_name in self._STORE_CONFIG_CLASSES:
value = getattr(self, store_name)
if not value:
continue
d[store_name] = self._serialize_store_input(value)
if self.namespace != "global":
d["namespace"] = self.namespace
if self.max_updates_per_run != 10:
d["max_updates_per_run"] = self.max_updates_per_run
if self.custom_stores:
d["custom_stores"] = {
name: f"{type(store).__module__}.{type(store).__qualname__}"
for name, store in self.custom_stores.items()
}
if self.debug_mode:
d["debug_mode"] = True
return d
def _serialize_store_input(self, value: Any) -> Any:
"""bool -> True; Config -> field dict; Store instance -> its config's dict."""
import dataclasses
if value is True:
return True
# A store instance carries its config (stores are dataclasses too, so
# check for the carried config before the dataclass test).
inner = getattr(value, "config", None)
if inner is not None and dataclasses.is_dataclass(inner):
config = inner
store_module = type(value).__module__ or ""
if not store_module.startswith("agno.learn.stores"):
log_warning(
f"LearningMachine.to_dict: store {type(value).__name__} is serialized by its "
f"config only; the subclass itself cannot be rebuilt from a dict."
)
elif dataclasses.is_dataclass(value) and not isinstance(value, type):
config = value
else:
return True
serialized: Dict[str, Any] = {}
for f in dataclasses.fields(config):
if f.name in ("db", "model", "knowledge"):
continue
field_value = getattr(config, f.name)
if field_value is None:
continue
if isinstance(field_value, LearningMode):
serialized["mode"] = field_value.value
elif isinstance(field_value, type):
serialized[f.name] = f"{field_value.__module__}.{field_value.__qualname__}"
elif callable(field_value):
serialized[f.name] = {"__callable__": getattr(field_value, "__name__", "callable")}
elif isinstance(field_value, (str, int, float, bool, list, dict)):
import json
try:
json.dumps(field_value)
except (TypeError, ValueError):
log_warning(
f"LearningMachine.to_dict: dropping non-JSON-serializable config value {f.name!r}; "
f"set it programmatically after rebuild."
)
continue
serialized[f.name] = field_value
return serialized
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "LearningMachine":
"""Reconstruct a LearningMachine from a serialized dictionary.
db and model must be injected separately (e.g. during agent/team init).
db and model must be injected separately (e.g. during agent/team
init). Accepts both the lossless per-store dicts and the old
boolean-only payloads. Unrestorable entries (callables, custom-store
refs) are logged once, never silently dropped to a different default.
"""
kwargs: Dict[str, Any] = {}
for store_name, config_cls in cls._STORE_CONFIG_CLASSES.items():
value = data.get(store_name, False)
if isinstance(value, dict):
kwargs[store_name] = cls._deserialize_store_config(store_name, config_cls, value)
else:
kwargs[store_name] = bool(value)
if data.get("custom_stores"):
log_warning(
f"LearningMachine.from_dict: custom stores {sorted(data['custom_stores'])} cannot be "
f"rebuilt from a serialized config; re-attach them programmatically."
)
return cls(
user_profile=data.get("user_profile", False),
user_memory=data.get("user_memory", False),
session_context=data.get("session_context", False),
entity_memory=data.get("entity_memory", False),
learned_knowledge=data.get("learned_knowledge", False),
decision_log=data.get("decision_log", False),
namespace=data.get("namespace", "global"),
max_updates_per_run=data.get("max_updates_per_run", 10),
debug_mode=data.get("debug_mode", False),
**kwargs,
)
@classmethod
def _deserialize_store_config(cls, store_name: str, config_cls: Any, payload: Dict[str, Any]) -> Any:
import dataclasses
import importlib
field_names = {f.name for f in dataclasses.fields(config_cls)}
kwargs: Dict[str, Any] = {}
for key, value in payload.items():
if key not in field_names:
continue
if key == "mode":
try:
kwargs["mode"] = LearningMode(value)
except ValueError:
# AGENTIC, not the class default: several stores default to
# ALWAYS, and an unrecognized mode must never turn into a
# surprise per-run extraction pass.
log_warning(f"LearningMachine.from_dict: unknown mode {value!r} on {store_name}; using AGENTIC.")
kwargs["mode"] = LearningMode.AGENTIC
continue
if key == "schema" and isinstance(value, str):
module_path, _, attr_path = value.rpartition(".")
try:
target: Any = importlib.import_module(module_path)
for part in attr_path.split("."):
target = getattr(target, part)
kwargs["schema"] = target
except Exception as e:
if module_path == "__main__":
log_warning(
f"LearningMachine.from_dict: schema {value!r} on {store_name} was defined "
f"in a __main__ script and cannot be imported from another process; "
f"define custom schemas in an importable module. The store falls back "
f"to its default schema."
)
else:
log_warning(
f"LearningMachine.from_dict: could not import schema {value!r} for {store_name}: {e}"
)
continue
if isinstance(value, dict) and "__callable__" in value:
log_warning(
f"LearningMachine.from_dict: {store_name}.{key} was a callable "
f"({value['__callable__']}) and cannot be restored from a serialized config; "
f"set it programmatically."
)
continue
kwargs[key] = value
try:
return config_cls(**kwargs)
except Exception as e:
# Keep every valid field: a payload carrying an invalid mode (e.g. a
# legacy 'always' for entity memory) must not also lose its
# namespace and knobs.
retry_kwargs = {k: v for k, v in kwargs.items() if k != "mode"}
if retry_kwargs != kwargs:
try:
rebuilt = config_cls(**retry_kwargs)
log_warning(
f"LearningMachine.from_dict: dropped invalid mode on {store_name} ({e}); "
f"other settings preserved."
)
return rebuilt
except Exception:
pass
log_warning(f"LearningMachine.from_dict: could not rebuild {store_name} config ({e}); enabling defaults.")
return True
# =========================================================================
# Representation
# =========================================================================
+121 -120
View File
@@ -28,8 +28,6 @@ Schemas:
- LearnedKnowledge: Reusable knowledge/insights
- EntityMemory: Third-party entity facts
- DecisionLog: Decision logs
- Feedback: Behavioral feedback (Phase 2)
- InstructionUpdate: Self-improvement (Phase 3)
"""
from dataclasses import asdict, dataclass, field, fields
@@ -594,6 +592,7 @@ class EntityMemory:
properties: Dict[str, str] = field(
default_factory=dict, metadata={"description": "Key-value properties (industry, tech_stack, etc)"}
)
aliases: List[str] = field(default_factory=list, metadata={"description": "Other names this entity resolves from"})
# Semantic memory (facts)
facts: List[Dict[str, Any]] = field(default_factory=list)
@@ -614,6 +613,9 @@ class EntityMemory:
team_id: Optional[str] = field(default=None, metadata={"internal": True})
created_at: Optional[str] = field(default=None, metadata={"internal": True})
updated_at: Optional[str] = field(default=None, metadata={"internal": True})
# Set when the entity is archived via forget(); archived entities are excluded
# from recall, rendering and the directory, but stay reachable by search.
archived_at: Optional[str] = field(default=None, metadata={"internal": True})
@classmethod
def from_dict(cls, data: Any) -> Optional["EntityMemory"]:
@@ -689,6 +691,18 @@ class EntityMemory:
if content and content.strip():
now = _utc_now_iso()
# Idempotent for the same day, like add_relationship: models
# re-state what they already recorded, and a duplicated event
# renders twice and retires ambiguously.
for existing in self.events:
if (
isinstance(existing, dict)
and existing.get("content") == content.strip()
and existing.get("date") == date
):
existing["updated_at"] = now
return str(existing.get("id", ""))
event = {"id": event_id, "content": content.strip(), "created_at": now, "updated_at": now, **kwargs}
if date:
event["date"] = date
@@ -710,9 +724,27 @@ class EntityMemory:
"""
import uuid
rel_id = str(uuid.uuid4())[:8]
now = _utc_now_iso()
# Idempotent: models re-assert links they already know, and an appended
# duplicate cannot be retired - forget lists byte-identical candidates
# no wording can tell apart. Touch the existing edge instead.
far_type = kwargs.get("entity_type")
for existing in self.relationships:
if (
isinstance(existing, dict)
and existing.get("entity_id") == related_entity_id
and existing.get("relation") == relation
and existing.get("direction") == direction
# entity_type is part of the identity: project/Harbor and
# company/Harbor share a slug and are different things.
and existing.get("entity_type") == far_type
):
existing["updated_at"] = now
existing.update(kwargs)
return str(existing.get("id", ""))
rel_id = str(uuid.uuid4())[:8]
self.relationships.append(
{
"id": rel_id,
@@ -758,8 +790,44 @@ class EntityMemory:
self.facts = [f for f in self.facts if not (isinstance(f, dict) and f.get("id") == fact_id)]
return len(self.facts) < original_len
def get_context_text(self) -> str:
"""Get entity as formatted string for prompts."""
def live_facts(self) -> List[Dict[str, Any]]:
"""Facts that have not been superseded. Only these render."""
return [f for f in self.facts if not (isinstance(f, dict) and f.get("superseded_at"))]
def retire_fact(self, fact_id: str, superseded_by: str) -> bool:
"""Mark a fact superseded without deleting it.
Args:
fact_id: The fact to retire.
superseded_by: What replaced it - a new fact's id, "forgotten", or
"superseded" when several new facts jointly replaced it.
Returns:
True if the fact was found and retired, False otherwise.
"""
for fact in self.facts:
if isinstance(fact, dict) and fact.get("id") == fact_id and not fact.get("superseded_at"):
fact["superseded_at"] = _utc_now_iso()
fact["superseded_by"] = superseded_by
return True
return False
def get_context_text(
self,
max_facts: Optional[int] = 10,
max_events: Optional[int] = 5,
related_names: Optional[Dict[str, str]] = None,
) -> str:
"""Get entity as formatted string for prompts.
Bounded and honest: at most ``max_facts`` live facts (each with an
as-of date, so July's truth outranks March's) and the last
``max_events`` events, with explicit truncation markers so a capped
render is never mistaken for the whole record. Relationships render in
full - they are one line each; ``related_names`` maps far-end entity
ids to display names (the one-hop expansion), falling back to the id.
Pass ``None`` to lift a cap.
"""
parts = []
if self.name:
@@ -774,19 +842,58 @@ class EntityMemory:
props = ", ".join(f"{k}: {v}" for k, v in self.properties.items())
parts.append(f"Properties: {props}")
if self.facts:
facts_text = "\n".join(f" - {f.get('content', f)}" for f in self.facts)
parts.append(f"Facts:\n{facts_text}")
live = self.live_facts()
if live:
# The NEWEST N facts render (in chronological order) - showing the
# oldest slice would date-stamp stale state as current.
if max_facts is None:
shown = live
elif max_facts <= 0:
shown = []
else:
shown = live[-max_facts:]
marker = f" (newest {len(shown)} of {len(live)} facts)" if len(shown) < len(live) else ""
fact_lines = []
for f in shown:
if isinstance(f, dict):
as_of = str(f.get("updated_at") or f.get("created_at") or "")[:10]
as_of_text = f" (as of {as_of})" if as_of else ""
fact_lines.append(f" - {f.get('content', f)}{as_of_text}")
else:
fact_lines.append(f" - {f}")
if shown:
parts.append(f"Facts:{marker}\n" + "\n".join(fact_lines))
if self.events:
events_text = "\n".join(
f" - {e.get('content', e)}" + (f" ({e.get('date')})" if e.get("date") else "") for e in self.events
if max_events is None:
shown_events = self.events
elif max_events <= 0:
shown_events = []
else:
shown_events = self.events[-max_events:]
marker = (
f" (last {len(shown_events)} of {len(self.events)} events)"
if len(shown_events) < len(self.events)
else ""
)
parts.append(f"Events:\n{events_text}")
event_lines = []
for e in shown_events:
if isinstance(e, dict):
date = f" ({e.get('date')})" if e.get("date") else ""
event_lines.append(f" - {e.get('content', e)}{date}")
else:
event_lines.append(f" - {e}")
if shown_events:
parts.append(f"Events:{marker}\n" + "\n".join(event_lines))
if self.relationships:
rels_text = "\n".join(f" - {r.get('relation')}: {r.get('entity_id')}" for r in self.relationships)
parts.append(f"Relationships:\n{rels_text}")
rel_lines = []
for r in self.relationships:
far_id = r.get("entity_id", "?")
far_label = (related_names or {}).get(far_id, far_id)
arrow = "->" if r.get("direction", "outgoing") == "outgoing" else "<-"
rel_lines.append(f" - {r.get('relation')} {arrow} {far_label}")
parts.append("Relationships:\n" + "\n".join(rel_lines))
return "\n\n".join(parts)
@@ -1054,109 +1161,3 @@ class DecisionLog:
# Backwards compatibility alias
Decision = DecisionLog
# =============================================================================
# Placeholder Schemas (Not yet implemented)
# =============================================================================
@dataclass
class Feedback:
"""Schema for Behavioral Feedback. (Phase 2)
Captures signals about what worked and what didn't.
"""
signal: str # thumbs_up, thumbs_down, correction, regeneration
learning: Optional[str] = None
context: Optional[str] = None
agent_id: Optional[str] = None
team_id: Optional[str] = None
created_at: Optional[str] = None
@classmethod
def from_dict(cls, data: Any) -> Optional["Feedback"]:
"""Parse from dict/JSON, returning None on any failure."""
if data is None:
return None
if isinstance(data, cls):
return data
try:
parsed = _parse_json(data)
if not parsed:
log_debug(f"{cls.__name__}.from_dict: _parse_json returned None for data={_truncate_for_log(data)}")
return None
if not parsed.get("signal"):
log_debug(f"{cls.__name__}.from_dict: missing required field 'signal'")
return None
field_names = {f.name for f in fields(cls)}
kwargs = {k: v for k, v in parsed.items() if k in field_names}
return cls(**kwargs)
except Exception as e:
log_debug(f"{cls.__name__}.from_dict failed: {e}, data={_truncate_for_log(data)}")
return None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dict."""
try:
return asdict(self)
except Exception as e:
log_debug(f"{self.__class__.__name__}.to_dict failed: {e}")
return {}
@dataclass
class InstructionUpdate:
"""Schema for Self-Improvement. (Phase 3)
Proposes updates to agent instructions based on feedback patterns.
"""
current_instruction: str
proposed_instruction: str
reasoning: str
evidence: Optional[List[str]] = None
agent_id: Optional[str] = None
team_id: Optional[str] = None
created_at: Optional[str] = None
@classmethod
def from_dict(cls, data: Any) -> Optional["InstructionUpdate"]:
"""Parse from dict/JSON, returning None on any failure."""
if data is None:
return None
if isinstance(data, cls):
return data
try:
parsed = _parse_json(data)
if not parsed:
log_debug(f"{cls.__name__}.from_dict: _parse_json returned None for data={_truncate_for_log(data)}")
return None
required = ["current_instruction", "proposed_instruction", "reasoning"]
missing = [k for k in required if not parsed.get(k)]
if missing:
log_debug(f"{cls.__name__}.from_dict: missing required fields {missing}")
return None
field_names = {f.name for f in fields(cls)}
kwargs = {k: v for k, v in parsed.items() if k in field_names}
return cls(**kwargs)
except Exception as e:
log_debug(f"{cls.__name__}.from_dict failed: {e}, data={_truncate_for_log(data)}")
return None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dict."""
try:
return asdict(self)
except Exception as e:
log_debug(f"{self.__class__.__name__}.to_dict failed: {e}")
return {}
+193 -258
View File
@@ -17,8 +17,7 @@ Scope:
- Can be queried by agent_id, session_id, or time range
Supported Modes:
- ALWAYS: Automatic extraction of decisions from tool calls
- AGENTIC: Agent explicitly logs decisions via tools
- AGENTIC: the agent logs decisions via tools.
"""
import uuid
@@ -31,7 +30,7 @@ from typing import Any, Callable, List, Optional, Union
from agno.learn.config import DecisionLogConfig, LearningMode
from agno.learn.schemas import DecisionLog
from agno.learn.stores.protocol import LearningStore
from agno.learn.utils import from_dict_safe, to_dict_safe
from agno.learn.utils import from_dict_safe, to_dict_safe, values_match_query
from agno.utils.log import (
log_debug,
log_warning,
@@ -41,7 +40,6 @@ from agno.utils.log import (
try:
from agno.db.base import AsyncBaseDb, BaseDb
from agno.models.message import Message
except ImportError:
pass
@@ -64,10 +62,14 @@ class DecisionLogStore(LearningStore):
# State tracking (internal)
decisions_updated: bool = field(default=False, init=False)
_schema: Any = field(default=None, init=False)
_degraded_search_logged: bool = field(default=False, init=False)
def __post_init__(self):
self._schema = self.config.schema or DecisionLog
if self.config.mode != LearningMode.AGENTIC:
log_warning("DecisionLogStore is AGENTIC-only: the agent logs decisions via tools. Proceeding as AGENTIC.")
# =========================================================================
# LearningStore Protocol Implementation
# =========================================================================
@@ -93,9 +95,15 @@ class DecisionLogStore(LearningStore):
) -> Optional[List[DecisionLog]]:
"""Retrieve recent decisions.
A decision log is worth having because it crosses sessions: the run's
session_id is accepted and deliberately NOT used as a filter, or the
injected block would say "No recent decisions logged" to every new
session while the store is full. Explicit callers scope with
search(session_id=...).
Args:
agent_id: Filter by agent (optional).
session_id: Filter by session (optional).
session_id: Accepted for the store protocol; not used as a filter.
decision_type: Filter by decision type (optional).
limit: Maximum number of decisions to return.
days: Only return decisions from last N days.
@@ -106,7 +114,6 @@ class DecisionLogStore(LearningStore):
"""
return self.search(
agent_id=agent_id,
session_id=session_id,
decision_type=decision_type,
limit=limit,
days=days,
@@ -124,78 +131,25 @@ class DecisionLogStore(LearningStore):
"""Async version of recall."""
return await self.asearch(
agent_id=agent_id,
session_id=session_id,
decision_type=decision_type,
limit=limit,
days=days,
)
def process(
self,
messages: List[Any],
agent_id: Optional[str] = None,
session_id: Optional[str] = None,
user_id: Optional[str] = None,
team_id: Optional[str] = None,
**kwargs,
) -> None:
"""Extract decisions from messages (tool calls, etc).
def process(self, messages: List[Any], **kwargs) -> None:
"""No-op: decision logging is AGENTIC-only, capture happens through the tools."""
return
In ALWAYS mode, this extracts decisions from tool calls and
significant response choices. In AGENTIC mode, this is a no-op
as decisions are logged explicitly via tools.
Args:
messages: Conversation messages to analyze.
agent_id: Agent context.
session_id: Session context.
user_id: User context.
team_id: Team context.
**kwargs: Additional context (ignored).
"""
if self.config.mode != LearningMode.ALWAYS:
return
if not messages:
return
# Extract decisions from tool calls in messages
self._extract_decisions_from_messages(
messages=messages,
agent_id=agent_id,
session_id=session_id,
user_id=user_id,
team_id=team_id,
)
async def aprocess(
self,
messages: List[Any],
agent_id: Optional[str] = None,
session_id: Optional[str] = None,
user_id: Optional[str] = None,
team_id: Optional[str] = None,
**kwargs,
) -> None:
"""Async version of process."""
if self.config.mode != LearningMode.ALWAYS:
return
if not messages:
return
await self._aextract_decisions_from_messages(
messages=messages,
agent_id=agent_id,
session_id=session_id,
user_id=user_id,
team_id=team_id,
)
async def aprocess(self, messages: List[Any], **kwargs) -> None:
"""Async version of process (no-op)."""
return
def build_context(self, data: Any) -> str:
"""Build context for the agent.
"""Build the DATA context for the agent.
Formats recent decisions for injection into the agent's system prompt.
Data only - the how-to-use guidance lives in instructions(); the
automatic path concatenates the two at the injection site.
Args:
data: List of decisions from recall().
@@ -208,9 +162,6 @@ class DecisionLogStore(LearningStore):
return dedent("""\
<decision_log>
No recent decisions logged.
Use `log_decision` to record significant decisions with reasoning.
Use `search_decisions` to find past decisions.
</decision_log>""")
return ""
@@ -235,17 +186,24 @@ class DecisionLogStore(LearningStore):
context += f" Outcome: {decision['outcome']}\n"
context += "\n"
if self._should_expose_tools:
context += dedent("""
Use `log_decision` to record new decisions.
Use `search_decisions` to find past decisions.
Use `record_outcome` to update a decision with its outcome.
""")
context += "</decision_log>"
return context
def instructions(self) -> str:
"""Agent-facing guidance for this store: when to log and search decisions.
Guidance only - the recalled decisions live in build_context().
"""
if not self._should_expose_tools:
return ""
return dedent("""\
<decision_log_instructions>
Use `log_decision` to record significant decisions with reasoning.
Use `search_decisions` to find past decisions.
Use `record_outcome` to update a decision with its outcome.
</decision_log_instructions>""")
def get_tools(
self,
agent_id: Optional[str] = None,
@@ -710,6 +668,10 @@ class DecisionLogStore(LearningStore):
) -> List[DecisionLog]:
"""Search decisions with filters.
Text queries route through the db's server-side search_learnings; the
client-side scan is only the fallback for backends without it.
decision_type and days filters are applied client-side either way.
Args:
query: Text to search for.
agent_id: Filter by agent.
@@ -728,62 +690,35 @@ class DecisionLogStore(LearningStore):
if not isinstance(self.db, BaseDb):
return []
try:
# Get all matching records
results = self.db.get_learnings(
learning_type=self.learning_type,
agent_id=agent_id,
limit=limit * 3, # Over-fetch for filtering
)
# Headroom for the client-side verification and decision_type/days filters.
fetch_limit = limit * 3 if (query or decision_type or days) else limit
if not results:
return []
if query:
if not callable(getattr(self.db, "search_learnings", None)):
self._log_degraded_search_once()
results = self._fetch_recent_rows(agent_id=agent_id, session_id=session_id, limit=limit * 3)
else:
try:
results = self.db.search_learnings(
query=query,
learning_type=self.learning_type,
agent_id=agent_id,
session_id=session_id,
limit=fetch_limit,
)
except NotImplementedError:
self._log_degraded_search_once()
results = self._fetch_recent_rows(agent_id=agent_id, session_id=session_id, limit=limit * 3)
else:
results = self._fetch_recent_rows(agent_id=agent_id, session_id=session_id, limit=fetch_limit)
decisions = []
cutoff_date = None
if days:
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days)
for record in results:
content = record.get("content") if isinstance(record, dict) else None
if not content:
continue
decision = from_dict_safe(DecisionLog, content)
if not decision:
continue
# Apply filters
if decision_type and decision.decision_type != decision_type:
continue
if cutoff_date and decision.created_at:
try:
created = datetime.fromisoformat(decision.created_at.replace("Z", "+00:00"))
# Records written before this fix are naive; assume UTC.
if created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
if created < cutoff_date:
continue
except (ValueError, AttributeError):
pass
if query:
query_lower = query.lower()
text = decision.to_text().lower()
if query_lower not in text:
continue
decisions.append(decision)
if len(decisions) >= limit:
break
return decisions
except Exception as e:
log_debug(f"DecisionLogStore.search failed: {e}")
return []
return self._filter_decisions(
results or [],
query=query,
decision_type=decision_type,
days=days,
limit=limit,
)
async def asearch(
self,
@@ -798,68 +733,144 @@ class DecisionLogStore(LearningStore):
if not self.db:
return []
fetch_limit = limit * 3 if (query or decision_type or days) else limit
if query:
if not callable(getattr(self.db, "search_learnings", None)):
self._log_degraded_search_once()
results = await self._afetch_recent_rows(agent_id=agent_id, session_id=session_id, limit=limit * 3)
else:
try:
if isinstance(self.db, AsyncBaseDb):
results = await self.db.search_learnings(
query=query,
learning_type=self.learning_type,
agent_id=agent_id,
session_id=session_id,
limit=fetch_limit,
)
else:
results = self.db.search_learnings(
query=query,
learning_type=self.learning_type,
agent_id=agent_id,
session_id=session_id,
limit=fetch_limit,
)
except NotImplementedError:
self._log_degraded_search_once()
results = await self._afetch_recent_rows(agent_id=agent_id, session_id=session_id, limit=limit * 3)
else:
results = await self._afetch_recent_rows(agent_id=agent_id, session_id=session_id, limit=fetch_limit)
return self._filter_decisions(
results or [],
query=query,
decision_type=decision_type,
days=days,
limit=limit,
)
def _log_degraded_search_once(self) -> None:
if not self._degraded_search_logged:
self._degraded_search_logged = True
log_warning(
"DecisionLogStore: this db backend has no search_learnings implementation; "
"falling back to a client-side scan over the most recently updated rows. "
"Search quality degrades as the store grows."
)
def _fetch_recent_rows(self, agent_id: Optional[str], limit: int, session_id: Optional[str] = None) -> List[Any]:
# Callers guard isinstance(self.db, BaseDb), so the call is sync here.
if not isinstance(self.db, BaseDb):
return []
try:
return (
self.db.get_learnings(
learning_type=self.learning_type,
agent_id=agent_id,
session_id=session_id,
limit=limit,
)
or []
)
except Exception as e:
log_debug(f"DecisionLogStore._fetch_recent_rows failed: {e}")
return []
async def _afetch_recent_rows(
self, agent_id: Optional[str], limit: int, session_id: Optional[str] = None
) -> List[Any]:
try:
if isinstance(self.db, AsyncBaseDb):
results = await self.db.get_learnings(
rows = await self.db.get_learnings(
learning_type=self.learning_type,
agent_id=agent_id,
limit=limit * 3,
session_id=session_id,
limit=limit,
)
else:
results = self.db.get_learnings(
rows = self.db.get_learnings( # type: ignore[union-attr]
learning_type=self.learning_type,
agent_id=agent_id,
limit=limit * 3,
session_id=session_id,
limit=limit,
)
if not results:
return []
decisions = []
cutoff_date = None
if days:
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days)
for record in results:
content = record.get("content") if isinstance(record, dict) else None
if not content:
continue
decision = from_dict_safe(DecisionLog, content)
if not decision:
continue
if decision_type and decision.decision_type != decision_type:
continue
if cutoff_date and decision.created_at:
try:
created = datetime.fromisoformat(decision.created_at.replace("Z", "+00:00"))
# Records written before this fix are naive; assume UTC.
if created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
if created < cutoff_date:
continue
except (ValueError, AttributeError):
pass
if query:
query_lower = query.lower()
text = decision.to_text().lower()
if query_lower not in text:
continue
decisions.append(decision)
if len(decisions) >= limit:
break
return decisions
return rows or []
except Exception as e:
log_debug(f"DecisionLogStore.asearch failed: {e}")
log_debug(f"DecisionLogStore._afetch_recent_rows failed: {e}")
return []
def _filter_decisions(
self,
records: List[Any],
query: Optional[str],
decision_type: Optional[str],
days: Optional[int],
limit: int,
) -> List[DecisionLog]:
decisions: List[DecisionLog] = []
cutoff_date = None
if days:
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days)
for record in records:
content = record.get("content") if isinstance(record, dict) else None
if not content:
continue
decision = from_dict_safe(DecisionLog, content)
if not decision:
continue
if decision_type and decision.decision_type != decision_type:
continue
if cutoff_date and decision.created_at:
try:
created = datetime.fromisoformat(decision.created_at.replace("Z", "+00:00"))
# A stored timestamp without a zone is UTC.
if created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
if created < cutoff_date:
continue
except (ValueError, AttributeError):
pass
if query:
# Value-scoped verification: the db-side ILIKE matched the whole
# serialized document (keys included); this check keeps the match
# surface at the record's values, across every field.
if not values_match_query(content, query):
continue
decisions.append(decision)
if len(decisions) >= limit:
break
return decisions
def get(self, decision_id: str) -> Optional[DecisionLog]:
"""Get a specific decision by ID."""
if not self.db:
@@ -1025,82 +1036,6 @@ class DecisionLogStore(LearningStore):
await self.asave(decision=decision)
return True
# =========================================================================
# Extraction (ALWAYS mode)
# =========================================================================
def _extract_decisions_from_messages(
self,
messages: List["Message"],
agent_id: Optional[str] = None,
session_id: Optional[str] = None,
user_id: Optional[str] = None,
team_id: Optional[str] = None,
) -> None:
"""Extract decisions from tool calls in messages."""
for msg in messages:
if not hasattr(msg, "tool_calls") or not msg.tool_calls:
continue
for tool_call in msg.tool_calls:
tool_name = getattr(tool_call, "name", None) or getattr(
getattr(tool_call, "function", None), "name", None
)
if not tool_name:
continue
decision_id = f"dec_{uuid.uuid4().hex[:8]}"
decision = DecisionLog(
id=decision_id,
decision=f"Called tool: {tool_name}",
decision_type="tool_selection",
context="During conversation with user",
session_id=session_id,
user_id=user_id,
agent_id=agent_id,
team_id=team_id,
created_at=datetime.now(timezone.utc).isoformat(),
)
self.save(decision=decision)
async def _aextract_decisions_from_messages(
self,
messages: List["Message"],
agent_id: Optional[str] = None,
session_id: Optional[str] = None,
user_id: Optional[str] = None,
team_id: Optional[str] = None,
) -> None:
"""Async version of _extract_decisions_from_messages."""
for msg in messages:
if not hasattr(msg, "tool_calls") or not msg.tool_calls:
continue
for tool_call in msg.tool_calls:
tool_name = getattr(tool_call, "name", None) or getattr(
getattr(tool_call, "function", None), "name", None
)
if not tool_name:
continue
decision_id = f"dec_{uuid.uuid4().hex[:8]}"
decision = DecisionLog(
id=decision_id,
decision=f"Called tool: {tool_name}",
decision_type="tool_selection",
context="During conversation with user",
session_id=session_id,
user_id=user_id,
agent_id=agent_id,
team_id=team_id,
created_at=datetime.now(timezone.utc).isoformat(),
)
await self.asave(decision=decision)
# =========================================================================
# Representation
# =========================================================================
File diff suppressed because it is too large Load Diff
@@ -229,7 +229,11 @@ class LearnedKnowledgeStore(LearningStore):
)
def build_context(self, data: Any) -> str:
"""Build context for the agent.
"""Build the DATA context for the agent.
Data only (the relevant learnings) - the mode-specific how-to-use
guidance lives in instructions(); the automatic path concatenates the
two at the injection site.
Args:
data: List of learning objects from recall() (may be None).
@@ -237,14 +241,19 @@ class LearnedKnowledgeStore(LearningStore):
Returns:
Context string to inject into the agent's system prompt.
"""
mode = self.config.mode
return self._build_always_mode_context(data=data)
def instructions(self) -> str:
"""Agent-facing guidance for this store, mode-aware.
Guidance only - the recalled learnings live in build_context().
"""
mode = self.config.mode
if mode == LearningMode.PROPOSE:
return self._build_propose_mode_context(data=data)
elif mode == LearningMode.AGENTIC:
return self._build_agentic_mode_context(data=data)
else:
return self._build_background_mode_context(data=data)
return self._build_propose_mode_context(data=None)
if mode == LearningMode.AGENTIC:
return self._build_agentic_mode_context(data=None)
return ""
def _build_agentic_mode_context(self, data: Any) -> str:
"""Build context for AGENTIC mode."""
@@ -373,7 +382,7 @@ class LearnedKnowledgeStore(LearningStore):
return instructions
def _build_background_mode_context(self, data: Any) -> str:
def _build_always_mode_context(self, data: Any) -> str:
"""Build context for ALWAYS mode (just show relevant learnings)."""
if not data:
return ""
@@ -383,16 +392,16 @@ class LearnedKnowledgeStore(LearningStore):
return ""
formatted = self._format_learnings_for_context(learnings=learnings)
return dedent(f"""\
<relevant_learnings>
Prior insights that may help with this task:
{formatted}
Apply these naturally if they're relevant to the current request.
Your current analysis and the user's specific context take precedence.
</relevant_learnings>\
""")
# Assembled by concatenation, not dedent: the interpolated learnings are
# flush-left and multi-line, which would defeat dedent's common prefix.
return (
"<relevant_learnings>\n"
"Prior insights that may help with this task:\n\n"
f"{formatted}\n\n"
"Apply these naturally if they're relevant to the current request.\n"
"Your current analysis and the user's specific context take precedence.\n"
"</relevant_learnings>"
)
def _format_learnings_for_context(self, learnings: List[Any]) -> str:
"""Format learnings for inclusion in context."""
+13 -3
View File
@@ -76,10 +76,11 @@ class LearningStore(Protocol):
...
def build_context(self, data: Any) -> str:
"""Build context string for agent prompts.
"""Build the DATA context string for agent prompts.
Formats the recalled data into a string that can be
injected into the agent's system prompt.
Formats the recalled data into a string that can be injected into the
agent's system prompt. Data only - the how-to-use guidance lives in
instructions().
Args:
data: Data returned from recall().
@@ -89,6 +90,15 @@ class LearningStore(Protocol):
"""
...
def instructions(self) -> str:
"""The agent-facing guidance for this store.
How and when to use the store's tools, mode-aware. Guidance only - the
recalled data lives in build_context(). Returns an empty string when the
store has nothing to instruct (e.g. no tools exposed).
"""
...
def get_tools(self, **kwargs) -> List[Callable]:
"""Get tools to expose to the agent.
@@ -229,6 +229,10 @@ class SessionContextStore(LearningStore):
</session_context>\
""")
def instructions(self) -> str:
"""Session context is system-managed (ALWAYS-only, no tools): no guidance."""
return ""
def get_tools(self, **kwargs) -> List[Callable]:
"""Session context has no agent tools (system-managed only)."""
return []
+77 -46
View File
@@ -172,11 +172,11 @@ class UserMemoryStore(LearningStore):
)
def build_context(self, data: Any) -> str:
"""Build context for the agent.
"""Build the DATA context for the agent.
Formats memories data for injection into the agent's system prompt.
Designed to enable natural, personalized responses without meta-commentary
about memory systems.
Data only - the how-to-use guidance lives in instructions(); the
automatic path concatenates the two at the injection site.
Args:
data: Memories data from recall().
@@ -184,22 +184,13 @@ class UserMemoryStore(LearningStore):
Returns:
Context string to inject into the agent's system prompt.
"""
# Build tool documentation based on what's enabled
tool_docs = self._build_tool_documentation()
empty_block = dedent("""\
<user_memory>
No memories saved about this user yet.
</user_memory>""")
if not data:
if self._should_expose_tools:
return (
dedent("""\
<user_memory>
No memories saved about this user yet.
""")
+ tool_docs
+ dedent("""
</user_memory>""")
)
return ""
return empty_block if self._should_expose_tools else ""
# Build memories section
memories_text = None
@@ -209,18 +200,7 @@ class UserMemoryStore(LearningStore):
memories_text = "\n".join(f"- {m.get('content', str(m))}" for m in data.memories)
if not memories_text:
if self._should_expose_tools:
return (
dedent("""\
<user_memory>
No memories saved about this user yet.
""")
+ tool_docs
+ dedent("""
</user_memory>""")
)
return ""
return empty_block if self._should_expose_tools else ""
context = "<user_memory>\n"
context += memories_text + "\n"
@@ -236,22 +216,23 @@ class UserMemoryStore(LearningStore):
- Use memories to calibrate tone, depth, and examples without announcing it
</memory_application_guidelines>""")
if self._should_expose_tools:
context += (
dedent("""
<memory_updates>
""")
+ tool_docs
+ dedent("""
</memory_updates>""")
)
context += "\n</user_memory>"
return context
def instructions(self) -> str:
"""Agent-facing guidance for this store: when to save user memories.
Guidance only - the recalled data lives in build_context(). Empty when
no tools are exposed (ALWAYS mode captures without agent involvement).
"""
if not self._should_expose_tools:
return ""
tool_docs = self._build_tool_documentation()
if not tool_docs:
return ""
return f"<user_memory_instructions>\n{tool_docs}\n</user_memory_instructions>"
def _build_tool_documentation(self) -> str:
"""Build documentation for available memory tools.
@@ -750,6 +731,7 @@ class UserMemoryStore(LearningStore):
user_id: str,
agent_id: Optional[str] = None,
team_id: Optional[str] = None,
instructed: bool = False,
run_metrics: Optional["RunMetrics"] = None,
) -> str:
"""Extract memories from messages and save.
@@ -793,7 +775,7 @@ class UserMemoryStore(LearningStore):
functions = self._build_functions_for_model(tools=tools)
messages_for_model = [
self._get_system_message(existing_data=existing_data),
self._get_system_message(existing_data=existing_data, instructed=instructed),
Message(role="user", content=f"Extract memories from this conversation:\n\n{conversation_text}"),
]
@@ -822,6 +804,7 @@ class UserMemoryStore(LearningStore):
user_id: str,
agent_id: Optional[str] = None,
team_id: Optional[str] = None,
instructed: bool = False,
run_metrics: Optional["RunMetrics"] = None,
) -> str:
"""Async version of extract_and_save."""
@@ -855,7 +838,7 @@ class UserMemoryStore(LearningStore):
functions = self._build_functions_for_model(tools=tools)
messages_for_model = [
self._get_system_message(existing_data=existing_data),
self._get_system_message(existing_data=existing_data, instructed=instructed),
Message(role="user", content=f"Extract memories from this conversation:\n\n{conversation_text}"),
]
@@ -908,6 +891,7 @@ class UserMemoryStore(LearningStore):
user_id=user_id,
agent_id=agent_id,
team_id=team_id,
instructed=True,
)
async def arun_memories_update(
@@ -926,6 +910,7 @@ class UserMemoryStore(LearningStore):
user_id=user_id,
agent_id=agent_id,
team_id=team_id,
instructed=True,
)
# =========================================================================
@@ -981,8 +966,19 @@ class UserMemoryStore(LearningStore):
def _get_system_message(
self,
existing_data: List[dict],
instructed: bool = False,
) -> "Message":
"""Build system message for memory extraction."""
"""Build system message for memory extraction.
Args:
existing_data: The user's current memories.
instructed: True when an agent called update_user_memory with an
explicit task. The decision to store has already been made, by
the agent that has the conversation and its instructions; this
store's job is then to execute it, not to re-judge it. What to
capture and what to leave alone belongs in the calling agent's
instructions, where the context to decide it exists.
"""
from agno.models.message import Message
if self.config.system_message is not None:
@@ -1027,7 +1023,6 @@ class UserMemoryStore(LearningStore):
- Patterns in how they think and work
**DO NOT save:**
- Sensitive personal information (health conditions, financial details, relationships) unless directly relevant to helping them
- One-off details unlikely to matter in future conversations
- Information they'd find creepy to have remembered
- Inferences or assumptions - only save what they've actually stated
@@ -1035,6 +1030,33 @@ class UserMemoryStore(LearningStore):
- Trivial preferences that don't affect interactions\
""")
if instructed:
# An agent called update_user_memory with a task. It had the
# conversation and its own instructions; it decided. Re-judging
# here is how an explicit "remember this privately" ended up
# stored as nothing.
capture_instructions = dedent("""\
## What To Capture
An agent has instructed you to record something. Carry out that
instruction: save what it asked you to save, worded as given.
- Do not second-guess whether the information is worth keeping,
sensitive, or awkward - that judgement was already made by the
agent holding the conversation, under its own instructions.
- Do still avoid duplicates: prefer update_memory over add_memory
when the instruction revises something already stored, and
remove what it asks you to remove.
- Record only what the instruction says. Do not add surrounding
detail it did not ask for.\
""")
# A configured capture policy still applies - the execute contract
# replaces the store's own gatekeeping, not the operator's. The tool
# path is the ONLY path in AGENTIC mode, so dropping it here made
# UserMemoryConfig(instructions=...) a no-op in the flagship mode.
if self.config.instructions:
capture_instructions += f"\n\n{self.config.instructions}\n"
system_prompt += capture_instructions
system_prompt += dedent("""
@@ -1098,7 +1120,8 @@ class UserMemoryStore(LearningStore):
system_prompt += "- `clear_all_memories`: Reset all memories (use rarely)\n"
# Examples
system_prompt += dedent("""
system_prompt += (
dedent("""
## Examples
**Example 1: New user introduction**
@@ -1125,6 +1148,14 @@ class UserMemoryStore(LearningStore):
- Respect boundaries: when in doubt about whether to save something, don't
- It's fine to do nothing if the conversation reveals nothing worth remembering\
""")
if not instructed
else dedent("""
## Final Guidance
- Carry out the instruction; do not weigh whether it should have been given
- Prefer updating an existing memory over adding a near-duplicate\
""")
)
if self.config.additional_instructions:
system_prompt += f"\n\n{self.config.additional_instructions}"
+22 -40
View File
@@ -182,11 +182,11 @@ class UserProfileStore(LearningStore):
)
def build_context(self, data: Any) -> str:
"""Build context for the agent.
"""Build the DATA context for the agent.
Formats user profile data for injection into the agent's system prompt.
Designed to enable natural, personalized responses without meta-commentary
about memory systems.
Data only - the how-to-use guidance lives in instructions(); the
automatic path concatenates the two at the injection site.
Args:
data: User profile data from recall().
@@ -194,22 +194,13 @@ class UserProfileStore(LearningStore):
Returns:
Context string to inject into the agent's system prompt.
"""
# Build tool documentation based on what's enabled
tool_docs = self._build_tool_documentation()
empty_block = dedent("""\
<user_profile>
No profile information saved about this user yet.
</user_profile>""")
if not data:
if self._should_expose_tools:
return (
dedent("""\
<user_profile>
No profile information saved about this user yet.
""")
+ tool_docs
+ dedent("""
</user_profile>""")
)
return ""
return empty_block if self._should_expose_tools else ""
# Build profile fields section
profile_parts = []
@@ -220,18 +211,7 @@ class UserProfileStore(LearningStore):
profile_parts.append(f"{field_name.replace('_', ' ').title()}: {value}")
if not profile_parts:
if self._should_expose_tools:
return (
dedent("""\
<user_profile>
No profile information saved about this user yet.
""")
+ tool_docs
+ dedent("""
</user_profile>""")
)
return ""
return empty_block if self._should_expose_tools else ""
context = "<user_profile>\n"
context += "\n".join(profile_parts) + "\n"
@@ -246,21 +226,23 @@ class UserProfileStore(LearningStore):
- Current conversation always takes precedence over stored profile data
</profile_application_guidelines>""")
if self._should_expose_tools:
context += (
dedent("""
<profile_updates>
""")
+ tool_docs
+ dedent("""
</profile_updates>""")
)
context += "\n</user_profile>"
return context
def instructions(self) -> str:
"""Agent-facing guidance for this store: when to update the profile.
Guidance only - the recalled data lives in build_context(). Empty when
no tools are exposed (ALWAYS mode captures without agent involvement).
"""
if not self._should_expose_tools:
return ""
tool_docs = self._build_tool_documentation()
if not tool_docs:
return ""
return f"<user_profile_instructions>\n{tool_docs}\n</user_profile_instructions>"
def _build_tool_documentation(self) -> str:
"""Build documentation for available profile tools.
+83
View File
@@ -57,6 +57,89 @@ def build_learning_id(
return None
def content_values_text(content: Any) -> str:
"""Flatten a content payload to a lowercased text of its VALUES only.
Used to verify text-search hits: the db-side ILIKE matches the whole
serialized JSON document, keys included, so a query like "facts" or "name"
would match every row. Matching against the value projection restores
value-scoped precision without dropping any field.
"""
parts: List[str] = []
def walk(node: Any) -> None:
if isinstance(node, dict):
for value in node.values():
walk(value)
elif isinstance(node, (list, tuple)):
for value in node:
walk(value)
elif node is not None:
parts.append(str(node))
walk(content)
# casefold, not lower: a stored "Ος" has to match a query of "ΟΣ".
return "\n".join(parts).casefold()
def separator_folded(text: str) -> str:
"""Collapse runs of space, underscore and hyphen to one space, casefolded.
The db-side pattern turns each separator run into the LIKE single-char
wildcard, which matches ANY separator. A verifier that enumerates uniform
rewrites cannot express mixed-separator text, so it threw away hits the
server had legitimately found: "end-to-end tests" stored and "end to end
tests" asked (or the reverse) verified as a miss, and the store answered
"no entities matching" for a fact it was holding. Folding both sides is
what the wildcard already means.
Newlines are left alone: they separate one stored value from the next, and
a match must not span two of them.
"""
import re
return re.sub(r"[ \t\r\f\v_\-]+", " ", text.casefold())
def values_match_query(content: Any, query: str) -> bool:
"""Whether the content's VALUES contain the query, separator-insensitively.
The value-scoped half of the loose-prefilter/precise-verify pair: the
db-side ILIKE matches the whole serialized document, key names included,
so "facts" or "name" would otherwise match every row.
"""
needle = separator_folded(query).strip()
if not needle:
return False
return needle in separator_folded(content_values_text(content))
def query_variants(query: str) -> List[str]:
"""Lowercased query variants with word separators swapped.
Mirrors the space/underscore(/hyphen) crossing the db-side search performs
with the LIKE single-char wildcard, so a client-side verification pass does
not drop hits the server legitimately matched ("sarah chen" vs sarah_chen).
The query itself is the first variant. Rewriting every separator to one
character produces no form that matches a query which MIXES them - the db
matches "end-to-end tests" through the single-char wildcard, and a verifier
that only knows "end to end tests" / "end_to_end_tests" throws that hit
away.
"""
import re
lowered = query.strip().casefold()
if not lowered:
return []
variants: List[str] = [lowered]
for separator in (" ", "_", "-"):
variant = re.sub(r"[\s_\-]+", separator, lowered)
if variant and variant not in variants:
variants.append(variant)
return variants
def _safe_get(data: Any, key: str, default: Any = None) -> Any:
"""Safely get a key from dict-like data.
+6
View File
@@ -633,6 +633,12 @@ def _set_learning_machine(team: "Team") -> None:
team.learning.db = team.db
if team.learning.model is None:
team.learning.model = team.model
if (
team.learning.learned_knowledge
and team.learning.knowledge is None
and getattr(team, "knowledge", None) is not None
):
team.learning.knowledge = team.knowledge
team._learning = team.learning
# PROPOSE/HITL modes need chat history for multi-turn confirmation
+48 -6
View File
@@ -52,6 +52,25 @@ from agno.utils.team import (
from agno.utils.timer import Timer
def _input_kwarg(method: Any, input_message: Any) -> Dict[str, Any]:
"""``{"input": ...}`` only when the callee accepts it.
``Team.get_system_message`` is a public extension point and this is the
bound method, so a subclass written against the pre-2.8.4 signature is what
actually runs. Passing the new kwarg unconditionally makes every run of
such a team fail.
"""
import inspect
try:
parameters = inspect.signature(method).parameters
except (TypeError, ValueError):
return {}
if "input" in parameters or any(p.kind == p.VAR_KEYWORD for p in parameters.values()):
return {"input": input_message}
return {}
def _get_tool_names(member: Any, async_mode: bool = False) -> List[str]:
"""Extract tool names from a member's tools list."""
tool_names: List[str] = []
@@ -345,6 +364,7 @@ def get_system_message(
files: Optional[Sequence[File]] = None,
tools: Optional[List[Union[Function, dict]]] = None,
add_session_state_to_context: Optional[bool] = None,
input: Optional[Any] = None,
) -> Optional[Message]:
"""Get the system message for the team.
@@ -470,15 +490,25 @@ def get_system_message(
# 2.2 Identity sections: description, role, instructions
system_message_content += _build_identity_sections(team, instructions)
# 2.3 Learning context (user profile, user memory, session context)
# 2.3 Learning context: guidance + data, concatenated so the automatic door
# renders exactly what the manual door's instructions() + build_context() would
if team._learning is not None and team.add_learnings_to_context:
from agno.agent._messages import _learning_message_text
learning_guidance = team._learning._framework_instructions()
learning_context = team._learning.build_context(
user_id=user_id,
session_id=session.session_id if session else None,
team_id=team.id,
message=_learning_message_text(input),
run_context=run_context,
metadata=run_context.metadata if run_context else None,
dependencies=run_context.dependencies if run_context else None,
session_state=run_context.session_state if run_context else None,
)
if learning_context:
system_message_content += learning_context + "\n"
learning_block = "\n".join(part for part in (learning_guidance, learning_context) if part)
if learning_block:
system_message_content += learning_block + "\n"
# 2.4 Knowledge base instructions
if team.knowledge is not None and team.search_knowledge and team.add_search_knowledge_instructions:
@@ -581,6 +611,7 @@ async def aget_system_message(
files: Optional[Sequence[File]] = None,
tools: Optional[List[Union[Function, dict]]] = None,
add_session_state_to_context: Optional[bool] = None,
input: Optional[Any] = None,
) -> Optional[Message]:
"""Get the system message for the team."""
@@ -701,15 +732,24 @@ async def aget_system_message(
# 2.2 Identity sections: description, role, instructions
system_message_content += _build_identity_sections(team, instructions)
# 2.3 Learning context (user profile, user memory, session context)
# 2.3 Learning context (see the sync twin)
if team._learning is not None and team.add_learnings_to_context:
from agno.agent._messages import _learning_message_text
learning_guidance = team._learning._framework_instructions()
learning_context = await team._learning.abuild_context(
user_id=user_id,
session_id=session.session_id if session else None,
team_id=team.id,
message=_learning_message_text(input),
run_context=run_context,
metadata=run_context.metadata if run_context else None,
dependencies=run_context.dependencies if run_context else None,
session_state=run_context.session_state if run_context else None,
)
if learning_context:
system_message_content += learning_context + "\n"
learning_block = "\n".join(part for part in (learning_guidance, learning_context) if part)
if learning_block:
system_message_content += learning_block + "\n"
# 2.4 Knowledge base instructions
if team.knowledge is not None and team.search_knowledge and team.add_search_knowledge_instructions:
@@ -850,6 +890,7 @@ def _get_run_messages(
files=files,
add_session_state_to_context=add_session_state_to_context,
tools=tools,
**_input_kwarg(team.get_system_message, input_message),
)
if system_message is not None:
run_messages.system_message = system_message
@@ -985,6 +1026,7 @@ async def _aget_run_messages(
files=files,
add_session_state_to_context=add_session_state_to_context,
tools=tools,
**_input_kwarg(team.aget_system_message, input_message),
)
if system_message is not None:
run_messages.system_message = system_message
+8 -1
View File
@@ -299,7 +299,10 @@ class Team:
parse_response: bool = True
# --- History ---
# Enable the agent to manage memories of the user
# Enable the agent to manage memories of the user.
# Do not combine with a LearningMachine that has a user_memory store: both
# register a tool named update_user_memory, tool parsing keeps the first
# name it sees, and the learning store's tool is dropped without a word.
enable_agentic_memory: bool = False
# If True, the agent creates/updates user memories at the end of runs
update_memory_on_run: bool = False
@@ -1442,6 +1445,7 @@ class Team:
files: Optional[Sequence[File]] = None,
tools: Optional[List[Union[Function, dict]]] = None,
add_session_state_to_context: Optional[bool] = None,
input: Optional[Any] = None,
) -> Optional[Message]:
return _messages.get_system_message(
self,
@@ -1453,6 +1457,7 @@ class Team:
files=files,
tools=tools,
add_session_state_to_context=add_session_state_to_context,
input=input,
)
async def aget_system_message(
@@ -1465,6 +1470,7 @@ class Team:
files: Optional[Sequence[File]] = None,
tools: Optional[List[Union[Function, dict]]] = None,
add_session_state_to_context: Optional[bool] = None,
input: Optional[Any] = None,
) -> Optional[Message]:
return await _messages.aget_system_message(
self,
@@ -1476,6 +1482,7 @@ class Team:
files=files,
tools=tools,
add_session_state_to_context=add_session_state_to_context,
input=input,
)
###########################################################################
@@ -0,0 +1,107 @@
"""Integration tests for search_learnings on PostgresDb and AsyncPostgresDb.
The JSONB content column has no ILIKE operator; these tests prove the
CAST-to-TEXT path live. Requires the pgvector container from
./cookbook/scripts/run_pgvector.sh (localhost:5532).
"""
import uuid
import pytest
SYNC_DB_URL = "postgresql+psycopg://ai:ai@localhost:5532/ai"
ASYNC_DB_URL = "postgresql+psycopg_async://ai:ai@localhost:5532/ai"
def _postgres_reachable() -> bool:
try:
from sqlalchemy import create_engine, text
engine = create_engine(SYNC_DB_URL)
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
engine.dispose()
return True
except Exception:
return False
pytestmark = pytest.mark.skipif(not _postgres_reachable(), reason="postgres not reachable on localhost:5532")
@pytest.fixture
def db():
from agno.db.postgres import PostgresDb
return PostgresDb(db_url=SYNC_DB_URL)
@pytest.fixture
def learning_ids():
return [f"test-search-{uuid.uuid4().hex[:8]}" for _ in range(3)]
def test_search_learnings_casts_jsonb_and_crosses_slug_boundary(db, learning_ids):
needle, spaced, decoy = learning_ids
try:
db.upsert_learning(
id=needle,
learning_type="entity_memory",
entity_id="sarah_chen",
namespace="test_search",
content={"entity_id": "sarah_chen", "facts": [{"content": "designs radar"}]},
)
db.upsert_learning(
id=spaced,
learning_type="entity_memory",
entity_id="acme_corp",
namespace="test_search",
content={"entity_id": "acme_corp", "name": "Acme Corp"},
)
db.upsert_learning(
id=decoy,
learning_type="entity_memory",
entity_id="unrelated",
namespace="test_search",
content={"entity_id": "unrelated", "facts": [{"content": "nothing here"}]},
)
# JSONB path works at all (no cast -> this raises live)
rows = db.search_learnings(query="designs radar", namespace="test_search")
assert [r["learning_id"] for r in rows] == [needle]
# Slug boundary: spaced query finds the slug...
rows = db.search_learnings(query="sarah chen", namespace="test_search")
assert [r["learning_id"] for r in rows] == [needle]
# ...and the underscore query finds the spaced display name
rows = db.search_learnings(query="acme_corp", namespace="test_search")
assert spaced in [r["learning_id"] for r in rows]
# Filters compose
rows = db.search_learnings(query="sarah chen", namespace="test_search", entity_type="person")
assert rows == []
finally:
for learning_id in learning_ids:
db.delete_learning(id=learning_id)
async def test_async_search_learnings_casts_jsonb(learning_ids):
from agno.db.postgres import AsyncPostgresDb
adb = AsyncPostgresDb(db_url=ASYNC_DB_URL)
needle = learning_ids[0]
try:
await adb.upsert_learning(
id=needle,
learning_type="entity_memory",
entity_id="sarah_chen",
namespace="test_search_async",
content={"entity_id": "sarah_chen", "facts": [{"content": "prefers async communication"}]},
)
rows = await adb.search_learnings(query="sarah chen", namespace="test_search_async")
assert [r["learning_id"] for r in rows] == [needle]
rows = await adb.search_learnings(query="ASYNC COMMUNICATION", namespace="test_search_async")
assert [r["learning_id"] for r in rows] == [needle]
finally:
await adb.delete_learning(id=needle)
@@ -66,7 +66,7 @@ class TestToolkitOverDb:
found = {m.path for m in fs.list(directory="seen")}
assert found == {"seen/a.md"}
def test_read_only_toolkit_has_four_tools_over_db(self, db_fs):
def test_read_only_toolkit_has_three_tools_over_db(self, db_fs):
tools = _fs(db_fs).tools(read_only=True)
names = {f.name for f in tools.functions.values()}
assert names == {"read_file", "list_files", "search_content", "check_lines"}
assert names == {"read_file", "list_files", "search_content"}
@@ -0,0 +1,153 @@
"""Unit tests for the search_learnings query surface.
The sqlite path is proven here against a real SqliteDb; the postgres and
async_postgres paths share the same statement shape and are covered by the
integration suite. The base-class default must raise NotImplementedError so
stores know to fall back to their client-side scan.
"""
import pytest
from agno.db.base import AsyncBaseDb, BaseDb
from agno.db.sqlite import SqliteDb
from agno.db.utils import learning_search_patterns
@pytest.fixture
def db(tmp_path) -> SqliteDb:
return SqliteDb(db_file=str(tmp_path / "learnings.db"))
def _seed(db: SqliteDb, id: str, content: dict, **kwargs) -> None:
db.upsert_learning(id=id, learning_type=kwargs.pop("learning_type", "entity_memory"), content=content, **kwargs)
class TestPatterns:
def test_space_and_underscore_cross_via_wildcard(self) -> None:
# Spaces and underscores collapse to the single-char LIKE wildcard, so
# one pattern matches both "sarah chen" and "sarah_chen".
assert learning_search_patterns("sarah chen") == ["%sarah_chen%"]
assert learning_search_patterns("sarah_chen") == ["%sarah_chen%"]
assert learning_search_patterns("radar") == ["%radar%"]
def test_percent_is_escaped(self) -> None:
assert learning_search_patterns("50% done") == ["%50\\%_done%"]
def test_wildcard_only_query_yields_no_patterns(self) -> None:
assert learning_search_patterns(" ") == []
assert learning_search_patterns("%") == []
assert learning_search_patterns("___") == []
assert learning_search_patterns("% _ %") == []
def test_non_ascii_gets_a_wildcarded_escape_variant(self) -> None:
patterns = learning_search_patterns("café")
assert patterns[0] == "%café%"
# SQLite stores JSON with ensure_ascii escapes and LIKE folds ASCII
# only, so a literal \\u00e9 can never case-match a stored \\u00c9.
# The escape is six characters wide and carries a wildcard per
# character; the caller's value-scoped Python check casefolds and
# rejects whatever that lets through.
assert patterns[1] == "%caf______%"
def test_ascii_queries_get_no_wildcard_padding(self) -> None:
assert all("______" not in pattern for pattern in learning_search_patterns("sarah chen"))
class TestBaseDefaults:
def test_base_db_default_raises(self) -> None:
assert BaseDb.search_learnings.__qualname__ == "BaseDb.search_learnings"
with pytest.raises(NotImplementedError):
BaseDb.search_learnings(object(), query="x") # type: ignore[arg-type]
async def test_async_base_db_default_raises(self) -> None:
with pytest.raises(NotImplementedError):
await AsyncBaseDb.search_learnings(object(), query="x") # type: ignore[arg-type]
class TestSqliteSearchLearnings:
def test_matches_content_case_insensitively(self, db: SqliteDb) -> None:
_seed(db, "e1", {"entity_id": "acme_corp", "facts": [{"content": "Uses PostgreSQL"}]}, entity_id="acme_corp")
rows = db.search_learnings(query="postgresql")
assert [r["learning_id"] for r in rows] == ["e1"]
def test_search_crosses_slug_boundary(self, db: SqliteDb) -> None:
_seed(db, "e1", {"entity_id": "sarah_chen", "name": "sarah_chen"}, entity_id="sarah_chen")
_seed(db, "e2", {"entity_id": "acme_corp", "name": "acme_corp"}, entity_id="acme_corp")
assert [r["learning_id"] for r in db.search_learnings(query="sarah chen")] == ["e1"]
assert [r["learning_id"] for r in db.search_learnings(query="Acme Corp")] == ["e2"]
# And the reverse direction: an underscore query finds spaced content
_seed(db, "e3", {"entity_id": "jane_doe", "name": "Jane Doe"}, entity_id="jane_doe")
assert any(r["learning_id"] == "e3" for r in db.search_learnings(query="jane_doe"))
def test_finds_match_outside_recent_window(self, db: SqliteDb) -> None:
# Insert 100 entities, then touch 40 of them so the needle row is far
# outside any recency window. The old over-fetch path scanned only the
# most recently updated rows and missed this by construction.
_seed(db, "needle", {"entity_id": "old_needle", "facts": [{"content": "the rare zanzibar detail"}]})
for i in range(99):
_seed(db, f"filler-{i}", {"entity_id": f"filler_{i}", "facts": [{"content": f"filler fact {i}"}]})
for i in range(40):
_seed(db, f"filler-{i}", {"entity_id": f"filler_{i}", "facts": [{"content": f"updated filler {i}"}]})
rows = db.search_learnings(query="zanzibar", limit=5)
assert [r["learning_id"] for r in rows] == ["needle"]
def test_filters_and_order(self, db: SqliteDb) -> None:
_seed(db, "a", {"x": "same needle"}, namespace="global", entity_type="person")
_seed(db, "b", {"x": "same needle"}, namespace="global", entity_type="project")
_seed(db, "c", {"x": "same needle"}, namespace="private", entity_type="person")
_seed(db, "a", {"x": "same needle updated"}, namespace="global", entity_type="person")
rows = db.search_learnings(query="needle", namespace="global")
assert [r["learning_id"] for r in rows] == ["a", "b"] or [r["learning_id"] for r in rows] == ["b", "a"]
rows = db.search_learnings(query="needle", namespace="global", entity_type="person")
assert [r["learning_id"] for r in rows] == ["a"]
rows = db.search_learnings(query="needle", limit=2)
assert len(rows) == 2
def test_empty_query_returns_empty(self, db: SqliteDb) -> None:
_seed(db, "a", {"x": "content"})
assert db.search_learnings(query=" ") == []
def test_non_ascii_query_finds_json_escaped_content(self, db: SqliteDb) -> None:
# SQLite serializes JSON with ensure_ascii=True, so "café" is stored as
# "café" - the raw pattern alone would silently miss it.
_seed(db, "e1", {"entity_id": "cafe", "name": "Café Régulier"}, entity_id="cafe")
rows = db.search_learnings(query="café")
assert [r["learning_id"] for r in rows] == ["e1"]
def test_wildcard_query_does_not_match_everything(self, db: SqliteDb) -> None:
_seed(db, "a", {"x": "alpha"})
_seed(db, "b", {"x": "beta"})
assert db.search_learnings(query="%") == []
assert db.search_learnings(query="___") == []
# An embedded % is literal, not a wildcard
_seed(db, "c", {"x": "progress: 50% done"})
assert [r["learning_id"] for r in db.search_learnings(query="50% done")] == ["c"]
assert db.search_learnings(query="50%zzz") == []
def test_null_updated_at_sorts_last(self, db: SqliteDb) -> None:
from sqlalchemy import text
_seed(db, "real", {"x": "needle latest"})
_seed(db, "nulled", {"x": "needle nulled"})
with db.Session() as sess, sess.begin():
sess.execute(text("UPDATE agno_learnings SET updated_at = NULL WHERE learning_id = 'nulled'"))
rows = db.search_learnings(query="needle")
assert [r["learning_id"] for r in rows] == ["real", "nulled"]
def test_search_fails_loudly_on_db_error(self, db: SqliteDb, monkeypatch: pytest.MonkeyPatch) -> None:
# get_learnings swallows db errors into []; search_learnings must not.
_seed(db, "a", {"x": "content"})
def broken_session() -> None:
raise RuntimeError("connection lost")
monkeypatch.setattr(db, "Session", broken_session)
with pytest.raises(RuntimeError, match="connection lost"):
db.search_learnings(query="content")
# ...while get_learnings demonstrates today's swallowing behavior
assert db.get_learnings() == []
+130 -19
View File
@@ -46,13 +46,19 @@ def toolkit(fs) -> FileSystemTools:
return fs.tools()
@pytest.fixture
def full_toolkit(fs) -> FileSystemTools:
# The whole nine-tool surface, selected explicitly.
return fs.tools(include_tools=FileSystemTools.FULL_TOOLS)
class TestSchemas:
"""The empty-schema regression (spec D1): every schema must be non-empty AND
name every documented parameter AND lack the framework-injected ones."""
@pytest.mark.parametrize("tool_name", list(EXPECTED_TOOL_PARAMS.keys()))
def test_schema_names_documented_params_and_lacks_injected(self, toolkit, tool_name):
function = toolkit.functions[tool_name]
def test_schema_names_documented_params_and_lacks_injected(self, full_toolkit, tool_name):
function = full_toolkit.functions[tool_name]
function.process_entrypoint()
properties = function.parameters.get("properties", {})
assert list(properties.keys()) == EXPECTED_TOOL_PARAMS[tool_name]
@@ -62,8 +68,8 @@ class TestSchemas:
assert prop.get("description"), "every model-facing parameter carries its docstring description"
@pytest.mark.parametrize("tool_name", list(EXPECTED_TOOL_PARAMS.keys()))
def test_async_schema_names_documented_params(self, toolkit, tool_name):
function = toolkit.async_functions[tool_name]
def test_async_schema_names_documented_params(self, full_toolkit, tool_name):
function = full_toolkit.async_functions[tool_name]
function.process_entrypoint()
properties = function.parameters.get("properties", {})
assert list(properties.keys()) == EXPECTED_TOOL_PARAMS[tool_name]
@@ -74,9 +80,9 @@ class TestSchemas:
assert prop.get("description"), "every async parameter carries its docstring description"
@pytest.mark.parametrize("tool_name", list(EXPECTED_TOOL_PARAMS.keys()))
def test_async_description_matches_sync(self, toolkit, tool_name):
sync_fn = toolkit.functions[tool_name]
async_fn = toolkit.async_functions[tool_name]
def test_async_description_matches_sync(self, full_toolkit, tool_name):
sync_fn = full_toolkit.functions[tool_name]
async_fn = full_toolkit.async_functions[tool_name]
sync_fn.process_entrypoint()
async_fn.process_entrypoint()
assert async_fn.description == sync_fn.description
@@ -130,19 +136,45 @@ class TestWorkspaceParity:
assert toolkit.requires_confirmation_tools == []
def test_confirmation_opt_in_via_kwargs(self, fs):
tk = fs.tools(requires_confirmation_tools=["delete_file"])
tk = fs.tools(allow_delete=True, requires_confirmation_tools=["delete_file"])
assert tk.functions["delete_file"].requires_confirmation is True
class TestSurface:
def test_full_surface_registers_nine_sync_and_async(self, toolkit):
assert list(toolkit.functions.keys()) == FileSystemTools.FULL_TOOLS
assert list(toolkit.async_functions.keys()) == FileSystemTools.FULL_TOOLS
def test_default_surface_is_the_notes_seven(self, toolkit):
assert list(toolkit.functions.keys()) == FileSystemTools.DEFAULT_TOOLS
assert list(toolkit.async_functions.keys()) == FileSystemTools.DEFAULT_TOOLS
assert len(FileSystemTools.DEFAULT_TOOLS) == 7
assert "check_lines" not in toolkit.functions
assert "delete_file" not in toolkit.functions
def test_read_only_registers_exactly_four(self, fs):
def test_allow_delete_adds_delete_file(self, fs):
tk = fs.tools(allow_delete=True)
assert list(tk.functions.keys()) == FileSystemTools.DEFAULT_TOOLS + ["delete_file"]
def test_read_only_registers_exactly_three(self, fs):
tk = fs.tools(read_only=True)
assert list(tk.functions.keys()) == FileSystemTools.READ_ONLY_TOOLS
assert list(tk.async_functions.keys()) == FileSystemTools.READ_ONLY_TOOLS
assert len(FileSystemTools.READ_ONLY_TOOLS) == 3
def test_check_lines_reachable_via_include_tools(self, fs):
tk = fs.tools(include_tools=FileSystemTools.DEFAULT_TOOLS + ["check_lines"])
assert "check_lines" in tk.functions
read_tk = fs.tools(read_only=True, include_tools=["read_file", "check_lines"])
assert set(read_tk.functions.keys()) == {"read_file", "check_lines"}
def test_include_tools_cannot_reach_write_tools_when_read_only(self, fs):
with pytest.raises(ValueError):
fs.tools(read_only=True, include_tools=["write_file"])
def test_read_only_with_allow_delete_raises(self, fs):
with pytest.raises(ValueError):
fs.tools(read_only=True, allow_delete=True)
def test_full_tools_stays_exported_as_the_whole_surface(self, fs):
tk = fs.tools(include_tools=FileSystemTools.FULL_TOOLS)
assert list(tk.functions.keys()) == FileSystemTools.FULL_TOOLS
def test_toolkit_name(self, toolkit):
assert toolkit.name == "filesystem"
@@ -158,7 +190,14 @@ class TestInstructions:
text = FileSystem.instructions()
assert text.startswith("You have your own private, durable filesystem")
assert "Never store secrets, passwords, or API keys." in text
assert 'check_lines(lines, directory="seen")' in text
assert "replace_lines" in text
assert "move_file" in text and "archive/" in text
# The record-set/seen-directory conventions moved to the durable-records
# cookbook, and the user-memory steer is gone: this text is the notes
# contract, nothing else.
assert "check_lines" not in text
assert "seen/" not in text
assert "user memory" not in text
def test_read_only_variant_names_no_write_tool(self, fs):
text = FileSystem.instructions(read_only=True)
@@ -256,8 +295,7 @@ class TestErrorStringsVerbatim:
result = toolkit.write_file("a.md", "0123456789x")
assert result == (
"Error: a.md would be 11 bytes (limit 10 per file). "
"Start a new file (for record logs, partition by date, e.g. seen/2026-07-24.md) "
"or delete files you no longer need."
"Split the topic into smaller files (or partition by date) and retry."
)
def test_quota_namespace_string(self, tmp_path):
@@ -266,10 +304,10 @@ class TestErrorStringsVerbatim:
toolkit.write_file("a.md", "123456")
result = toolkit.write_file("b.md", "78901")
assert result == (
"Error: storage is full (6 of 10 bytes). Delete only files you are certain are obsolete "
"(see list_files), such as an old date partition, then retry. Do not overwrite or delete "
"records you might still need to make room; if nothing is safely disposable, stop and "
"report that storage is full."
"Error: storage is full (6 of 10 bytes). Free space only if you have a tool for it and "
"only from files you are certain are obsolete (see list_files). Never overwrite or "
"discard records you might still need to make room; if nothing is safely disposable, "
"stop and report that storage is full."
)
def test_check_lines_count_string(self, toolkit):
@@ -660,3 +698,76 @@ class TestReplaceLines:
def test_not_in_the_read_only_surface(self, fs):
assert "replace_lines" not in fs.tools(read_only=True).functions
class TestExcludeToolsTypos:
"""Tolerating an exclusion that left the default set is the upgrade idiom.
Tolerating a name that is no tool at all is a silent regression: the
caller believes a tool is gone and it is still registered.
"""
def test_a_typo_warns_and_excludes_nothing(self, fs, caplog) -> None:
import logging
with caplog.at_level(logging.WARNING):
toolkit = fs.tools(exclude_tools=["delete_fil"])
assert any("not FileSystem tools" in r.getMessage() for r in caplog.records)
# the call still resolves; nothing was excluded for the misspelled name
assert set(toolkit.functions) == set(FileSystemTools.DEFAULT_TOOLS)
def test_excluding_a_tool_outside_this_set_is_a_no_op(self, fs) -> None:
# delete_file left the default set in 2.8.4; the old safety idiom stays valid
names = set(fs.tools(exclude_tools=["delete_file"]).functions)
assert names == set(FileSystemTools.DEFAULT_TOOLS)
def test_excluding_a_registered_tool_still_removes_it(self, fs) -> None:
names = set(fs.tools(exclude_tools=["move_file"]).functions)
assert "move_file" not in names
assert names == set(FileSystemTools.DEFAULT_TOOLS) - {"move_file"}
class TestConcurrentEditsInOneTurn:
"""A model's tool calls for one turn are gathered concurrently, and every
mutating fs tool is a read-modify-write. Without a per-file lock the second
write lands on the pre-edit content and one edit vanishes, with both tool
results reporting success."""
@pytest.mark.asyncio
async def test_two_replace_lines_on_one_note_both_land(self, tmp_path) -> None:
import asyncio
from agno.db.sqlite import SqliteDb
from agno.fs import FileSystem
fs = FileSystem(SqliteDb(db_file=str(tmp_path / "n.db")), namespace="brain")
tools = fs.tools()
fs.write("notes/radar.md", "line one\nline two\nline three\n")
await asyncio.gather(
tools.areplace_lines("notes/radar.md", 1, 1, "EDIT ONE"),
tools.areplace_lines("notes/radar.md", 3, 3, "EDIT THREE"),
)
content = fs.read("notes/radar.md")
assert "EDIT ONE" in content and "EDIT THREE" in content
assert "line two" in content
@pytest.mark.asyncio
async def test_an_append_beside_an_edit_survives(self, tmp_path) -> None:
import asyncio
from agno.db.sqlite import SqliteDb
from agno.fs import FileSystem
fs = FileSystem(SqliteDb(db_file=str(tmp_path / "m.db")), namespace="brain")
tools = fs.tools()
fs.write("notes/log.md", "start\n")
await asyncio.gather(
tools.aappend_file("notes/log.md", "appended"),
tools.areplace_lines("notes/log.md", 1, 1, "REPLACED"),
)
content = fs.read("notes/log.md")
assert "REPLACED" in content and "appended" in content
@@ -0,0 +1,265 @@
"""Unit tests for DecisionLogStore search routing through search_learnings."""
import logging
from typing import Any, Dict, List
import pytest
from agno.db.base import BaseDb
from agno.learn.config import DecisionLogConfig
from agno.learn.schemas import DecisionLog
from agno.learn.stores.decision_log import DecisionLogStore
class FakeDecisionDb(BaseDb):
"""Subclasses BaseDb (the sync search path requires a real BaseDb) but
only implements what DecisionLogStore touches."""
def __init__(self) -> None: # noqa: D401 - do not call BaseDb.__init__
self.rows: List[Dict[str, Any]] = []
self.search_calls: List[Dict[str, Any]] = []
# --- what the store uses ---
def upsert_learning(self, id: str, **kwargs: Any) -> None:
self.rows.append({"learning_id": id, **kwargs})
def get_learnings(self, **kwargs: Any) -> List[Dict[str, Any]]:
limit = kwargs.get("limit")
rows = list(reversed(self.rows))
return rows[:limit] if limit else rows
def search_learnings(self, query: str, **kwargs: Any) -> List[Dict[str, Any]]:
import json
self.search_calls.append({"query": query, **kwargs})
rows = [row for row in reversed(self.rows) if query.lower() in json.dumps(row.get("content", {})).lower()]
limit = kwargs.get("limit")
return rows[:limit] if limit else rows
# --- abstract surface stubs ---
def __getattr__(self, name: str) -> Any:
raise AttributeError(name)
# BaseDb is an ABC with many abstract methods; bypass instantiation checks.
FakeDecisionDb.__abstractmethods__ = frozenset()
def _store(db: FakeDecisionDb) -> DecisionLogStore:
return DecisionLogStore(config=DecisionLogConfig(db=db))
def _log(store: DecisionLogStore, decision: str, decision_type: str = "tool_selection") -> None:
import uuid
store.save(decision=DecisionLog(id=f"dec_{uuid.uuid4().hex[:6]}", decision=decision, decision_type=decision_type))
def test_query_routes_through_search_learnings() -> None:
db = FakeDecisionDb()
store = _store(db)
_log(store, "Chose Postgres over Dynamo")
_log(store, "Used web search for news")
results = store.search(query="postgres")
assert len(results) == 1
assert results[0].decision == "Chose Postgres over Dynamo"
assert db.search_calls and db.search_calls[0]["learning_type"] == "decision_log"
def test_no_query_uses_listing_path() -> None:
db = FakeDecisionDb()
store = _store(db)
_log(store, "first")
_log(store, "second")
results = store.search()
assert len(results) == 2
assert db.search_calls == []
def test_fallback_on_not_implemented_filters_client_side(caplog: pytest.LogCaptureFixture) -> None:
class NoSearchDb(FakeDecisionDb):
def search_learnings(self, query: str, **kwargs: Any) -> List[Dict[str, Any]]:
raise NotImplementedError
NoSearchDb.__abstractmethods__ = frozenset()
db = NoSearchDb()
store = _store(db)
_log(store, "Chose Postgres over Dynamo")
_log(store, "Used web search for news")
with caplog.at_level(logging.WARNING):
results = store.search(query="postgres")
store.search(query="postgres")
assert len(results) == 1
assert results[0].decision == "Chose Postgres over Dynamo"
degraded = [r for r in caplog.records if "no search_learnings implementation" in r.getMessage()]
assert len(degraded) == 1
async def test_async_query_routes_through_search_learnings() -> None:
db = FakeDecisionDb()
store = _store(db)
_log(store, "Chose Postgres over Dynamo")
results = await store.asearch(query="postgres", session_id="sess-9")
assert len(results) == 1
assert db.search_calls and db.search_calls[0]["query"] == "postgres"
# The sync-db branch of asearch honors session_id too
assert db.search_calls[0]["session_id"] == "sess-9"
def test_server_hit_in_any_field_is_kept() -> None:
# A hit whose only match is in a field to_text() omits (alternatives) must
# survive the client-side value verification.
import uuid
db = FakeDecisionDb()
store = _store(db)
store.save(
decision=DecisionLog(
id=f"dec_{uuid.uuid4().hex[:6]}",
decision="Picked the database",
alternatives=["postgres", "dynamo"],
)
)
results = store.search(query="postgres")
assert len(results) == 1
assert db.search_calls # served by the server-side path, kept by the verifier
def test_json_key_names_do_not_match_decisions() -> None:
db = FakeDecisionDb()
store = _store(db)
_log(store, "Chose Postgres over Dynamo")
assert store.search(query="reasoning") == []
assert store.search(query="decision_type") == []
def test_decision_log_defaults_agentic_and_never_writes_contentless_rows() -> None:
from agno.learn.config import LearningMode
assert DecisionLogConfig().mode is LearningMode.AGENTIC
db = FakeDecisionDb()
store = _store(db)
# The old ALWAYS extraction wrote one contentless row per tool call from
# the message snapshot; process is now a no-op.
class FakeToolCallMessage:
tool_calls = [type("TC", (), {"name": "web_search"})()]
store.process(messages=[FakeToolCallMessage()], agent_id="a1", session_id="s1")
assert db.rows == []
assert not hasattr(store, "_extract_decisions_from_messages")
def test_search_honors_session_id() -> None:
db = FakeDecisionDb()
captured: List[Dict[str, Any]] = []
original = db.search_learnings
def spy(query: str, **kwargs: Any) -> List[Dict[str, Any]]:
captured.append(kwargs)
return original(query, **kwargs)
db.search_learnings = spy # type: ignore[method-assign]
store = _store(db)
_log(store, "Chose Postgres over Dynamo")
store.search(query="postgres", session_id="sess-42")
assert captured and captured[0]["session_id"] == "sess-42"
# And the listing path passes it to get_learnings
listing_calls: List[Dict[str, Any]] = []
original_get = db.get_learnings
def spy_get(**kwargs: Any) -> List[Dict[str, Any]]:
listing_calls.append(kwargs)
return original_get(**kwargs)
db.get_learnings = spy_get # type: ignore[method-assign]
store.search(session_id="sess-42")
assert listing_calls and listing_calls[0]["session_id"] == "sess-42"
def test_decision_type_filter_composes_with_query() -> None:
db = FakeDecisionDb()
store = _store(db)
_log(store, "Chose Postgres over Dynamo", decision_type="architecture")
_log(store, "Postgres connection retry", decision_type="tool_selection")
results = store.search(query="postgres", decision_type="architecture")
assert len(results) == 1
assert results[0].decision_type == "architecture"
def test_query_variants_keeps_the_query_itself() -> None:
"""A mixed-separator query has no uniform-separator form.
The db matches "end-to-end tests" through the LIKE single-char wildcard;
a verifier that only knows "end to end tests" and "end_to_end_tests"
discards that hit and the store answers "No entities matching".
"""
from agno.learn.utils import query_variants
assert "end-to-end tests" in query_variants("end-to-end tests")
assert "user_id column" in query_variants("user_id column")
# the separator-swapped forms are still generated
assert "end_to_end_tests" in query_variants("end-to-end tests")
assert "sarah_chen" in query_variants("sarah chen")
assert query_variants(" ") == []
def test_search_patterns_wildcard_non_ascii_escapes() -> None:
"""SQLite compares the JSON escape as literal text and LIKE folds ASCII
only, so no pre-cased whole-string variant reaches a mixed form like "Ος".
The escape carries a wildcard per character instead; the caller's
value-scoped Python check casefolds and rejects the slack.
"""
from agno.db.utils import learning_search_patterns
patterns = learning_search_patterns("Ος")
assert any("______" in p for p in patterns), patterns
# the raw form is still offered, for Postgres where ::text is real characters
assert any("Ος" in p for p in patterns), patterns
# ASCII queries gain no wildcard padding
assert all("______" not in p for p in learning_search_patterns("sarah chen"))
def test_value_projection_and_variants_casefold() -> None:
from agno.learn.utils import content_values_text, query_variants
assert content_values_text({"facts": ["Ος"]}) == query_variants("ΟΣ")[0]
assert query_variants("CAFÉ")[0] == "café"
def test_recall_is_not_scoped_to_the_current_session(tmp_path) -> None:
"""A decision log's value is cross-session.
recall() receives the run's session_id from LearningMachine, and forwarding
it into search() made the injected block say "No recent decisions logged" to
every new session while the store was full. search(session_id=...) still
scopes for explicit callers.
"""
from agno.db.sqlite import SqliteDb
from agno.learn.config import DecisionLogConfig, LearningMode
from agno.learn.stores.decision_log import DecisionLogStore
store = DecisionLogStore(
config=DecisionLogConfig(db=SqliteDb(db_file=str(tmp_path / "d.db")), mode=LearningMode.AGENTIC)
)
log_decision = next(
t for t in store.get_tools(agent_id="a1", session_id="session-one") if t.__name__ == "log_decision"
)
log_decision(decision="Postgres over Dynamo", reasoning="advisory locks")
from_another_session = store.recall(agent_id="a1", session_id="session-two")
assert from_another_session and from_another_session[0].decision == "Postgres over Dynamo"
assert store.search(agent_id="a1", session_id="session-two") == []
assert len(store.search(agent_id="a1", session_id="session-one")) == 1
@@ -0,0 +1,245 @@
"""Unit tests for the 2.8.4 deprecation warnings and visibility guards."""
import logging
from typing import Any, Dict, List, Optional
from agno.learn import LearningMachine
class RecordingLearningDb:
def __init__(self) -> None:
self.rows: Dict[str, Dict[str, Any]] = {}
def get_learning(self, **kwargs: Any) -> Optional[Dict[str, Any]]:
return None
def upsert_learning(self, id: str, **kwargs: Any) -> None:
self.rows[id] = {**kwargs, "learning_id": id}
def get_learnings(self, **kwargs: Any) -> List[Dict[str, Any]]:
return []
def search_learnings(self, query: str, **kwargs: Any) -> List[Dict[str, Any]]:
return []
def delete_learning(self, id: str) -> bool:
return self.rows.pop(id, None) is not None
def test_enable_user_memories_is_quiet(caplog, tmp_path) -> None:
"""enable_user_memories still aliases update_memory_on_run and says nothing.
Every rebuild path passes the alias explicitly (deep_copy from the attribute,
Agent.load with a False default), so a deprecation warning here fires on
agents whose author never wrote the parameter.
"""
from agno.agent import Agent
from agno.db.sqlite import SqliteDb
with caplog.at_level(logging.WARNING):
agent = Agent(db=SqliteDb(db_file=str(tmp_path / "a.db")), enable_user_memories=True)
agent.deep_copy()
assert agent.update_memory_on_run is True
assert not any("enable_user_memories" in r.getMessage() for r in caplog.records)
def test_hitl_mode_warns_unsupported_without_deprecating(caplog) -> None:
from agno.learn.config import LearningMode, UserMemoryConfig
from agno.learn.stores.user_memory import UserMemoryStore
with caplog.at_level(logging.WARNING):
UserMemoryStore(config=UserMemoryConfig(db=RecordingLearningDb(), mode=LearningMode.HITL)) # type: ignore[arg-type]
messages = [r.getMessage() for r in caplog.records]
assert any("does not support HITL mode" in m for m in messages)
assert not any("deprecated" in m for m in messages)
def test_agentic_memory_collision_is_quiet(caplog, tmp_path) -> None:
"""set_learning_machine runs on every run, so a guard that logs there logs
once per run. The collision is documented instead."""
from agno.agent import Agent
from agno.agent._init import set_learning_machine
from agno.db.sqlite import SqliteDb
db = SqliteDb(db_file=str(tmp_path / "b.db"))
agent = Agent(db=db, learning=True, enable_agentic_memory=True)
with caplog.at_level(logging.WARNING):
set_learning_machine(agent)
set_learning_machine(agent)
assert not any("silently dropped" in r.getMessage() for r in caplog.records)
def test_missing_user_id_with_per_user_stores_warns_once(caplog) -> None:
machine = LearningMachine(db=RecordingLearningDb(), user_memory=True, user_profile=True) # type: ignore[arg-type]
with caplog.at_level(logging.WARNING):
machine.get_tools(user_id=None)
machine.get_tools(user_id=None)
warnings = [r for r in caplog.records if "no user_id" in r.getMessage()]
assert len(warnings) == 1
assert "user_profile" in warnings[0].getMessage() and "user_memory" in warnings[0].getMessage()
def test_no_missing_user_id_warning_when_user_present_or_no_per_user_stores(caplog) -> None:
machine = LearningMachine(db=RecordingLearningDb(), user_memory=True) # type: ignore[arg-type]
with caplog.at_level(logging.WARNING):
machine.get_tools(user_id="u1")
assert not any("no user_id" in r.getMessage() for r in caplog.records)
entity_only = LearningMachine(db=RecordingLearningDb(), entity_memory=True) # type: ignore[arg-type]
with caplog.at_level(logging.WARNING):
entity_only.get_tools(user_id=None)
assert not any("no user_id" in r.getMessage() for r in caplog.records)
def test_backend_without_search_warns_once_on_the_write_path(caplog, tmp_path) -> None:
"""A backend with no search_learnings degrades RESOLUTION, not just search.
Past ~50 entities an alias stops resolving and the write mints a second
entity for the same thing, with no unmerge - so the warning has to reach
the write path, and has to name that consequence.
"""
import asyncio
from agno.db.sqlite import AsyncSqliteDb
from agno.learn.config import EntityMemoryConfig, LearningMode
from agno.learn.stores.entity_memory import EntityMemoryStore
db = AsyncSqliteDb(db_file=str(tmp_path / "a.db"))
store = EntityMemoryStore(
config=EntityMemoryConfig(db=db, mode=LearningMode.AGENTIC, namespace="global") # type: ignore[arg-type]
)
async def write_three() -> None:
for i in range(3):
await store.aremember_about(
entity=f"Thing {i}", entity_type="company", aliases=[f"T{i}"], namespace="global"
)
with caplog.at_level(logging.WARNING):
asyncio.run(write_three())
warnings = [r.getMessage() for r in caplog.records if "no search_learnings implementation" in r.getMessage()]
assert len(warnings) == 1, warnings
assert "SECOND entity" in warnings[0]
assert "SqliteDb, PostgresDb and AsyncPostgresDb" in warnings[0]
def test_the_three_first_tier_backends_implement_search() -> None:
from agno.db.postgres import AsyncPostgresDb, PostgresDb
from agno.db.sqlite import SqliteDb
for backend in (SqliteDb, PostgresDb, AsyncPostgresDb):
assert "search_learnings" in backend.__dict__, backend.__name__
def test_manual_door_without_a_model_says_capture_is_off(caplog, tmp_path) -> None:
"""The manual door injects nothing, so a hand-built machine keeps model=None.
Capture is a model call: the tools return "No model provided" and entity
memory retires nothing, while the guidance block promises the opposite.
"""
from agno.db.sqlite import SqliteDb
from agno.learn.config import LearningMode, UserMemoryConfig
machine = LearningMachine(
db=SqliteDb(db_file=str(tmp_path / "a.db")),
user_memory=UserMemoryConfig(mode=LearningMode.AGENTIC),
entity_memory=True,
)
with caplog.at_level(logging.WARNING):
machine.get_tools(user_id="[email protected]")
machine.get_tools(user_id="[email protected]")
warnings = [r.getMessage() for r in caplog.records if "LearningMachine has no model" in r.getMessage()]
assert len(warnings) == 1, warnings
assert "user_memory" in warnings[0] and "entity_memory" in warnings[0]
def test_the_automatic_door_is_quiet_because_the_agent_injects_its_model(caplog, tmp_path) -> None:
from agno.agent import Agent
from agno.db.sqlite import SqliteDb
from agno.learn.config import LearningMode, UserMemoryConfig
machine = LearningMachine(user_memory=UserMemoryConfig(mode=LearningMode.AGENTIC), entity_memory=True)
agent = Agent(model="openai:gpt-5.5", db=SqliteDb(db_file=str(tmp_path / "b.db")), learning=machine)
with caplog.at_level(logging.WARNING):
agent.initialize_agent()
machine.get_tools(user_id="[email protected]")
assert not any("LearningMachine has no model" in r.getMessage() for r in caplog.records)
def test_agent_knowledge_does_not_conjure_a_learned_knowledge_store(tmp_path) -> None:
"""`knowledge=` on the agent must not add stores the machine never asked for.
The copy exists to repair a round trip (from_dict cannot carry a knowledge
base). Unconditional, it turned any `Agent(knowledge=kb, learning=machine)`
into two extra tools - `save_learning` writes into the user's production
index - plus a second guidance block competing with the machine's own.
"""
from agno.agent import Agent
from agno.db.sqlite import SqliteDb
from agno.learn import EntityMemoryConfig, LearningMode, UserMemoryConfig
class FakeKnowledge:
name = "kb"
machine = LearningMachine(
user_memory=UserMemoryConfig(mode=LearningMode.AGENTIC), entity_memory=EntityMemoryConfig()
)
agent = Agent(
model="openai:gpt-5.5",
db=SqliteDb(db_file=str(tmp_path / "a.db")),
knowledge=FakeKnowledge(),
learning=machine,
user_id="u1",
)
agent.initialize_agent()
assert sorted(machine.stores) == ["entity_memory", "user_memory"]
assert "save_learning" not in {t.__name__ for t in machine.get_tools(user_id="u1")}
def test_a_wanted_learned_knowledge_store_still_gets_the_agents_knowledge(tmp_path) -> None:
from agno.agent import Agent
from agno.db.sqlite import SqliteDb
from agno.learn import LearningMode, UserMemoryConfig
class FakeKnowledge:
name = "kb"
machine = LearningMachine(user_memory=UserMemoryConfig(mode=LearningMode.AGENTIC), learned_knowledge=True)
agent = Agent(
model="openai:gpt-5.5",
db=SqliteDb(db_file=str(tmp_path / "b.db")),
knowledge=FakeKnowledge(),
learning=machine,
user_id="u1",
)
agent.initialize_agent()
assert machine.knowledge is not None
assert "learned_knowledge" in machine.stores
def test_a_configured_capture_policy_survives_the_execute_contract(tmp_path) -> None:
"""`instructions` is the capture-policy knob, and the tool path is the only
path AGENTIC mode takes. The execute contract replaces the store's own
gatekeeping, not the operator's policy."""
from agno.db.sqlite import SqliteDb
from agno.learn.config import LearningMode, UserMemoryConfig
from agno.learn.stores.user_memory import UserMemoryStore
store = UserMemoryStore(
config=UserMemoryConfig(
db=SqliteDb(db_file=str(tmp_path / "m.db")),
mode=LearningMode.AGENTIC,
instructions="House policy: never record salaries.",
)
)
instructed = store._get_system_message(existing_data=[], instructed=True).content
passive = store._get_system_message(existing_data=[], instructed=False).content
assert "House policy: never record salaries." in instructed
assert "House policy: never record salaries." in passive
assert "Carry out that" in instructed
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,324 @@
"""Unit tests for fact supersession on EntityMemoryStore.
The judge is one structured model call in the write path: given the entity's
live facts and the newly stated ones, it marks superseded fact ids with
confidences, and only entries at or above supersession_threshold are retired.
The fake model must subclass Model so the real tool-execution loop runs.
"""
import json
from typing import Any, AsyncIterator, Dict, Iterator, List, Optional, Tuple
import pytest
from agno.learn.config import EntityMemoryConfig
from agno.learn.stores.entity_memory import EntityMemoryStore
from agno.models.base import Model
from agno.models.response import ModelResponse
class RecordingLearningDb:
"""In-memory fake of the learnings table (duplicated per test file - the
tests/unit/learn directory is not a package)."""
def __init__(self) -> None:
self.rows: Dict[str, Dict[str, Any]] = {}
self._clock = 0
def get_learning(self, **kwargs: Any) -> Optional[Dict[str, Any]]:
for row in self.rows.values():
if (
row.get("learning_type") == kwargs.get("learning_type")
and row.get("entity_id") == kwargs.get("entity_id")
and row.get("entity_type") == kwargs.get("entity_type")
and row.get("namespace") == kwargs.get("namespace")
):
return row
return None
def upsert_learning(self, id: str, **kwargs: Any) -> None:
existing = self.rows.get(id, {})
row = {**existing, **kwargs, "learning_id": id}
self._clock += 1
row["updated_at"] = self._clock
self.rows[id] = row
def get_learnings(self, **kwargs: Any) -> List[Dict[str, Any]]:
rows = [
row
for row in self.rows.values()
if all(
kwargs.get(key) is None or row.get(key) == kwargs.get(key)
for key in ("learning_type", "entity_id", "entity_type", "namespace")
)
]
rows.sort(key=lambda r: r.get("updated_at", 0), reverse=True)
limit = kwargs.get("limit")
return rows[:limit] if limit is not None else rows
def delete_learning(self, id: str) -> bool:
return self.rows.pop(id, None) is not None
def search_learnings(self, query: str, **kwargs: Any) -> List[Dict[str, Any]]:
limit = kwargs.pop("limit", None)
for key in ("workflow_id", "session_id", "agent_id", "team_id", "user_id"):
kwargs.pop(key, None)
candidates = self.get_learnings(**kwargs)
variants = {query.lower(), query.lower().replace(" ", "_"), query.lower().replace("_", " ")}
rows = [row for row in candidates if any(v in json.dumps(row.get("content", {})).lower() for v in variants)]
return rows[:limit] if limit is not None else rows
class SupersessionJudgeModel(Model):
"""Calls mark_superseded once with a canned verdict, then stops."""
def __init__(
self,
verdict: List[Tuple[str, float]],
provider_calls: Optional[List[dict]] = None,
verdict_by_content: Optional[Tuple[str, float]] = None,
) -> None:
super().__init__(id="supersession-judge-test", name="supersession-judge-test", provider="test")
self.verdict = verdict
self.provider_calls = provider_calls if provider_calls is not None else []
self.verdict_by_content = verdict_by_content
def __deepcopy__(self, memo: dict) -> "SupersessionJudgeModel":
# Share mutable state so the recorder survives the store's deepcopy
return type(self)(
verdict=self.verdict, provider_calls=self.provider_calls, verdict_by_content=self.verdict_by_content
)
def _resolve_verdict(self, messages: Any) -> List[Tuple[str, float]]:
if self.verdict_by_content is None:
return self.verdict
# Find the fact id whose content contains the marker, judge that id.
marker, confidence = self.verdict_by_content
user_text = next(m.content for m in messages if m.role == "user")
for line in user_text.splitlines():
if marker in line and line.strip().startswith("- ["):
fact_id = line.strip()[3 : line.strip().index("]")]
return [(fact_id, confidence)]
return []
def _response_for_call(self, messages: Any) -> ModelResponse:
call_number = len(self.provider_calls) + 1
self.provider_calls.append({"call": call_number, "messages": messages})
if call_number == 1:
verdict = self._resolve_verdict(messages)
return ModelResponse(
role="assistant",
tool_calls=[
{
"id": "mark-superseded-1",
"type": "function",
"function": {
"name": "mark_superseded",
"arguments": json.dumps(
{
"fact_ids": [fact_id for fact_id, _ in verdict],
"confidences": [confidence for _, confidence in verdict],
}
),
},
}
],
)
return ModelResponse(role="assistant", content="Judged.")
def invoke(self, *args: Any, messages: Any = None, **kwargs: Any) -> ModelResponse:
return self._response_for_call(messages)
async def ainvoke(self, *args: Any, messages: Any = None, **kwargs: Any) -> ModelResponse:
return self._response_for_call(messages)
def invoke_stream(self, *args: Any, **kwargs: Any) -> Iterator[ModelResponse]:
raise AssertionError("streaming should not be used")
yield # pragma: no cover
async def ainvoke_stream(self, *args: Any, **kwargs: Any) -> AsyncIterator[ModelResponse]:
raise AssertionError("streaming should not be used")
yield # pragma: no cover
def _parse_provider_response(self, response: Any, **kwargs: Any) -> ModelResponse:
return response
def _parse_provider_response_delta(self, response: Any) -> ModelResponse:
return response
class RaisingModel(Model):
def __init__(self) -> None:
super().__init__(id="raising-test", name="raising-test", provider="test")
def __deepcopy__(self, memo: dict) -> "RaisingModel":
return self
def invoke(self, *args: Any, **kwargs: Any) -> ModelResponse:
raise RuntimeError("judge model unavailable")
async def ainvoke(self, *args: Any, **kwargs: Any) -> ModelResponse:
raise RuntimeError("judge model unavailable")
def invoke_stream(self, *args: Any, **kwargs: Any) -> Iterator[ModelResponse]:
raise AssertionError("streaming should not be used")
yield # pragma: no cover
async def ainvoke_stream(self, *args: Any, **kwargs: Any) -> AsyncIterator[ModelResponse]:
raise AssertionError("streaming should not be used")
yield # pragma: no cover
def _parse_provider_response(self, response: Any, **kwargs: Any) -> ModelResponse:
return response
def _parse_provider_response_delta(self, response: Any) -> ModelResponse:
return response
def _store_with_judge(db: RecordingLearningDb, model: Model, **config_kwargs: Any) -> EntityMemoryStore:
return EntityMemoryStore(config=EntityMemoryConfig(db=db, model=model, **config_kwargs)) # type: ignore[arg-type]
@pytest.fixture
def db() -> RecordingLearningDb:
return RecordingLearningDb()
def test_supersession_retires_contradicted_fact(db: RecordingLearningDb) -> None:
model = SupersessionJudgeModel(verdict=[], verdict_by_content=("radar is blocked", 0.95))
store = _store_with_judge(db, model)
store.remember_about(entity="radar", entity_type="project", facts=["radar is blocked on review"])
store.remember_about(entity="radar", entity_type="project", facts=["radar shipped"])
entity = store.get(entity_id="radar", entity_type="project")
assert entity is not None
live = entity.live_facts()
assert [f["content"] for f in live] == ["radar shipped"]
# Both facts are still in the record; the superseded one carries the marker
assert len(entity.facts) == 2
retired = [f for f in entity.facts if f.get("superseded_at")]
assert len(retired) == 1
assert retired[0]["content"] == "radar is blocked on review"
# With one new fact, superseded_by points at the new fact's id
new_fact_id = live[0]["id"]
assert retired[0]["superseded_by"] == new_fact_id
def test_supersession_below_threshold_keeps_fact(db: RecordingLearningDb) -> None:
model = SupersessionJudgeModel(verdict=[], verdict_by_content=("radar is blocked", 0.5))
store = _store_with_judge(db, model)
store.remember_about(entity="radar", entity_type="project", facts=["radar is blocked on review"])
message = store.remember_about(entity="radar", entity_type="project", facts=["radar shipped"])
assert "Superseded" not in message
entity = store.get(entity_id="radar", entity_type="project")
assert entity is not None
assert len(entity.live_facts()) == 2
def test_supersession_threshold_is_configurable(db: RecordingLearningDb) -> None:
model = SupersessionJudgeModel(verdict=[], verdict_by_content=("radar is blocked", 0.95))
store = _store_with_judge(db, model, supersession_threshold=0.99)
store.remember_about(entity="radar", entity_type="project", facts=["radar is blocked on review"])
store.remember_about(entity="radar", entity_type="project", facts=["radar shipped"])
entity = store.get(entity_id="radar", entity_type="project")
assert entity is not None
assert len(entity.live_facts()) == 2
def test_judge_skipped_when_entity_is_new(db: RecordingLearningDb) -> None:
calls: List[dict] = []
model = SupersessionJudgeModel(verdict=[], provider_calls=calls)
store = _store_with_judge(db, model)
store.remember_about(entity="radar", entity_type="project", facts=["first fact"])
assert calls == []
def test_judge_skipped_when_write_has_no_facts(db: RecordingLearningDb) -> None:
calls: List[dict] = []
model = SupersessionJudgeModel(verdict=[], provider_calls=calls)
store = _store_with_judge(db, model)
store.remember_about(entity="radar", entity_type="project", facts=["first fact"])
store.remember_about(entity="radar", entity_type="project", events=["shipped v1"])
assert calls == []
def test_judge_skipped_without_model(db: RecordingLearningDb) -> None:
store = EntityMemoryStore(config=EntityMemoryConfig(db=db)) # type: ignore[arg-type]
store.remember_about(entity="radar", entity_type="project", facts=["a"])
message = store.remember_about(entity="radar", entity_type="project", facts=["b"])
assert "Recorded 1 fact(s)" in message
def test_judge_failure_does_not_lose_the_write(db: RecordingLearningDb) -> None:
store = _store_with_judge(db, RaisingModel())
store.remember_about(entity="radar", entity_type="project", facts=["first fact"])
message = store.remember_about(entity="radar", entity_type="project", facts=["second fact"])
assert "Recorded 1 fact(s)" in message
entity = store.get(entity_id="radar", entity_type="project")
assert entity is not None
assert len(entity.live_facts()) == 2
def test_exact_duplicate_fact_not_appended_and_judge_skipped(db: RecordingLearningDb) -> None:
calls: List[dict] = []
model = SupersessionJudgeModel(verdict=[], provider_calls=calls)
store = _store_with_judge(db, model)
store.remember_about(entity="radar", entity_type="project", facts=["db: Postgres"])
message = store.remember_about(entity="radar", entity_type="project", facts=["DB: Postgres"])
assert "already-recorded" in message
assert calls == [] # nothing novel to judge
entity = store.get(entity_id="radar", entity_type="project")
assert entity is not None
assert len(entity.facts) == 1
def test_hallucinated_fact_id_cannot_retire_new_fact(db: RecordingLearningDb) -> None:
store_no_model = EntityMemoryStore(config=EntityMemoryConfig(db=db)) # type: ignore[arg-type]
store_no_model.remember_about(entity="radar", entity_type="project", facts=["old fact"])
entity = store_no_model.get(entity_id="radar", entity_type="project")
assert entity is not None
# Judge marks an id that does not belong to any pre-existing fact
model = SupersessionJudgeModel(verdict=[("not_a_real_id", 0.99)])
store = _store_with_judge(db, model)
store.remember_about(entity="radar", entity_type="project", facts=["new fact"])
entity = store.get(entity_id="radar", entity_type="project")
assert entity is not None
assert len(entity.live_facts()) == 2
def test_custom_system_message_reaches_the_judge(db: RecordingLearningDb) -> None:
calls: List[dict] = []
model = SupersessionJudgeModel(verdict=[], provider_calls=calls)
store = _store_with_judge(db, model, system_message="CUSTOM JUDGE PROMPT")
store.remember_about(entity="radar", entity_type="project", facts=["a"])
store.remember_about(entity="radar", entity_type="project", facts=["b"])
assert calls
system = next(m for m in calls[0]["messages"] if m.role == "system")
assert system.content == "CUSTOM JUDGE PROMPT"
async def test_async_supersession_retires_contradicted_fact(db: RecordingLearningDb) -> None:
model = SupersessionJudgeModel(verdict=[], verdict_by_content=("radar is blocked", 0.95))
store = _store_with_judge(db, model)
await store.aremember_about(entity="radar", entity_type="project", facts=["radar is blocked on review"])
message = await store.aremember_about(entity="radar", entity_type="project", facts=["radar shipped"])
assert "Superseded 1 earlier fact(s)" in message
entity = await store.aget(entity_id="radar", entity_type="project")
assert entity is not None
assert [f["content"] for f in entity.live_facts()] == ["radar shipped"]
@@ -0,0 +1,480 @@
"""Unit tests for the guidance/data split (spec §4.1) and the manual door (§4.2).
instructions() returns guidance only (mode-aware, aggregated on the machine);
build_context() returns data only; the automatic injection path concatenates the
two, so the manual door and the automatic door render the same text.
"""
from typing import Any, Dict, List, Optional
from agno.learn import LearningMachine
from agno.learn.config import (
DecisionLogConfig,
EntityMemoryConfig,
LearningMode,
UserMemoryConfig,
UserProfileConfig,
)
from agno.learn.stores.decision_log import DecisionLogStore
from agno.learn.stores.entity_memory import EntityMemoryStore
from agno.learn.stores.session_context import SessionContextStore
from agno.learn.stores.user_memory import UserMemoryStore
from agno.learn.stores.user_profile import UserProfileStore
class RecordingLearningDb:
"""In-memory fake of the learnings table (duplicated per test file - the
tests/unit/learn directory is not a package)."""
def __init__(self) -> None:
self.rows: Dict[str, Dict[str, Any]] = {}
self._clock = 0
def get_learning(self, **kwargs: Any) -> Optional[Dict[str, Any]]:
for row in self.rows.values():
if all(
kwargs.get(key) is None or row.get(key) == kwargs.get(key)
for key in ("learning_type", "entity_id", "entity_type", "namespace", "user_id")
if kwargs.get(key) is not None
):
return row
return None
def upsert_learning(self, id: str, **kwargs: Any) -> None:
existing = self.rows.get(id, {})
row = {**existing, **kwargs, "learning_id": id}
self._clock += 1
row["updated_at"] = self._clock
self.rows[id] = row
def get_learnings(self, **kwargs: Any) -> List[Dict[str, Any]]:
rows = [
row
for row in self.rows.values()
if all(
kwargs.get(key) is None or row.get(key) == kwargs.get(key)
for key in ("learning_type", "entity_id", "entity_type", "namespace")
)
]
rows.sort(key=lambda r: r.get("updated_at", 0), reverse=True)
limit = kwargs.get("limit")
return rows[:limit] if limit is not None else rows
def delete_learning(self, id: str) -> bool:
return self.rows.pop(id, None) is not None
def search_learnings(self, query: str, **kwargs: Any) -> List[Dict[str, Any]]:
import json
limit = kwargs.pop("limit", None)
for key in ("workflow_id", "session_id", "agent_id", "team_id", "user_id"):
kwargs.pop(key, None)
candidates = self.get_learnings(**kwargs)
variants = {query.lower(), query.lower().replace(" ", "_"), query.lower().replace("_", " ")}
rows = [row for row in candidates if any(v in json.dumps(row.get("content", {})).lower() for v in variants)]
return rows[:limit] if limit is not None else rows
class TestStoreSplit:
def test_agentic_stores_guidance_never_in_data(self) -> None:
db = RecordingLearningDb()
cases = [
(
UserProfileStore(config=UserProfileConfig(db=db, mode=LearningMode.AGENTIC)), # type: ignore[arg-type]
"update_profile",
),
(
UserMemoryStore(config=UserMemoryConfig(db=db, mode=LearningMode.AGENTIC)), # type: ignore[arg-type]
"update_user_memory",
),
(
EntityMemoryStore(config=EntityMemoryConfig(db=db)), # type: ignore[arg-type]
"remember_about",
),
(
DecisionLogStore(config=DecisionLogConfig(db=db, mode=LearningMode.AGENTIC)), # type: ignore[arg-type]
"log_decision",
),
]
for store, tool_name in cases:
guidance = store.instructions()
assert tool_name in guidance, store
data = store.build_context(data=store.recall(user_id="u1"))
assert tool_name not in data, store
def test_always_mode_stores_have_no_guidance(self) -> None:
db = RecordingLearningDb()
profile = UserProfileStore(config=UserProfileConfig(db=db, mode=LearningMode.ALWAYS)) # type: ignore[arg-type]
assert profile.instructions() == ""
session = SessionContextStore()
assert session.instructions() == ""
def test_machine_instructions_aggregates_enabled_stores(self) -> None:
db = RecordingLearningDb()
machine = LearningMachine(
db=db, # type: ignore[arg-type]
user_memory=UserMemoryConfig(mode=LearningMode.AGENTIC),
entity_memory=True,
)
guidance = machine.instructions()
assert "update_user_memory" in guidance
assert "remember_about" in guidance
assert "update_profile" not in guidance # not enabled
def test_machine_instructions_is_mode_aware(self) -> None:
db = RecordingLearningDb()
always_machine = LearningMachine(db=db, user_memory=UserMemoryConfig(mode=LearningMode.ALWAYS)) # type: ignore[arg-type]
assert always_machine.instructions() == ""
def test_custom_store_without_instructions_still_works(self) -> None:
class OldStyleStore:
# A pre-2.8.4 third-party store: no instructions() method.
learning_type = "old_style"
schema = dict
was_updated = False
def recall(self, **kwargs: Any) -> Any:
return {"x": 1}
async def arecall(self, **kwargs: Any) -> Any:
return {"x": 1}
def process(self, messages: Any, **kwargs: Any) -> None:
pass
async def aprocess(self, messages: Any, **kwargs: Any) -> None:
pass
def build_context(self, data: Any) -> str:
return "<old_style>x</old_style>"
def get_tools(self, **kwargs: Any) -> List[Any]:
return []
async def aget_tools(self, **kwargs: Any) -> List[Any]:
return []
machine = LearningMachine(db=RecordingLearningDb(), custom_stores={"old_style": OldStyleStore()}) # type: ignore[arg-type]
assert "old_style" in machine.stores
assert machine.instructions() == "" # contributes no guidance, no crash
assert "<old_style>x</old_style>" in machine.build_context()
class TestRunContextThreading:
def test_narrow_custom_store_keeps_working(self) -> None:
"""The §8 bullet: a custom store whose recall(self, user_id=None) takes
no **kwargs still works after the new context kwargs arrive."""
calls: Dict[str, Any] = {}
class NarrowStore:
learning_type = "narrow"
schema = dict
was_updated = False
def recall(self, user_id=None): # no **kwargs, deliberately
calls["recall_user_id"] = user_id
return {"seen": True}
async def arecall(self, user_id=None):
calls["arecall_user_id"] = user_id
return {"seen": True}
def process(self, messages, user_id=None):
calls["process_messages"] = messages
async def aprocess(self, messages, user_id=None):
calls["aprocess_messages"] = messages
def build_context(self, data):
return "<narrow>ok</narrow>"
def get_tools(self, user_id=None):
calls["get_tools_user_id"] = user_id
return []
async def aget_tools(self, user_id=None):
return []
machine = LearningMachine(db=RecordingLearningDb(), custom_stores={"narrow": NarrowStore()}) # type: ignore[arg-type]
context = machine.build_context(
user_id="u1",
message="the current message",
run_context=object(),
metadata={"k": "v"},
dependencies={"d": 1},
session_state={"s": 2},
)
assert "<narrow>ok</narrow>" in context
assert calls["recall_user_id"] == "u1"
machine.get_tools(user_id="u1", run_context=object())
assert calls["get_tools_user_id"] == "u1"
machine.process(messages=["m"], user_id="u1", run_context=object(), metadata={}, session_state={})
assert calls["process_messages"] == ["m"]
async def test_narrow_custom_store_async_paths(self) -> None:
class NarrowStore:
learning_type = "narrow"
schema = dict
was_updated = False
def recall(self, user_id=None):
return None
async def arecall(self, user_id=None):
return {"seen": True}
def process(self, messages, user_id=None):
pass
async def aprocess(self, messages, user_id=None):
pass
def build_context(self, data):
return "<narrow>async ok</narrow>" if data else ""
def get_tools(self, user_id=None):
return []
async def aget_tools(self, user_id=None):
return []
machine = LearningMachine(db=RecordingLearningDb(), custom_stores={"narrow": NarrowStore()}) # type: ignore[arg-type]
context = await machine.abuild_context(user_id="u1", run_context=object(), metadata={})
assert "<narrow>async ok</narrow>" in context
await machine.aprocess(messages=["m"], run_context=object(), session_state={})
def test_builtin_store_receives_the_current_message(self) -> None:
"""Built-in stores take **kwargs and see everything, including message."""
seen: Dict[str, Any] = {}
machine = LearningMachine(db=RecordingLearningDb(), entity_memory=True) # type: ignore[arg-type]
store = machine.entity_memory_store
assert store is not None
original_recall = store.recall
def spy_recall(**kwargs: Any) -> Any:
seen.update(kwargs)
return original_recall(
**{k: v for k, v in kwargs.items() if k in ("entity_id", "entity_type", "user_id", "namespace")}
)
store.recall = spy_recall # type: ignore[method-assign]
machine.build_context(user_id="u1", message="tell me about radar", run_context="RC")
assert seen.get("message") == "tell me about radar"
assert seen.get("run_context") == "RC"
def test_injection_site_threads_the_input_message(self) -> None:
"""End to end: get_system_message(input=...) reaches build_context(message=...)."""
from unittest.mock import MagicMock
from agno.agent import Agent
from agno.agent._messages import get_system_message
from agno.models.openai import OpenAIResponses
from agno.run.base import RunContext
from agno.session import AgentSession
db = RecordingLearningDb()
machine = LearningMachine(db=db, entity_memory=True) # type: ignore[arg-type]
machine.build_context = MagicMock(return_value="<ctx/>") # type: ignore[method-assign]
agent = Agent(db=db, learning=machine, model=OpenAIResponses(id="gpt-5.5")) # type: ignore[arg-type]
agent._learning = machine
session = AgentSession(session_id="s1")
run_context = RunContext(run_id="r1", session_id="s1", user_id="u1", metadata={"m": 1}, session_state={"x": 2})
get_system_message(agent, session=session, run_context=run_context, input="what about radar?")
kwargs = machine.build_context.call_args.kwargs
assert kwargs["message"] == "what about radar?"
assert kwargs["run_context"] is run_context
assert kwargs["metadata"] == {"m": 1}
assert kwargs["session_state"] == {"x": 2}
class TestManualDoor:
def test_capture_hook_runs_process(self) -> None:
from types import SimpleNamespace
db = RecordingLearningDb()
machine = LearningMachine(db=db, user_memory=True) # type: ignore[arg-type]
captured: Dict[str, Any] = {}
def spy_process(**kwargs: Any) -> None:
captured.update(kwargs)
machine.process = spy_process # type: ignore[method-assign]
hook = machine.capture_hook()
hook(
run_output=SimpleNamespace(messages=["m1"]),
agent=SimpleNamespace(id="agent-1", team_id=None),
session=SimpleNamespace(session_id="s1"),
user_id="u1",
)
assert captured["messages"] == ["m1"]
assert captured["user_id"] == "u1"
assert captured["session_id"] == "s1"
assert captured["agent_id"] == "agent-1"
async def test_acapture_hook_schedules_aprocess(self) -> None:
import asyncio
from types import SimpleNamespace
db = RecordingLearningDb()
machine = LearningMachine(db=db, user_memory=True) # type: ignore[arg-type]
captured: Dict[str, Any] = {}
async def spy_aprocess(**kwargs: Any) -> None:
captured.update(kwargs)
machine.aprocess = spy_aprocess # type: ignore[method-assign]
hook = machine.acapture_hook()
await hook(
run_output=SimpleNamespace(messages=["m1"]),
agent=SimpleNamespace(id="agent-1", team_id=None),
session=SimpleNamespace(session_id="s1"),
user_id="u1",
)
# Fire-and-forget: the capture runs as a background task off the
# response path; drain it before asserting.
await asyncio.gather(*machine._capture_tasks)
assert captured["messages"] == ["m1"]
def test_capture_hook_is_post_hooks_compatible(self) -> None:
# filter_hook_args must find only parameters it can supply.
import inspect
from agno.agent._hooks import filter_hook_args
machine = LearningMachine(db=RecordingLearningDb(), user_memory=True) # type: ignore[arg-type]
hook = machine.capture_hook()
params = set(inspect.signature(hook).parameters)
assert params <= {"run_output", "agent", "session", "user_id", "run_context"}
filtered = filter_hook_args(
hook,
{
"run_output": None,
"agent": None,
"session": None,
"user_id": "u1",
"run_context": object(),
"debug_mode": False,
"metadata": None,
},
)
assert set(filtered) == params
def test_double_render_warns_once(self, caplog) -> None:
import logging
machine = LearningMachine(db=RecordingLearningDb(), entity_memory=True) # type: ignore[arg-type]
machine.instructions() # the manual door was used
with caplog.at_level(logging.WARNING):
machine._framework_instructions() # ...and the framework injects too
machine._framework_instructions()
warnings = [r for r in caplog.records if "render twice" in r.getMessage()]
assert len(warnings) == 1
def test_no_warning_on_a_single_door(self, caplog) -> None:
import logging
machine = LearningMachine(db=RecordingLearningDb(), entity_memory=True) # type: ignore[arg-type]
with caplog.at_level(logging.WARNING):
machine._framework_instructions()
machine._framework_instructions()
assert [r for r in caplog.records if "render twice" in r.getMessage()] == []
class TestManualDoorMatchesAutomaticDoor:
def test_injected_block_equals_instructions_plus_build_context(self) -> None:
"""The §8 regression guard: for the same machine, what the automatic
injection site renders equals instructions() + build_context()."""
db = RecordingLearningDb()
machine = LearningMachine(
db=db, # type: ignore[arg-type]
user_memory=UserMemoryConfig(mode=LearningMode.AGENTIC),
entity_memory=True,
)
entity_store = machine.entity_memory_store
assert entity_store is not None
entity_store.remember_about(entity="radar", entity_type="project", facts=["db: Postgres"])
# What the automatic door assembles at the injection site
# (agent/_messages.py): instructions() + "\n" + build_context(...)
guidance = machine.instructions()
data = machine.build_context(user_id="u1", session_id="s1", agent_id="a1")
auto_block = "\n".join(part for part in (guidance, data) if part)
# The manual door places the same two surfaces by hand
manual_block = "\n".join(
part
for part in (
machine.instructions(),
machine.build_context(user_id="u1", session_id="s1", agent_id="a1"),
)
if part
)
assert auto_block == manual_block
assert "remember_about" in auto_block # guidance present
assert "Entity directory" in auto_block # data present
def test_agent_system_message_carries_guidance_and_data(self) -> None:
"""End to end through the real injection site in agent/_messages.py."""
from typing import Any as _Any
from agno.agent import Agent
from agno.agent._messages import get_system_message
from agno.models.base import Model
from agno.models.response import ModelResponse
from agno.run.base import RunContext
from agno.session import AgentSession
class _NullModel(Model):
def __init__(self) -> None:
super().__init__(id="null-test", name="null-test", provider="test")
def invoke(self, *args: _Any, **kwargs: _Any) -> ModelResponse:
return ModelResponse(role="assistant", content="")
async def ainvoke(self, *args: _Any, **kwargs: _Any) -> ModelResponse:
return ModelResponse(role="assistant", content="")
def invoke_stream(self, *args: _Any, **kwargs: _Any):
raise AssertionError("not used")
yield # pragma: no cover
async def ainvoke_stream(self, *args: _Any, **kwargs: _Any):
raise AssertionError("not used")
yield # pragma: no cover
def _parse_provider_response(self, response: _Any, **kwargs: _Any) -> ModelResponse:
return response
def _parse_provider_response_delta(self, response: _Any) -> ModelResponse:
return response
db = RecordingLearningDb()
machine = LearningMachine(db=db, entity_memory=True) # type: ignore[arg-type]
entity_store = machine.entity_memory_store
assert entity_store is not None
entity_store.remember_about(entity="radar", entity_type="project", facts=["db: Postgres"])
agent = Agent(db=db, learning=machine, model=_NullModel()) # type: ignore[arg-type]
agent._learning = machine
session = AgentSession(session_id="s1")
run_context = RunContext(run_id="r1", session_id="s1", user_id="u1")
message = get_system_message(agent, session=session, run_context=run_context)
assert message is not None
content = str(message.content)
assert "remember_about" in content # guidance reached the system prompt
assert "Entity directory" in content # data reached the system prompt
# And it equals the manual door's assembly, embedded verbatim
manual = "\n".join(
part
for part in (
machine.instructions(),
machine.build_context(user_id="u1", session_id="s1", agent_id=agent.id),
)
if part
)
assert manual in content
@@ -0,0 +1,197 @@
"""Unit tests for the lossless LearningMachine config round-trip (spec §3.9).
Verified live before the fix: an AGENTIC machine round-tripped through
Agent.save()/load() came back as ALWAYS and get_tools() returned [] - the
agent silently lost its entire learning tool surface.
"""
import logging
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from agno.learn import LearningMachine, UserProfile
from agno.learn.config import (
EntityMemoryConfig,
LearningMode,
UserMemoryConfig,
)
class RecordingLearningDb:
def __init__(self) -> None:
self.rows: Dict[str, Dict[str, Any]] = {}
def get_learning(self, **kwargs: Any) -> Optional[Dict[str, Any]]:
return None
def upsert_learning(self, id: str, **kwargs: Any) -> None:
self.rows[id] = {**kwargs, "learning_id": id}
def get_learnings(self, **kwargs: Any) -> List[Dict[str, Any]]:
return []
def search_learnings(self, query: str, **kwargs: Any) -> List[Dict[str, Any]]:
return []
def delete_learning(self, id: str) -> bool:
return self.rows.pop(id, None) is not None
@dataclass
class ExtendedProfile(UserProfile):
company: Optional[str] = field(default=None, metadata={"description": "Where they work"})
def test_round_trip_preserves_mode_and_tools() -> None:
"""The §8 bullet: from_dict(to_dict()) keeps AGENTIC, and the rebuilt
machine still has its tool surface."""
machine = LearningMachine(
db=RecordingLearningDb(), # type: ignore[arg-type]
user_memory=UserMemoryConfig(mode=LearningMode.AGENTIC),
entity_memory=EntityMemoryConfig(namespace="global", supersession_threshold=0.9),
)
rebuilt = LearningMachine.from_dict(machine.to_dict())
rebuilt.db = RecordingLearningDb() # type: ignore[assignment]
assert isinstance(rebuilt.user_memory, UserMemoryConfig)
assert rebuilt.user_memory.mode is LearningMode.AGENTIC
assert isinstance(rebuilt.entity_memory, EntityMemoryConfig)
assert rebuilt.entity_memory.supersession_threshold == 0.9
tools = rebuilt.get_tools(user_id="u1")
names = {t.__name__ for t in tools}
assert "update_user_memory" in names
assert {"remember_about", "link_entities", "search_entities", "forget"} <= names
def test_round_trip_preserves_prompts_limits_and_namespace() -> None:
machine = LearningMachine(
db=RecordingLearningDb(), # type: ignore[arg-type]
user_memory=UserMemoryConfig(
mode=LearningMode.AGENTIC,
instructions="capture durable facts",
additional_instructions="ignore small talk",
max_updates_per_run=7,
enable_delete_memory=False,
),
namespace="team_west",
max_updates_per_run=25,
)
rebuilt = LearningMachine.from_dict(machine.to_dict())
assert rebuilt.namespace == "team_west"
assert rebuilt.max_updates_per_run == 25
assert isinstance(rebuilt.user_memory, UserMemoryConfig)
assert rebuilt.user_memory.instructions == "capture durable facts"
assert rebuilt.user_memory.additional_instructions == "ignore small talk"
assert rebuilt.user_memory.max_updates_per_run == 7
assert rebuilt.user_memory.enable_delete_memory is False
def test_round_trip_preserves_schema_by_import_path() -> None:
machine = LearningMachine(
db=RecordingLearningDb(), # type: ignore[arg-type]
user_profile=__import__("agno.learn.config", fromlist=["UserProfileConfig"]).UserProfileConfig(
mode=LearningMode.AGENTIC, schema=ExtendedProfile
),
)
payload = machine.to_dict()
assert payload["user_profile"]["schema"].endswith("ExtendedProfile")
rebuilt = LearningMachine.from_dict(payload)
assert rebuilt.user_profile.schema is ExtendedProfile # type: ignore[union-attr]
def test_from_dict_accepts_old_boolean_payloads() -> None:
rebuilt = LearningMachine.from_dict(
{"user_profile": True, "user_memory": True, "namespace": "global", "debug_mode": False}
)
assert rebuilt.user_profile is True
assert rebuilt.user_memory is True
assert rebuilt.entity_memory is False
def test_store_instance_serializes_its_config() -> None:
from agno.learn.stores.entity_memory import EntityMemoryStore
store = EntityMemoryStore(config=EntityMemoryConfig(db=RecordingLearningDb(), namespace="ops")) # type: ignore[arg-type]
machine = LearningMachine(db=RecordingLearningDb(), entity_memory=store) # type: ignore[arg-type]
payload = machine.to_dict()
assert payload["entity_memory"]["namespace"] == "ops"
rebuilt = LearningMachine.from_dict(payload)
assert isinstance(rebuilt.entity_memory, EntityMemoryConfig)
assert rebuilt.entity_memory.namespace == "ops"
def test_custom_stores_round_trip_as_logged_refs(caplog) -> None:
class TinyStore:
learning_type = "tiny"
schema = dict
was_updated = False
def recall(self, **kwargs: Any) -> Any:
return None
async def arecall(self, **kwargs: Any) -> Any:
return None
def process(self, messages: Any, **kwargs: Any) -> None:
pass
async def aprocess(self, messages: Any, **kwargs: Any) -> None:
pass
def build_context(self, data: Any) -> str:
return ""
def instructions(self) -> str:
return ""
def get_tools(self, **kwargs: Any) -> List[Any]:
return []
async def aget_tools(self, **kwargs: Any) -> List[Any]:
return []
machine = LearningMachine(db=RecordingLearningDb(), custom_stores={"tiny": TinyStore()}) # type: ignore[arg-type]
payload = machine.to_dict()
assert "tiny" in payload["custom_stores"]
with caplog.at_level(logging.WARNING):
rebuilt = LearningMachine.from_dict(payload)
assert rebuilt.custom_stores is None
assert any("cannot be rebuilt" in r.getMessage() for r in caplog.records)
def test_agent_save_load_keeps_the_tool_surface(tmp_path) -> None:
"""End to end through the real Agent storage path (the launch-blocking bug:
an AGENTIC machine reloaded as ALWAYS and lost its tools)."""
from agno.agent import Agent
from agno.db.sqlite import SqliteDb
from agno.models.openai import OpenAIResponses
db = SqliteDb(db_file=str(tmp_path / "agents.db"))
machine = LearningMachine(
user_memory=UserMemoryConfig(mode=LearningMode.AGENTIC),
entity_memory=True,
)
agent = Agent(
id="round-trip-agent",
db=db,
model=OpenAIResponses(id="gpt-5.5"),
learning=machine,
)
agent.save()
loaded = Agent.load(id="round-trip-agent", db=db)
assert loaded is not None
assert isinstance(loaded.learning, LearningMachine)
assert isinstance(loaded.learning.user_memory, UserMemoryConfig)
assert loaded.learning.user_memory.mode is LearningMode.AGENTIC
loaded.learning.db = db
tools = loaded.learning.get_tools(user_id="u1")
names = {t.__name__ for t in tools}
assert "update_user_memory" in names
assert "remember_about" in names
@@ -10,7 +10,6 @@ from agno.learn.config import (
UserMemoryConfig,
UserProfileConfig,
)
from agno.learn.stores.entity_memory import EntityMemoryStore
from agno.learn.stores.learned_knowledge import LearnedKnowledgeStore
from agno.learn.stores.session_context import SessionContextStore
from agno.learn.stores.user_memory import UserMemoryStore
@@ -162,14 +161,6 @@ def _build_user_memory_store(model: Any, **config_kwargs: Any) -> tuple[Any, dic
return store, {"user_id": "user-1"}
def _build_entity_memory_store(model: Any, **config_kwargs: Any) -> tuple[Any, dict[str, str]]:
config_kwargs.setdefault("max_updates_per_run", DEFAULT_MAX_UPDATES)
store = EntityMemoryStore(
config=EntityMemoryConfig(db=_RecordingLearningDb(), model=model, **config_kwargs) # type: ignore[arg-type]
)
return store, {"user_id": "user-1"}
def _build_learned_knowledge_store(model: Any, **config_kwargs: Any) -> tuple[Any, dict[str, str]]:
config_kwargs.setdefault("max_updates_per_run", DEFAULT_MAX_UPDATES)
store = LearnedKnowledgeStore(
@@ -178,11 +169,11 @@ def _build_learned_knowledge_store(model: Any, **config_kwargs: Any) -> tuple[An
return store, {"user_id": "user-1"}
# Entity memory is AGENTIC-only (no extraction pass), so it does not appear here.
STORE_BUILDERS = [
pytest.param(_build_session_context_store, id="session_context"),
pytest.param(_build_user_profile_store, id="user_profile"),
pytest.param(_build_user_memory_store, id="user_memory"),
pytest.param(_build_entity_memory_store, id="entity_memory"),
pytest.param(_build_learned_knowledge_store, id="learned_knowledge"),
]
@@ -0,0 +1,484 @@
"""Unit tests for relevance recall (spec §3.7 / commit 15).
recall(message=...) returns the top-k relevant entities: name/alias matches
against the message first, then a bounded lexical term search. The keyed
lookup stays available as get(); the directory is always injected.
"""
import json
from typing import Any, Dict, List, Optional
import pytest
from agno.learn.config import EntityMemoryConfig
from agno.learn.stores.entity_memory import EntityMemoryStore, _message_terms
class RecordingLearningDb:
"""In-memory fake of the learnings table (duplicated per test file - the
tests/unit/learn directory is not a package)."""
def __init__(self) -> None:
self.rows: Dict[str, Dict[str, Any]] = {}
self._clock = 0
def get_learning(self, **kwargs: Any) -> Optional[Dict[str, Any]]:
for row in self.rows.values():
if all(
kwargs.get(key) is None or row.get(key) == kwargs.get(key)
for key in ("learning_type", "entity_id", "entity_type", "namespace")
):
return row
return None
def upsert_learning(self, id: str, **kwargs: Any) -> None:
existing = self.rows.get(id, {})
row = {**existing, **kwargs, "learning_id": id}
self._clock += 1
row["updated_at"] = self._clock
self.rows[id] = row
def get_learnings(self, **kwargs: Any) -> List[Dict[str, Any]]:
rows = [
row
for row in self.rows.values()
if all(
kwargs.get(key) is None or row.get(key) == kwargs.get(key)
for key in ("learning_type", "entity_id", "entity_type", "namespace")
)
]
rows.sort(key=lambda r: r.get("updated_at", 0), reverse=True)
limit = kwargs.get("limit")
return rows[:limit] if limit is not None else rows
def delete_learning(self, id: str) -> bool:
return self.rows.pop(id, None) is not None
def search_learnings(self, query: str, **kwargs: Any) -> List[Dict[str, Any]]:
limit = kwargs.pop("limit", None)
for key in ("workflow_id", "session_id", "agent_id", "team_id", "user_id"):
kwargs.pop(key, None)
candidates = self.get_learnings(**kwargs)
variants = {query.lower(), query.lower().replace(" ", "_"), query.lower().replace("_", " ")}
rows = [row for row in candidates if any(v in json.dumps(row.get("content", {})).lower() for v in variants)]
return rows[:limit] if limit is not None else rows
@pytest.fixture
def db() -> RecordingLearningDb:
return RecordingLearningDb()
@pytest.fixture
def store(db: RecordingLearningDb) -> EntityMemoryStore:
return EntityMemoryStore(config=EntityMemoryConfig(db=db)) # type: ignore[arg-type]
class TestMessageTerms:
def test_extracts_distinctive_terms(self) -> None:
assert _message_terms("What did we decide about the radar project?") == ["decide", "radar", "project"]
def test_empty_and_stopword_only(self) -> None:
assert _message_terms("") == []
assert _message_terms("what is the") == []
class TestRelevanceRecall:
def test_name_path_serves_without_term_search(self, db: RecordingLearningDb) -> None:
# Discriminating test for the NAME path: with k=1 filled by the name
# match, term search must not run at all - so the name path cannot be
# silently replaced by the more expensive search fallback.
store = EntityMemoryStore(config=EntityMemoryConfig(db=db, max_entities_in_context=1)) # type: ignore[arg-type]
store.remember_about(entity="radar", entity_type="project", facts=["db: Postgres"])
def forbidden_search(**kwargs: Any) -> Any:
raise AssertionError("term search must not run when the name path fills k")
store.search = forbidden_search # type: ignore[method-assign]
recalled = store.recall(message="what is the status of radar?")
assert recalled is not None
assert [e.entity_id for e in recalled["entities"]] == ["radar"]
def test_short_names_match_on_word_boundaries_only(self, store: EntityMemoryStore) -> None:
# A two-letter entity must not match inside other words and evict the
# entity the turn is actually about.
store.remember_about(entity="Al", entity_type="person", facts=["works in finance"])
store.remember_about(entity="radar", entity_type="project", facts=["db: Postgres"])
recalled = store.recall(message="always check the radar dashboard")
assert recalled is not None
ids = [e.entity_id for e in recalled["entities"]]
assert "radar" in ids and "al" not in ids
# ...but naming Al on a word boundary matches
recalled = store.recall(message="ask Al about the budget")
assert recalled is not None
assert "al" in [e.entity_id for e in recalled["entities"]]
def test_message_naming_an_entity_expands_it(self, store: EntityMemoryStore) -> None:
store.remember_about(entity="radar", entity_type="project", facts=["db: Postgres"])
store.remember_about(entity="unrelated", entity_type="project", facts=["nothing here"])
recalled = store.recall(message="what is the status of radar?")
assert recalled is not None
assert [e.entity_id for e in recalled["entities"]] == ["radar"]
def test_multi_word_name_matches(self, store: EntityMemoryStore) -> None:
store.remember_about(entity="Sarah Chen", entity_type="person", facts=["designs radar"])
recalled = store.recall(message="Ask Sarah Chen about the deadline")
assert recalled is not None
assert [e.entity_id for e in recalled["entities"]] == ["sarah_chen"]
def test_alias_matches(self, store: EntityMemoryStore, db: RecordingLearningDb) -> None:
db.upsert_learning(
id="entity_global_project_radar",
learning_type="entity_memory",
entity_id="radar",
entity_type="project",
namespace="global",
content={
"entity_id": "radar",
"entity_type": "project",
"name": "radar",
"aliases": ["The Radar Initiative"],
"facts": [],
},
)
recalled = store.recall(message="Where does the radar initiative stand?")
assert recalled is not None
assert [e.entity_id for e in recalled["entities"]] == ["radar"]
def test_term_search_finds_entity_by_fact_content(self, store: EntityMemoryStore) -> None:
# The entity's NAME is not in the message; a fact matches the term.
store.remember_about(entity="radar", entity_type="project", facts=["uses postgresql for storage"])
recalled = store.recall(message="who knows postgresql here?")
assert recalled is not None
assert [e.entity_id for e in recalled["entities"]] == ["radar"]
def test_no_match_returns_directory_only(self, store: EntityMemoryStore) -> None:
store.remember_about(entity="radar", entity_type="project")
recalled = store.recall(message="have we discussed quantum tunneling?")
assert recalled is not None
assert recalled["entities"] == []
assert [e.entity_id for e in recalled["directory"]] == ["radar"]
def test_top_k_bound_is_honored(self, db: RecordingLearningDb) -> None:
store = EntityMemoryStore(config=EntityMemoryConfig(db=db, max_entities_in_context=2)) # type: ignore[arg-type]
for i in range(5):
store.remember_about(entity=f"radar {i}", entity_type="project")
recalled = store.recall(message="radar 0 radar 1 radar 2 radar 3 radar 4")
assert recalled is not None
assert len(recalled["entities"]) == 2
def test_archived_entities_do_not_recall(self, store: EntityMemoryStore) -> None:
store.remember_about(entity="radar", entity_type="project")
store.forget(entity="radar")
recalled = store.recall(message="what about radar?")
assert recalled is not None
assert recalled["entities"] == []
def test_keyed_lookup_composes_with_message(self, store: EntityMemoryStore) -> None:
store.remember_about(entity="radar", entity_type="project")
store.remember_about(entity="lidar", entity_type="project")
recalled = store.recall(entity_id="radar", entity_type="project", message="compare with lidar")
assert recalled is not None
ids = [e.entity_id for e in recalled["entities"]]
assert ids[0] == "radar" and "lidar" in ids
async def test_async_relevance_recall(self, store: EntityMemoryStore) -> None:
await store.aremember_about(entity="radar", entity_type="project", facts=["db: Postgres"])
recalled = await store.arecall(message="what about radar?")
assert recalled is not None
assert [e.entity_id for e in recalled["entities"]] == ["radar"]
class TestRecallReachesTheAgent:
def test_stored_entity_reaches_the_system_message(self) -> None:
"""The §8 bullet whose absence let entity memory ship write-only: given
a stored entity and a message mentioning it, the rendered system
message contains that entity's facts."""
from agno.agent import Agent
from agno.agent._messages import get_system_message
from agno.learn import LearningMachine
from agno.models.openai import OpenAIResponses
from agno.run.base import RunContext
from agno.session import AgentSession
db = RecordingLearningDb()
machine = LearningMachine(db=db, entity_memory=True) # type: ignore[arg-type]
entity_store = machine.entity_memory_store
assert entity_store is not None
entity_store.remember_about(entity="radar", entity_type="project", facts=["db: Postgres, over Dynamo"])
agent = Agent(db=db, learning=machine, model=OpenAIResponses(id="gpt-5.5")) # type: ignore[arg-type]
agent._learning = machine
session = AgentSession(session_id="s1")
run_context = RunContext(run_id="r1", session_id="s1", user_id="u1")
message = get_system_message(
agent, session=session, run_context=run_context, input="what did we decide for radar?"
)
assert message is not None
content = str(message.content)
assert "db: Postgres, over Dynamo" in content
def test_stored_entity_reaches_the_system_message_through_get_run_messages(self) -> None:
"""The same bullet, guarded one layer up.
The original write-only bug was a dropped kwarg at the CALL SITE, not
inside get_system_message, so the regression guard has to run the seam
that broke: get_run_messages must hand the run input down.
"""
from agno.agent import Agent
from agno.agent._messages import get_run_messages
from agno.learn import LearningMachine
from agno.models.openai import OpenAIResponses
from agno.run.agent import RunOutput
from agno.run.base import RunContext
from agno.session import AgentSession
db = RecordingLearningDb()
machine = LearningMachine(db=db, entity_memory=True) # type: ignore[arg-type]
entity_store = machine.entity_memory_store
assert entity_store is not None
entity_store.remember_about(entity="radar", entity_type="project", facts=["db: Postgres, over Dynamo"])
agent = Agent(db=db, learning=machine, model=OpenAIResponses(id="gpt-5.5")) # type: ignore[arg-type]
agent._learning = machine
session = AgentSession(session_id="s1")
run_context = RunContext(run_id="r1", session_id="s1", user_id="u1")
run_messages = get_run_messages(
agent,
run_response=RunOutput(run_id="r1", session_id="s1"),
run_context=run_context,
input="what did we decide for radar?",
session=session,
)
assert run_messages.system_message is not None
assert "db: Postgres, over Dynamo" in str(run_messages.system_message.content)
async def test_stored_entity_reaches_the_system_message_through_aget_run_messages(self) -> None:
"""Async twin of the call-site guard."""
from agno.agent import Agent
from agno.agent._messages import aget_run_messages
from agno.learn import LearningMachine
from agno.models.openai import OpenAIResponses
from agno.run.agent import RunOutput
from agno.run.base import RunContext
from agno.session import AgentSession
db = RecordingLearningDb()
machine = LearningMachine(db=db, entity_memory=True) # type: ignore[arg-type]
entity_store = machine.entity_memory_store
assert entity_store is not None
await entity_store.aremember_about(entity="radar", entity_type="project", facts=["db: Postgres, over Dynamo"])
agent = Agent(db=db, learning=machine, model=OpenAIResponses(id="gpt-5.5")) # type: ignore[arg-type]
agent._learning = machine
session = AgentSession(session_id="s1")
run_context = RunContext(run_id="r1", session_id="s1", user_id="u1")
run_messages = await aget_run_messages(
agent,
run_response=RunOutput(run_id="r1", session_id="s1"),
run_context=run_context,
input="what did we decide for radar?",
session=session,
)
assert run_messages.system_message is not None
assert "db: Postgres, over Dynamo" in str(run_messages.system_message.content)
async def test_async_far_edge_detach_reaches_an_async_db(tmp_path) -> None:
"""The sync helpers no-op against an AsyncBaseDb.
aforget dropped the near end's edge and left the far end holding a
reciprocal edge to a relationship that no longer exists.
"""
from agno.db.sqlite import AsyncSqliteDb
from agno.learn.config import LearningMode
from agno.learn.stores.entity_memory import EntityMemoryStore
db = AsyncSqliteDb(db_file=str(tmp_path / "a.db"))
store = EntityMemoryStore(
config=EntityMemoryConfig(db=db, mode=LearningMode.AGENTIC, namespace="global") # type: ignore[arg-type]
)
await store.aremember_about(entity="quill", entity_type="project", namespace="global")
await store.alink_entities(entity="quill", relation="written_in", related_entity="Rust", namespace="global")
await store.aforget(entity="quill", fact="written_in -> Rust", namespace="global")
quill = await store.aget(entity_id="quill", entity_type="project", namespace="global")
rust = await store.aget(entity_id="rust", entity_type="unknown", namespace="global")
assert quill is not None and rust is not None
assert quill.relationships == []
assert rust.relationships == []
class TestAsyncEntityToolsAgainstARealAsyncDb:
"""The sync helpers no-op against an AsyncBaseDb.
Every one of these passed against the in-memory fake while the real async
backend was broken - the fake is synchronous, so it cannot see the gap.
"""
async def _store(self, tmp_path, name):
from agno.db.sqlite import AsyncSqliteDb
from agno.learn.config import LearningMode
from agno.learn.stores.entity_memory import EntityMemoryStore
db = AsyncSqliteDb(db_file=str(tmp_path / name))
return EntityMemoryStore(
config=EntityMemoryConfig(db=db, mode=LearningMode.AGENTIC, namespace="global") # type: ignore[arg-type]
)
async def test_ambiguous_name_refuses(self, tmp_path) -> None:
store = await self._store(tmp_path, "a.db")
await store.aremember_about(entity="Harbor", entity_type="project", namespace="global")
await store.aremember_about(entity="Harbor", entity_type="company", namespace="global")
assert "matches more than one entity" in await store.aforget(entity="Harbor", namespace="global")
assert "matches more than one entity" in await store.alink_entities(
entity="Harbor", relation="uses", related_entity="Postgres", namespace="global"
)
project = await store.aget(entity_id="harbor", entity_type="project", namespace="global")
assert project is not None and not getattr(project, "archived_at", None)
async def test_qualified_name_reaches_either_entity(self, tmp_path) -> None:
store = await self._store(tmp_path, "b.db")
await store.aremember_about(entity="Harbor", entity_type="project", namespace="global")
await store.aremember_about(entity="Harbor", entity_type="company", namespace="global")
message = await store.alink_entities(
entity="project/Harbor", relation="uses", related_entity="Postgres", namespace="global"
)
assert "project/harbor" in message
assert "Archived company/harbor" in await store.aforget(entity="company/Harbor", namespace="global")
project = await store.aget(entity_id="harbor", entity_type="project", namespace="global")
company = await store.aget(entity_id="harbor", entity_type="company", namespace="global")
assert project is not None and company is not None
assert [r["entity_id"] for r in project.relationships] == ["postgres"]
assert getattr(company, "archived_at", None) and not getattr(project, "archived_at", None)
async def test_sibling_signal_reaches_the_async_write(self, tmp_path) -> None:
store = await self._store(tmp_path, "c.db")
await store.aremember_about(entity="Atlas", entity_type="system", namespace="global")
message = await store.aremember_about(entity="Atlas", entity_type="team", namespace="global")
assert "system/atlas already exists under this name" in message
class TestTheSeamsThatBrokeBefore:
"""Guards for the two seams whose absence is how this release's bugs shipped.
Both were found by mutation: the shipped code is correct, and every test
stayed green when it was broken.
"""
def test_stored_entity_reaches_a_teams_system_message(self, tmp_path) -> None:
"""The Team half of the write-only bug.
team/_messages.py threads the run input, the guidance block and the
message kwarg through three separate lines, and deleting any of them
left 1,679 tests green while Team recall died. Agents had a guard from
the first review pass; Teams did not.
"""
from agno.db.sqlite import SqliteDb
from agno.learn import LearningMachine
from agno.models.openai import OpenAIResponses
from agno.run.base import RunContext
from agno.run.team import TeamRunOutput
from agno.session import TeamSession
from agno.team._messages import _get_run_messages
from agno.team.team import Team
db = SqliteDb(db_file=str(tmp_path / "team.db"))
machine = LearningMachine(db=db, entity_memory=True)
store = machine.entity_memory_store
assert store is not None
store.remember_about(entity="radar", entity_type="project", facts=["db: Postgres, over Dynamo"])
team = Team(members=[], db=db, learning=machine, model=OpenAIResponses(id="gpt-5.5"))
team._learning = machine
session = TeamSession(session_id="s1")
run_context = RunContext(run_id="r1", session_id="s1", user_id="u1")
run_messages = _get_run_messages(
team,
run_response=TeamRunOutput(run_id="r1", session_id="s1"),
run_context=run_context,
session=session,
input_message="what did we decide for radar?",
)
system = next(m for m in run_messages.messages if m.role == "system")
content = str(system.content)
assert "db: Postgres, over Dynamo" in content
assert "entity_memory_instructions" in content
def test_the_entity_a_query_names_outranks_the_ones_that_mention_it(self, tmp_path) -> None:
"""search_learnings orders by recency and does not rank.
Five newer entities whose facts merely mention "Harbor" filled the
verified window - they all legitimately match - and Harbor's own row,
older, was never fetched. Recall then injected the wrong entities and
search_entities confirmed them.
"""
import time
from agno.db.sqlite import SqliteDb
from agno.learn.config import EntityMemoryConfig, LearningMode
from agno.learn.stores.entity_memory import EntityMemoryStore
store = EntityMemoryStore(
config=EntityMemoryConfig( # type: ignore[arg-type]
db=SqliteDb(db_file=str(tmp_path / "rank.db")), mode=LearningMode.AGENTIC, namespace="global"
)
)
store.remember_about(entity="Harbor", entity_type="project", facts=["db: Postgres, over Dynamo"])
# Rows written in the same second do not order under updated_at DESC.
time.sleep(1.1)
for i in range(8):
store.remember_about(
entity=f"Person {i}", entity_type="person", facts=[f"works on Harbor ingest, shift {i}"]
)
hits = store.search(query="Harbor", namespace="global", limit=5)
assert hits and hits[0].entity_id == "harbor"
recalled = store.recall(message="where does Harbor stand?", namespace="global")
assert recalled is not None
assert "harbor" in [e.entity_id for e in recalled["entities"]]
@pytest.mark.asyncio
async def test_concurrent_writes_in_one_turn_all_land(self, tmp_path) -> None:
"""The write lock, guarded against its own removal.
Every entity write is a read-modify-write over one JSON row, and a
model's tool calls for one turn are gathered concurrently - the path
AgentOS REST and /mcp take. Without the lock this loses 7 of 8 facts on
a real AsyncSqliteDb, and the fake db cannot see it because it aliases
the content dict.
"""
import asyncio
from agno.db.sqlite import AsyncSqliteDb
from agno.learn.config import EntityMemoryConfig, LearningMode
from agno.learn.stores.entity_memory import EntityMemoryStore
db = AsyncSqliteDb(db_file=str(tmp_path / "concurrent.db"))
store = EntityMemoryStore(
config=EntityMemoryConfig(db=db, mode=LearningMode.AGENTIC, namespace="global") # type: ignore[arg-type]
)
await store.aremember_about(entity="radar", entity_type="project", facts=["seed"], namespace="global")
await asyncio.gather(
*[
store.aremember_about(entity="radar", entity_type="project", facts=[f"fact {i}"], namespace="global")
for i in range(8)
]
)
radar = await store.aget(entity_id="radar", entity_type="project", namespace="global")
assert radar is not None
stored = {f["content"] for f in radar.live_facts()}
assert stored == {"seed", *(f"fact {i}" for i in range(8))}
@@ -16,6 +16,7 @@ from agno.agent.agent import Agent
from agno.learn.config import LearnedKnowledgeConfig, LearningMode, UserMemoryConfig, UserProfileConfig
from agno.learn.machine import LearningMachine
from agno.run import RunContext
from agno.run.team import TeamRunOutput
from agno.session import TeamSession
from agno.team._init import _set_learning_machine
from agno.team._messages import aget_system_message, get_system_message
@@ -160,6 +161,11 @@ class TestGetSystemMessageLearningContext:
user_id="user-456",
session_id="sess-123",
team_id="my-team",
message=None,
run_context=run_context,
metadata=None,
dependencies=None,
session_state=None,
)
def test_learning_context_not_added_when_build_context_returns_none(self, mock_db, mock_model, member_agent):
@@ -186,6 +192,11 @@ class TestGetSystemMessageLearningContext:
user_id=None,
session_id="sess-123",
team_id="test-team",
message=None,
run_context=None,
metadata=None,
dependencies=None,
session_state=None,
)
def test_learning_context_forwards_empty_user_id(self, mock_db, mock_model, member_agent):
@@ -206,6 +217,11 @@ class TestGetSystemMessageLearningContext:
user_id="",
session_id="sess-123",
team_id="test-team",
message=None,
run_context=run_context,
metadata=None,
dependencies=None,
session_state=None,
)
def test_custom_system_message_bypasses_learning_context(self, mock_db, mock_model, member_agent):
@@ -302,6 +318,11 @@ class TestAgetSystemMessageLearningContext:
user_id="async-user",
session_id="async-sess",
team_id="async-team",
message=None,
run_context=run_context,
metadata=None,
dependencies=None,
session_state=None,
)
@pytest.mark.asyncio
@@ -345,6 +366,11 @@ class TestAgetSystemMessageLearningContext:
user_id=None,
session_id="async-sess",
team_id="test-team",
message=None,
run_context=None,
metadata=None,
dependencies=None,
session_state=None,
)
@@ -701,3 +727,63 @@ class TestLearningContextFalseyValues:
assert "None" not in msg.content
assert "\n\n\n" not in msg.content
team._learning.abuild_context.assert_awaited_once()
class TestSystemMessageOverrideCompatibility:
"""Team.get_system_message is a public extension point.
The framework calls the BOUND method, so a subclass written against the
pre-2.8.4 signature is what actually runs - passing a new kwarg
unconditionally would fail every run of such a team.
"""
def test_legacy_subclass_override_still_runs(self, mock_db, mock_model, member_agent):
from agno.team._messages import _get_run_messages
from agno.team.team import Team
class BannerTeam(Team):
def get_system_message(
self,
session,
run_context=None,
audio=None,
images=None,
videos=None,
files=None,
tools=None,
add_session_state_to_context=None,
):
message = super().get_system_message(
session=session,
run_context=run_context,
audio=audio,
images=images,
videos=videos,
files=files,
tools=tools,
add_session_state_to_context=add_session_state_to_context,
)
if message is not None:
message.content = "[BANNER]\n" + str(message.content)
return message
team = BannerTeam(db=mock_db, id="t", name="T", members=[member_agent])
team.model = mock_model
run_messages = _get_run_messages(
team,
run_response=TeamRunOutput(run_id="r", session_id="s"),
run_context=RunContext(run_id="r", session_id="s"),
input_message="hello",
session=TeamSession(session_id="s"),
)
assert run_messages.system_message is not None
assert str(run_messages.system_message.content).startswith("[BANNER]")
def test_current_signature_still_receives_the_input(self, mock_db, mock_model, member_agent):
from agno.team._messages import _input_kwarg
from agno.team.team import Team
team = Team(db=mock_db, id="t2", name="T2", members=[member_agent])
assert _input_kwarg(team.get_system_message, "hello") == {"input": "hello"}
assert _input_kwarg(lambda session: None, "hello") == {}
assert _input_kwarg(lambda **kw: None, "hello") == {"input": "hello"}