DIN Protocol¶
DIN (Directed Interaction Notation) is the structured communication protocol between Lethe's LLM and its orchestrator. The LLM reads graph state through DIN-Read (a text projection of the subgraph) and writes operations through DIN-Write (a JSON object with typed operations). This protocol replaces free-form chat with a validated, schema-enforced interaction language.
Why a Structured Protocol¶
Standard LLM agents communicate through natural language --- the model is asked to "think step by step" and its text output is parsed heuristically. This creates three problems:
-
Ambiguous intent. "I should look into X" --- is this a task creation, a search query, or a remark? Parsing natural language for structured operations is fragile.
-
Lost structure. The graph has typed nodes, edges, and constraints. Natural language flattens this structure into prose the orchestrator must reconstruct.
-
Validation gap. There is no schema to validate against. Malformed output is detected only when downstream code fails.
DIN addresses all three: operations are typed JSON objects validated by Pydantic models, with a repair loop for malformed output. The protocol works in concert with the context composition pipeline (which determines what the LLM sees) and the agent loop (which orchestrates when the LLM is called).
Design constraint: llama.cpp thinking mode
DIN was also motivated by a practical constraint. When llama.cpp runs in thinking mode (extended reasoning), it cannot produce structured output during thinking --- only the final response is controllable. DIN's single-response JSON format works within this constraint.
DIN-Write: LLM to Orchestrator¶
The LLM produces a JSON object with an operations array:
{
"operations": [
{
"op": "create",
"id": "t-b2c4",
"description": "Analyze liquidation mechanism parameters",
"embed_text": "Aave V3 liquidation health factor threshold parameters",
"depends": ["t-a3f2"]
},
{
"op": "call",
"task_id": "t-a3f2",
"tool": "web_search",
"params": {"query": "Aave V3 liquidation bonus close factor"}
},
{
"op": "evidence",
"task_id": "t-f1e8",
"content": "The governance process requires a two-phase vote...",
"embed_text": "Aave governance two-phase voting quorum requirements"
}
]
}
The remaining five operation types cover task lifecycle management and evidence consolidation:
{
"operations": [
{
"op": "update",
"id": "t-a3f2",
"result": "Liquidation triggers at health factor < 1.0 with a 5% bonus...",
"embed_text": "Aave V3 liquidation health factor trigger threshold bonus"
},
{
"op": "compose",
"parent_id": "t-0001",
"synthesis": "Combining subtask findings: the liquidation mechanism...",
"embed_text": "Aave V3 liquidation mechanism comprehensive analysis"
},
{
"op": "dead_end",
"task_id": "t-d4e5",
"reason": "Flash loan attack vector is mitigated by Chainlink's TWAP",
"embed_text": "flash loan oracle attack mitigated by TWAP price feed"
},
{
"op": "synthesize",
"evidence_ids": ["e-7c0b", "e-9a1f", "e-3d4e"],
"summary": "Three sources confirm: Aave V3 uses per-asset risk parameters...",
"embed_text": "Aave V3 per-asset risk parameter configuration sources"
},
{
"op": "continue",
"note": "Need to verify governance timelock on parameter changes"
}
]
}
Operation Types¶
| Operation | Required Fields | Optional Fields | Effect |
|---|---|---|---|
create |
id, description, embed_text |
prompt, antifocus, depends |
New subtask + SUBTASK_OF edge |
update |
id |
result, embed_text |
Update task; providing result marks DONE |
evidence |
task_id, content, embed_text |
--- | New EvidenceNode + PRODUCED edge |
call |
task_id, tool |
params |
Execute tool (web_search) |
compose |
parent_id, synthesis |
embed_text |
Set parent result from subtask synthesis |
dead_end |
task_id, reason, embed_text |
--- | Mark task FAILED |
continue |
--- | note |
Request another iteration |
synthesize |
evidence_ids (min 2), summary, embed_text |
--- | Compress evidence + SUPERSEDES edges |
Discriminated Union¶
Operations use a discriminated union on the op field, validated by Pydantic:
Each variant enforces its own field constraints. CreateTask requires a hex short ID (t-xxxx pattern) and an embed_text for embedding projection. CallTool requires task_id identifying which task the call is for. Synthesize operates on evidence_ids (minimum 2), producing a summary that supersedes the originals.
embed_text Requirement¶
Every content-producing operation (create, evidence, dead_end,
synthesize) requires an embed_text field --- a short semantic projection
optimized for the embedding model. This is not optional; the schema rejects
operations without it.
The LLM is instructed to write embed_text as a concise declarative
summary of the semantic content (minimum 1 character, enforced by schema). This standardized projection ensures consistent behavior in the embedding algebra, regardless of how verbose or conversational the full content is.
DIN-Read: Orchestrator to LLM¶
The orchestrator serializes the packed context subgraph into an incident format the LLM can parse:
=GOAL "What are the security risks in Aave V3's liquidation mechanism?"
=NOTE Focus on oracle price manipulation vectors.
=WARN "Subtasks:
t-f1e8 [done] [well-covered] Review access control
result: "Access control uses role-based permissions..."
t-c3a1 [pending] [sparse] Analyze oracle dependencies"
=WARN "Parent goal: Comprehensive Aave V3 security assessment"
=WARN "Failed:
t-d5b2 [failed] Static analysis of Solidity bytecode"
=TASK t-a3f2 "Analyze liquidation mechanism"
>PROMPT "Focus on health factor calculation and close factor bounds"
>ANTI "Do not re-examine access control"
>SUB t-f1e8
>DEP t-c3a1
>EVI e-7b1c "The health factor threshold triggers liquidation at HF < 1.0.
Each asset has a liquidation bonus (5-15%) and close factor."
>EVI e-2d9e "Flash loan attacks in 2023 exploited price oracle latency..."
=TASK t-f1e8 "Review access control" st=done
>RESULT "Access control uses role-based permissions with a 2-day timelock..."
=TOOLS
web_search
Key serialization rules:
- Nodes are serialized flat in U-shape position order, not nested
- Status tags (
st=done,st=failed) appear only on terminal tasks; active/pending tasks have no status suffix >SUBand>DEPrelationship lines are injected under parent tasks from the edge set>PROMPTand>ANTIappear on VERBATIM tasks that carry these fields=TOOLSlists tool names on indented lines (descriptions are in the system prompt)=WARNlines carry structured context: subtask status with qualitative coverage tags, parent goal, dead-end siblings, and zero-yield hints
Format Elements¶
| Prefix | Content |
|---|---|
=GOAL |
User's goal or active task with root goal context |
=NOTE |
Continuation note from the prior iteration's continue operation |
=WARN |
Structured context: subtask status + qualitative coverage tags, parent goal, siblings, zero-yield |
=TASK |
Task with short ID and description; st= suffix only on done/failed tasks |
>PROMPT |
Task's prepared prompt (VERBATIM verbosity only) |
>ANTI |
Task's antifocus text (VERBATIM verbosity only) |
>RESULT |
Terminal task's result text (full for VERBATIM, first sentence for GIST) |
>EVI |
Evidence content (full for VERBATIM, first sentence for GIST) |
>SUB |
Subtask short ID (injected from SUBTASK_OF edges) |
>DEP |
Dependency short ID (injected from DEPENDS_ON edges) |
=TOOLS |
Available tool names on indented lines (descriptions in system prompt) |
What DIN-Read Excludes¶
DIN-Read deliberately omits:
- Numeric scores --- No goal alignment values, convergence scores, or budget percentages. The LLM should reason about content, not numbers.
- Embedding distances --- No similarity metrics between nodes.
- Iteration counts --- No "you are on iteration 7 of 50."
- Pressure signals --- Budget pressure affects which nodes appear in context (via knapsack scoring), not the text the LLM sees.
This exclusion is the architectural manifestation of the three-layer separation: quantitative control happens in the embedding algebra, not in the prompt.
Parse and Repair¶
din/parse_json.py implements a two-stage validation pipeline:
flowchart TD
Raw["LLM Response Text"] --> Extract["Extract JSON<br/><small>find { ... } block</small>"]
Extract --> Pydantic["Pydantic Validation<br/><small>discriminated union</small>"]
Pydantic -->|"all valid"| Ops["ParseResult<br/><small>operations + diagnostics</small>"]
Pydantic -->|"errors"| Diag["Generate Diagnostics<br/><small>per-operation errors</small>"]
Diag --> Format["format_errors_for_retry()<br/><small>schema + error context</small>"]
Format --> Retry["LLM Retry<br/><small>halved thinking budget</small>"]
Retry --> Extract
Diagnostic Types¶
| Diagnostic | Meaning |
|---|---|
bad_syntax |
JSON parse failure or structural error |
validation |
Pydantic field constraint violation |
unknown_ref |
References a node ID not in the current graph snapshot |
Repair Strategy¶
When validation fails, format_errors_for_retry() constructs a follow-up
prompt containing:
- The full JSON schema (so the model has a fresh reference)
- The specific errors encountered (field-level, with context)
- Instructions to emit the complete corrected JSON
The retry call sends only the system prompt and the error-formatted text as the user message --- the original DIN-Read context is not re-sent, since the model has already done the substantive reasoning and just needs to fix the format. Thinking budget is halved on each retry with exponential backoff.
Internal Operation Types¶
After Pydantic validation, operations are converted to frozen dataclasses
for the apply_ops stage:
@dataclass(frozen=True, slots=True)
class CreateOp:
description: str
embed_text: str
depends_on: tuple[str, ...]
@dataclass(frozen=True, slots=True)
class EvidenceOp:
task_id: str
content: str
embed_text: str
# CreateOp, UpdateOp, EvidenceOp, DeadEndOp,
# CallOp, ComposeOp, ContinueOp, SynthesizeOp
The separation between Pydantic models (API boundary validation) and frozen
dataclasses (internal processing) follows the project's typing discipline:
Pydantic at boundaries, frozen dataclasses internally. The frozen dataclasses
are what apply_ops in the agent loop
consumes to mutate the graph.
Schema for Prompt¶
The JSON schema is generated programmatically from the Pydantic models and
included in the LLM's system prompt via the SCHEMA_JSON module constant.
This ensures the schema the LLM sees always matches the validation rules.
If a new operation type is added to the Pydantic models, the prompt schema
updates automatically. The generated schema is embedded in the
config/system_prompt template alongside tool descriptions and protocol
rules.
Further Reading¶
- "How Structured Outputs and Constrained Decoding Work." Let's Data Science, 2025 --- Comprehensive overview of JSON schema enforcement via FSM-based token masking. Lethe uses Pydantic validation with repair loops rather than constrained decoding, since llama.cpp thinking mode separates reasoning from structured output.
- Dong, Y. et al. "XGrammar: Flexible and Efficient Structured Generation Engine." 2024 --- Grammar-based constrained decoding for agentic tool calling; the approach Lethe would adopt if it moved from post-hoc validation to grammar-constrained generation.
- "Reliable JSON from Any LLM: Pydantic + Zod." TECHSY, 2026 --- Practical guide to schema-enforced LLM output with Pydantic, covering the same validation patterns DIN uses.