mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-27 08:12:22 +01:00
271 lines
15 KiB
Python
271 lines
15 KiB
Python
"""Ajax Spec turn: the Spec router drives every step; the chat model never chooses a tool.
|
|
|
|
Each round the router (spec_router.next_action) picks one action from the full tool surface:
|
|
tool -> the chat model gets only that tool (named tool_choice) and writes its arguments;
|
|
the executor runs it; the result goes back to the router for the next pick.
|
|
answer_directly -> the chat model writes the answer with no tools (tools key omitted).
|
|
ask_user -> the chat model writes the question through the ask_user tool.
|
|
Router unavailable -> the chat model answers without tools. Execution keeps the normal request
|
|
authority and tool policy (execute_tool_block); nothing else is layered on top.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
import uuid
|
|
|
|
import httpx
|
|
|
|
from src import spec_router
|
|
from src.agent_runtime.authority import MISSING_AUTHORITY, with_request_authority
|
|
from src.clean_agent_preview import (
|
|
event, preview_model_response, preview_tool_result_text,
|
|
)
|
|
from src.tool_capabilities import ToolRunSecurityContext
|
|
from src.tool_execution import execute_tool_block
|
|
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS, function_call_to_tool_block
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _text(content):
|
|
if isinstance(content, str):
|
|
return content
|
|
if isinstance(content, list):
|
|
return '\n'.join(str(p.get('text') or '') for p in content
|
|
if isinstance(p, dict) and p.get('type') == 'text')
|
|
return ''
|
|
|
|
|
|
def _name(schema):
|
|
return (schema.get('function') or {}).get('name') or ''
|
|
|
|
|
|
# JSON-schema keywords the provider's guided decoding (named tool_choice) cannot compile.
|
|
_GRAMMAR_UNSUPPORTED = frozenset({
|
|
'uniqueItems', 'contains', 'minContains', 'maxContains', 'patternProperties', 'propertyNames',
|
|
'dependentRequired', 'dependentSchemas', 'if', 'then', 'else',
|
|
'unevaluatedProperties', 'unevaluatedItems',
|
|
})
|
|
|
|
|
|
def grammar_safe(schema):
|
|
"""Copy of a schema without keywords guided decoding rejects (validation still runs at execution)."""
|
|
if isinstance(schema, dict):
|
|
return {k: grammar_safe(v) for k, v in schema.items() if k not in _GRAMMAR_UNSUPPORTED}
|
|
if isinstance(schema, list):
|
|
return [grammar_safe(v) for v in schema]
|
|
return schema
|
|
|
|
|
|
def spec_surface(disabled_tools):
|
|
disabled = set(disabled_tools or ())
|
|
return [s for s in FUNCTION_TOOL_SCHEMAS if _name(s) and _name(s) not in disabled]
|
|
|
|
|
|
async def _model_round(client, endpoint_url, headers, request, recovery):
|
|
"""Run one chat-model round; return (content, tool_calls, usage)."""
|
|
content, calls, usage, raw = '', {}, {}, []
|
|
async with preview_model_response(client, endpoint_url, headers, request, recovery) as response:
|
|
async for line in response.aiter_lines():
|
|
if len(raw) < 4 and line.strip():
|
|
raw.append(line[:400])
|
|
if line.strip() and not line.startswith('data: '):
|
|
logger.warning('[spec-driver] unexpected stream line: %s', line[:500])
|
|
if not line.startswith('data: ') or line.strip() == 'data: [DONE]':
|
|
continue
|
|
try:
|
|
chunk = json.loads(line[6:])
|
|
except ValueError:
|
|
continue
|
|
if chunk.get('error') or chunk.get('object') == 'error':
|
|
raise RuntimeError(f"model stream error: {str(chunk.get('error') or chunk)[:500]}")
|
|
usage = chunk.get('usage') or usage
|
|
for choice in chunk.get('choices') or []:
|
|
delta = choice.get('delta') or {}
|
|
text = delta.get('content') or ''
|
|
content += text
|
|
for frag in delta.get('tool_calls') or []:
|
|
call = calls.setdefault(frag.get('index', 0), {'id': '', 'name': '', 'arguments': ''})
|
|
call['id'] = frag.get('id') or call['id']
|
|
fn = frag.get('function') or {}
|
|
call['name'] += fn.get('name') or ''
|
|
call['arguments'] += fn.get('arguments') or ''
|
|
if not calls:
|
|
logger.warning('[spec-driver] no tool call; status=%s max_tokens=%s msgs=%s first lines=%s',
|
|
getattr(response, 'status_code', None), request.get('max_tokens'),
|
|
[(m.get('role'), type(m.get('content')).__name__,
|
|
[p.get('type') for p in m['content']] if isinstance(m.get('content'), list) else len(str(m.get('content') or '')))
|
|
for m in request.get('messages', [])], raw[-2:])
|
|
return content, [calls[i] for i in sorted(calls)], usage
|
|
|
|
|
|
@with_request_authority
|
|
async def stream_spec_turn(*, endpoint_url, model, messages, headers, session_id, owner,
|
|
disabled_tools, tool_policy, workspace=None, active_document=None,
|
|
client_runtime_context=None, external_untrusted_context_seen=False,
|
|
max_tokens=768, max_rounds=8, temperature=0.0,
|
|
request_authority=MISSING_AUTHORITY, **ignored):
|
|
from src.turn_contract import _ACTIVE_CONTRACT
|
|
_ACTIVE_CONTRACT.set(None) # Spec, not the regex turn contract, decides the tools.
|
|
started = time.monotonic()
|
|
surface = spec_surface(disabled_tools)
|
|
by_name = {_name(s): s for s in surface}
|
|
user_text = next((_text(m.get('content')) for m in reversed(messages) if m.get('role') == 'user'), '')
|
|
history = list(messages)
|
|
security = ToolRunSecurityContext(
|
|
external_untrusted_context_seen=bool(external_untrusted_context_seen), unattended_tools=frozenset())
|
|
security.observe_messages(messages)
|
|
picks, executions, done_calls = [], [], set()
|
|
latest, first_token, usage_in, usage_out, rounds = '', None, 0, 0, 0
|
|
recovery = {}
|
|
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=10.0)) as client:
|
|
for round_number in range(1, max_rounds + 1):
|
|
rounds = round_number
|
|
action = await asyncio.to_thread(
|
|
spec_router.next_action, user_text, latest, surface, messages[:-1], disabled_tools)
|
|
if action is None:
|
|
action = {'kind': 'answer_directly', 'name': 'router_unavailable', 'p': None}
|
|
if round_number == max_rounds and action['kind'] == 'tool':
|
|
action = {'kind': 'answer_directly', 'name': 'round_limit', 'p': None}
|
|
picks.append({'round': round_number, **action})
|
|
logger.info('[spec-driver] round=%s pick=%s p=%s', round_number, action['name'], action.get('p'))
|
|
request = {'model': model, 'messages': history, 'max_tokens': max_tokens,
|
|
'temperature': temperature, 'stream': True,
|
|
'stream_options': {'include_usage': True},
|
|
'chat_template_kwargs': {'enable_thinking': False}}
|
|
|
|
if action['kind'] == 'answer_directly':
|
|
answer_parts = []
|
|
async with preview_model_response(client, endpoint_url, headers, request, recovery) as response:
|
|
async for line in response.aiter_lines():
|
|
if not line.startswith('data: ') or line.strip() == 'data: [DONE]':
|
|
continue
|
|
try:
|
|
chunk = json.loads(line[6:])
|
|
except ValueError:
|
|
continue
|
|
if chunk.get('error') or chunk.get('object') == 'error':
|
|
raise RuntimeError(f"model stream error: {str(chunk.get('error') or chunk)[:500]}")
|
|
u = chunk.get('usage') or {}
|
|
usage_in += u.get('prompt_tokens') or 0
|
|
usage_out += u.get('completion_tokens') or 0
|
|
for choice in chunk.get('choices') or []:
|
|
text = (choice.get('delta') or {}).get('content') or ''
|
|
if text:
|
|
first_token = first_token or time.monotonic()
|
|
answer_parts.append(text)
|
|
yield event({'delta': text})
|
|
answer = ''.join(answer_parts).strip()
|
|
if not answer:
|
|
yield event({'delta': 'The model returned no answer.'})
|
|
history.append({'role': 'assistant', 'content': answer})
|
|
break
|
|
|
|
name = 'ask_user' if action['kind'] == 'ask_user' else action['name']
|
|
# ODYSSEUS_SPEC_DRIVER_TOPK=k>1: offer Spec's top-k tools and let the chat model choose among them.
|
|
topk = int(os.getenv('ODYSSEUS_SPEC_DRIVER_TOPK', '1') or 1)
|
|
options = [name]
|
|
if action['kind'] == 'tool' and topk > 1:
|
|
options = [n for n, _ in action.get('top') or []][:topk] or [name]
|
|
schemas = [by_name.get(n) or next((s for s in FUNCTION_TOOL_SCHEMAS if _name(s) == n), None)
|
|
for n in options]
|
|
options = [n for n, sc in zip(options, schemas) if sc is not None]
|
|
schemas = [sc for sc in schemas if sc is not None]
|
|
if not schemas:
|
|
logger.info('[spec-driver] pick %s has no schema; answering instead', name)
|
|
latest = f'Tool {name} is not available.'
|
|
continue
|
|
request['tools'] = [grammar_safe(sc) for sc in schemas]
|
|
request['tool_choice'] = ({'type': 'function', 'function': {'name': options[0]}}
|
|
if len(options) == 1 else 'required')
|
|
if len(options) > 1:
|
|
logger.info('[spec-driver] round=%s options=%s', round_number, options)
|
|
try:
|
|
content, calls, usage = await _model_round(client, endpoint_url, headers, request, recovery)
|
|
except (RuntimeError, httpx.HTTPError) as exc:
|
|
logger.warning('[spec-driver] round=%s %s call failed: %s', round_number, name, exc)
|
|
latest = f'The {name} call could not be produced: {exc}'
|
|
continue
|
|
first_token = first_token or time.monotonic()
|
|
usage_in += usage.get('prompt_tokens') or 0
|
|
usage_out += usage.get('completion_tokens') or 0
|
|
call = next((c for c in calls if c['name'] in options), None)
|
|
if call is not None and len(options) > 1:
|
|
name = call['name']
|
|
logger.info('[spec-driver] round=%s model chose=%s rank=%s of %s',
|
|
round_number, name, options.index(name) + 1, options)
|
|
picks[-1]['chosen'] = name
|
|
if call is None:
|
|
latest = f'The {name} call was not produced.'
|
|
logger.info('[spec-driver] round=%s no %s call from the model', round_number, name)
|
|
continue
|
|
arguments = call['arguments'] or '{}'
|
|
signature = (name, arguments)
|
|
call_id = call['id'] or f'call_{uuid.uuid4().hex[:12]}'
|
|
history.append({'role': 'assistant', 'content': content or '',
|
|
'tool_calls': [{'id': call_id, 'type': 'function',
|
|
'function': {'name': name, 'arguments': arguments}}]})
|
|
if signature in done_calls:
|
|
output = 'This exact call already succeeded; its result is above. Do not repeat it.'
|
|
history.append({'role': 'tool', 'tool_call_id': call_id, 'content': output})
|
|
latest = output
|
|
continue
|
|
try:
|
|
args = json.loads(arguments)
|
|
except ValueError:
|
|
args = {}
|
|
block = function_call_to_tool_block(name, arguments)
|
|
yield event({'type': 'tool_start', 'tool': name, 'command': arguments,
|
|
'full_command': arguments, 'round': round_number})
|
|
tool_started = time.monotonic()
|
|
if block is None:
|
|
desc, result = f'{name}: invalid arguments', {
|
|
'error': 'Tool arguments could not be converted for execution.', 'exit_code': 1}
|
|
else:
|
|
desc, result = await execute_tool_block(
|
|
block, session_id=session_id, owner=owner, disabled_tools=disabled_tools,
|
|
tool_policy=tool_policy, security_context=security,
|
|
active_document_id=getattr(active_document, 'id', None), workspace=workspace,
|
|
client_runtime_context=client_runtime_context, request_authority=request_authority)
|
|
failed = bool(result.get('error')) or result.get('exit_code') not in (None, 0)
|
|
output = preview_tool_result_text(result, name, args)
|
|
tool_event = {'type': 'tool_output', 'tool': name, 'command': arguments, 'output': output,
|
|
'exit_code': result.get('exit_code', 1 if failed else 0), 'error': failed,
|
|
'execution_attempted': block is not None, 'blocked': bool(result.get('blocked')),
|
|
'desc': desc, 'round': round_number,
|
|
'seconds': round(time.monotonic() - tool_started, 3)}
|
|
if result.get('image_url'):
|
|
yield event({'type': 'generated_image', 'url': result['image_url'],
|
|
**{k: result[k] for k in ('image_url', 'image_id', 'image_prompt') if k in result}})
|
|
if result.get('ui_event'):
|
|
yield event({'type': 'ui_control', 'data': result})
|
|
executions.append(tool_event)
|
|
yield event(tool_event)
|
|
history.append({'role': 'tool', 'tool_call_id': call_id, 'content': output})
|
|
if not failed:
|
|
done_calls.add(signature)
|
|
latest = output
|
|
ask = result.get('ask_user')
|
|
if isinstance(ask, dict) and str(ask.get('question') or '').strip():
|
|
question = str(ask['question']).strip()
|
|
history.append({'role': 'assistant', 'content': question})
|
|
yield event({'type': 'final_response', 'content': question})
|
|
yield event({'type': 'ask_user', 'data': ask})
|
|
break
|
|
elapsed = time.monotonic() - started
|
|
yield event({'type': 'metrics', 'data': {
|
|
'model': model, 'input_tokens': usage_in, 'output_tokens': usage_out,
|
|
'total_tokens': usage_in + usage_out, 'response_time': round(elapsed, 3),
|
|
'time_to_first_token': round(first_token - started, 3) if first_token else None,
|
|
'tokens_per_second': round(usage_out / elapsed, 2) if elapsed > 0 else 0,
|
|
'agent_rounds': rounds, 'tool_calls': len(executions), 'tool_events': executions,
|
|
'spec_driver_picks': picks, 'tool_schema_count': len(surface),
|
|
'schema_mode': 'spec_driver', 'temperature': temperature, 'max_output_tokens': max_tokens,
|
|
}})
|
|
yield 'data: [DONE]\n\n'
|