hermes - 💡(How to fix) Fix [Bug]: delegate_task batch mode fails silently when model emits tasks as JSON string

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

Error Message

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." )

Root Cause

The issue spans three layers:

Fix Action

Workaround

Use single-task mode (pass goal instead of tasks) until the model output is fixed or the coercion chain is improved.

Code Example

🔀 preparing delegate_task…
🔀 delegate    0.0s [error]

---

{
  "tasks": "[{\"goal\": \"task1\", \"context\": \"ctx1\", \"toolsets\": [\"file\"]}, {\"goal\": \"task2\", ...}]"
}

---

{
  "tasks": [{"goal": "task1", "context": "ctx1", "toolsets": ["file"]}, {"goal": "task2", ...}]
}

---

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!

---

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!

---

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!

---

{"tasks": "[{\"goal\": \"...\", ...}]"}

---

# 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}")

---

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

---

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,
    )

---

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."
        )
RAW_BUFFERClick to expand / collapse

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:

  1. args["tasks"] = "[{...}, {...}]" (string)
  2. _coerce_json fails → returns string
  3. coerce_tool_args wraps: args["tasks"] = ["[{...}, {...}]"] (list with one string element)
  4. delegate_task receives tasks=["[{...}, {...}]"]
  5. isinstance(tasks, list) = True → passes
  6. len(tasks) = 1 → within max_children
  7. task = "[{...}, {...}]" (a string)
  8. 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:

  1. coerce_tool_args was never called for this tool
  2. 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:

  1. Didn't happen (maybe the string was already being treated as a list by some other path)
  2. 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

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING