agentu 2.2.0__tar.gz → 2.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agentu-2.2.0 → agentu-2.3.0}/CHANGELOG.md +14 -0
- {agentu-2.2.0 → agentu-2.3.0}/PKG-INFO +1 -1
- {agentu-2.2.0 → agentu-2.3.0}/pyproject.toml +1 -1
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/_core/agent.py +4 -0
- agentu-2.3.0/src/agentu/_core/agent_memory.py +519 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/memory/memory.py +52 -8
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/workspace.py +16 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu.egg-info/PKG-INFO +1 -1
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu.egg-info/SOURCES.txt +1 -0
- agentu-2.3.0/tests/test_memory_patterns.py +141 -0
- agentu-2.2.0/src/agentu/_core/agent_memory.py +0 -244
- {agentu-2.2.0 → agentu-2.3.0}/.agents/workflows/version-bump.md +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/.github/workflows/python-package.yml +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/.github/workflows/test.yml +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/.gitignore +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/LICENSE +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/README.md +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/docs/ENHANCEMENTS.md +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/docs/index.html +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/examples/01_basics/basic.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/examples/01_basics/code_mode.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/examples/01_basics/memory.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/examples/01_basics/multimodal.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/examples/01_basics/simple_agent.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/examples/01_basics/structured_output.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/examples/02_skills/skills/pdf-processing/FORMS.md +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/examples/02_skills/skills/pdf-processing/SKILL.md +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/examples/02_skills/skills/pdf_utils.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/examples/02_skills/skills_auto_demo.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/examples/02_skills/skills_demo.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/examples/03_workflow/orchestrator.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/examples/03_workflow/ralph_demo.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/examples/03_workflow/workflow.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/examples/04_server/api.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/examples/04_server/example_mcp_sessions.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/examples/04_server/example_sessions.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/examples/04_server/serve_agent.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/examples/05_observe/example_eval.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/examples/05_observe/example_observe.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/examples/05_observe/rationale_demo.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/setup.cfg +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/__init__.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/_core/__init__.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/_core/agent_context.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/_core/agent_hooks.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/_core/agent_sandbox.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/_core/agent_storage.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/_core/agent_workflow.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/_core/config.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/_core/context.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/_core/hooks.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/_core/multimodal.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/_core/safety.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/_core/structured.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/_core/tools.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/_core/utils.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/cache/__init__.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/cache/cache.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/cache/embeddings.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/cache/semantic.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/cache/storage.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/cache/sync.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/cache/tiered.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/discovery.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/eval/__init__.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/eval/eval.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/eval/trajectory.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/mcp/__init__.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/mcp/config.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/mcp/extensions.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/mcp/tool.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/mcp/transport.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/memory/__init__.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/memory/storage.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/middleware/__init__.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/middleware/guardrails.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/middleware/middleware.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/middleware/notify.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/middleware/observe.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/middleware/otel.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/runtime/__init__.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/runtime/checkpoint.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/runtime/cli.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/runtime/redis_backend.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/runtime/sandbox.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/runtime/serve.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/runtime/session.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/runtime/tasks.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/skills/__init__.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/skills/loader.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/skills/search.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/skills/skill.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/static/dashboard.html +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/storage/__init__.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/workflow/__init__.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/workflow/ralph.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/workflow/schedule.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/workflow/subagent.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/workflow/workflow.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu/workflow/worktree.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu.egg-info/dependency_links.txt +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu.egg-info/entry_points.txt +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu.egg-info/requires.txt +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/src/agentu.egg-info/top_level.txt +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/__init__.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/conftest.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_agent.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_agent_skills.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_agents_md.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_auto_skills.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_builders.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_cache.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_cache_embeddings.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_cache_integration.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_cache_semantic.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_cache_storage.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_cache_sync.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_cache_tiered.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_checkpoint.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_codemode.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_config.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_context.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_eval.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_eval_reporting.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_github_skills.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_guardrails.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_harness.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_hooks.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_mcp.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_mcp_extensions.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_mcp_stdio.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_memory.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_memory_semantic.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_memory_storage.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_middleware.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_middleware_notify.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_multi_turn.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_observe.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_otel.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_ralph.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_resume.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_safety.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_sandbox.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_schedule.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_search.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_serve.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_server_scale.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_session.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_skill.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_skill_md.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_storage.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_streaming.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_structured_output.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_subagent.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_tools.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_workflow.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_workspace.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/tests/test_worktree.py +0 -0
- {agentu-2.2.0 → agentu-2.3.0}/uv.lock +0 -0
|
@@ -4,6 +4,20 @@ All notable changes to agentu will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
## [2.3.0] - 2026-07-18
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Always-on memory patterns** (inspired by [Google's always-on-memory-agent](https://github.com/GoogleCloudPlatform/generative-ai/tree/main/gemini/agents/always-on-memory-agent)):
|
|
12
|
+
- **Structured memory extraction** — `MemoryEntry` now carries `summary`, `entities`, `topics`, `source`, and `consolidated` fields. The LLM auto-extracts these on every `remember()` call by default. No manual tagging needed.
|
|
13
|
+
- **Background consolidation** — `agent.with_consolidation(every=30)` runs a timer that reviews unconsolidated memories, finds cross-cutting patterns, and stores synthesized insights. Like the brain during sleep.
|
|
14
|
+
- **Inbox file watcher** — `agent.with_inbox("./inbox")` polls a directory for new files, processes them via the agent, and moves them to `.processed/`. Workspace-configurable via `inbox.watch` in `agent.yaml`.
|
|
15
|
+
- Auto-extraction skips `conversation`-type memories (chat turns don't need it)
|
|
16
|
+
- Disable with `Agent(..., auto_extract_memory=False)` if you want raw storage
|
|
17
|
+
- `consolidate_memories` tool injected automatically when consolidation is enabled
|
|
18
|
+
- Workspace support: `inbox.watch`, `inbox.poll_interval` in `agent.yaml`
|
|
19
|
+
- 14 new tests across 3 test classes
|
|
20
|
+
|
|
7
21
|
|
|
8
22
|
## [2.2.0] - 2026-07-16
|
|
9
23
|
|
|
@@ -355,6 +355,10 @@ class Agent(MemoryMixin, SandboxMixin, HooksMixin, ContextMixin, WorkflowMixin,
|
|
|
355
355
|
if config.permissions:
|
|
356
356
|
agent.with_permissions(**config.permissions)
|
|
357
357
|
|
|
358
|
+
# Apply inbox
|
|
359
|
+
if config.inbox_path:
|
|
360
|
+
agent.with_inbox(config.inbox_path, config.inbox_poll_interval)
|
|
361
|
+
|
|
358
362
|
return agent
|
|
359
363
|
|
|
360
364
|
def _add_tool_internal(self, tool: Union[Tool, Callable], deferred: bool = False) -> Tool:
|
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
"""MemoryMixin – memory-related methods extracted from Agent."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Dict, Any, Optional, List, TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .agent import Agent
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MemoryMixin:
|
|
14
|
+
"""Mixin providing memory operations for the Agent class.
|
|
15
|
+
|
|
16
|
+
Methods here assume they are mixed into an Agent instance that has
|
|
17
|
+
``memory_enabled``, ``memory``, and ``observer`` attributes.
|
|
18
|
+
|
|
19
|
+
When the agent has a ``_vector_backend`` configured (via
|
|
20
|
+
``with_vectors()``), ``remember()`` and ``recall(semantic=True)``
|
|
21
|
+
will use it for embedding-based storage and retrieval.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def _get_vector_backend_sync(self):
|
|
25
|
+
"""Resolve vector backend, lazily creating from DSN if needed.
|
|
26
|
+
|
|
27
|
+
Returns the backend or None if not configured.
|
|
28
|
+
LanceDBBackend.create() is synchronous, so this works in any
|
|
29
|
+
context (sync or async).
|
|
30
|
+
"""
|
|
31
|
+
# Direct backend (set via with_vectors(backend_obj))
|
|
32
|
+
backend = getattr(self, '_vector_backend', None)
|
|
33
|
+
if backend is not None:
|
|
34
|
+
return backend
|
|
35
|
+
# DSN configured but not yet created (with_vectors("./path"))
|
|
36
|
+
dsn = getattr(self, '_vector_dsn', None)
|
|
37
|
+
if dsn is not None:
|
|
38
|
+
try:
|
|
39
|
+
from ..storage import LanceDBBackend
|
|
40
|
+
backend = LanceDBBackend.create(dsn)
|
|
41
|
+
# Cache so we don't re-create on every call
|
|
42
|
+
self._vector_backend = backend
|
|
43
|
+
return backend
|
|
44
|
+
except Exception:
|
|
45
|
+
logger.debug("Failed to lazily create vector backend", exc_info=True)
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
def remember(self, content: str, memory_type: str = 'conversation',
|
|
49
|
+
metadata: Optional[Dict[str, Any]] = None, importance: float = 0.5,
|
|
50
|
+
store_long_term: bool = False, summary: Optional[str] = None,
|
|
51
|
+
entities: Optional[List[str]] = None, topics: Optional[List[str]] = None,
|
|
52
|
+
source: Optional[str] = None) -> None:
|
|
53
|
+
"""Store information in memory.
|
|
54
|
+
|
|
55
|
+
The LLM automatically extracts summary, entities, topics, and
|
|
56
|
+
importance from the content. Just pass raw text — the LLM does
|
|
57
|
+
the rest. If you provide structured fields manually, the LLM
|
|
58
|
+
call is skipped.
|
|
59
|
+
|
|
60
|
+
Auto-extraction is skipped for ``memory_type='conversation'``
|
|
61
|
+
(chat turns don't need it) and can be disabled globally with
|
|
62
|
+
``Agent(..., auto_extract_memory=False)``.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
content: The content to remember
|
|
66
|
+
memory_type: Type of memory ('conversation', 'fact', 'task', 'observation')
|
|
67
|
+
metadata: Additional metadata
|
|
68
|
+
importance: Importance score (0.0 to 1.0)
|
|
69
|
+
store_long_term: If True, store directly in long-term memory
|
|
70
|
+
summary: Short summary (auto-extracted by default)
|
|
71
|
+
entities: Key entities (auto-extracted by default)
|
|
72
|
+
topics: Topic tags (auto-extracted by default)
|
|
73
|
+
source: Optional source identifier
|
|
74
|
+
"""
|
|
75
|
+
if not self.memory_enabled:
|
|
76
|
+
logger.warning("Memory is not enabled for this agent")
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
# Auto-extract structured metadata via LLM when:
|
|
80
|
+
# 1. No structured fields were provided manually
|
|
81
|
+
# 2. Not a conversation turn (those are just chat history)
|
|
82
|
+
# 3. auto_extract_memory is not disabled (defaults to True)
|
|
83
|
+
auto_extract = getattr(self, 'auto_extract_memory', True)
|
|
84
|
+
needs_extraction = (
|
|
85
|
+
auto_extract
|
|
86
|
+
and not summary and not entities and not topics
|
|
87
|
+
and memory_type != 'conversation'
|
|
88
|
+
)
|
|
89
|
+
if needs_extraction:
|
|
90
|
+
try:
|
|
91
|
+
loop = asyncio.get_running_loop()
|
|
92
|
+
loop.create_task(self._remember_with_extraction(
|
|
93
|
+
content, memory_type, metadata, importance,
|
|
94
|
+
store_long_term, source,
|
|
95
|
+
))
|
|
96
|
+
return # async path handles storage
|
|
97
|
+
except RuntimeError:
|
|
98
|
+
# No event loop — try sync extraction
|
|
99
|
+
try:
|
|
100
|
+
extracted = asyncio.run(self._extract_memory_metadata(content))
|
|
101
|
+
summary = extracted.get('summary', summary)
|
|
102
|
+
entities = extracted.get('entities', entities)
|
|
103
|
+
topics = extracted.get('topics', topics)
|
|
104
|
+
importance = extracted.get('importance', importance)
|
|
105
|
+
except Exception:
|
|
106
|
+
logger.debug("LLM extraction failed, storing without metadata", exc_info=True)
|
|
107
|
+
|
|
108
|
+
# Store in standard memory system
|
|
109
|
+
entry = self.memory.remember(
|
|
110
|
+
content=content,
|
|
111
|
+
memory_type=memory_type,
|
|
112
|
+
metadata=metadata,
|
|
113
|
+
importance=importance,
|
|
114
|
+
store_long_term=store_long_term,
|
|
115
|
+
summary=summary,
|
|
116
|
+
entities=entities,
|
|
117
|
+
topics=topics,
|
|
118
|
+
source=source
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Also store in vector backend for semantic search if available
|
|
122
|
+
vector_backend = self._get_vector_backend_sync()
|
|
123
|
+
if vector_backend is not None and (store_long_term or importance >= 0.7):
|
|
124
|
+
self._store_to_vector_backend(content, entry, memory_type, metadata)
|
|
125
|
+
|
|
126
|
+
async def _extract_memory_metadata(self, content: str) -> Dict[str, Any]:
|
|
127
|
+
"""Use the LLM to extract structured metadata from content.
|
|
128
|
+
|
|
129
|
+
Returns a dict with ``summary``, ``entities``, ``topics``,
|
|
130
|
+
and ``importance``.
|
|
131
|
+
"""
|
|
132
|
+
import json as _json
|
|
133
|
+
|
|
134
|
+
extraction_prompt = (
|
|
135
|
+
"Extract structured metadata from this text. "
|
|
136
|
+
"Return ONLY valid JSON with these fields:\n"
|
|
137
|
+
'{"summary": "1-2 sentence summary", '
|
|
138
|
+
'"entities": ["people", "orgs", "products", "concepts"], '
|
|
139
|
+
'"topics": ["2-4 topic tags"], '
|
|
140
|
+
'"importance": 0.0 to 1.0}\n\n'
|
|
141
|
+
f"Text:\n{content[:3000]}"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
raw = await self._call_llm(extraction_prompt)
|
|
146
|
+
# Parse JSON from the response — handle markdown fences
|
|
147
|
+
text = raw.strip()
|
|
148
|
+
if text.startswith('```'):
|
|
149
|
+
text = text.split('\n', 1)[1] if '\n' in text else text[3:]
|
|
150
|
+
text = text.rsplit('```', 1)[0]
|
|
151
|
+
result = _json.loads(text.strip())
|
|
152
|
+
return {
|
|
153
|
+
'summary': result.get('summary'),
|
|
154
|
+
'entities': result.get('entities', []),
|
|
155
|
+
'topics': result.get('topics', []),
|
|
156
|
+
'importance': float(result.get('importance', 0.5)),
|
|
157
|
+
}
|
|
158
|
+
except Exception:
|
|
159
|
+
logger.debug("Failed to parse LLM extraction response", exc_info=True)
|
|
160
|
+
return {}
|
|
161
|
+
|
|
162
|
+
async def _remember_with_extraction(
|
|
163
|
+
self, content: str, memory_type: str,
|
|
164
|
+
metadata: Optional[Dict[str, Any]], importance: float,
|
|
165
|
+
store_long_term: bool, source: Optional[str],
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Async path: extract metadata then store."""
|
|
168
|
+
try:
|
|
169
|
+
extracted = await self._extract_memory_metadata(content)
|
|
170
|
+
except Exception:
|
|
171
|
+
logger.debug("LLM extraction failed, storing without metadata", exc_info=True)
|
|
172
|
+
extracted = {}
|
|
173
|
+
|
|
174
|
+
entry = self.memory.remember(
|
|
175
|
+
content=content,
|
|
176
|
+
memory_type=memory_type,
|
|
177
|
+
metadata=metadata,
|
|
178
|
+
importance=extracted.get('importance', importance),
|
|
179
|
+
store_long_term=store_long_term,
|
|
180
|
+
summary=extracted.get('summary'),
|
|
181
|
+
entities=extracted.get('entities'),
|
|
182
|
+
topics=extracted.get('topics'),
|
|
183
|
+
source=source,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
vector_backend = self._get_vector_backend_sync()
|
|
187
|
+
if vector_backend is not None and (store_long_term or importance >= 0.7):
|
|
188
|
+
self._store_to_vector_backend(content, entry, memory_type, metadata)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _store_to_vector_backend(self, content: str, entry: Any,
|
|
192
|
+
memory_type: str,
|
|
193
|
+
metadata: Optional[Dict[str, Any]]) -> None:
|
|
194
|
+
"""Store a memory entry in the vector backend (fire-and-forget)."""
|
|
195
|
+
embedding_provider = getattr(self.memory, 'embedding_provider', None)
|
|
196
|
+
if embedding_provider is None:
|
|
197
|
+
logger.debug("No embedding provider for vector backend storage")
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
# Resolve backend once (already cached by _get_vector_backend_sync)
|
|
201
|
+
backend = self._get_vector_backend_sync()
|
|
202
|
+
if backend is None:
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
async def _store():
|
|
206
|
+
try:
|
|
207
|
+
embedding = await embedding_provider.embed(content)
|
|
208
|
+
meta = dict(metadata or {})
|
|
209
|
+
meta['memory_type'] = memory_type
|
|
210
|
+
meta['content'] = content
|
|
211
|
+
key = f"mem:{getattr(entry, 'id', id(entry))}"
|
|
212
|
+
await backend.store(key, embedding, meta)
|
|
213
|
+
except Exception:
|
|
214
|
+
logger.debug("Vector backend store failed", exc_info=True)
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
loop = asyncio.get_running_loop()
|
|
218
|
+
loop.create_task(_store())
|
|
219
|
+
except RuntimeError:
|
|
220
|
+
asyncio.run(_store())
|
|
221
|
+
|
|
222
|
+
def recall(self, query: Optional[str] = None, memory_type: Optional[str] = None,
|
|
223
|
+
limit: int = 5, include_short_term: bool = True,
|
|
224
|
+
semantic: bool = False, semantic_threshold: float = 0.0) -> list:
|
|
225
|
+
"""Recall memories.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
query: Search query (if None, returns recent memories)
|
|
229
|
+
memory_type: Filter by memory type
|
|
230
|
+
limit: Maximum number of results
|
|
231
|
+
include_short_term: Whether to include short-term memories
|
|
232
|
+
semantic: If True, use embedding-based similarity search
|
|
233
|
+
instead of substring matching (requires embedding provider
|
|
234
|
+
or a vector backend configured via ``with_vectors()``)
|
|
235
|
+
semantic_threshold: Minimum similarity score for semantic recall
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
List of MemoryEntry objects
|
|
239
|
+
"""
|
|
240
|
+
if not self.memory_enabled:
|
|
241
|
+
logger.warning("Memory is not enabled for this agent")
|
|
242
|
+
return []
|
|
243
|
+
|
|
244
|
+
# Try vector backend for semantic recall if available and
|
|
245
|
+
# the in-memory semantic index isn't ready
|
|
246
|
+
vector_backend = self._get_vector_backend_sync()
|
|
247
|
+
if (semantic and query and vector_backend is not None
|
|
248
|
+
and not getattr(self.memory.long_term, '_semantic_ready', False)):
|
|
249
|
+
vector_results = self._recall_from_vector_backend(
|
|
250
|
+
query, limit, semantic_threshold
|
|
251
|
+
)
|
|
252
|
+
if vector_results:
|
|
253
|
+
return vector_results
|
|
254
|
+
|
|
255
|
+
return self.memory.recall(
|
|
256
|
+
query, memory_type, limit, include_short_term,
|
|
257
|
+
semantic=semantic, semantic_threshold=semantic_threshold,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
def _recall_from_vector_backend(self, query: str, limit: int,
|
|
261
|
+
threshold: float) -> list:
|
|
262
|
+
"""Search the vector backend for semantically similar memories."""
|
|
263
|
+
embedding_provider = getattr(self.memory, 'embedding_provider', None)
|
|
264
|
+
if embedding_provider is None:
|
|
265
|
+
return []
|
|
266
|
+
|
|
267
|
+
# Resolve backend once (already cached)
|
|
268
|
+
backend = self._get_vector_backend_sync()
|
|
269
|
+
if backend is None:
|
|
270
|
+
return []
|
|
271
|
+
|
|
272
|
+
async def _search():
|
|
273
|
+
query_embedding = await embedding_provider.embed(query)
|
|
274
|
+
results = await backend.search(
|
|
275
|
+
query_embedding, limit=limit
|
|
276
|
+
)
|
|
277
|
+
return results
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
loop = asyncio.get_running_loop()
|
|
281
|
+
except RuntimeError:
|
|
282
|
+
loop = None
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
if loop and loop.is_running():
|
|
286
|
+
import concurrent.futures
|
|
287
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
288
|
+
results = pool.submit(asyncio.run, _search()).result()
|
|
289
|
+
else:
|
|
290
|
+
results = asyncio.run(_search())
|
|
291
|
+
except Exception:
|
|
292
|
+
logger.debug("Vector backend search failed", exc_info=True)
|
|
293
|
+
return []
|
|
294
|
+
|
|
295
|
+
# Convert vector backend results to MemoryEntry-like objects
|
|
296
|
+
from ..memory.memory import MemoryEntry
|
|
297
|
+
entries = []
|
|
298
|
+
for key, score, meta in results:
|
|
299
|
+
if score < threshold:
|
|
300
|
+
continue
|
|
301
|
+
content = meta.get('content', '')
|
|
302
|
+
entry = MemoryEntry(
|
|
303
|
+
content=content,
|
|
304
|
+
memory_type=meta.get('memory_type', 'fact'),
|
|
305
|
+
metadata=meta,
|
|
306
|
+
importance=score,
|
|
307
|
+
)
|
|
308
|
+
entries.append(entry)
|
|
309
|
+
return entries
|
|
310
|
+
|
|
311
|
+
def get_memory_context(self, max_entries: int = 5) -> str:
|
|
312
|
+
"""Get formatted context from memories.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
max_entries: Maximum number of memory entries to include
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
Formatted string with memory context
|
|
319
|
+
"""
|
|
320
|
+
if not self.memory_enabled:
|
|
321
|
+
return ""
|
|
322
|
+
|
|
323
|
+
return self.memory.get_context(max_entries)
|
|
324
|
+
|
|
325
|
+
def consolidate_memory(self, importance_threshold: float = 0.6) -> None:
|
|
326
|
+
"""Consolidate short-term memories to long-term storage.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
importance_threshold: Minimum importance to consolidate
|
|
330
|
+
"""
|
|
331
|
+
if not self.memory_enabled:
|
|
332
|
+
logger.warning("Memory is not enabled for this agent")
|
|
333
|
+
return
|
|
334
|
+
|
|
335
|
+
self.memory.consolidate_to_long_term(importance_threshold)
|
|
336
|
+
|
|
337
|
+
def clear_short_term_memory(self) -> None:
|
|
338
|
+
"""Clear short-term memory."""
|
|
339
|
+
if not self.memory_enabled:
|
|
340
|
+
logger.warning("Memory is not enabled for this agent")
|
|
341
|
+
return
|
|
342
|
+
|
|
343
|
+
self.memory.clear_short_term()
|
|
344
|
+
|
|
345
|
+
def save_memory(self) -> None:
|
|
346
|
+
"""Save memory to persistent storage."""
|
|
347
|
+
if not self.memory_enabled:
|
|
348
|
+
logger.warning("Memory is not enabled for this agent")
|
|
349
|
+
return
|
|
350
|
+
|
|
351
|
+
self.memory.save()
|
|
352
|
+
|
|
353
|
+
def get_memory_stats(self) -> Dict[str, Any]:
|
|
354
|
+
"""Get memory statistics.
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
Dictionary with memory stats
|
|
358
|
+
"""
|
|
359
|
+
if not self.memory_enabled:
|
|
360
|
+
return {'memory_enabled': False}
|
|
361
|
+
|
|
362
|
+
stats = self.memory.stats()
|
|
363
|
+
stats['memory_enabled'] = True
|
|
364
|
+
return stats
|
|
365
|
+
|
|
366
|
+
def with_consolidation(self, every: int = 30) -> 'Agent':
|
|
367
|
+
"""Enable background memory consolidation.
|
|
368
|
+
|
|
369
|
+
Runs on a timer, reviewing unconsolidated memories, finding
|
|
370
|
+
connections and patterns, and generating synthesized insights.
|
|
371
|
+
Like the human brain during sleep.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
every: Minutes between consolidation runs (default: 30)
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
Self for chaining.
|
|
378
|
+
"""
|
|
379
|
+
consolidation_prompt = (
|
|
380
|
+
"Review all unconsolidated memories. For each group of related memories:\n"
|
|
381
|
+
"1. Find connections and patterns between them\n"
|
|
382
|
+
"2. Generate a synthesized insight\n"
|
|
383
|
+
"3. Use the consolidate_memories tool to store your findings\n"
|
|
384
|
+
"Focus on cross-cutting themes that connect different memories."
|
|
385
|
+
)
|
|
386
|
+
self.with_schedule(every=every, prompt=consolidation_prompt)
|
|
387
|
+
|
|
388
|
+
# Add the consolidation tool
|
|
389
|
+
from .tools import Tool
|
|
390
|
+
|
|
391
|
+
def consolidate_memories(
|
|
392
|
+
insight: str,
|
|
393
|
+
related_topics: list,
|
|
394
|
+
source_summaries: list,
|
|
395
|
+
) -> dict:
|
|
396
|
+
"""Store a consolidation insight from reviewing memories.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
insight: The cross-cutting pattern or insight discovered.
|
|
400
|
+
related_topics: Topics that connect the consolidated memories.
|
|
401
|
+
source_summaries: Summaries of the memories that were consolidated.
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
Confirmation of stored consolidation.
|
|
405
|
+
"""
|
|
406
|
+
# Store the insight as a high-importance memory
|
|
407
|
+
self.remember(
|
|
408
|
+
content=insight,
|
|
409
|
+
memory_type='consolidation',
|
|
410
|
+
importance=0.9,
|
|
411
|
+
store_long_term=True,
|
|
412
|
+
summary=insight,
|
|
413
|
+
topics=related_topics,
|
|
414
|
+
source='consolidation',
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
# Mark source memories as consolidated
|
|
418
|
+
if self.memory:
|
|
419
|
+
for entry in self.memory.short_term.entries:
|
|
420
|
+
if not entry.consolidated and entry.summary in source_summaries:
|
|
421
|
+
entry.consolidated = True
|
|
422
|
+
for entry in self.memory.long_term.entries:
|
|
423
|
+
if not entry.consolidated and entry.summary in source_summaries:
|
|
424
|
+
entry.consolidated = True
|
|
425
|
+
|
|
426
|
+
return {
|
|
427
|
+
"status": "consolidated",
|
|
428
|
+
"insight": insight,
|
|
429
|
+
"topics": related_topics,
|
|
430
|
+
"sources_processed": len(source_summaries),
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
self._add_tool_internal(Tool(consolidate_memories))
|
|
434
|
+
return self
|
|
435
|
+
|
|
436
|
+
def with_inbox(self, inbox_path: str = './inbox', poll_interval: int = 5) -> 'Agent':
|
|
437
|
+
"""Watch a directory for new files and ingest them as memories.
|
|
438
|
+
|
|
439
|
+
Dropped files are processed by the agent and stored as memories.
|
|
440
|
+
After processing, files are moved to a .processed/ subdirectory.
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
inbox_path: Directory to watch for new files.
|
|
444
|
+
poll_interval: Seconds between directory polls.
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
Self for chaining.
|
|
448
|
+
"""
|
|
449
|
+
import os
|
|
450
|
+
self._inbox_path = os.path.abspath(inbox_path)
|
|
451
|
+
self._inbox_poll_interval = poll_interval
|
|
452
|
+
self._inbox_processed = set() # Track processed files
|
|
453
|
+
return self
|
|
454
|
+
|
|
455
|
+
async def _poll_inbox(self) -> None:
|
|
456
|
+
"""Poll the inbox directory for new files (called by the event loop)."""
|
|
457
|
+
import os
|
|
458
|
+
inbox = getattr(self, '_inbox_path', None)
|
|
459
|
+
if not inbox or not os.path.isdir(inbox):
|
|
460
|
+
return
|
|
461
|
+
|
|
462
|
+
processed_dir = os.path.join(inbox, '.processed')
|
|
463
|
+
os.makedirs(processed_dir, exist_ok=True)
|
|
464
|
+
|
|
465
|
+
processed = getattr(self, '_inbox_processed', set())
|
|
466
|
+
|
|
467
|
+
for filename in os.listdir(inbox):
|
|
468
|
+
filepath = os.path.join(inbox, filename)
|
|
469
|
+
|
|
470
|
+
# Skip directories, hidden files, already processed
|
|
471
|
+
if (os.path.isdir(filepath) or filename.startswith('.')
|
|
472
|
+
or filepath in processed):
|
|
473
|
+
continue
|
|
474
|
+
|
|
475
|
+
try:
|
|
476
|
+
# Read file content
|
|
477
|
+
with open(filepath, 'r', errors='replace') as f:
|
|
478
|
+
content = f.read()
|
|
479
|
+
|
|
480
|
+
if not content.strip():
|
|
481
|
+
continue
|
|
482
|
+
|
|
483
|
+
# Ingest via the agent
|
|
484
|
+
logger.info(f"Inbox: processing {filename}")
|
|
485
|
+
await self.infer(
|
|
486
|
+
f"Process and remember this information from file '{filename}':\n\n{content[:4000]}"
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
# Move to processed
|
|
490
|
+
import shutil
|
|
491
|
+
dest = os.path.join(processed_dir, filename)
|
|
492
|
+
shutil.move(filepath, dest)
|
|
493
|
+
processed.add(filepath)
|
|
494
|
+
logger.info(f"Inbox: processed {filename} → .processed/")
|
|
495
|
+
|
|
496
|
+
except Exception as e:
|
|
497
|
+
logger.error(f"Inbox: failed to process {filename}: {e}")
|
|
498
|
+
processed.add(filepath) # Don't retry on error
|
|
499
|
+
|
|
500
|
+
async def start_inbox(self) -> None:
|
|
501
|
+
"""Start the inbox file watcher as a background task."""
|
|
502
|
+
import asyncio
|
|
503
|
+
interval = getattr(self, '_inbox_poll_interval', 5)
|
|
504
|
+
|
|
505
|
+
async def _watch_loop():
|
|
506
|
+
while True:
|
|
507
|
+
await self._poll_inbox()
|
|
508
|
+
await asyncio.sleep(interval)
|
|
509
|
+
|
|
510
|
+
self._inbox_task = asyncio.create_task(_watch_loop())
|
|
511
|
+
logger.info(f"Inbox watcher started: {self._inbox_path}")
|
|
512
|
+
|
|
513
|
+
def stop_inbox(self) -> None:
|
|
514
|
+
"""Stop the inbox file watcher."""
|
|
515
|
+
task = getattr(self, '_inbox_task', None)
|
|
516
|
+
if task:
|
|
517
|
+
task.cancel()
|
|
518
|
+
self._inbox_task = None
|
|
519
|
+
logger.info("Inbox watcher stopped")
|