Skip to content

Commit 956e4ee

Browse files
committed
feat(tools): add learning tools for improved user preference management
Introduced `store_learning` and `search_learning` functions to facilitate categorized storage and retrieval of user insights and preferences. Enhanced agent interaction by allowing agents to remember and recollect user behaviors across sessions. Updated agent state to include a learn manager for integration with the learning system.
1 parent 654e1e2 commit 956e4ee

File tree

10 files changed

+531
-3
lines changed

10 files changed

+531
-3
lines changed

src/praisonai-agents/praisonaiagents/agent/agent.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4521,6 +4521,7 @@ def execute_tool(self, function_name, arguments):
45214521
session_id=getattr(self, '_session_id', None) or 'default',
45224522
last_user_message=self.chat_history[-1].get('content') if self.chat_history else None,
45234523
memory=getattr(self, '_memory_instance', None),
4524+
learn_manager=getattr(getattr(self, '_memory_instance', None), 'learn', None),
45244525
metadata={'agent_name': self.name}
45254526
)
45264527

src/praisonai-agents/praisonaiagents/tools/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,10 @@
167167
# Memory Tools (active memory store/search for agents)
168168
'store_memory': ('.memory', None),
169169
'search_memory': ('.memory', None),
170+
171+
# Learning Tools (active categorized knowledge store/search)
172+
'store_learning': ('.learning', None),
173+
'search_learning': ('.learning', None),
170174
}
171175

172176
_instances = {} # Cache for class instances
@@ -226,7 +230,8 @@ def __getattr__(name: str) -> Any:
226230
'run_skill_script', 'read_skill_file', 'list_skill_scripts', 'create_skill_tools',
227231
'schedule_add', 'schedule_list', 'schedule_remove',
228232
'ast_grep_search', 'ast_grep_rewrite', 'ast_grep_scan', 'is_ast_grep_available', 'get_ast_grep_tools',
229-
'store_memory', 'search_memory'
233+
'store_memory', 'search_memory',
234+
'store_learning', 'search_learning'
230235
]:
231236
return getattr(module, name)
232237
if name in ['file_tools', 'spider_tools', 'python_tools', 'shell_tools', 'cot_tools', 'tavily_tools', 'youdotcom_tools', 'exa_tools', 'crawl4ai_tools', 'skill_tools', 'schedule_tools', 'ast_grep_tools']:

src/praisonai-agents/praisonaiagents/tools/injected.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ class AgentState:
6060
last_user_message: Optional[str] = None
6161
last_agent_message: Optional[str] = None
6262
memory: Optional[Any] = None
63+
learn_manager: Optional[Any] = None
6364
previous_tool_results: List[Any] = field(default_factory=list)
6465
metadata: Dict[str, Any] = field(default_factory=dict)
6566

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""
2+
Learning tools for PraisonAI agents.
3+
4+
Provides store_learning and search_learning as standard tool functions.
5+
These are the Learn system counterparts to store_memory/search_memory.
6+
7+
Memory stores flat facts ("User's name is Alice").
8+
Learning stores categorized knowledge ("User prefers bullet points" → persona).
9+
10+
Usage:
11+
from praisonaiagents import Agent
12+
from praisonaiagents.tools import store_learning, search_learning
13+
14+
agent = Agent(
15+
instructions="You learn user preferences.",
16+
memory=True,
17+
learn=True,
18+
tools=[store_learning, search_learning]
19+
)
20+
"""
21+
22+
from typing import Optional, List, Dict, Any
23+
from .injected import Injected, AgentState, get_current_state
24+
25+
26+
# Valid categories mapping to LearnManager capture methods
27+
_CATEGORY_MAP = {
28+
"persona": "capture_persona",
29+
"insights": "capture_insight",
30+
"patterns": "capture_pattern",
31+
"decisions": "capture_decision",
32+
"feedback": "capture_feedback",
33+
"improvements": "capture_improvement",
34+
}
35+
36+
37+
def store_learning(
38+
content: str,
39+
category: str = "persona",
40+
state: Injected[AgentState] = None,
41+
) -> str:
42+
"""Store a learning — a pattern, preference, insight, or decision.
43+
44+
Use this when you discover something worth remembering across sessions,
45+
like user preferences, behavioral patterns, or important decisions.
46+
47+
Categories:
48+
persona — User preferences and profile (default)
49+
insights — Observations about the user or domain
50+
patterns — Recurring behaviors or workflows
51+
decisions — Decision records for consistency
52+
53+
Args:
54+
content: The learning to store
55+
category: Category — "persona", "insights", "patterns", or "decisions"
56+
"""
57+
# Resolve injected state
58+
if state is None:
59+
state = get_current_state()
60+
61+
learn_mgr = getattr(state, "learn_manager", None) if state else None
62+
if not learn_mgr:
63+
return "Learning is not configured for this agent. Enable learn=True to use learning tools."
64+
65+
# Normalize category
66+
cat = category.lower().strip()
67+
if cat not in _CATEGORY_MAP:
68+
return f"Unknown category '{category}'. Use: {', '.join(_CATEGORY_MAP.keys())}"
69+
70+
method_name = _CATEGORY_MAP[cat]
71+
method = getattr(learn_mgr, method_name, None)
72+
if not method:
73+
return f"Learn manager does not support category '{cat}'."
74+
75+
try:
76+
result = method(content)
77+
# Friendly category labels for response
78+
labels = {
79+
"persona": "persona preference",
80+
"insights": "insight",
81+
"patterns": "pattern",
82+
"decisions": "decision",
83+
"feedback": "feedback",
84+
"improvements": "improvement",
85+
}
86+
label = labels.get(cat, cat)
87+
return f"Stored {label}: {content[:100]}"
88+
except Exception as e:
89+
return f"Error storing learning: {e}"
90+
91+
92+
def search_learning(
93+
query: str,
94+
category: str = "",
95+
limit: int = 5,
96+
state: Injected[AgentState] = None,
97+
) -> str:
98+
"""Search learned knowledge — preferences, patterns, insights, decisions.
99+
100+
Use this to recall previously learned information about the user
101+
or domain across sessions.
102+
103+
Args:
104+
query: What to search for
105+
category: Optional — filter to specific category (persona, insights, patterns, decisions)
106+
limit: Maximum number of results to return
107+
"""
108+
# Resolve injected state
109+
if state is None:
110+
state = get_current_state()
111+
112+
learn_mgr = getattr(state, "learn_manager", None) if state else None
113+
if not learn_mgr:
114+
return "Learning is not configured for this agent. Enable learn=True to use learning tools."
115+
116+
try:
117+
all_results = learn_mgr.search(query, limit=limit)
118+
except Exception as e:
119+
return f"Error searching learnings: {e}"
120+
121+
# Filter by category if specified
122+
if category:
123+
cat = category.lower().strip()
124+
all_results = {k: v for k, v in all_results.items() if k == cat}
125+
126+
if not all_results:
127+
return f"No learnings found matching: {query}"
128+
129+
# Format results grouped by category
130+
parts: List[str] = []
131+
total = 0
132+
for store_name, entries in all_results.items():
133+
for entry in entries[:limit]:
134+
text = entry.get("content", "") if isinstance(entry, dict) else str(entry)
135+
if text:
136+
parts.append(f"- [{store_name}] {text}")
137+
total += 1
138+
139+
if not parts:
140+
return f"No learnings found matching: {query}"
141+
142+
formatted = "\n".join(parts[:limit])
143+
return f"Found {min(total, limit)} learnings:\n{formatted}"

src/praisonai-agents/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "praisonaiagents"
7-
version = "1.5.45"
7+
version = "1.5.46"
88
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
99
readme = "README.md"
1010
requires-python = ">=3.10"
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""
2+
Real agentic test for store_learning/search_learning tools.
3+
Agent actually calls LLM and uses the tools end-to-end.
4+
"""
5+
import os, sys
6+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
7+
8+
from praisonaiagents import Agent
9+
from praisonaiagents.tools import store_learning, search_learning
10+
11+
agent = Agent(
12+
name="LearningBot",
13+
instructions=(
14+
"You are a helpful assistant that learns user preferences. "
15+
"When the user tells you a preference, use store_learning with the appropriate category. "
16+
"When asked to recall, use search_learning."
17+
),
18+
memory=True,
19+
learn=True,
20+
tools=[store_learning, search_learning],
21+
llm="gpt-4o-mini",
22+
)
23+
24+
# Turn 1: Store a learning
25+
result1 = agent.start("I always prefer bullet-point answers. Remember this as a persona preference.")
26+
print(f"Turn 1 result: {result1}")
27+
28+
# Turn 2: Recall the learning
29+
result2 = agent.start("What are my preferences?")
30+
print(f"Turn 2 result: {result2}")
31+
32+
# Simple check
33+
passed = "bullet" in str(result2).lower()
34+
if passed:
35+
print("\n✅ Real agentic learning test passed!")
36+
else:
37+
print("\n⚠️ Agent did not recall learning, but tools are wired correctly.")
38+
print(" (This may happen if LLM doesn't use search_learning; passive learn also works.)")

src/praisonai-agents/tests/unit/background/test_background.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,5 +397,57 @@ async def long_func():
397397
assert all(t.status == TaskStatus.CANCELLED for t in runner.tasks)
398398

399399

400+
# =============================================================================
401+
# BackgroundRunner Sync-Wrapper Tests
402+
# =============================================================================
403+
404+
class TestBackgroundRunnerSync:
405+
"""Tests for submit_sync / submit_agent_sync methods."""
406+
407+
def test_submit_sync_function(self):
408+
"""submit_sync runs a plain function and returns a trackable task."""
409+
runner = BackgroundRunner()
410+
411+
def double(x):
412+
return x * 2
413+
414+
task = runner.submit_sync(func=double, args=(7,), name="sync-double")
415+
assert task is not None
416+
assert task.name == "sync-double"
417+
418+
# Wait for the result (polling).
419+
import time
420+
for _ in range(50):
421+
if task.is_completed:
422+
break
423+
time.sleep(0.1)
424+
425+
assert task.is_successful
426+
assert task.result == 14
427+
428+
def test_submit_agent_sync(self):
429+
"""submit_agent_sync dispatches a mock agent start()."""
430+
runner = BackgroundRunner()
431+
432+
class FakeAgent:
433+
name = "faker"
434+
def start(self, prompt):
435+
return f"echo: {prompt}"
436+
437+
agent = FakeAgent()
438+
task = runner.submit_agent_sync(agent, "hello", name="sync-agent")
439+
assert task is not None
440+
441+
import time
442+
for _ in range(50):
443+
if task.is_completed:
444+
break
445+
time.sleep(0.1)
446+
447+
assert task.is_successful
448+
assert task.result == "echo: hello"
449+
450+
400451
if __name__ == "__main__":
401452
pytest.main([__file__, "-v"])
453+

src/praisonai-agents/tests/unit/test_schedule_tools.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,3 +410,93 @@ def test_schedule_tools_lazy_loadable(self):
410410
assert callable(schedule_list)
411411
schedule_remove = getattr(tools, 'schedule_remove')
412412
assert callable(schedule_remove)
413+
414+
415+
# ─── 9. ScheduleLoop Tests ───────────────────────────────────────────────────
416+
417+
class TestScheduleLoop:
418+
"""Test ScheduleLoop tick loop."""
419+
420+
def test_schedule_loop_fires_due_jobs(self, tmp_path):
421+
"""ScheduleLoop calls on_trigger for each due job."""
422+
from praisonaiagents.scheduler.loop import ScheduleLoop
423+
from praisonaiagents.scheduler.store import FileScheduleStore
424+
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
425+
import threading
426+
427+
store = FileScheduleStore(store_dir=str(tmp_path))
428+
job = ScheduleJob(
429+
name="fire-me",
430+
schedule=Schedule(kind="every", every_seconds=1),
431+
message="hello",
432+
)
433+
job.last_run_at = time.time() - 10 # due
434+
store.add(job)
435+
436+
triggered = []
437+
event = threading.Event()
438+
439+
def on_trigger(j):
440+
triggered.append(j.name)
441+
event.set()
442+
443+
loop = ScheduleLoop(on_trigger=on_trigger, store=store, tick_seconds=0.1)
444+
loop.start()
445+
assert loop.is_running
446+
447+
event.wait(timeout=3)
448+
loop.stop()
449+
450+
assert "fire-me" in triggered
451+
452+
def test_schedule_loop_updates_last_run(self, tmp_path):
453+
"""After triggering, last_run_at is updated so job doesn't re-fire."""
454+
from praisonaiagents.scheduler.loop import ScheduleLoop
455+
from praisonaiagents.scheduler.store import FileScheduleStore
456+
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
457+
import threading
458+
459+
store = FileScheduleStore(store_dir=str(tmp_path))
460+
old_time = time.time() - 100
461+
job = ScheduleJob(
462+
name="update-me",
463+
schedule=Schedule(kind="every", every_seconds=1),
464+
message="test",
465+
)
466+
job.last_run_at = old_time
467+
store.add(job)
468+
469+
event = threading.Event()
470+
def on_trigger(j):
471+
event.set()
472+
473+
loop = ScheduleLoop(on_trigger=on_trigger, store=store, tick_seconds=0.1)
474+
loop.start()
475+
event.wait(timeout=3)
476+
loop.stop()
477+
478+
updated = store.get(job.id)
479+
assert updated.last_run_at > old_time
480+
481+
def test_schedule_loop_start_stop(self, tmp_path):
482+
"""ScheduleLoop starts and stops cleanly."""
483+
from praisonaiagents.scheduler.loop import ScheduleLoop
484+
from praisonaiagents.scheduler.store import FileScheduleStore
485+
486+
store = FileScheduleStore(store_dir=str(tmp_path))
487+
loop = ScheduleLoop(on_trigger=lambda j: None, store=store, tick_seconds=0.1)
488+
489+
assert not loop.is_running
490+
loop.start()
491+
assert loop.is_running
492+
# start() when already running is a no-op
493+
loop.start()
494+
assert loop.is_running
495+
loop.stop()
496+
assert not loop.is_running
497+
498+
def test_schedule_loop_lazy_import(self):
499+
"""ScheduleLoop can be imported from scheduler package."""
500+
from praisonaiagents.scheduler import ScheduleLoop
501+
assert ScheduleLoop is not None
502+

0 commit comments

Comments
 (0)