|
# retoor <retoor@molodetz.nl>
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
from typing import Any, Callable, Optional
|
|
|
|
from ..config import Settings
|
|
from ..errors import LLMError
|
|
from ..chunks import reset_store, set_store
|
|
from ..cost import reset_tracker, set_tracker
|
|
from .compaction import compact_messages, context_size
|
|
from .state import AgentState, reset_state, set_state
|
|
|
|
logger = logging.getLogger("devii.agentic.loop")
|
|
|
|
TraceCallback = Callable[[str, str, str], None]
|
|
OUTPUT_CAP_CHARS = 200_000
|
|
|
|
REFLECTION_TRIGGER = (
|
|
"[reflection-trigger] One or more tool calls returned an error. Call reflect() with the "
|
|
"observation, root-cause conclusion, and next action before retrying. Do not repeat the "
|
|
"same call without diagnosis."
|
|
)
|
|
PLAN_VIOLATION = (
|
|
"Protocol violation: your first tool call must be plan(). Restart with a structured plan "
|
|
"before any other action."
|
|
)
|
|
VERIFICATION_GATE = (
|
|
"[verification-gate] You produced a final answer after performing changes without calling "
|
|
"verify(). Confirm the change took effect and call verify() with a summary. If verification "
|
|
"truly does not apply, reply explicitly starting with: 'No verification applicable: <reason>'."
|
|
)
|
|
ITERATION_LIMIT_MESSAGE = "[stopped] Maximum iterations reached without a final answer."
|
|
|
|
|
|
LABEL_KEYS = (
|
|
"post_slug",
|
|
"project_slug",
|
|
"gist_slug",
|
|
"news_slug",
|
|
"username",
|
|
"comment_uid",
|
|
"notification_uid",
|
|
"attachment_uid",
|
|
"poll_uid",
|
|
"receiver_uid",
|
|
"uid",
|
|
"name",
|
|
"q",
|
|
"query",
|
|
"title",
|
|
"goal",
|
|
"task",
|
|
"email",
|
|
)
|
|
LABEL_MAX_CHARS = 60
|
|
|
|
|
|
def call_label(call: dict[str, Any]) -> str:
|
|
raw = call.get("function", {}).get("arguments") or "{}"
|
|
try:
|
|
arguments = json.loads(raw) if isinstance(raw, str) else raw
|
|
except (ValueError, TypeError):
|
|
return ""
|
|
if not isinstance(arguments, dict):
|
|
return ""
|
|
|
|
def clean(value: Any) -> str:
|
|
return str(value).replace("\n", " ").strip()[:LABEL_MAX_CHARS]
|
|
|
|
if arguments.get("target_uid"):
|
|
target_type = arguments.get("target_type")
|
|
target = clean(arguments["target_uid"])
|
|
return f"{target_type}/{target}" if target_type else target
|
|
for key in LABEL_KEYS:
|
|
value = arguments.get(key)
|
|
if value not in (None, ""):
|
|
return clean(value)
|
|
for value in arguments.values():
|
|
if isinstance(value, (str, int, float, bool)) and clean(value):
|
|
return clean(value)
|
|
return ""
|
|
|
|
|
|
def normalize(message: dict[str, Any]) -> dict[str, Any]:
|
|
normalized: dict[str, Any] = {
|
|
"role": message.get("role", "assistant"),
|
|
"content": message.get("content") or "",
|
|
}
|
|
if message.get("tool_calls"):
|
|
normalized["tool_calls"] = message["tool_calls"]
|
|
return normalized
|
|
|
|
|
|
def _is_error(result: str) -> bool:
|
|
try:
|
|
return isinstance(json.loads(result), dict) and json.loads(result).get("error") is not None
|
|
except (ValueError, TypeError):
|
|
return False
|
|
|
|
|
|
def _summary(result: str) -> str:
|
|
try:
|
|
parsed = json.loads(result)
|
|
except (ValueError, TypeError):
|
|
return ""
|
|
if not isinstance(parsed, dict):
|
|
return ""
|
|
if parsed.get("error"):
|
|
return str(parsed.get("message") or parsed.get("error"))
|
|
if "status" in parsed:
|
|
return str(parsed["status"])
|
|
return ""
|
|
|
|
|
|
async def _run_tool_call(dispatcher: Any, call: dict[str, Any]) -> str:
|
|
function = call.get("function", {})
|
|
name = function.get("name", "")
|
|
raw_arguments = function.get("arguments") or "{}"
|
|
try:
|
|
arguments = json.loads(raw_arguments) if isinstance(raw_arguments, str) else raw_arguments
|
|
if not isinstance(arguments, dict):
|
|
raise ValueError("arguments must be an object")
|
|
except ValueError as exc:
|
|
return json.dumps({"error": "tool_input_error", "message": f"Invalid arguments: {exc}"})
|
|
return await dispatcher.dispatch(name, arguments)
|
|
|
|
|
|
async def react_loop(
|
|
llm: Any,
|
|
dispatcher: Any,
|
|
messages: list[dict[str, Any]],
|
|
tools: list[dict[str, Any]],
|
|
state: AgentState,
|
|
settings: Settings,
|
|
max_iterations: int,
|
|
plan_required: bool,
|
|
verify_required: bool,
|
|
on_trace: Optional[TraceCallback] = None,
|
|
cost_tracker: Any = None,
|
|
chunk_store: Any = None,
|
|
) -> str:
|
|
def trace(event: str, name: str = "", detail: str = "") -> None:
|
|
if on_trace is not None:
|
|
on_trace(event, name, detail)
|
|
|
|
token = set_state(state)
|
|
cost_token = set_tracker(cost_tracker) if cost_tracker is not None else None
|
|
chunk_token = set_store(chunk_store) if chunk_store is not None else None
|
|
final_content = ""
|
|
try:
|
|
while state.iteration < max_iterations:
|
|
state.iteration += 1
|
|
|
|
if context_size(messages) > settings.context_compact_threshold:
|
|
trace("compact")
|
|
messages[:] = await compact_messages(llm, messages, settings.context_keep_tail)
|
|
|
|
try:
|
|
message = await llm.complete(messages, tools)
|
|
except LLMError as exc:
|
|
logger.info("LLM error: %s", exc.message)
|
|
return f"[model error] {exc.message}"
|
|
|
|
messages.append(normalize(message))
|
|
tool_calls = message.get("tool_calls") or []
|
|
|
|
if tool_calls:
|
|
if plan_required and state.plan is None and tool_calls[0]["function"]["name"] != "plan":
|
|
trace("plan-gate")
|
|
for call in tool_calls:
|
|
messages.append(
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": call.get("id", ""),
|
|
"name": call.get("function", {}).get("name", ""),
|
|
"content": json.dumps({"error": "protocol", "message": PLAN_VIOLATION}),
|
|
}
|
|
)
|
|
continue
|
|
|
|
for call in tool_calls:
|
|
trace("call", call.get("function", {}).get("name", ""), call_label(call))
|
|
|
|
results = await asyncio.gather(
|
|
*[_run_tool_call(dispatcher, call) for call in tool_calls]
|
|
)
|
|
|
|
any_error = False
|
|
for call, result in zip(tool_calls, results):
|
|
if len(result) > OUTPUT_CAP_CHARS:
|
|
result = result[:OUTPUT_CAP_CHARS] + f"\n...[truncated {len(result)} chars]"
|
|
name = call.get("function", {}).get("name", "")
|
|
messages.append(
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": call.get("id", ""),
|
|
"name": name,
|
|
"content": result,
|
|
}
|
|
)
|
|
label = call_label(call)
|
|
summary = _summary(result)
|
|
detail = " ".join(part for part in (label, f"({summary})" if summary else "") if part)
|
|
if _is_error(result):
|
|
any_error = True
|
|
trace("err", name, detail)
|
|
else:
|
|
trace("ok", name, detail)
|
|
|
|
if any_error:
|
|
trace("reflect-trigger")
|
|
messages.append({"role": "user", "content": REFLECTION_TRIGGER})
|
|
continue
|
|
|
|
content = message.get("content")
|
|
if content:
|
|
if (
|
|
verify_required
|
|
and not state.verified
|
|
and not state.gate_triggered
|
|
and state.mutations
|
|
):
|
|
state.gate_triggered = True
|
|
trace("verify-gate")
|
|
messages.append({"role": "user", "content": VERIFICATION_GATE})
|
|
continue
|
|
final_content = content
|
|
break
|
|
|
|
if state.iteration >= max_iterations and not final_content:
|
|
return ITERATION_LIMIT_MESSAGE
|
|
return final_content
|
|
finally:
|
|
if chunk_token is not None:
|
|
reset_store(chunk_token)
|
|
if cost_token is not None:
|
|
reset_tracker(cost_token)
|
|
reset_state(token)
|