agno 2.3.26__py3-none-any.whl → 2.4.0__py3-none-any.whl

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.
Files changed (128) hide show
  1. agno/agent/__init__.py +4 -0
  2. agno/agent/agent.py +1368 -541
  3. agno/agent/remote.py +13 -0
  4. agno/db/base.py +339 -0
  5. agno/db/postgres/async_postgres.py +116 -12
  6. agno/db/postgres/postgres.py +1229 -25
  7. agno/db/postgres/schemas.py +48 -1
  8. agno/db/sqlite/async_sqlite.py +119 -4
  9. agno/db/sqlite/schemas.py +51 -0
  10. agno/db/sqlite/sqlite.py +1173 -13
  11. agno/db/utils.py +37 -1
  12. agno/knowledge/__init__.py +4 -0
  13. agno/knowledge/chunking/code.py +1 -1
  14. agno/knowledge/chunking/semantic.py +1 -1
  15. agno/knowledge/chunking/strategy.py +4 -0
  16. agno/knowledge/filesystem.py +412 -0
  17. agno/knowledge/knowledge.py +2767 -2254
  18. agno/knowledge/protocol.py +134 -0
  19. agno/knowledge/reader/arxiv_reader.py +2 -2
  20. agno/knowledge/reader/base.py +9 -7
  21. agno/knowledge/reader/csv_reader.py +5 -5
  22. agno/knowledge/reader/docx_reader.py +2 -2
  23. agno/knowledge/reader/field_labeled_csv_reader.py +2 -2
  24. agno/knowledge/reader/firecrawl_reader.py +2 -2
  25. agno/knowledge/reader/json_reader.py +2 -2
  26. agno/knowledge/reader/markdown_reader.py +2 -2
  27. agno/knowledge/reader/pdf_reader.py +5 -4
  28. agno/knowledge/reader/pptx_reader.py +2 -2
  29. agno/knowledge/reader/reader_factory.py +110 -0
  30. agno/knowledge/reader/s3_reader.py +2 -2
  31. agno/knowledge/reader/tavily_reader.py +2 -2
  32. agno/knowledge/reader/text_reader.py +2 -2
  33. agno/knowledge/reader/web_search_reader.py +2 -2
  34. agno/knowledge/reader/website_reader.py +5 -3
  35. agno/knowledge/reader/wikipedia_reader.py +2 -2
  36. agno/knowledge/reader/youtube_reader.py +2 -2
  37. agno/knowledge/utils.py +37 -29
  38. agno/learn/__init__.py +6 -0
  39. agno/learn/machine.py +35 -0
  40. agno/learn/schemas.py +82 -11
  41. agno/learn/stores/__init__.py +3 -0
  42. agno/learn/stores/decision_log.py +1156 -0
  43. agno/learn/stores/learned_knowledge.py +6 -6
  44. agno/models/anthropic/claude.py +24 -0
  45. agno/models/aws/bedrock.py +20 -0
  46. agno/models/base.py +48 -4
  47. agno/models/cohere/chat.py +25 -0
  48. agno/models/google/gemini.py +50 -5
  49. agno/models/litellm/chat.py +38 -0
  50. agno/models/openai/chat.py +7 -0
  51. agno/models/openrouter/openrouter.py +46 -0
  52. agno/models/response.py +16 -0
  53. agno/os/app.py +83 -44
  54. agno/os/middleware/__init__.py +2 -0
  55. agno/os/middleware/trailing_slash.py +27 -0
  56. agno/os/router.py +1 -0
  57. agno/os/routers/agents/router.py +29 -16
  58. agno/os/routers/agents/schema.py +6 -4
  59. agno/os/routers/components/__init__.py +3 -0
  60. agno/os/routers/components/components.py +466 -0
  61. agno/os/routers/evals/schemas.py +4 -3
  62. agno/os/routers/health.py +3 -3
  63. agno/os/routers/knowledge/knowledge.py +3 -3
  64. agno/os/routers/memory/schemas.py +4 -2
  65. agno/os/routers/metrics/metrics.py +9 -11
  66. agno/os/routers/metrics/schemas.py +10 -6
  67. agno/os/routers/registry/__init__.py +3 -0
  68. agno/os/routers/registry/registry.py +337 -0
  69. agno/os/routers/teams/router.py +20 -8
  70. agno/os/routers/teams/schema.py +6 -4
  71. agno/os/routers/traces/traces.py +5 -5
  72. agno/os/routers/workflows/router.py +38 -11
  73. agno/os/routers/workflows/schema.py +1 -1
  74. agno/os/schema.py +92 -26
  75. agno/os/utils.py +84 -19
  76. agno/reasoning/anthropic.py +2 -2
  77. agno/reasoning/azure_ai_foundry.py +2 -2
  78. agno/reasoning/deepseek.py +2 -2
  79. agno/reasoning/default.py +6 -7
  80. agno/reasoning/gemini.py +2 -2
  81. agno/reasoning/helpers.py +6 -7
  82. agno/reasoning/manager.py +4 -10
  83. agno/reasoning/ollama.py +2 -2
  84. agno/reasoning/openai.py +2 -2
  85. agno/reasoning/vertexai.py +2 -2
  86. agno/registry/__init__.py +3 -0
  87. agno/registry/registry.py +68 -0
  88. agno/run/agent.py +57 -0
  89. agno/run/base.py +7 -0
  90. agno/run/team.py +57 -0
  91. agno/skills/agent_skills.py +10 -3
  92. agno/team/__init__.py +3 -1
  93. agno/team/team.py +1145 -326
  94. agno/tools/duckduckgo.py +25 -71
  95. agno/tools/exa.py +0 -21
  96. agno/tools/function.py +35 -83
  97. agno/tools/knowledge.py +9 -4
  98. agno/tools/mem0.py +11 -10
  99. agno/tools/memory.py +47 -46
  100. agno/tools/parallel.py +0 -7
  101. agno/tools/reasoning.py +30 -23
  102. agno/tools/tavily.py +4 -1
  103. agno/tools/websearch.py +93 -0
  104. agno/tools/website.py +1 -1
  105. agno/tools/wikipedia.py +1 -1
  106. agno/tools/workflow.py +48 -47
  107. agno/utils/agent.py +42 -5
  108. agno/utils/events.py +160 -2
  109. agno/utils/print_response/agent.py +0 -31
  110. agno/utils/print_response/team.py +0 -2
  111. agno/utils/print_response/workflow.py +0 -2
  112. agno/utils/team.py +61 -11
  113. agno/vectordb/lancedb/lance_db.py +4 -1
  114. agno/vectordb/mongodb/mongodb.py +1 -1
  115. agno/vectordb/qdrant/qdrant.py +4 -4
  116. agno/workflow/__init__.py +3 -1
  117. agno/workflow/condition.py +0 -21
  118. agno/workflow/loop.py +0 -21
  119. agno/workflow/parallel.py +0 -21
  120. agno/workflow/router.py +0 -21
  121. agno/workflow/step.py +117 -24
  122. agno/workflow/steps.py +0 -21
  123. agno/workflow/workflow.py +427 -63
  124. {agno-2.3.26.dist-info → agno-2.4.0.dist-info}/METADATA +46 -76
  125. {agno-2.3.26.dist-info → agno-2.4.0.dist-info}/RECORD +128 -117
  126. {agno-2.3.26.dist-info → agno-2.4.0.dist-info}/WHEEL +0 -0
  127. {agno-2.3.26.dist-info → agno-2.4.0.dist-info}/licenses/LICENSE +0 -0
  128. {agno-2.3.26.dist-info → agno-2.4.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1156 @@
1
+ """
2
+ Decision Log Store
3
+ ==================
4
+ Storage backend for Decision Log learning type.
5
+
6
+ Records decisions made by agents with reasoning, context, and outcomes.
7
+ Useful for auditing, debugging, and learning from past decisions.
8
+
9
+ Key Features:
10
+ - Log decisions with reasoning and context
11
+ - Record outcomes for feedback loops
12
+ - Search past decisions by type, time range, or content
13
+ - Agent tools for explicit decision logging
14
+
15
+ Scope:
16
+ - Decisions are stored per agent/session
17
+ - Can be queried by agent_id, session_id, or time range
18
+
19
+ Supported Modes:
20
+ - ALWAYS: Automatic extraction of decisions from tool calls
21
+ - AGENTIC: Agent explicitly logs decisions via tools
22
+ """
23
+
24
+ import uuid
25
+ from dataclasses import dataclass, field
26
+ from datetime import datetime, timedelta
27
+ from os import getenv
28
+ from textwrap import dedent
29
+ from typing import Any, Callable, List, Optional, Union
30
+
31
+ from agno.learn.config import DecisionLogConfig, LearningMode
32
+ from agno.learn.schemas import DecisionLog
33
+ from agno.learn.stores.protocol import LearningStore
34
+ from agno.learn.utils import from_dict_safe, to_dict_safe
35
+ from agno.utils.log import (
36
+ log_debug,
37
+ log_warning,
38
+ set_log_level_to_debug,
39
+ set_log_level_to_info,
40
+ )
41
+
42
+ try:
43
+ from agno.db.base import AsyncBaseDb, BaseDb
44
+ from agno.models.message import Message
45
+ except ImportError:
46
+ pass
47
+
48
+
49
+ @dataclass
50
+ class DecisionLogStore(LearningStore):
51
+ """Storage backend for Decision Log learning type.
52
+
53
+ Records and retrieves decisions made by agents. Decisions include
54
+ the choice made, reasoning, context, and optionally the outcome.
55
+
56
+ Args:
57
+ config: DecisionLogConfig with all settings including db and model.
58
+ debug_mode: Enable debug logging.
59
+ """
60
+
61
+ config: DecisionLogConfig = field(default_factory=DecisionLogConfig)
62
+ debug_mode: bool = False
63
+
64
+ # State tracking (internal)
65
+ decisions_updated: bool = field(default=False, init=False)
66
+ _schema: Any = field(default=None, init=False)
67
+
68
+ def __post_init__(self):
69
+ self._schema = self.config.schema or DecisionLog
70
+
71
+ # =========================================================================
72
+ # LearningStore Protocol Implementation
73
+ # =========================================================================
74
+
75
+ @property
76
+ def learning_type(self) -> str:
77
+ """Unique identifier for this learning type."""
78
+ return "decision_log"
79
+
80
+ @property
81
+ def schema(self) -> Any:
82
+ """Schema class used for decisions."""
83
+ return self._schema
84
+
85
+ def recall(
86
+ self,
87
+ agent_id: Optional[str] = None,
88
+ session_id: Optional[str] = None,
89
+ decision_type: Optional[str] = None,
90
+ limit: int = 10,
91
+ days: Optional[int] = None,
92
+ **kwargs,
93
+ ) -> Optional[List[DecisionLog]]:
94
+ """Retrieve recent decisions.
95
+
96
+ Args:
97
+ agent_id: Filter by agent (optional).
98
+ session_id: Filter by session (optional).
99
+ decision_type: Filter by decision type (optional).
100
+ limit: Maximum number of decisions to return.
101
+ days: Only return decisions from last N days.
102
+ **kwargs: Additional context (ignored).
103
+
104
+ Returns:
105
+ List of decisions, or None if none found.
106
+ """
107
+ return self.search(
108
+ agent_id=agent_id,
109
+ session_id=session_id,
110
+ decision_type=decision_type,
111
+ limit=limit,
112
+ days=days,
113
+ )
114
+
115
+ async def arecall(
116
+ self,
117
+ agent_id: Optional[str] = None,
118
+ session_id: Optional[str] = None,
119
+ decision_type: Optional[str] = None,
120
+ limit: int = 10,
121
+ days: Optional[int] = None,
122
+ **kwargs,
123
+ ) -> Optional[List[DecisionLog]]:
124
+ """Async version of recall."""
125
+ return await self.asearch(
126
+ agent_id=agent_id,
127
+ session_id=session_id,
128
+ decision_type=decision_type,
129
+ limit=limit,
130
+ days=days,
131
+ )
132
+
133
+ def process(
134
+ self,
135
+ messages: List[Any],
136
+ agent_id: Optional[str] = None,
137
+ session_id: Optional[str] = None,
138
+ user_id: Optional[str] = None,
139
+ team_id: Optional[str] = None,
140
+ **kwargs,
141
+ ) -> None:
142
+ """Extract decisions from messages (tool calls, etc).
143
+
144
+ In ALWAYS mode, this extracts decisions from tool calls and
145
+ significant response choices. In AGENTIC mode, this is a no-op
146
+ as decisions are logged explicitly via tools.
147
+
148
+ Args:
149
+ messages: Conversation messages to analyze.
150
+ agent_id: Agent context.
151
+ session_id: Session context.
152
+ user_id: User context.
153
+ team_id: Team context.
154
+ **kwargs: Additional context (ignored).
155
+ """
156
+ if self.config.mode != LearningMode.ALWAYS:
157
+ return
158
+
159
+ if not messages:
160
+ return
161
+
162
+ # Extract decisions from tool calls in messages
163
+ self._extract_decisions_from_messages(
164
+ messages=messages,
165
+ agent_id=agent_id,
166
+ session_id=session_id,
167
+ user_id=user_id,
168
+ team_id=team_id,
169
+ )
170
+
171
+ async def aprocess(
172
+ self,
173
+ messages: List[Any],
174
+ agent_id: Optional[str] = None,
175
+ session_id: Optional[str] = None,
176
+ user_id: Optional[str] = None,
177
+ team_id: Optional[str] = None,
178
+ **kwargs,
179
+ ) -> None:
180
+ """Async version of process."""
181
+ if self.config.mode != LearningMode.ALWAYS:
182
+ return
183
+
184
+ if not messages:
185
+ return
186
+
187
+ await self._aextract_decisions_from_messages(
188
+ messages=messages,
189
+ agent_id=agent_id,
190
+ session_id=session_id,
191
+ user_id=user_id,
192
+ team_id=team_id,
193
+ )
194
+
195
+ def build_context(self, data: Any) -> str:
196
+ """Build context for the agent.
197
+
198
+ Formats recent decisions for injection into the agent's system prompt.
199
+
200
+ Args:
201
+ data: List of decisions from recall().
202
+
203
+ Returns:
204
+ Context string to inject into the agent's system prompt.
205
+ """
206
+ if not data:
207
+ if self._should_expose_tools:
208
+ return dedent("""\
209
+ <decision_log>
210
+ No recent decisions logged.
211
+
212
+ Use `log_decision` to record significant decisions with reasoning.
213
+ Use `search_decisions` to find past decisions.
214
+ </decision_log>""")
215
+ return ""
216
+
217
+ decisions = data if isinstance(data, list) else [data]
218
+
219
+ context = "<decision_log>\n"
220
+ context += "Recent decisions:\n\n"
221
+
222
+ for decision in decisions[:5]: # Limit to 5 most recent
223
+ if isinstance(decision, DecisionLog):
224
+ context += f"- **{decision.decision}**\n"
225
+ if decision.reasoning:
226
+ context += f" Reasoning: {decision.reasoning}\n"
227
+ if decision.outcome:
228
+ context += f" Outcome: {decision.outcome}\n"
229
+ context += "\n"
230
+ elif isinstance(decision, dict):
231
+ context += f"- **{decision.get('decision', 'Unknown')}**\n"
232
+ if decision.get("reasoning"):
233
+ context += f" Reasoning: {decision['reasoning']}\n"
234
+ if decision.get("outcome"):
235
+ context += f" Outcome: {decision['outcome']}\n"
236
+ context += "\n"
237
+
238
+ if self._should_expose_tools:
239
+ context += dedent("""
240
+ Use `log_decision` to record new decisions.
241
+ Use `search_decisions` to find past decisions.
242
+ Use `record_outcome` to update a decision with its outcome.
243
+ """)
244
+
245
+ context += "</decision_log>"
246
+
247
+ return context
248
+
249
+ def get_tools(
250
+ self,
251
+ agent_id: Optional[str] = None,
252
+ session_id: Optional[str] = None,
253
+ user_id: Optional[str] = None,
254
+ team_id: Optional[str] = None,
255
+ **kwargs,
256
+ ) -> List[Callable]:
257
+ """Get tools to expose to agent.
258
+
259
+ Args:
260
+ agent_id: Agent context.
261
+ session_id: Session context.
262
+ user_id: User context.
263
+ team_id: Team context.
264
+ **kwargs: Additional context (ignored).
265
+
266
+ Returns:
267
+ List containing decision logging and search tools if enabled.
268
+ """
269
+ if not self._should_expose_tools:
270
+ return []
271
+ return self.get_agent_tools(
272
+ agent_id=agent_id,
273
+ session_id=session_id,
274
+ user_id=user_id,
275
+ team_id=team_id,
276
+ )
277
+
278
+ async def aget_tools(
279
+ self,
280
+ agent_id: Optional[str] = None,
281
+ session_id: Optional[str] = None,
282
+ user_id: Optional[str] = None,
283
+ team_id: Optional[str] = None,
284
+ **kwargs,
285
+ ) -> List[Callable]:
286
+ """Async version of get_tools."""
287
+ if not self._should_expose_tools:
288
+ return []
289
+ return await self.aget_agent_tools(
290
+ agent_id=agent_id,
291
+ session_id=session_id,
292
+ user_id=user_id,
293
+ team_id=team_id,
294
+ )
295
+
296
+ @property
297
+ def was_updated(self) -> bool:
298
+ """Check if decisions were updated in last operation."""
299
+ return self.decisions_updated
300
+
301
+ @property
302
+ def _should_expose_tools(self) -> bool:
303
+ """Check if tools should be exposed to the agent."""
304
+ return self.config.mode == LearningMode.AGENTIC or self.config.enable_agent_tools
305
+
306
+ # =========================================================================
307
+ # Properties
308
+ # =========================================================================
309
+
310
+ @property
311
+ def db(self) -> Optional[Union["BaseDb", "AsyncBaseDb"]]:
312
+ """Database backend."""
313
+ return self.config.db
314
+
315
+ @property
316
+ def model(self):
317
+ """Model for extraction."""
318
+ return self.config.model
319
+
320
+ # =========================================================================
321
+ # Debug/Logging
322
+ # =========================================================================
323
+
324
+ def set_log_level(self):
325
+ """Set log level based on debug_mode or environment variable."""
326
+ if self.debug_mode or getenv("AGNO_DEBUG", "false").lower() == "true":
327
+ self.debug_mode = True
328
+ set_log_level_to_debug()
329
+ else:
330
+ set_log_level_to_info()
331
+
332
+ # =========================================================================
333
+ # Agent Tools
334
+ # =========================================================================
335
+
336
+ def get_agent_tools(
337
+ self,
338
+ agent_id: Optional[str] = None,
339
+ session_id: Optional[str] = None,
340
+ user_id: Optional[str] = None,
341
+ team_id: Optional[str] = None,
342
+ ) -> List[Callable]:
343
+ """Get the tools to expose to the agent."""
344
+ tools = []
345
+
346
+ if self.config.agent_can_save:
347
+ log_decision = self._build_log_decision_tool(
348
+ agent_id=agent_id,
349
+ session_id=session_id,
350
+ user_id=user_id,
351
+ team_id=team_id,
352
+ )
353
+ if log_decision:
354
+ tools.append(log_decision)
355
+
356
+ record_outcome = self._build_record_outcome_tool(
357
+ agent_id=agent_id,
358
+ team_id=team_id,
359
+ )
360
+ if record_outcome:
361
+ tools.append(record_outcome)
362
+
363
+ if self.config.agent_can_search:
364
+ search_decisions = self._build_search_decisions_tool(
365
+ agent_id=agent_id,
366
+ session_id=session_id,
367
+ )
368
+ if search_decisions:
369
+ tools.append(search_decisions)
370
+
371
+ return tools
372
+
373
+ async def aget_agent_tools(
374
+ self,
375
+ agent_id: Optional[str] = None,
376
+ session_id: Optional[str] = None,
377
+ user_id: Optional[str] = None,
378
+ team_id: Optional[str] = None,
379
+ ) -> List[Callable]:
380
+ """Async version of get_agent_tools."""
381
+ tools = []
382
+
383
+ if self.config.agent_can_save:
384
+ log_decision = await self._abuild_log_decision_tool(
385
+ agent_id=agent_id,
386
+ session_id=session_id,
387
+ user_id=user_id,
388
+ team_id=team_id,
389
+ )
390
+ if log_decision:
391
+ tools.append(log_decision)
392
+
393
+ record_outcome = await self._abuild_record_outcome_tool(
394
+ agent_id=agent_id,
395
+ team_id=team_id,
396
+ )
397
+ if record_outcome:
398
+ tools.append(record_outcome)
399
+
400
+ if self.config.agent_can_search:
401
+ search_decisions = await self._abuild_search_decisions_tool(
402
+ agent_id=agent_id,
403
+ session_id=session_id,
404
+ )
405
+ if search_decisions:
406
+ tools.append(search_decisions)
407
+
408
+ return tools
409
+
410
+ def _build_log_decision_tool(
411
+ self,
412
+ agent_id: Optional[str] = None,
413
+ session_id: Optional[str] = None,
414
+ user_id: Optional[str] = None,
415
+ team_id: Optional[str] = None,
416
+ ) -> Optional[Callable]:
417
+ """Build the log_decision tool."""
418
+ store = self
419
+
420
+ def log_decision(
421
+ decision: str,
422
+ reasoning: Optional[str] = None,
423
+ decision_type: Optional[str] = None,
424
+ context: Optional[str] = None,
425
+ alternatives: Optional[str] = None,
426
+ confidence: Optional[float] = None,
427
+ ) -> str:
428
+ """Log a significant decision with reasoning.
429
+
430
+ Use this to record important choices you make, especially:
431
+ - Tool selection decisions
432
+ - Response style choices
433
+ - When you decide to ask for clarification
434
+ - When you choose between different approaches
435
+
436
+ Args:
437
+ decision: What you decided to do.
438
+ reasoning: Why you made this decision.
439
+ decision_type: Category (tool_selection, response_style, clarification, etc).
440
+ context: The situation that required this decision.
441
+ alternatives: Other options you considered (comma-separated).
442
+ confidence: How confident you are (0.0 to 1.0).
443
+
444
+ Returns:
445
+ Confirmation with decision ID.
446
+ """
447
+ try:
448
+ decision_id = f"dec_{uuid.uuid4().hex[:8]}"
449
+ alt_list = [a.strip() for a in alternatives.split(",")] if alternatives else None
450
+
451
+ decision_obj = DecisionLog(
452
+ id=decision_id,
453
+ decision=decision,
454
+ reasoning=reasoning,
455
+ decision_type=decision_type,
456
+ context=context,
457
+ alternatives=alt_list,
458
+ confidence=confidence,
459
+ session_id=session_id,
460
+ user_id=user_id,
461
+ agent_id=agent_id,
462
+ team_id=team_id,
463
+ created_at=datetime.utcnow().isoformat(),
464
+ )
465
+
466
+ store.save(decision=decision_obj)
467
+ log_debug(f"DecisionLogStore: Logged decision {decision_id}")
468
+ return f"Decision logged: {decision_id}"
469
+
470
+ except Exception as e:
471
+ log_warning(f"Error logging decision: {e}")
472
+ return f"Error: {e}"
473
+
474
+ return log_decision
475
+
476
+ async def _abuild_log_decision_tool(
477
+ self,
478
+ agent_id: Optional[str] = None,
479
+ session_id: Optional[str] = None,
480
+ user_id: Optional[str] = None,
481
+ team_id: Optional[str] = None,
482
+ ) -> Optional[Callable]:
483
+ """Async version of _build_log_decision_tool."""
484
+ store = self
485
+
486
+ async def log_decision(
487
+ decision: str,
488
+ reasoning: Optional[str] = None,
489
+ decision_type: Optional[str] = None,
490
+ context: Optional[str] = None,
491
+ alternatives: Optional[str] = None,
492
+ confidence: Optional[float] = None,
493
+ ) -> str:
494
+ """Log a significant decision with reasoning."""
495
+ try:
496
+ decision_id = f"dec_{uuid.uuid4().hex[:8]}"
497
+ alt_list = [a.strip() for a in alternatives.split(",")] if alternatives else None
498
+
499
+ decision_obj = DecisionLog(
500
+ id=decision_id,
501
+ decision=decision,
502
+ reasoning=reasoning,
503
+ decision_type=decision_type,
504
+ context=context,
505
+ alternatives=alt_list,
506
+ confidence=confidence,
507
+ session_id=session_id,
508
+ user_id=user_id,
509
+ agent_id=agent_id,
510
+ team_id=team_id,
511
+ created_at=datetime.utcnow().isoformat(),
512
+ )
513
+
514
+ await store.asave(decision=decision_obj)
515
+ log_debug(f"DecisionLogStore: Logged decision {decision_id}")
516
+ return f"Decision logged: {decision_id}"
517
+
518
+ except Exception as e:
519
+ log_warning(f"Error logging decision: {e}")
520
+ return f"Error: {e}"
521
+
522
+ return log_decision
523
+
524
+ def _build_record_outcome_tool(
525
+ self,
526
+ agent_id: Optional[str] = None,
527
+ team_id: Optional[str] = None,
528
+ ) -> Optional[Callable]:
529
+ """Build the record_outcome tool."""
530
+ store = self
531
+
532
+ def record_outcome(
533
+ decision_id: str,
534
+ outcome: str,
535
+ outcome_quality: Optional[str] = None,
536
+ ) -> str:
537
+ """Record the outcome of a previous decision.
538
+
539
+ Use this to update a decision with what actually happened.
540
+ This helps build feedback loops for learning.
541
+
542
+ Args:
543
+ decision_id: The ID of the decision to update.
544
+ outcome: What happened as a result of the decision.
545
+ outcome_quality: Was it good, bad, or neutral?
546
+
547
+ Returns:
548
+ Confirmation message.
549
+ """
550
+ try:
551
+ success = store.update_outcome(
552
+ decision_id=decision_id,
553
+ outcome=outcome,
554
+ outcome_quality=outcome_quality,
555
+ )
556
+ if success:
557
+ return f"Outcome recorded for decision {decision_id}"
558
+ else:
559
+ return f"Decision {decision_id} not found"
560
+
561
+ except Exception as e:
562
+ log_warning(f"Error recording outcome: {e}")
563
+ return f"Error: {e}"
564
+
565
+ return record_outcome
566
+
567
+ async def _abuild_record_outcome_tool(
568
+ self,
569
+ agent_id: Optional[str] = None,
570
+ team_id: Optional[str] = None,
571
+ ) -> Optional[Callable]:
572
+ """Async version of _build_record_outcome_tool."""
573
+ store = self
574
+
575
+ async def record_outcome(
576
+ decision_id: str,
577
+ outcome: str,
578
+ outcome_quality: Optional[str] = None,
579
+ ) -> str:
580
+ """Record the outcome of a previous decision."""
581
+ try:
582
+ success = await store.aupdate_outcome(
583
+ decision_id=decision_id,
584
+ outcome=outcome,
585
+ outcome_quality=outcome_quality,
586
+ )
587
+ if success:
588
+ return f"Outcome recorded for decision {decision_id}"
589
+ else:
590
+ return f"Decision {decision_id} not found"
591
+
592
+ except Exception as e:
593
+ log_warning(f"Error recording outcome: {e}")
594
+ return f"Error: {e}"
595
+
596
+ return record_outcome
597
+
598
+ def _build_search_decisions_tool(
599
+ self,
600
+ agent_id: Optional[str] = None,
601
+ session_id: Optional[str] = None,
602
+ ) -> Optional[Callable]:
603
+ """Build the search_decisions tool."""
604
+ store = self
605
+
606
+ def search_decisions(
607
+ query: Optional[str] = None,
608
+ decision_type: Optional[str] = None,
609
+ days: Optional[int] = None,
610
+ limit: int = 5,
611
+ ) -> str:
612
+ """Search past decisions.
613
+
614
+ Use this to find relevant past decisions for context.
615
+
616
+ Args:
617
+ query: Text to search for in decisions.
618
+ decision_type: Filter by type (tool_selection, response_style, etc).
619
+ days: Only search last N days.
620
+ limit: Maximum results to return.
621
+
622
+ Returns:
623
+ Formatted list of matching decisions.
624
+ """
625
+ try:
626
+ results = store.search(
627
+ query=query,
628
+ decision_type=decision_type,
629
+ days=days,
630
+ limit=limit,
631
+ agent_id=agent_id,
632
+ )
633
+
634
+ if not results:
635
+ return "No matching decisions found."
636
+
637
+ output = []
638
+ for d in results:
639
+ line = f"[{d.id}] {d.decision}"
640
+ if d.reasoning:
641
+ line += f" - {d.reasoning[:50]}..."
642
+ if d.outcome:
643
+ line += f" -> {d.outcome[:30]}..."
644
+ output.append(line)
645
+
646
+ return "\n".join(output)
647
+
648
+ except Exception as e:
649
+ log_warning(f"Error searching decisions: {e}")
650
+ return f"Error: {e}"
651
+
652
+ return search_decisions
653
+
654
+ async def _abuild_search_decisions_tool(
655
+ self,
656
+ agent_id: Optional[str] = None,
657
+ session_id: Optional[str] = None,
658
+ ) -> Optional[Callable]:
659
+ """Async version of _build_search_decisions_tool."""
660
+ store = self
661
+
662
+ async def search_decisions(
663
+ query: Optional[str] = None,
664
+ decision_type: Optional[str] = None,
665
+ days: Optional[int] = None,
666
+ limit: int = 5,
667
+ ) -> str:
668
+ """Search past decisions."""
669
+ try:
670
+ results = await store.asearch(
671
+ query=query,
672
+ decision_type=decision_type,
673
+ days=days,
674
+ limit=limit,
675
+ agent_id=agent_id,
676
+ )
677
+
678
+ if not results:
679
+ return "No matching decisions found."
680
+
681
+ output = []
682
+ for d in results:
683
+ line = f"[{d.id}] {d.decision}"
684
+ if d.reasoning:
685
+ line += f" - {d.reasoning[:50]}..."
686
+ if d.outcome:
687
+ line += f" -> {d.outcome[:30]}..."
688
+ output.append(line)
689
+
690
+ return "\n".join(output)
691
+
692
+ except Exception as e:
693
+ log_warning(f"Error searching decisions: {e}")
694
+ return f"Error: {e}"
695
+
696
+ return search_decisions
697
+
698
+ # =========================================================================
699
+ # Read Operations
700
+ # =========================================================================
701
+
702
+ def search(
703
+ self,
704
+ query: Optional[str] = None,
705
+ agent_id: Optional[str] = None,
706
+ session_id: Optional[str] = None,
707
+ decision_type: Optional[str] = None,
708
+ days: Optional[int] = None,
709
+ limit: int = 10,
710
+ ) -> List[DecisionLog]:
711
+ """Search decisions with filters.
712
+
713
+ Args:
714
+ query: Text to search for.
715
+ agent_id: Filter by agent.
716
+ session_id: Filter by session.
717
+ decision_type: Filter by type.
718
+ days: Only last N days.
719
+ limit: Maximum results.
720
+
721
+ Returns:
722
+ List of matching decisions.
723
+ """
724
+ if not self.db:
725
+ return []
726
+
727
+ # Ensure sync db for sync method
728
+ if not isinstance(self.db, BaseDb):
729
+ return []
730
+
731
+ try:
732
+ # Get all matching records
733
+ results = self.db.get_learnings(
734
+ learning_type=self.learning_type,
735
+ agent_id=agent_id,
736
+ limit=limit * 3, # Over-fetch for filtering
737
+ )
738
+
739
+ if not results:
740
+ return []
741
+
742
+ decisions = []
743
+ cutoff_date = None
744
+ if days:
745
+ cutoff_date = datetime.utcnow() - timedelta(days=days)
746
+
747
+ for record in results:
748
+ content = record.get("content") if isinstance(record, dict) else None
749
+ if not content:
750
+ continue
751
+
752
+ decision = from_dict_safe(DecisionLog, content)
753
+ if not decision:
754
+ continue
755
+
756
+ # Apply filters
757
+ if decision_type and decision.decision_type != decision_type:
758
+ continue
759
+
760
+ if cutoff_date and decision.created_at:
761
+ try:
762
+ created = datetime.fromisoformat(decision.created_at.replace("Z", "+00:00"))
763
+ if created < cutoff_date:
764
+ continue
765
+ except (ValueError, AttributeError):
766
+ pass
767
+
768
+ if query:
769
+ query_lower = query.lower()
770
+ text = decision.to_text().lower()
771
+ if query_lower not in text:
772
+ continue
773
+
774
+ decisions.append(decision)
775
+
776
+ if len(decisions) >= limit:
777
+ break
778
+
779
+ return decisions
780
+
781
+ except Exception as e:
782
+ log_debug(f"DecisionLogStore.search failed: {e}")
783
+ return []
784
+
785
+ async def asearch(
786
+ self,
787
+ query: Optional[str] = None,
788
+ agent_id: Optional[str] = None,
789
+ session_id: Optional[str] = None,
790
+ decision_type: Optional[str] = None,
791
+ days: Optional[int] = None,
792
+ limit: int = 10,
793
+ ) -> List[DecisionLog]:
794
+ """Async version of search."""
795
+ if not self.db:
796
+ return []
797
+
798
+ try:
799
+ if isinstance(self.db, AsyncBaseDb):
800
+ results = await self.db.get_learnings(
801
+ learning_type=self.learning_type,
802
+ agent_id=agent_id,
803
+ limit=limit * 3,
804
+ )
805
+ else:
806
+ results = self.db.get_learnings(
807
+ learning_type=self.learning_type,
808
+ agent_id=agent_id,
809
+ limit=limit * 3,
810
+ )
811
+
812
+ if not results:
813
+ return []
814
+
815
+ decisions = []
816
+ cutoff_date = None
817
+ if days:
818
+ cutoff_date = datetime.utcnow() - timedelta(days=days)
819
+
820
+ for record in results:
821
+ content = record.get("content") if isinstance(record, dict) else None
822
+ if not content:
823
+ continue
824
+
825
+ decision = from_dict_safe(DecisionLog, content)
826
+ if not decision:
827
+ continue
828
+
829
+ if decision_type and decision.decision_type != decision_type:
830
+ continue
831
+
832
+ if cutoff_date and decision.created_at:
833
+ try:
834
+ created = datetime.fromisoformat(decision.created_at.replace("Z", "+00:00"))
835
+ if created < cutoff_date:
836
+ continue
837
+ except (ValueError, AttributeError):
838
+ pass
839
+
840
+ if query:
841
+ query_lower = query.lower()
842
+ text = decision.to_text().lower()
843
+ if query_lower not in text:
844
+ continue
845
+
846
+ decisions.append(decision)
847
+
848
+ if len(decisions) >= limit:
849
+ break
850
+
851
+ return decisions
852
+
853
+ except Exception as e:
854
+ log_debug(f"DecisionLogStore.asearch failed: {e}")
855
+ return []
856
+
857
+ def get(self, decision_id: str) -> Optional[DecisionLog]:
858
+ """Get a specific decision by ID."""
859
+ if not self.db:
860
+ return None
861
+
862
+ # Ensure sync db for sync method
863
+ if not isinstance(self.db, BaseDb):
864
+ return None
865
+
866
+ try:
867
+ # Get learnings and filter by decision_id in content
868
+ results = self.db.get_learnings(
869
+ learning_type=self.learning_type,
870
+ limit=100,
871
+ )
872
+
873
+ if not results:
874
+ return None
875
+
876
+ for record in results:
877
+ content = record.get("content") if isinstance(record, dict) else None
878
+ if content and content.get("id") == decision_id:
879
+ return from_dict_safe(DecisionLog, content)
880
+
881
+ return None
882
+
883
+ except Exception as e:
884
+ log_debug(f"DecisionLogStore.get failed: {e}")
885
+ return None
886
+
887
+ async def aget(self, decision_id: str) -> Optional[DecisionLog]:
888
+ """Async version of get."""
889
+ if not self.db:
890
+ return None
891
+
892
+ try:
893
+ # Get learnings and filter by decision_id in content
894
+ if isinstance(self.db, AsyncBaseDb):
895
+ results = await self.db.get_learnings(
896
+ learning_type=self.learning_type,
897
+ limit=100,
898
+ )
899
+ else:
900
+ results = self.db.get_learnings(
901
+ learning_type=self.learning_type,
902
+ limit=100,
903
+ )
904
+
905
+ if not results:
906
+ return None
907
+
908
+ for record in results:
909
+ content = record.get("content") if isinstance(record, dict) else None
910
+ if content and content.get("id") == decision_id:
911
+ return from_dict_safe(DecisionLog, content)
912
+
913
+ return None
914
+
915
+ except Exception as e:
916
+ log_debug(f"DecisionLogStore.aget failed: {e}")
917
+ return None
918
+
919
+ # =========================================================================
920
+ # Write Operations
921
+ # =========================================================================
922
+
923
+ def save(self, decision: DecisionLog) -> None:
924
+ """Save a decision to the database."""
925
+ if not self.db or not decision:
926
+ return
927
+
928
+ try:
929
+ content = to_dict_safe(decision)
930
+ if not content:
931
+ return
932
+
933
+ self.db.upsert_learning(
934
+ id=decision.id,
935
+ learning_type=self.learning_type,
936
+ agent_id=decision.agent_id,
937
+ session_id=decision.session_id,
938
+ user_id=decision.user_id,
939
+ team_id=decision.team_id,
940
+ content=content,
941
+ )
942
+
943
+ self.decisions_updated = True
944
+ log_debug(f"DecisionLogStore.save: saved decision {decision.id}")
945
+
946
+ except Exception as e:
947
+ log_debug(f"DecisionLogStore.save failed: {e}")
948
+
949
+ async def asave(self, decision: DecisionLog) -> None:
950
+ """Async version of save."""
951
+ if not self.db or not decision:
952
+ return
953
+
954
+ try:
955
+ content = to_dict_safe(decision)
956
+ if not content:
957
+ return
958
+
959
+ if isinstance(self.db, AsyncBaseDb):
960
+ await self.db.upsert_learning(
961
+ id=decision.id,
962
+ learning_type=self.learning_type,
963
+ agent_id=decision.agent_id,
964
+ session_id=decision.session_id,
965
+ user_id=decision.user_id,
966
+ team_id=decision.team_id,
967
+ content=content,
968
+ )
969
+ else:
970
+ self.db.upsert_learning(
971
+ id=decision.id,
972
+ learning_type=self.learning_type,
973
+ agent_id=decision.agent_id,
974
+ session_id=decision.session_id,
975
+ user_id=decision.user_id,
976
+ team_id=decision.team_id,
977
+ content=content,
978
+ )
979
+
980
+ self.decisions_updated = True
981
+ log_debug(f"DecisionLogStore.asave: saved decision {decision.id}")
982
+
983
+ except Exception as e:
984
+ log_debug(f"DecisionLogStore.asave failed: {e}")
985
+
986
+ def update_outcome(
987
+ self,
988
+ decision_id: str,
989
+ outcome: str,
990
+ outcome_quality: Optional[str] = None,
991
+ ) -> bool:
992
+ """Update a decision with its outcome."""
993
+ decision = self.get(decision_id=decision_id)
994
+ if not decision:
995
+ return False
996
+
997
+ decision.outcome = outcome
998
+ decision.outcome_quality = outcome_quality
999
+ decision.updated_at = datetime.utcnow().isoformat()
1000
+
1001
+ self.save(decision=decision)
1002
+ return True
1003
+
1004
+ async def aupdate_outcome(
1005
+ self,
1006
+ decision_id: str,
1007
+ outcome: str,
1008
+ outcome_quality: Optional[str] = None,
1009
+ ) -> bool:
1010
+ """Async version of update_outcome."""
1011
+ decision = await self.aget(decision_id=decision_id)
1012
+ if not decision:
1013
+ return False
1014
+
1015
+ decision.outcome = outcome
1016
+ decision.outcome_quality = outcome_quality
1017
+ decision.updated_at = datetime.utcnow().isoformat()
1018
+
1019
+ await self.asave(decision=decision)
1020
+ return True
1021
+
1022
+ # =========================================================================
1023
+ # Extraction (ALWAYS mode)
1024
+ # =========================================================================
1025
+
1026
+ def _extract_decisions_from_messages(
1027
+ self,
1028
+ messages: List["Message"],
1029
+ agent_id: Optional[str] = None,
1030
+ session_id: Optional[str] = None,
1031
+ user_id: Optional[str] = None,
1032
+ team_id: Optional[str] = None,
1033
+ ) -> None:
1034
+ """Extract decisions from tool calls in messages."""
1035
+ for msg in messages:
1036
+ if not hasattr(msg, "tool_calls") or not msg.tool_calls:
1037
+ continue
1038
+
1039
+ for tool_call in msg.tool_calls:
1040
+ tool_name = getattr(tool_call, "name", None) or getattr(
1041
+ getattr(tool_call, "function", None), "name", None
1042
+ )
1043
+
1044
+ if not tool_name:
1045
+ continue
1046
+
1047
+ decision_id = f"dec_{uuid.uuid4().hex[:8]}"
1048
+ decision = DecisionLog(
1049
+ id=decision_id,
1050
+ decision=f"Called tool: {tool_name}",
1051
+ decision_type="tool_selection",
1052
+ context="During conversation with user",
1053
+ session_id=session_id,
1054
+ user_id=user_id,
1055
+ agent_id=agent_id,
1056
+ team_id=team_id,
1057
+ created_at=datetime.utcnow().isoformat(),
1058
+ )
1059
+
1060
+ self.save(decision=decision)
1061
+
1062
+ async def _aextract_decisions_from_messages(
1063
+ self,
1064
+ messages: List["Message"],
1065
+ agent_id: Optional[str] = None,
1066
+ session_id: Optional[str] = None,
1067
+ user_id: Optional[str] = None,
1068
+ team_id: Optional[str] = None,
1069
+ ) -> None:
1070
+ """Async version of _extract_decisions_from_messages."""
1071
+ for msg in messages:
1072
+ if not hasattr(msg, "tool_calls") or not msg.tool_calls:
1073
+ continue
1074
+
1075
+ for tool_call in msg.tool_calls:
1076
+ tool_name = getattr(tool_call, "name", None) or getattr(
1077
+ getattr(tool_call, "function", None), "name", None
1078
+ )
1079
+
1080
+ if not tool_name:
1081
+ continue
1082
+
1083
+ decision_id = f"dec_{uuid.uuid4().hex[:8]}"
1084
+ decision = DecisionLog(
1085
+ id=decision_id,
1086
+ decision=f"Called tool: {tool_name}",
1087
+ decision_type="tool_selection",
1088
+ context="During conversation with user",
1089
+ session_id=session_id,
1090
+ user_id=user_id,
1091
+ agent_id=agent_id,
1092
+ team_id=team_id,
1093
+ created_at=datetime.utcnow().isoformat(),
1094
+ )
1095
+
1096
+ await self.asave(decision=decision)
1097
+
1098
+ # =========================================================================
1099
+ # Representation
1100
+ # =========================================================================
1101
+
1102
+ def __repr__(self) -> str:
1103
+ """String representation for debugging."""
1104
+ has_db = self.db is not None
1105
+ has_model = self.model is not None
1106
+ return (
1107
+ f"DecisionLogStore("
1108
+ f"mode={self.config.mode.value}, "
1109
+ f"db={has_db}, "
1110
+ f"model={has_model}, "
1111
+ f"enable_agent_tools={self.config.enable_agent_tools})"
1112
+ )
1113
+
1114
+ def print(
1115
+ self,
1116
+ agent_id: Optional[str] = None,
1117
+ session_id: Optional[str] = None,
1118
+ limit: int = 10,
1119
+ *,
1120
+ raw: bool = False,
1121
+ ) -> None:
1122
+ """Print formatted decision log.
1123
+
1124
+ Args:
1125
+ agent_id: Filter by agent.
1126
+ session_id: Filter by session.
1127
+ limit: Maximum decisions to show.
1128
+ raw: If True, print raw dict using pprint.
1129
+ """
1130
+ from agno.learn.utils import print_panel
1131
+
1132
+ decisions = self.search(
1133
+ agent_id=agent_id,
1134
+ session_id=session_id,
1135
+ limit=limit,
1136
+ )
1137
+
1138
+ lines = []
1139
+ for d in decisions:
1140
+ lines.append(f"[{d.id}] {d.decision}")
1141
+ if d.reasoning:
1142
+ lines.append(f" Reasoning: {d.reasoning}")
1143
+ if d.outcome:
1144
+ lines.append(f" Outcome: {d.outcome}")
1145
+ lines.append("")
1146
+
1147
+ subtitle = agent_id or session_id or "all"
1148
+
1149
+ print_panel(
1150
+ title="Decision Log",
1151
+ subtitle=subtitle,
1152
+ lines=lines,
1153
+ empty_message="No decisions logged",
1154
+ raw_data=decisions,
1155
+ raw=raw,
1156
+ )