Files
semantica/integrations/agno/decision_kit.py
T
KaifAhmad1andClaude Sonnet 4.6 62c7970b32 feat(integrations): add Agno agentic framework integration (#249)
Implements the full Semantica × Agno integration stack as described in
issue #249, wiring Semantica's semantic intelligence layer into Agno's
agent/team primitives via five focused components.

## New components

### integrations/agno/
- `AgnoContextStore`    — graph-backed MemoryDb (AgentMemory/storage)
- `AgnoKnowledgeGraph`  — relational AgentKnowledge with multi-hop GraphRAG
- `AgnoDecisionKit`     — Agno Toolkit: 6 decision-intelligence tools
- `AgnoKGToolkit`       — Agno Toolkit: 7 knowledge-graph tools
- `AgnoSharedContext`   — team-level shared ContextGraph with role scoping

### tests/integrations/agno/
- 110 tests, 0 failures
- conftest.py installs comprehensive agno stubs for offline testing
- Covers MemoryDb protocol, tool registration, shared memory pool,
  thread-safety, GraphRAG search, NER/relation extraction, and inference

### cookbook/integrations/
- agno_decision_intelligence.ipynb     (finance/loan underwriting)
- agno_graphrag_context.ipynb          (regulatory compliance GraphRAG)
- agno_multi_agent_shared_context.ipynb (multi-agent product strategy team)

### docs/integrations/agno.md
- Full reference documentation with examples for all 5 components

## pyproject.toml
- Added `agno = ["agno>=1.0.0"]` optional dependency
- Added agno to the `all` extra

## Design notes
- Zero breaking changes — fully additive
- Graceful degradation when agno is not installed
- Auto-creates VectorStore(backend="faiss") when none provided
- _tools always populated for inspection regardless of agno install state
- Works with both real agno package and offline stubs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 03:25:14 +05:30

385 lines
13 KiB
Python

"""
AgnoDecisionKit — Decision Intelligence Toolkit for Agno agents.
Exposes Semantica's decision intelligence as native Agno tools so that agents
can actively record, query, and validate decisions during their reasoning loop.
Follows Agno's ``Toolkit`` pattern — each method decorated with ``@register``
(or manually registered via ``self.register()``) becomes a tool the LLM can
call.
Install
-------
pip install semantica[agno]
Example
-------
>>> from semantica.context import AgentContext
>>> from integrations.agno import AgnoDecisionKit
>>> ctx = AgentContext(decision_tracking=True)
>>> from agno.agent import Agent
>>> agent = Agent(tools=[AgnoDecisionKit(context=ctx)], show_tool_calls=True)
Tools exposed
-------------
record_decision — Record a decision with reasoning and outcome
find_precedents — Search for similar past decisions
trace_causal_chain — Trace causal chain of a decision node
analyze_impact — Assess downstream influence of a decision
check_policy — Validate a decision against policy rules
get_decision_summary — Summarise decision history by category
"""
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional
from semantica.utils.logging import get_logger
logger = get_logger(__name__)
# ---------------------------------------------------------------------------
# Optional: Agno Toolkit base class
# ---------------------------------------------------------------------------
AGNO_AVAILABLE = False
AGNO_IMPORT_ERROR: Optional[str] = None
_ToolkitBase: Any = object
try:
from agno.tools.toolkit import Toolkit as _AgnoToolkit # type: ignore
_ToolkitBase = _AgnoToolkit
AGNO_AVAILABLE = True
except ImportError as exc:
AGNO_IMPORT_ERROR = str(exc)
# ---------------------------------------------------------------------------
# AgnoDecisionKit
# ---------------------------------------------------------------------------
class AgnoDecisionKit(_ToolkitBase): # type: ignore[misc]
"""
Agno Toolkit that surfaces Semantica's decision intelligence as agent tools.
Parameters
----------
context:
A ``semantica.context.AgentContext`` (or ``AgentContext``-compatible
object with ``record_decision``, ``find_precedents_advanced``,
``analyze_decision_influence`` methods). A fresh in-memory context is
created when ``None``.
max_precedents:
Default number of precedents returned by ``find_precedents``.
causal_depth:
Default chain depth used by ``trace_causal_chain``.
enable_policy_check:
Register the ``check_policy`` tool (default: ``True``).
"""
def __init__(
self,
context: Any = None,
max_precedents: int = 5,
causal_depth: int = 3,
enable_policy_check: bool = True,
**kwargs: Any,
) -> None:
if AGNO_AVAILABLE:
super().__init__(name="decision_kit", **kwargs) # type: ignore[call-arg]
# Always initialise _tools so the attribute exists regardless of agno
if not hasattr(self, "_tools"):
self._tools: list = []
self.max_precedents = max_precedents
self.causal_depth = causal_depth
# Build or reuse AgentContext
if context is None:
from semantica.context import AgentContext
from semantica.vector_store import VectorStore
context = AgentContext(
vector_store=VectorStore(backend="faiss"),
decision_tracking=True,
)
self._ctx = context
# Register tools.
# _tools is always kept as a plain list so callers can inspect registered
# tools regardless of whether agno is installed. When agno IS available
# we also call Toolkit.register() so the real agno runtime picks them up.
tools_to_register = [
self.record_decision,
self.find_precedents,
self.trace_causal_chain,
self.analyze_impact,
self.get_decision_summary,
]
if enable_policy_check:
tools_to_register.append(self.check_policy)
for fn in tools_to_register:
self._tools.append(fn)
if AGNO_AVAILABLE:
try:
self.register(fn)
except Exception:
pass
logger.info("AgnoDecisionKit initialised")
# ------------------------------------------------------------------
# Tools
# ------------------------------------------------------------------
def record_decision(
self,
category: str,
scenario: str,
reasoning: str,
outcome: str,
confidence: float = 0.8,
entities: Optional[str] = None,
) -> str:
"""
Record a decision with its reasoning and outcome.
Parameters
----------
category:
Domain category, e.g. ``"loan_approval"``, ``"content_moderation"``.
scenario:
Short description of the situation being decided.
reasoning:
Why this outcome was chosen.
outcome:
The decision result, e.g. ``"approved"``, ``"rejected"``.
confidence:
Confidence score in [0, 1].
entities:
Comma-separated list of entity names relevant to the decision.
Returns
-------
str
JSON with ``{"decision_id": "<id>", "status": "recorded"}``.
"""
entity_list: Optional[List[str]] = None
if entities:
entity_list = [e.strip() for e in entities.split(",") if e.strip()]
try:
decision_id = self._ctx.record_decision(
category=category,
scenario=scenario,
reasoning=reasoning,
outcome=outcome,
confidence=float(confidence),
entities=entity_list,
)
result = {"decision_id": str(decision_id), "status": "recorded"}
logger.info("record_decision → %s", decision_id)
except Exception as exc:
result = {"error": str(exc), "status": "failed"}
logger.warning("record_decision failed: %s", exc)
return json.dumps(result)
def find_precedents(
self,
scenario: str,
category: Optional[str] = None,
limit: Optional[int] = None,
) -> str:
"""
Search for past decisions similar to the given scenario.
Parameters
----------
scenario:
Description of the current situation.
category:
Optional category filter.
limit:
Maximum number of precedents to return.
Returns
-------
str
JSON list of precedent summaries.
"""
k = limit or self.max_precedents
try:
precedents = self._ctx.find_precedents_advanced(
scenario=scenario,
category=category,
)
# Normalise to a serialisable list
out: List[Dict[str, Any]] = []
for p in (precedents or [])[:k]:
if isinstance(p, dict):
out.append(p)
else:
out.append(
{
"scenario": getattr(p, "scenario", str(p)),
"outcome": getattr(p, "outcome", ""),
"confidence": getattr(p, "confidence", 0.0),
"category": getattr(p, "category", ""),
}
)
logger.info("find_precedents('%s') → %d results", scenario, len(out))
return json.dumps({"precedents": out, "count": len(out)})
except Exception as exc:
logger.warning("find_precedents failed: %s", exc)
return json.dumps({"precedents": [], "count": 0, "error": str(exc)})
def trace_causal_chain(
self,
decision_id: str,
depth: Optional[int] = None,
) -> str:
"""
Trace the causal chain starting from a decision node.
Parameters
----------
decision_id:
Identifier of the decision to trace.
depth:
Maximum chain depth to traverse.
Returns
-------
str
JSON representation of the causal chain.
"""
max_depth = depth or self.causal_depth
try:
chain = self._ctx.knowledge_graph.trace_decision_causality( # type: ignore[attr-defined]
decision_id, depth=max_depth
)
return json.dumps({"causal_chain": chain, "decision_id": decision_id})
except AttributeError:
# Fallback if the graph doesn't expose trace_decision_causality
try:
chain = self._ctx.knowledge_graph.find_precedents( # type: ignore[attr-defined]
category="decision", limit=max_depth
)
return json.dumps({"causal_chain": chain, "decision_id": decision_id})
except Exception as exc:
return json.dumps({"error": str(exc), "decision_id": decision_id})
except Exception as exc:
logger.warning("trace_causal_chain failed: %s", exc)
return json.dumps({"error": str(exc), "decision_id": decision_id})
def analyze_impact(self, decision_id: str) -> str:
"""
Assess the downstream influence of a decision using graph centrality.
Parameters
----------
decision_id:
Identifier of the decision to analyse.
Returns
-------
str
JSON with influence metrics.
"""
try:
influence = self._ctx.analyze_decision_influence(decision_id)
if not isinstance(influence, dict):
influence = {"influence": str(influence)}
influence["decision_id"] = decision_id
return json.dumps(influence)
except Exception as exc:
logger.warning("analyze_impact failed: %s", exc)
return json.dumps({"error": str(exc), "decision_id": decision_id})
def check_policy(
self,
decision_data: str,
policy_rules: Optional[str] = None,
) -> str:
"""
Validate a proposed decision against policy rules.
Parameters
----------
decision_data:
JSON string describing the decision (must include ``category``,
``outcome``, ``confidence`` keys at minimum).
policy_rules:
JSON list of policy rule strings, e.g.
``'["confidence >= 0.7", "category != \\"test\\""]'``.
Returns
-------
str
JSON with ``{"compliant": bool, "violations": [...], "warnings": [...]}``
"""
try:
data = json.loads(decision_data) if isinstance(decision_data, str) else decision_data
except json.JSONDecodeError as exc:
return json.dumps({"error": f"Invalid decision_data JSON: {exc}"})
rules: List[str] = []
if policy_rules:
try:
rules = json.loads(policy_rules)
except json.JSONDecodeError:
rules = [r.strip() for r in policy_rules.split(",") if r.strip()]
try:
from semantica.context import PolicyEngine # lazy import
engine = PolicyEngine(graph_store=self._ctx.knowledge_graph) # type: ignore[attr-defined]
result = engine.check_compliance(data, rules)
return json.dumps(
{
"compliant": getattr(result, "compliant", True),
"violations": getattr(result, "violations", []),
"warnings": getattr(result, "warnings", []),
}
)
except Exception as exc:
logger.warning("check_policy failed: %s", exc)
return json.dumps({"compliant": True, "violations": [], "warnings": [], "note": str(exc)})
def get_decision_summary(
self,
category: Optional[str] = None,
since: Optional[str] = None,
limit: int = 10,
) -> str:
"""
Summarise the decision history, optionally filtered by category.
Parameters
----------
category:
Filter to a specific decision category.
since:
ISO-8601 timestamp — only include decisions after this time.
limit:
Maximum number of decisions to include.
Returns
-------
str
JSON summary of recent decisions.
"""
try:
insights = self._ctx.get_context_insights()
if not isinstance(insights, dict):
insights = {"raw": str(insights)}
insights["category_filter"] = category
return json.dumps(insights)
except Exception as exc:
logger.warning("get_decision_summary failed: %s", exc)
return json.dumps({"error": str(exc)})