This repository has been archived on 2026-07-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
shira-hermes/api/services/agent_runner.py
T
chaim 4aad81e11e feat: add skills, memory, delegation and self-learning (Phase 3)
Add three Hermes-inspired capabilities to Shira:

- Skills system: firm-wide workflow templates (list/view/manage tools),
  4 pre-seeded skills (case summary, hearing prep, direct access report,
  deadline tracker), auto-learning via post-conversation meta-prompt
- Cross-conversation memory: per-lawyer profiles and per-case memory
  stored as bounded markdown files, injected into system prompts
- Sub-agent delegation: spawn up to 3 child AgentRunner instances for
  parallel document analysis and complex multi-step tasks

New files: skills.py, memory.py, delegate.py, learner.py, 4 SKILL.md
Updated: agent_runner (6 new tools, depth/blocked_tools support),
prompt_builder (skills/memory injection), route (memory loading,
background learning), main.py (data dirs), Dockerfile (pyyaml, skills)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:33:57 +00:00

170 lines
6.5 KiB
Python

"""Agent runner — wraps OpenAI client with tool-calling loop via ai-gateway."""
from __future__ import annotations
import json
import logging
from typing import Any
from openai import AsyncOpenAI
from mcp_server.espocrm_client import EspoCrmClient
from mcp_server.tools.crm_tools import register_crm_tools
from mcp_server.tools.document_tools import register_document_tools
from mcp_server.tools.legal_tools import register_legal_tools
from api.services.skills import register_skill_tools
from api.services.memory import register_memory_tools
from api.services.delegate import register_delegate_tool
logger = logging.getLogger("shira.agent")
class AgentRunner:
"""
Runs an agentic conversation loop using the OpenAI SDK pointed at ai-gateway.
The loop:
1. Send messages + tools to the model
2. If the model returns tool_calls, execute them and feed results back
3. Repeat until the model returns a text response (no tool_calls)
4. Return the final text
"""
def __init__(
self,
ai_gateway_url: str,
ai_gateway_api_key: str,
model: str = "sonnet",
max_tokens: int = 4096,
max_iterations: int = 10,
):
self.client = AsyncOpenAI(
base_url=f"{ai_gateway_url.rstrip('/')}/v1",
api_key=ai_gateway_api_key,
)
self.model = model
self.max_tokens = max_tokens
self.max_iterations = max_iterations
async def run(
self,
system_prompt: str,
messages: list[dict[str, str]],
context: dict,
espocrm_url: str,
espocrm_api_key: str,
depth: int = 0,
blocked_tools: set[str] | None = None,
allowed_toolsets: list[str] | None = None,
) -> str:
"""Run a conversation with tool-calling loop. Returns the final text response.
Args:
depth: Current delegation depth. 0 = main conversation, 1 = child agent.
blocked_tools: Tool names to exclude (used by delegation to prevent recursion).
allowed_toolsets: If set, only register tools from these categories (crm, documents, legal).
"""
# Build tools from context
crm = EspoCrmClient(espocrm_url, espocrm_api_key)
case_id = (context.get("case") or {}).get("id") or (context.get("drillDown", {}).get("case", {}).get("id"))
user_id = (context.get("currentUser") or {}).get("id")
tools_registry: dict[str, dict] = {}
# Core CRM/document/legal tools
should_register_all = allowed_toolsets is None
if should_register_all or "crm" in (allowed_toolsets or []):
register_crm_tools(tools_registry, crm, case_id, user_id, context)
if should_register_all or "documents" in (allowed_toolsets or []):
register_document_tools(tools_registry, crm, case_id, context)
if should_register_all or "legal" in (allowed_toolsets or []):
register_legal_tools(tools_registry, crm, case_id, context)
# Phase 3 tools (only for main conversation, not child agents)
if depth == 0:
register_skill_tools(tools_registry)
register_memory_tools(tools_registry, case_id, user_id)
register_delegate_tool(tools_registry, context, espocrm_url, espocrm_api_key)
# Remove blocked tools (for delegation safety)
if blocked_tools:
for name in blocked_tools:
tools_registry.pop(name, None)
# Build OpenAI tools format
openai_tools = []
for name, tool in tools_registry.items():
openai_tools.append({
"type": "function",
"function": {
"name": name,
"description": tool["description"],
"parameters": tool["parameters"],
},
})
# Build messages with system prompt
openai_messages = [{"role": "system", "content": system_prompt}]
openai_messages.extend(messages)
self._last_messages = openai_messages
# Agentic loop
for iteration in range(self.max_iterations):
logger.info("[agent] depth=%d iteration %d, messages=%d", depth, iteration + 1, len(openai_messages))
try:
response = await self.client.chat.completions.create(
model=self.model,
messages=openai_messages,
tools=openai_tools if openai_tools else None,
max_tokens=self.max_tokens,
)
except Exception as e:
logger.error("[agent] API error: %s", e)
return f"שגיאה בתקשורת עם שירות ה-AI: {e}"
choice = response.choices[0]
message = choice.message
# If no tool calls, return the text
if not message.tool_calls:
return message.content or "לא הצלחתי להבין את הבקשה. נסי שוב."
# Process tool calls
openai_messages.append(message.model_dump())
for tool_call in message.tool_calls:
fn_name = tool_call.function.name
try:
fn_args = json.loads(tool_call.function.arguments)
except json.JSONDecodeError:
fn_args = {}
logger.info("[agent] tool call: %s(%s)", fn_name, json.dumps(fn_args, ensure_ascii=False)[:100])
tool_def = tools_registry.get(fn_name)
if not tool_def:
result = f"❌ כלי לא ידוע: {fn_name}"
else:
try:
handler = tool_def["handler"]
result = await handler(**fn_args)
except Exception as e:
logger.error("[agent] tool error %s: %s", fn_name, e)
result = f"❌ שגיאה בהפעלת {fn_name}: {e}"
openai_messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": str(result),
})
# Exhausted iterations
logger.warning("[agent] max iterations (%d) reached", self.max_iterations)
return "הגעתי למספר המקסימלי של פעולות. נסי לשאול שוב בצורה ממוקדת יותר."
def get_messages(self) -> list[dict]:
"""Return the conversation messages from the last run (for post-conversation learning)."""
return getattr(self, "_last_messages", [])