wrolpi/modules/ai/agent.py
Roland 84883c36f6
ai: the /api/chat agent loop — SSE streaming, modes, tool execution
Tool definitions are generated in-process from the /api/ai blueprint's
own OpenAPI operations (operationId = tool name, description = tool
description, dataclass bodies + declared parameters = one flat JSON
schema) — the spec IS the catalog, nothing to drift.  Manage endpoints
never become tools.  A mode is a server-enforced system prompt + tool
allowlist (Help / Research / System per ai.md); the executor can only
reach allowlisted /api/ai endpoints, path parameters are encoded and
traversal-rejected so a crafted argument cannot escape an endpoint.

POST /api/chat streams SSE (status/token/tool_call/tool_result/done/
error), starts llama-server on demand, validates tool args server-side
with instructive errors the model can retry on, caps tool results with
a paging hint, and bounds the loop by ai.yaml max_tool_calls plus a
hard timeout.  Caddy passes the stream unbuffered (flush_interval -1)
in both deployments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-13 20:10:16 -06:00

236 lines
10 KiB
Python

"""Tool definitions and tool execution for the agent loop.
The /api/ai blueprint IS the tool catalog: tool definitions are generated from the blueprint's
own OpenAPI operations (in-process — the spec endpoint is not proxied by Caddy), so there is no
separate registry to drift. operationIds are the tool names; @openapi.description strings are
the tool descriptions; @validate dataclasses and @openapi.parameter declarations become the
JSON-schema parameters."""
import dataclasses
import json
from typing import Dict, List, Optional, Tuple
from urllib.parse import quote, urlencode
from sanic_ext.extensions.openapi.builders import OperationStore, SpecificationBuilder
from sanic_ext.utils.route import get_all_routes
from wrolpi.api_utils import api_app
from wrolpi.common import aiohttp_get, aiohttp_post, logger
from wrolpi.vars import API_PORT
from .lib import PAGE_SIZE
logger = logger.getChild(__name__)
AI_PATH_PREFIX = '/api/ai/'
# Manage endpoints are for the Manage tab, never the model.
EXCLUDED_PREFIX = '/api/ai/manage'
# Tool results are capped so one result cannot blow a small context; content endpoints page.
TOOL_RESULT_MAX_CHARS = PAGE_SIZE + 1_000
_TYPE_MAP = {'integer': int, 'number': float, 'boolean': bool, 'string': str}
@dataclasses.dataclass
class ToolDefinition:
name: str
description: str
method: str # 'get' or 'post'
path: str # e.g. '/api/ai/videos/{file_group_id}/captions'
path_params: Dict[str, dict] # name -> JSON schema
query_params: Dict[str, dict]
body_params: Dict[str, dict]
def parameters_schema(self) -> dict:
"""One flat JSON-schema object; the executor routes each arg to its place."""
properties = {**self.body_params, **self.query_params, **self.path_params}
required = sorted(self.path_params)
schema = dict(type='object', properties=properties)
if required:
schema['required'] = required
return schema
def as_openai_tool(self) -> dict:
return dict(type='function', function=dict(
name=self.name,
description=self.description,
parameters=self.parameters_schema(),
))
def _build_ai_spec() -> dict:
"""Populate and serialize the OpenAPI spec for the AI blueprint.
Replicates sanic-ext's build_spec startup hook (sanic_ext/extensions/openapi/blueprint.py) for
just our routes, so tool definitions work identically in production and under pytest."""
spec = SpecificationBuilder()
store = OperationStore()
for uri, route_name, route_parameters, method_handlers, host in get_all_routes(api_app, '/docs'):
if not uri.startswith(AI_PATH_PREFIX):
continue
for method, handler in method_handlers:
if method in ('OPTIONS', 'HEAD', 'TRACE'):
continue
if handler not in store and (func := getattr(handler, '__func__', None)) and func in store:
handler = func
operation = store[handler]
if operation._exclude:
continue
operation._default['operationId'] = f'{method.lower()}~{route_name}'
for parameter in route_parameters:
if not any(p.fields['name'] == parameter.name for p in operation.parameters):
operation.parameter(parameter.name, parameter.cast, 'path')
operation._app = api_app
spec.operation(uri, method, operation)
return spec.build(api_app).serialize()
def _resolve_ref(spec: dict, schema: Optional[dict]) -> dict:
if not schema:
return {}
if ref := schema.get('$ref'):
name = ref.rsplit('/', 1)[-1]
return (spec.get('components', {}).get('schemas', {}) or {}).get(name, {})
return schema
def _clean_property(schema: dict) -> dict:
"""A lean JSON-schema property for the model: type (+items), nothing else."""
schema = schema or {}
cleaned = {}
if type_ := schema.get('type'):
cleaned['type'] = type_
if items := schema.get('items'):
cleaned['items'] = _clean_property(items)
return cleaned or {'type': 'string'}
_TOOL_DEFINITIONS: Optional[Dict[str, ToolDefinition]] = None
def get_ai_tool_definitions(refresh: bool = False) -> Dict[str, ToolDefinition]:
"""All tools the agent loop can ever execute, keyed by name. Built lazily, cached.
This map is also the hard allowlist: the executor can only reach /api/ai endpoints that
appear here, and manage endpoints never do."""
global _TOOL_DEFINITIONS
if _TOOL_DEFINITIONS is not None and not refresh:
return _TOOL_DEFINITIONS
spec = _build_ai_spec()
tools = {}
for path, operations in (spec.get('paths') or {}).items():
if not path.startswith(AI_PATH_PREFIX) or path.startswith(EXCLUDED_PREFIX):
continue
for method, operation in operations.items():
if method not in ('get', 'post'):
continue
name = operation.get('operationId')
description = operation.get('description') or operation.get('summary') or ''
if not name or '~' in name:
# Every tool endpoint must declare a deliberate @openapi.operation name.
logger.warning(f'AI endpoint without an operation name is not a tool: {method} {path}')
continue
path_params, query_params = {}, {}
for parameter in operation.get('parameters') or []:
cleaned = _clean_property(parameter.get('schema'))
if parameter.get('in') == 'path':
path_params[parameter['name']] = cleaned
elif parameter.get('in') == 'query':
query_params[parameter['name']] = cleaned
body_params = {}
content = (operation.get('requestBody') or {}).get('content') or {}
# sanic-ext serializes dataclass bodies under '*/*'.
body_schema = (content.get('application/json') or content.get('*/*') or {}).get('schema')
body_schema = _resolve_ref(spec, body_schema)
for prop_name, prop_schema in (body_schema.get('properties') or {}).items():
body_params[prop_name] = _clean_property(_resolve_ref(spec, prop_schema))
tools[name] = ToolDefinition(
name=name, description=description, method=method, path=path,
path_params=path_params, query_params=query_params, body_params=body_params,
)
_TOOL_DEFINITIONS = tools
return tools
def get_mode_tools(mode_tools: tuple) -> List[dict]:
"""OpenAI-format tool definitions for one mode's allowlist."""
definitions = get_ai_tool_definitions()
return [definitions[name].as_openai_tool() for name in mode_tools if name in definitions]
async def call_ai_endpoint(method: str, path: str, query: dict, body: Optional[dict]) -> Tuple[int, dict]:
"""Execute one blueprint endpoint over localhost. Patched in tests."""
url = f'http://127.0.0.1:{API_PORT}{path}'
if query:
url = f'{url}?{urlencode(query)}'
if method == 'post':
async with aiohttp_post(url, json_=body or {}, timeout=60) as response:
return response.status, await response.json()
async with aiohttp_get(url, timeout=60) as response:
return response.status, await response.json()
def _validate_args(tool: ToolDefinition, args: dict) -> Optional[str]:
"""Return a crisp, instructive error string for the model, or None when the args are usable."""
known = {**tool.path_params, **tool.query_params, **tool.body_params}
if unknown := sorted(set(args) - set(known)):
return f'Unknown arguments {unknown}. {tool.name} accepts: {sorted(known)}.'
if missing := sorted(set(tool.path_params) - set(args)):
return f'Missing required arguments {missing} for {tool.name}.'
for name, value in args.items():
expected = _TYPE_MAP.get(known[name].get('type'))
if expected in (int, float) and value is not None and not isinstance(value, (int, float)) \
and not (isinstance(value, str) and value.lstrip('-').isdigit()):
return f'Argument {name} must be a {known[name]["type"]}, got {value!r}.'
# Path parameters land in the request URL; refuse anything that could escape the
# endpoint's own path (the allowlist must hold even against a crafted value).
if name in tool.path_params and isinstance(value, str):
segments = value.split('/')
if value.startswith('/') or '' in segments or '.' in segments or '..' in segments:
return f'Argument {name} must not contain path traversal.'
return None
async def execute_tool(mode_tools: tuple, name: str, args: dict) -> Tuple[bool, str]:
"""Validate and execute one tool call. Returns (success, result text for the model).
Every failure is a plain-language error string — small models retry well on crisp errors."""
definitions = get_ai_tool_definitions()
if name not in definitions or name not in mode_tools:
available = sorted(set(mode_tools) & set(definitions))
return False, f'Unknown tool {name!r}. Available tools: {available}.'
tool = definitions[name]
args = args or {}
if error := _validate_args(tool, args):
return False, error
path = tool.path
for param in tool.path_params:
# Encode everything but '/' (help-doc slugs contain it); traversal was rejected above.
path = path.replace(f'{{{param}}}', quote(str(args[param]), safe='/'))
# The substituted path must still be inside the endpoint the tool declares.
static_prefix = tool.path.split('{', 1)[0]
if not path.startswith(static_prefix) or '..' in path.split('/'):
return False, f'Arguments produced an invalid path for {tool.name}.'
query = {k: v for k, v in args.items() if k in tool.query_params and v is not None}
body = {k: v for k, v in args.items() if k in tool.body_params} if tool.method == 'post' else None
try:
status, response = await call_ai_endpoint(tool.method, path, query, body)
except Exception as e:
logger.error(f'Tool {name} failed', exc_info=e)
return False, f'Tool {name} failed to execute; try different arguments or another tool.'
text = json.dumps(response, default=str)
if len(text) > TOOL_RESULT_MAX_CHARS:
text = text[:TOOL_RESULT_MAX_CHARS] + '… (result truncated; use limit/offset to get less at a time)'
if status >= 400:
message = response.get('message') or response.get('error') or f'HTTP {status}'
return False, f'{tool.name} returned an error: {message}'
return True, text