FHC_OCR / agent /orchestrator.py
Shubadecka's picture
fixing thinking and search
5a6288c
Raw
History Blame Contribute Delete
7.02 kB
"""
Agentic orchestration loop for the VL model.
Calls `InferenceClient.chat_completion` with the three agent tools defined in
`agent/tools.py`. The loop continues as long as the model emits tool calls.
It stops (and returns a final string) when:
- The model calls `final_output` -> return the `answer` argument
- The model calls `abort` -> return "Aborted: {reason}"
- The model returns plain content with no tool calls
- `max_tool_rounds` is reached -> return the last assistant content
"""
from __future__ import annotations
import json
from typing import Any
from huggingface_hub import InferenceClient
from .tools import AGENT_TOOLS, dispatch_tool
DEFAULT_MAX_TOOL_ROUNDS = 10
# Qwen3 "Thinking" models emit reasoning between these tags; the user-facing
# answer follows the closing tag. See vLLM Qwen3ReasoningParser.
_QWEN_THINK_START = "<think>"
_QWEN_THINK_END = "</think>"
def strip_qwen_thinking(text: str) -> str:
"""Drop Qwen3-Thinking chain-of-thought; keep only the final answer segment."""
if not text or not isinstance(text, str):
return text
s = text
before, found, after = s.partition(_QWEN_THINK_START)
s = after if found else before
# Use the *last* closing tag: some replies contain multiple `</think>`
# segments; only the final segment is meant for the user.
if _QWEN_THINK_END in s:
return s.rsplit(_QWEN_THINK_END, 1)[-1].lstrip()
return s
def run_agent(
messages: list[dict[str, Any]],
client: InferenceClient,
model: str,
max_tokens: int = 512,
temperature: float = 0.7,
top_p: float = 0.95,
max_tool_rounds: int = DEFAULT_MAX_TOOL_ROUNDS,
) -> str:
"""
Run the agentic loop and return the final answer as a string.
Parameters
----------
messages:
Full conversation so far, including the system message, all history,
and the latest user message (which may contain an image as a multimodal
content list).
client:
An authenticated `InferenceClient` instance.
model:
HF model ID to use for inference.
max_tokens:
Maximum tokens per completion call.
temperature:
Sampling temperature.
top_p:
Nucleus sampling top-p.
max_tool_rounds:
Hard cap on how many tool-calling rounds are allowed before the loop
gives up and returns whatever the model last said.
"""
messages = list(messages) # work on a local copy
for _round in range(max_tool_rounds + 1):
response = client.chat_completion(
messages=messages,
model=model,
tools=AGENT_TOOLS,
tool_choice="auto",
max_tokens=max_tokens,
temperature=temperature,
top_p=top_p,
stream=False,
)
choice = response.choices[0]
msg = choice.message
tool_calls = getattr(msg, "tool_calls", None) or []
# ------------------------------------------------------------------ #
# No tool calls → plain text answer, we're done #
# ------------------------------------------------------------------ #
if not tool_calls:
return _visible_assistant_text(msg)
# ------------------------------------------------------------------ #
# There are tool calls → check for terminal tools first #
# ------------------------------------------------------------------ #
for tc in tool_calls:
fn = tc.function
name = fn.name
raw_args = fn.arguments or "{}"
args = raw_args if isinstance(raw_args, dict) else _safe_parse(raw_args)
if name == "final_output":
return strip_qwen_thinking(
args.get("answer", "") or _visible_assistant_text(msg)
)
if name == "abort":
reason = args.get("reason", "")
return f"Aborted: {reason}" if reason else "Task aborted."
# ------------------------------------------------------------------ #
# Non-terminal tool calls → execute each one and feed results back #
# ------------------------------------------------------------------ #
messages.append(_assistant_tool_call_message(msg))
for tc in tool_calls:
fn = tc.function
name = fn.name
raw_args = fn.arguments or "{}"
args = raw_args if isinstance(raw_args, dict) else _safe_parse(raw_args)
result = dispatch_tool(name, args)
messages.append(
{
"role": "tool",
"content": result,
"tool_call_id": tc.id,
}
)
# Max rounds exhausted — return the last assistant content if any
last_assistant = next(
(m["content"] for m in reversed(messages) if m.get("role") == "assistant"),
"I was unable to complete the task within the allowed number of steps.",
)
return strip_qwen_thinking(
last_assistant
or "I was unable to complete the task within the allowed number of steps."
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _visible_assistant_text(msg: Any) -> str:
"""
User-visible assistant string from `message.content` only. The Inference API
may return chain-of-thought in a separate `reasoning` field; we never merge
that into the UI. Strip any `</think>`...`</think>` blocks left in `content`.
"""
raw = getattr(msg, "content", None) or ""
return strip_qwen_thinking(raw)
def _safe_parse(raw: str) -> dict:
"""Parse a JSON string into a dict, returning {} on failure."""
try:
return json.loads(raw)
except (json.JSONDecodeError, TypeError):
return {}
def _assistant_tool_call_message(msg: Any) -> dict:
"""
Re-serialise the assistant message that contains tool_calls into the plain
dict format expected when appended back to `messages`.
"""
tool_calls_payload = []
for tc in msg.tool_calls or []:
fn = tc.function
tool_calls_payload.append(
{
"id": tc.id,
"type": "function",
"function": {
"name": fn.name,
"arguments": (
fn.arguments
if isinstance(fn.arguments, str)
else json.dumps(fn.arguments)
),
},
}
)
return {
"role": "assistant",
# Drop thinking from prior step so follow-up completions are not primed
# to dump huge `</think>` blocks into `content` on later turns.
"content": strip_qwen_thinking(msg.content or ""),
"tool_calls": tool_calls_payload,
}