Bug Description
delegate_task batch mode silently fails when open-weight models emit the tasks array as a JSON-encoded string instead of a native array. The coerce_tool_args system attempts to fix this via _coerce_json() fallback, but when the JSON string contains complex nested escaping, json.loads() fails silently and the string is wrapped as a single-element list [字符串], causing validation to pass but actual task execution to crash.
Error observed:
🔀 preparing delegate_task…
🔀 delegate 0.0s [error]
Repeated 4-5 times before the model falls back to single-task mode.
Severity
Medium — batch delegation (parallel subagents) is completely unusable with certain models.
Environment
- Hermes Agent: v0.10.0 (2026.4.16)
- Model: Qwen/Qwen3.6-27B-FP8 (via SGLang on 192.168.14.32:8000)
- Platform: CLI (macOS)
Root Cause Analysis
The issue spans three layers:
Layer 1: Model output drift
Qwen3.6-27B-FP8 emits tasks as a JSON string instead of a native array:
{
"tasks": "[{\"goal\": \"task1\", \"context\": \"ctx1\", \"toolsets\": [\"file\"]}, {\"goal\": \"task2\", ...}]"
}
Expected:
{
"tasks": [{"goal": "task1", "context": "ctx1", "toolsets": ["file"]}, {"goal": "task2", ...}]
}
Layer 2: coerce_tool_args fallback chain
In model_tools.py coerce_tool_args() (line 545-564):
if expected == "array" and value is not None and not isinstance(value, (list, tuple)):
if isinstance(value, str):
coerced = _coerce_value(value, expected, schema=prop_schema)
if coerced is not value:
args[key] = coerced
continue
args[key] = [value] # ← Falls back to wrapping string in list!
Layer 3: _coerce_json silent failure
In model_tools.py _coerce_json() (line 630-648):
def _coerce_json(value: str, expected_python_type: type):
try:
parsed = json.loads(value)
except (ValueError, TypeError):
return value # ← Returns original string silently!
if isinstance(parsed, expected_python_type):
return parsed
return value # ← Also silent if wrong type!
When json.loads() fails (e.g. due to malformed escaping in the model's output), the function returns the original string. coerce_tool_args then wraps it as [字符串].
Layer 4: Validation passes but task execution fails
In delegate_tool.py (line 1954-1969):
if tasks and isinstance(tasks, list): # ← [字符串] passes this check!
task_list = tasks
# Later:
for i, task in enumerate(task_list):
if not task.get("goal", "").strip(): # ← String has no .get() method!
Actually, the validation at line 1969 fires because isinstance("...", list) is False, so it falls through to else: return tool_error("Provide either 'goal' (single task) or 'tasks' (batch).").
Wait — let me re-examine. The actual flow when _coerce_json returns the string:
args["tasks"] = "[{...}, {...}]" (string)
_coerce_json fails → returns string
coerce_tool_args wraps: args["tasks"] = ["[{...}, {...}]"] (list with one string element)
delegate_task receives tasks=["[{...}, {...}]"]
isinstance(tasks, list) = True → passes
len(tasks) = 1 → within max_children
task = "[{...}, {...}]" (a string)
task.get("goal", "") → AttributeError on string!
But the actual error observed was "Provide either 'goal' (single task) or 'tasks' (batch).", which suggests tasks was NOT being coerced to a list. Let me check the actual args from session logs:
From session data, the model actually sent tasks as a properly structured string that json.loads() should parse. But the error "Provide either..." means tasks was either:
- Not recognized as a list (still a string after coercion)
- Or
goal was also present, causing ambiguity
Most likely scenario: The JSON string produced by the model has escaping issues that cause json.loads() to fail, _coerce_json returns the string, and coerce_tool_args does NOT wrap it because the string already looks like a list syntactically... Actually no, the code DOES wrap it. Let me re-check.
Actually the error message "Provide either 'goal' (single task) or 'tasks' (batch)." comes from line 1969, which only fires when:
tasks is falsy or not a list (first condition fails)
- AND
goal is falsy or not a string (second condition fails)
So tasks must have been a string that was NOT wrapped into a list. This means either:
coerce_tool_args was never called for this tool
- Or
_coerce_json returned the string and it was NOT wrapped
Looking more carefully: In coerce_tool_args line 545, the condition is expected == "array" and value is not None and not isinstance(value, (list, tuple)). If value is a string, this is True. Then it calls _coerce_value(value, "array", ...), which calls _coerce_json(value, list). If that returns the original string (JSON parse failed), then coerced is not value is False, so it falls through to args[key] = [value] (wrapping).
So args["tasks"] becomes ["[{...}]"] (a list). But in delegate_task, tasks parameter receives this list. isinstance(tasks, list) is True, so it enters the batch branch. Then task = "[{...}]" (a string). task.get("goal") would raise AttributeError.
But the observed error is "Provide either..." not an AttributeError. This means the coercion either:
- Didn't happen (maybe the string was already being treated as a list by some other path)
- Or the model sent BOTH
goal and tasks as strings
From session logs, the actual args structure was:
{"tasks": "[{\"goal\": \"...\", ...}]"}
No goal at top level. So if tasks was coerced to a list, the error should be different. The fact that we see "Provide either..." means tasks was still a string when it reached delegate_task.
Hypothesis: The coerce_tool_args wrapping at line 553 (args[key] = [value]) does NOT actually execute because the _coerce_value call at line 547-552 does something unexpected. Or the JSON string is valid but parses to something other than a list.
Regardless of the exact chain, the core issue is clear: the coercion fallback chain is silent and produces confusing errors.
Reproduction
# Simulate what happens with the model's output
import json
# Model sends tasks as a JSON string
args = {"tasks": "[{\"goal\": \"Test task 1\", \"context\": \"Context 1\", \"toolsets\": [\"file\"]}, {\"goal\": \"Test task 2\", \"context\": \"Context 2\", \"toolsets\": [\"file\"]}]"}
# After _coerce_json (should work with valid JSON):
parsed = json.loads(args["tasks"])
print(f"Parsed type: {type(parsed).__name__}") # list
print(f"Is list: {isinstance(parsed, list)}") # True
# But if JSON has escaping issues:
args_bad = {"tasks": "[{\"goal\": \"Test task with \"\"nested quotes\"\" inside\", \"context\": \"Ctx\"}]"}
try:
parsed = json.loads(args_bad["tasks"])
print(f"Bad JSON parsed: {type(parsed).__name__}")
except json.JSONDecodeError as e:
print(f"Bad JSON failed: {e}")
Proposed Fix
Fix 1: Improve _coerce_json error reporting
In model_tools.py _coerce_json():
def _coerce_json(value: str, expected_python_type: type):
try:
parsed = json.loads(value)
except (ValueError, TypeError) as e:
logger.warning(
"coerce_tool_args: failed to parse %s as JSON for %s: %s",
expected_python_type.__name__, tool_name, str(e),
)
return value
if isinstance(parsed, expected_python_type):
return parsed
logger.warning(
"coerce_tool_args: parsed JSON is %s, expected %s",
type(parsed).__name__, expected_python_type.__name__,
)
return value
Fix 2: Detect string-wrapped arrays in coerce_tool_args
After _coerce_value fails, before wrapping, check if the string looks like a JSON array and log a clearer warning:
if isinstance(value, str) and value.strip().startswith('['):
logger.warning(
"coerce_tool_args: %s.%s appears to be a JSON array string but failed to parse — "
"the model may need to emit native JSON arrays instead of JSON-encoded strings. "
"Falling back to wrapping as single-element list.",
tool_name, key,
)
Fix 3: Post-coercion validation in delegate_task
Add a defensive check before the main logic:
if tasks and isinstance(tasks, list) and len(tasks) == 1 and isinstance(tasks[0], str):
# Likely a model-emitted JSON string that failed coercion
try:
parsed = json.loads(tasks[0])
if isinstance(parsed, list):
tasks = parsed
logger.info("delegate_task: recovered tasks from failed coercion")
else:
return tool_error(
f"tasks parameter is a JSON string that parses to {type(parsed).__name__}, "
f"not an array. The model should emit a native JSON array."
)
except json.JSONDecodeError:
return tool_error(
f"tasks parameter appears to be a malformed JSON string. "
f"Ensure the model emits a native JSON array for batch delegation."
)
Workaround
Use single-task mode (pass goal instead of tasks) until the model output is fixed or the coercion chain is improved.
Related Issues
coerce_tool_args wrapping behavior already documented in model_tools.py line 515-519: "Also wraps bare scalar values in a single-element list when the schema declares type: array"
- This is a known pattern with open-weight models (DeepSeek, Qwen, GLM) as noted in the codebase
Bug Description
delegate_taskbatch mode silently fails when open-weight models emit thetasksarray as a JSON-encoded string instead of a native array. Thecoerce_tool_argssystem attempts to fix this via_coerce_json()fallback, but when the JSON string contains complex nested escaping,json.loads()fails silently and the string is wrapped as a single-element list[字符串], causing validation to pass but actual task execution to crash.Error observed:
Repeated 4-5 times before the model falls back to single-task mode.
Severity
Medium — batch delegation (parallel subagents) is completely unusable with certain models.
Environment
Root Cause Analysis
The issue spans three layers:
Layer 1: Model output drift
Qwen3.6-27B-FP8 emits
tasksas a JSON string instead of a native array:{ "tasks": "[{\"goal\": \"task1\", \"context\": \"ctx1\", \"toolsets\": [\"file\"]}, {\"goal\": \"task2\", ...}]" }Expected:
{ "tasks": [{"goal": "task1", "context": "ctx1", "toolsets": ["file"]}, {"goal": "task2", ...}] }Layer 2:
coerce_tool_argsfallback chainIn
model_tools.pycoerce_tool_args()(line 545-564):Layer 3:
_coerce_jsonsilent failureIn
model_tools.py_coerce_json()(line 630-648):When
json.loads()fails (e.g. due to malformed escaping in the model's output), the function returns the original string.coerce_tool_argsthen wraps it as[字符串].Layer 4: Validation passes but task execution fails
In
delegate_tool.py(line 1954-1969):Actually, the validation at line 1969 fires because
isinstance("...", list)is False, so it falls through toelse: return tool_error("Provide either 'goal' (single task) or 'tasks' (batch).").Wait — let me re-examine. The actual flow when
_coerce_jsonreturns the string:args["tasks"]="[{...}, {...}]"(string)_coerce_jsonfails → returns stringcoerce_tool_argswraps:args["tasks"]=["[{...}, {...}]"](list with one string element)delegate_taskreceivestasks=["[{...}, {...}]"]isinstance(tasks, list)= True → passeslen(tasks)= 1 → within max_childrentask = "[{...}, {...}]"(a string)task.get("goal", "")→ AttributeError on string!But the actual error observed was
"Provide either 'goal' (single task) or 'tasks' (batch).", which suggeststaskswas NOT being coerced to a list. Let me check the actual args from session logs:From session data, the model actually sent
tasksas a properly structured string thatjson.loads()should parse. But the error"Provide either..."meanstaskswas either:goalwas also present, causing ambiguityMost likely scenario: The JSON string produced by the model has escaping issues that cause
json.loads()to fail,_coerce_jsonreturns the string, andcoerce_tool_argsdoes NOT wrap it because the string already looks like a list syntactically... Actually no, the code DOES wrap it. Let me re-check.Actually the error message
"Provide either 'goal' (single task) or 'tasks' (batch)."comes from line 1969, which only fires when:tasksis falsy or not a list (first condition fails)goalis falsy or not a string (second condition fails)So
tasksmust have been a string that was NOT wrapped into a list. This means either:coerce_tool_argswas never called for this tool_coerce_jsonreturned the string and it was NOT wrappedLooking more carefully: In
coerce_tool_argsline 545, the condition isexpected == "array" and value is not None and not isinstance(value, (list, tuple)). Ifvalueis a string, this is True. Then it calls_coerce_value(value, "array", ...), which calls_coerce_json(value, list). If that returns the original string (JSON parse failed), thencoerced is not valueis False, so it falls through toargs[key] = [value](wrapping).So
args["tasks"]becomes["[{...}]"](a list). But indelegate_task,tasksparameter receives this list.isinstance(tasks, list)is True, so it enters the batch branch. Thentask = "[{...}]"(a string).task.get("goal")would raise AttributeError.But the observed error is
"Provide either..."not an AttributeError. This means the coercion either:goalandtasksas stringsFrom session logs, the actual args structure was:
{"tasks": "[{\"goal\": \"...\", ...}]"}No
goalat top level. So iftaskswas coerced to a list, the error should be different. The fact that we see"Provide either..."meanstaskswas still a string when it reacheddelegate_task.Hypothesis: The
coerce_tool_argswrapping at line 553 (args[key] = [value]) does NOT actually execute because the_coerce_valuecall at line 547-552 does something unexpected. Or the JSON string is valid but parses to something other than a list.Regardless of the exact chain, the core issue is clear: the coercion fallback chain is silent and produces confusing errors.
Reproduction
Proposed Fix
Fix 1: Improve
_coerce_jsonerror reportingIn
model_tools.py_coerce_json():Fix 2: Detect string-wrapped arrays in
coerce_tool_argsAfter
_coerce_valuefails, before wrapping, check if the string looks like a JSON array and log a clearer warning:Fix 3: Post-coercion validation in
delegate_taskAdd a defensive check before the main logic:
Workaround
Use single-task mode (pass
goalinstead oftasks) until the model output is fixed or the coercion chain is improved.Related Issues
coerce_tool_argswrapping behavior already documented inmodel_tools.pyline 515-519: "Also wraps bare scalar values in a single-element list when the schema declarestype: array"