mirror of
https://github.com/agno-agi/agno.git
synced 2026-10-12 02:10:54 +00:00
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:
co-authored by
Claude Fable 5
parent
5908093cad
commit
6aa13e8f85
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() == []
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user