Summary
When an agent returns syntactically valid JSON whose fields have the wrong shape, the copilot provider fails the entire workflow immediately with zero recovery attempts — even though the provider has a fully-built parse-recovery mechanism (max_parse_recovery_attempts, default 5) that is designed for exactly this class of "the model didn't follow the output contract" failure.
The cause is that the copilot provider never calls validate_output() inside its recovery loop. Schema validation happens one layer up, in executor/agent.py, after the provider has already returned — so the ValidationError is raised outside the loop that could have re-prompted the model.
The hermes provider does this correctly. copilot — the default provider — does not.
Impact
A ~3.5 hour, ~$2.30 multi-agent run died at the final agent, after all prior agents had succeeded and a git commit had already been made. The failure was a single field on a single response, recoverable by one re-prompt that the codebase already knows how to construct (_build_parse_recovery_prompt).
Workflow Failed
Output field 'decision' has wrong type: expected string, got dict
💡 Suggestion: Ensure agent returns correct type for 'decision'
The agent declared:
output:
decision:
type: string
description: Review decision - APPROVE or REQUEST_CHANGES
…and the model returned decision as a nested object rather than the bare string. Everything else about the response was well-formed JSON.
This is materially worse than a parse failure, because it tends to surface with non-Anthropic models whose structured-output conventions differ, and it strands work that is otherwise complete.
Environment
|
|
| Conductor |
v0.1.26 (latest release; also verified against main) |
| Provider |
copilot |
| Affected agent model |
gpt-5.6-sol (reasoning: xhigh, context_tier: long_context) |
| OS |
Windows 11, PowerShell 7 |
Reproduced with a workflow whose other five agents ran on claude-opus-5 / claude-sonnet-5 without incident — only the agent on a non-Anthropic model tripped it.
Root cause
Verified against main (paths below are current):
1. Validation lives outside the provider. src/conductor/executor/agent.py:318
# Validate output against schema (skip for partial output from interrupts)
if agent.output and not output.partial:
validate_output(output.content, agent.output)
2. The copilot provider never validates in-session. src/conductor/providers/copilot.py contains zero occurrences of validate_output. Its recovery loop catches syntax errors only:
# providers/copilot.py:1202
except (json.JSONDecodeError, ValueError) as e:
So max_parse_recovery_attempts (default 5 for copilot) is unreachable for schema-shape failures.
3. validate_output is strict, with no coercion. src/conductor/executor/output.py:47
if not _check_type(value, expected_type):
raise ValidationError(
f"Output field '{field_name}' has wrong type: "
f"expected {expected_type}, got {type(value).__name__}",
...
)
The providers disagree with each other
| Provider |
Validates inside recovery loop? |
Catches ValidationError? |
Result on wrong-shaped field |
hermes |
✅ hermes.py:524 |
✅ hermes.py:526 |
Recovers (up to 3 attempts) |
claude |
✅ claude.py:1239 |
❌ claude.py:1271 — "Re-raise ValidationError without wrapping (non-retryable)" |
Fails fast — an explicit, documented choice |
copilot |
❌ never calls it |
❌ n/a |
Fails, and cannot even try |
hermes already contains the intended shape:
# providers/hermes.py:521-526
for attempt in range(_MAX_PARSE_RECOVERY_ATTEMPTS + 1):
try:
content = parse_json_output(response)
validate_output(content, agent.output)
return content
except (json.JSONDecodeError, ValueError, ValidationError) as e:
The asymmetry looks unintentional rather than designed: copilot is the default provider and carries the largest recovery budget (5, versus hermes' 3), yet derives no benefit from it for this failure class.
Proposed fix
Mirror hermes in the copilot provider — validate inside the recovery loop and include ValidationError in the caught exceptions, so a wrong-shaped field is re-prompted like any other contract violation:
for recovery_attempt in range(max_recovery + 1):
try:
parsed_content = self._extract_json(response_content)
if schema_for_prompt is not None:
validate_output(parsed_content, schema_for_prompt) # NEW
return parsed_content, final_usage
except (json.JSONDecodeError, ValueError, ValidationError) as e: # ValidationError NEW
...
_build_parse_recovery_prompt(parse_error=..., original_response=..., schema=...) already takes the schema, so the recovery prompt would carry the exact type error and the expected shape without further changes.
Two smaller improvements worth considering alongside it:
- Unwrap the single-key case before failing. A very common model behaviour is emitting
{"decision": {"decision": "APPROVE", "reasoning": "..."}} or {"decision": {"value": "APPROVE"}}. When a string is expected and a dict is received, unwrapping a sole string-valued key (or a value/matching-field-name key) would resolve most occurrences without a round-trip. This should be conservative and only applied where unambiguous.
- Surface the offending value in the error. The message names the field and the two types but not the received value, so diagnosing from logs alone requires re-running with verbose output.
Reproduction
- Define an agent with
output.decision.type: string, prompted with something like "Provide your decision: APPROVE or REQUEST_CHANGES".
- Run it on
provider: copilot with a non-Anthropic model (gpt-5.6-sol here).
- When the model wraps the value in an object, the workflow terminates immediately — no entry in the log shows a recovery attempt, and
max_parse_recovery_attempts has no effect.
Running the same workflow with provider: hermes recovers from the identical response.
Note
This is unrelated to #342 (Windows UnicodeEncodeError truncating the --silent result after a successful run). That one crashes on output serialization once the workflow has completed; this one fails the workflow itself, mid-run, and leaves the final agent without a result.
Summary
When an agent returns syntactically valid JSON whose fields have the wrong shape, the
copilotprovider fails the entire workflow immediately with zero recovery attempts — even though the provider has a fully-built parse-recovery mechanism (max_parse_recovery_attempts, default 5) that is designed for exactly this class of "the model didn't follow the output contract" failure.The cause is that the
copilotprovider never callsvalidate_output()inside its recovery loop. Schema validation happens one layer up, inexecutor/agent.py, after the provider has already returned — so theValidationErroris raised outside the loop that could have re-prompted the model.The
hermesprovider does this correctly.copilot— the default provider — does not.Impact
A ~3.5 hour, ~$2.30 multi-agent run died at the final agent, after all prior agents had succeeded and a git commit had already been made. The failure was a single field on a single response, recoverable by one re-prompt that the codebase already knows how to construct (
_build_parse_recovery_prompt).The agent declared:
…and the model returned
decisionas a nested object rather than the bare string. Everything else about the response was well-formed JSON.This is materially worse than a parse failure, because it tends to surface with non-Anthropic models whose structured-output conventions differ, and it strands work that is otherwise complete.
Environment
main)copilotgpt-5.6-sol(reasoning: xhigh,context_tier: long_context)Reproduced with a workflow whose other five agents ran on
claude-opus-5/claude-sonnet-5without incident — only the agent on a non-Anthropic model tripped it.Root cause
Verified against
main(paths below are current):1. Validation lives outside the provider.
src/conductor/executor/agent.py:3182. The
copilotprovider never validates in-session.src/conductor/providers/copilot.pycontains zero occurrences ofvalidate_output. Its recovery loop catches syntax errors only:So
max_parse_recovery_attempts(default 5 for copilot) is unreachable for schema-shape failures.3.
validate_outputis strict, with no coercion.src/conductor/executor/output.py:47The providers disagree with each other
ValidationError?hermeshermes.py:524hermes.py:526claudeclaude.py:1239claude.py:1271— "Re-raise ValidationError without wrapping (non-retryable)"copilothermesalready contains the intended shape:The asymmetry looks unintentional rather than designed:
copilotis the default provider and carries the largest recovery budget (5, versus hermes' 3), yet derives no benefit from it for this failure class.Proposed fix
Mirror
hermesin thecopilotprovider — validate inside the recovery loop and includeValidationErrorin the caught exceptions, so a wrong-shaped field is re-prompted like any other contract violation:_build_parse_recovery_prompt(parse_error=..., original_response=..., schema=...)already takes the schema, so the recovery prompt would carry the exact type error and the expected shape without further changes.Two smaller improvements worth considering alongside it:
{"decision": {"decision": "APPROVE", "reasoning": "..."}}or{"decision": {"value": "APPROVE"}}. When astringis expected and adictis received, unwrapping a sole string-valued key (or avalue/matching-field-name key) would resolve most occurrences without a round-trip. This should be conservative and only applied where unambiguous.Reproduction
output.decision.type: string, prompted with something like "Provide your decision: APPROVE or REQUEST_CHANGES".provider: copilotwith a non-Anthropic model (gpt-5.6-solhere).max_parse_recovery_attemptshas no effect.Running the same workflow with
provider: hermesrecovers from the identical response.Note
This is unrelated to #342 (Windows
UnicodeEncodeErrortruncating the--silentresult after a successful run). That one crashes on output serialization once the workflow has completed; this one fails the workflow itself, mid-run, and leaves the final agent without a result.