fix(brain): recover colon-JSON and single tool_call object forms
qwen2.5:3b emits tool calls in text shapes the parser dropped, breaking
two reviewer-reported behaviours:
- `getWeather: {"location": "Seoul"}` (a JSON object after the colon) was
dumped wholesale into {"query": "{...}"}, so `location` never reached the
tool. getWeather then ran with empty args, returned the auto-detected
location's weather, the model noticed the mismatch and retried — looping up
to 8 times before giving up with an English error. Now the JSON object after
the colon is parsed directly as the argument dict.
- `call_stop: {"id":..., "function": {"name": "setBroadcast",
"arguments": "{\"action\": \"stop\"}"}}` — a single tool_call object without
the `tool_calls: [...]` array wrapper, behind a `call_xxx:` label — matched
no form, so the raw JSON leaked to the user AND setBroadcast never ran
("방송 꺼줘" did nothing). Now name + arguments are pulled from the embedded
`function` object when the name is in the allow-list.
Field-captured from the live qwen2.5:3b brain (2026-06-12). Tests cover both
shapes, non-ASCII args, dict/string arguments, and unknown-tool rejection.
This commit is contained in:
@@ -431,6 +431,21 @@ def _extract_text_tool_call(content_field: str, known_names: set):
|
||||
if m and m.group(1) in known_names:
|
||||
name = m.group(1)
|
||||
rest = m.group(2).strip()
|
||||
# If the value after the colon is itself a JSON object, it already IS
|
||||
# the argument dict — parse it directly. Small models routinely emit
|
||||
# `toolName: {"location": "Seoul"}`. Without this fast-path the whole
|
||||
# object is dumped into {"query": "{...}"} below, so the real named
|
||||
# arguments (e.g. location) never reach the tool. The tool then runs
|
||||
# with empty args (e.g. weather falls back to auto-detected location),
|
||||
# the model notices the answer doesn't match and retries, looping
|
||||
# until the turn cap.
|
||||
if rest.startswith("{"):
|
||||
try:
|
||||
obj = json.loads(rest)
|
||||
if isinstance(obj, dict):
|
||||
return name, obj, f"call_{uuid.uuid4().hex[:8]}"
|
||||
except Exception:
|
||||
pass
|
||||
args: dict = {}
|
||||
for pair in re.split(r"[\n,]", rest):
|
||||
pair = pair.strip()
|
||||
@@ -460,6 +475,42 @@ def _extract_text_tool_call(content_field: str, known_names: set):
|
||||
parsed_args = {"query": inside.strip().strip('"').strip("'")}
|
||||
return name, parsed_args, f"call_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Form: a single tool_call OBJECT emitted without the `tool_calls: [...]`
|
||||
# array wrapper, optionally behind a `call_xxx:` label. Captured from
|
||||
# qwen2.5:3b (2026-06-12) on "방송 꺼줘":
|
||||
# call_stop: {"id": "call_stop", "type": "function",
|
||||
# "function": {"name": "setBroadcast",
|
||||
# "arguments": "{\"action\": \"stop\"}"}}
|
||||
# The colon/array forms above don't match (the label isn't a tool name and
|
||||
# there's no array), so without this the raw JSON leaked to the user AND the
|
||||
# chosen tool never ran. Pull name + arguments straight out of the embedded
|
||||
# `"function": {...}` object.
|
||||
func_match = re.search(
|
||||
r'"function"\s*:\s*\{\s*"name"\s*:\s*"([^"]+)"'
|
||||
r'(?:\s*,\s*"arguments"\s*:\s*(\{.*?\}|"(?:[^"\\]|\\.)*"))?',
|
||||
content_field,
|
||||
re.DOTALL,
|
||||
)
|
||||
if func_match and func_match.group(1).strip() in known_names:
|
||||
fname = func_match.group(1).strip()
|
||||
raw_args = func_match.group(2)
|
||||
parsed_args = {}
|
||||
if raw_args:
|
||||
try:
|
||||
val = json.loads(raw_args)
|
||||
if isinstance(val, dict):
|
||||
parsed_args = val
|
||||
elif isinstance(val, str):
|
||||
# arguments was a JSON string (double-encoded) — unwrap once.
|
||||
try:
|
||||
inner = json.loads(val)
|
||||
parsed_args = inner if isinstance(inner, dict) else {"query": val}
|
||||
except Exception:
|
||||
parsed_args = {"query": val}
|
||||
except Exception:
|
||||
parsed_args = {}
|
||||
return fname, parsed_args, f"call_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
return None, None, None
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user