A structured call sends a request, lets the model use tools, and ends with
a validated response. Every call is a StructuredCall row with a kind. A
call can stand alone or be one turn of a conversation session. The code is
in django_ergo.conversation.structured.
from pydantic import BaseModel
from django_ergo.conversation.structured import (
PreSeedCall, StructuredCallSpec, revise_structured_call, run_structured_call,
)
class Plan(BaseModel):
title: str
steps: list[str]
spec = StructuredCallSpec(
kind="planner",
system_prompt="Plan the work.",
response_model=Plan,
toolkits=[my_toolkit],
pre_seeds=[PreSeedCall("get_ticket", {"id": 7}, load_ticket)],
max_turns=10,
)
result = await run_structured_call(spec, "Plan ticket 7", user=user)
result.parsed # Plan(...)
result.call # StructuredCall row: kind, request, response, status, tokens
# Correct it. The revision is a new call whose parent is the first one.
fixed = await revise_structured_call(spec, result.call, "Drop step 3")
# Or make the call one turn of an existing conversation.
result = await run_structured_call(spec, "Plan it", session=session)- No session is created. The tool loop runs in memory and is saved on the
row as
transcript(engine-native messages), along withsystem_promptandengine_type. revise_structured_callreplays the parent's transcript plus the correction on the same engine type, and links the new call throughparent(call.revisionsgoes the other way).- Attachment bytes are sent to the model but not stored in the transcript. A placeholder takes their place.
- Pass
session=. The call's messages go into the session like any chat turn, and the row records their span (first_sequence,last_sequence). - The spec's
system_promptis added to the session's own system prompt for that turn only. - With
response_model, the turn ends with the response as an assistant text message. Later chat turns, history tools and context builders then see the answer, not just a tool call. - Sessions can mix structured calls and ordinary chat turns freely. There is
no structured-only session type.
session.structured_callslists a session's calls. - Revising an in-session call just adds another structured turn to the
session, with
parentset.
- With
response_model, the model gets asubmit_outputtool whose input schema is the Pydantic schema. Invalid input goes back as a tool error. A plain-text answer gets a correction asking for the tool that quotes the text back; an empty response is asked to carry on with the task. Withtext_output(a function from the text to the tool's arguments), a plain-text answer is taken as the output instead, with no second model call: it is stored as that tool call and its result, and the row'smetadatagetstext_answer: true. Chat replies use it, so a reply given in plain text becomes amessagewith no suggestions or status. In a session, the accepted output is also stored as a plain-text assistant message for chat views and history tools. That copy is never sent back to the model, so its history doesn't teach it to answer in plain text. - Without it, the final text is parsed by
output_parser(json.loadsby default). A parse failure goes back as a correction message. - Tool failures and unknown tools go back to the model as error results. Tools that need approval are refused, because structured calls run unattended.
- Transient API failures (connection, timeout, 5xx) are retried with
backoff. Nothing is written until a call succeeds, so retries never
duplicate history. Other API errors are recorded on the row with an
error_category(auth,network,model,other). - A refusal (Claude's
refusalstop, OpenAI'scontent_filter) fails the call witherror_categoryrefusaland the refusal category in the error. Its tool calls never run, and it gets no correction: asking again repeats the refusal. An Auto chat offers the turn on another model in its tier, the same way as after a usage limit. - Statuses:
completed,failed(API error, refusal ormax_tokensstop), andturn_limited(no valid output withinmax_turns). - Rows never store engine config, so credentials are never written.
- Each model call carries only the newest
DJANGO_ERGO["TOOL_RESULTS_IN_CONTEXT"]large tool results (default 6, "large" meaning over 500 characters) in full, and older ones too while the kept results fit a budget: the engine'stool_results_tokens, elseDJANGO_ERGO["TOOL_RESULTS_TOKENS"], elseDJANGO_ERGO["TOOL_RESULTS_CHARS_IN_CONTEXT"]characters when set (0 keeps only the count), else 20% of the engine's context window. Older ones are sent as a stub such as[penpot_tree result, 180 lines, 9,412 chars; trimmed from context to save space. Note what you need; call the tool again only if you still need detail.]. Errors and short results are sent as they are. Only the request changes: the transcript and session rows keep every result, and tool call ids still pair up. Images in a stubbed result stay, and the image window (IMAGES_IN_CONTEXT) decides about them as before. An engine'stool_results_in_contextattribute overrides the setting;Nonein the setting sends every result in full. The same applies to chat turns (run_conversation_turn), since both engines apply it when they rebuild a session's messages.
Compaction summaries are structured calls of kind compaction; see
compaction.md.
Cabal (cabal/apps/agents/assistant.py) |
Ergo |
|---|---|
AssistantSpec |
StructuredCallSpec |
ToolDef list |
toolkits (any Toolkit) |
PreSeedCall |
PreSeedCall (same fields) |
output_parser + correction |
output_parser + correction, or response_model |
Assistant row |
StructuredCall |
Assistant.turns JSON |
StructuredCall.transcript |
run_assistant / arun_assistant |
run_structured_call |
| none | revise_structured_call, and session= for calls inside a chat |
Not carried over: Cabal's company/ticket/step foreign keys (put them in
metadata, or add a model in the host app), ToolCallLog, and cost
pricing.