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 4a3a8945af feat(kb): track page_number per chunk + expose sources_used from /kb/ask
To let the browse/ask UIs jump straight to the right PDF page:

- ingest.py: embed \x00KB_PAGE:<N>\x00 sentinels at the start of each
  PDF page during _parse_pdf.
- chunker.py: _strip_page_markers removes the sentinels and records the
  first page seen per chunk as page_number. Chunk dataclass + _as_dicts
  propagate page_number through both chunk_statute and chunk_circular.
- ingest.py INSERT: new kb_chunk.page_number column (DB migration
  applied manually).
- search.py SQL: SELECT c.page_number + s.id + s.original_path so the
  rerank results carry them forward.
- legal_kb_tools.py: register_legal_kb_tools(sources_used=list) — the
  tool appends {source_id, title, kind, identifier, heading_path,
  section_ref, page_number, original_path} for each hit, deduped by
  (source_id, page_number).
- agent_runner.py: new kb_sources_used kwarg threads the list through
  to the tool registration.
- kb_public.py /kb/ask: collects sources_used during the run, filters
  to PDF-only sources, and returns them alongside text.

Chunks stored before this change have page_number = NULL; PDFs need to
be re-ingested to populate them. The TXT law source is unaffected.

Refs Task Master #2

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:20:52 +00:00

176 lines
6.9 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 mcp_server.tools.legal_kb_tools import register_legal_kb_tools
from mcp_server.tools.signature_tools import register_signature_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,
kb_sources_used: list[dict] | 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)
register_legal_kb_tools(tools_registry, sources_used=kb_sources_used)
if should_register_all or "signature" in (allowed_toolsets or []):
register_signature_tools(tools_registry, crm, case_id, user_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", [])