Structured Output
Set response_model to a pydantic model and the agent's final text is validated against it, landing on AgentResult.parsed. One automatic repair attempt is made if the first response isn't valid JSON matching the schema; if that also fails, OutputParseError is raised with the raw text attached.
from pydantic import BaseModel
from deepcrew import Agent, run_agent
class Verdict(BaseModel):
approved: bool
reason: str
agent = Agent(name="reviewer", model="openai/gpt-4o", response_model=Verdict)
result = await run_agent(agent, [{"role": "user", "content": "Review this PR diff: ..."}])
result.parsed.approved # bool
result.parsed.reason # str
How it actually works
This is prompt-based, not provider-JSON-mode-enforced — worth knowing since it's a weaker guarantee than passing response_format={"type": "json_object"} directly to the provider. When response_model is set, deepcrew appends one extra system message to the conversation before the first turn:
Respond ONLY with JSON matching this schema: {"properties": {...}, "required": [...], ...}
...where the schema is response_model.model_json_schema(), pydantic's own schema dump. The model is trusted to follow that instruction; nothing on the LiteLLM call itself forces JSON output. Parsing happens once the agent's final turn produces text with no more tool calls:
- Any leading/trailing
```json ... ```(or plain```) fence around the response is stripped first. - The result is validated with
response_model.model_validate_json(...). - On failure (invalid JSON, or valid JSON that doesn't match the schema), deepcrew makes exactly one repair attempt: it re-prompts the same model with the full conversation history plus the failed response and the validation error text, asking for corrected JSON.
- If the repair attempt also fails to validate,
OutputParseErroris raised — not returned as a degraded result — carrying the repaired (still-invalid) text on.raw_text.
from deepcrew import OutputParseError
try:
result = await run_agent(agent, [{"role": "user", "content": "..."}])
except OutputParseError as exc:
print("Model never produced valid JSON, even after one repair attempt:")
print(exc.raw_text) # the raw (still invalid) text from the repair attempt
Combining with tools and with LoopConfig
An agent with both response_model and tools/skills works fine: the schema instruction is injected once as a system message, and the agent can still make tool calls across multiple turns as usual — parsing only happens against the text of the final turn (the one with no more tool calls), never against intermediate turns.
response_model also composes with loop_config: every outer-loop iteration runs the schema-instruction + parse-with-repair logic independently, and whichever AgentResult the loop ultimately returns (converged, adaptive-stopped, or the last iteration) carries that iteration's own .parsed value. If you want convergence to depend on the parsed structure rather than just the raw text, write a convergence_fn that inspects result.parsed directly — nothing does that for you automatically.
Common pitfalls
- This is a prompt instruction, not enforced JSON mode. Weaker models or highly complex schemas can still produce non-JSON text on both the first attempt and the repair attempt, ending in
OutputParseError. - Only one repair attempt, always. There's no configurable retry count for structured-output parsing specifically (separate from
RetryPolicy, which governs LLM-call-level failures, not schema-validation failures). result.parsedisNonewheneverresponse_modelisn't set. Always None-check it, or make it a habit to only read.parsedon agents you know were constructed with a schema.- The repair prompt reuses the same model. There's no way to route the repair attempt to a different (e.g. stronger) model than the one that produced the invalid output.
See also
- Looping — how
response_modelbehaves across outer-loop iterations. - Human-in-the-Loop Hooks — the other "Agent Behavior" feature, for intercepting tool calls rather than validating final output.