YAML Output Format in Thalabus Agents
Overview
Several agents (AgentPlanner, AgentToolPreparer) emit YAML text instead of JSON. YAML is easier for small LLMs to produce correctly: it avoids mandatory quoting, escape sequences, and bracket matching.
Parsing: get_yaml_from_text()
File: src/core/llm/utils_yaml.py
The function is the single entry point for all YAML parsing. It is robust against common LLM mistakes:
| Recovery strategy | Trigger |
|---|---|
| Markdown fence strip | Response wrapped in ```yaml … ``` or ``` … ``` |
| JSON fallback | yaml.safe_load fails but json.loads succeeds |
Colon-repair (_repair_unquoted_colon_values) |
Plain scalar value contains a bare colon (e.g. key: foo: bar) |
Prose-strip (_strip_prose_trailer) |
LLM appends a "Reasoning:" or numbered-list block after the YAML |
If all recovery attempts fail, the function returns None and logs a WARNING.
Lenient YAML subset used in practice
The agents instruct the LLM to emit a lenient YAML subset:
- Plain (unquoted) scalars for identifiers, plan names, tool names, short descriptions.
- No mandatory quoting for most strings. Quote with "…" only when the value contains a colon (:) or other YAML metacharacter.
- Avoid contractions ("does not" not "doesn't") to prevent apostrophe-quoting confusion.
- Do NOT wrap output in markdown code fences.
- 2-space indentation, list items with - prefix.
- Omit null/empty fields entirely — do not include keys whose value would be null or empty.
AgentPlanner YAML format
AgentPlanner outputs a YAML structure describing the macro-plan.
Format
optional_prebuilt_complex_plan_id: pre-built-plan-xxx # mutually exclusive with optional_complex_plan
optional_complex_plan:
optional_doc_ids: docid-xxxxxx,docid-yyyyyy # optional: focus goals on specific document(s)
goals:
- goal_sequence_id: Step-1
goal: First goal description
dependent_on_sequence_ids: []
optional_recommended_plan: plan_call_tool_and_answer
optional_recommended_tools:
- WeatherInfo
- goal_sequence_id: Step-2
goal: Second goal description
dependent_on_sequence_ids:
- Step-1
optional_recommended_plan: plan_simple_answer
optional_require_exact_time: false
Top-level fields reference
| Field | Type | Description |
|---|---|---|
optional_prebuilt_complex_plan_id |
string | ID of a pre-built workflow (e.g. pre-built-plan-CleanDocument). Mutually exclusive with optional_complex_plan — omit the other field entirely when using this. |
optional_complex_plan |
object | Custom complex plan. Mutually exclusive with optional_prebuilt_complex_plan_id. |
optional_complex_plan.optional_doc_ids |
string | Comma-separated document IDs (e.g. docid-13) that scope all goals in this plan. Must start with docid-. |
optional_complex_plan.goals |
list | Ordered list of goal objects (see below). |
optional_require_exact_time |
boolean | Set to true only when the user explicitly requires the exact current time. Omit otherwise. |
Goal object fields reference
| Field | Type | Description |
|---|---|---|
goal_sequence_id |
string | Unique label for this step (e.g. Step-1). Referenced by dependent steps. |
goal |
string | Free-text description of what this goal must accomplish. |
dependent_on_sequence_ids |
list of strings | goal_sequence_id values of goals this step depends on. Empty list [] means no dependency (can run in parallel). |
optional_recommended_plan |
string | Plan template to use (e.g. plan_call_tool_and_answer, plan_rag_assistant_no_answer). |
optional_recommended_tools |
list of strings | Tool names the micro-planner should prefer for this step. |
Key rules
optional_prebuilt_complex_plan_idandoptional_complex_planare mutually exclusive — include at most one; omit the other entirely.optional_doc_idslives insideoptional_complex_plan, not at the top level.dependent_on_sequence_idsreferencesgoal_sequence_idstrings from earlier steps; goals with no dependencies run in parallel.- Omit
optional_require_exact_timeunless the user explicitly needs the exact current time of day.
AgentToolPreparer YAML format
AgentToolPreparer outputs either the literal string OK (no tool needed) or a YAML tool-call block.
No tool needed
OK
Plain string, not YAML. No surrounding quotes, brackets, or markup.
Single tool call
tool_call:
- fname: WeatherInfo
arguments:
city_name_english: Milan
Multiple tool calls (same or different tools)
tool_call:
- fname: WeatherInfo
arguments:
city_name_english: Rome
- fname: WeatherInfo
arguments:
city_name_english: Paris
- fname: WeatherInfo
arguments:
city_name_english: Berlin
Tool call with nested dict argument
tool_call:
- fname: KnowledgeGraphEditor
arguments:
operation: ENTITY_CREATE_OR_UPDATE
community_id: DogsAndCats
label: Animal
entity_id: bob
attributes:
description: A fluffy orange cat named Bob
age: "3"
Tool call with list argument
tool_call:
- fname: WebSearch
arguments:
query_array:
- water quality publications
include_domains:
- http://example.com
Tool call with a value containing a colon (must be quoted)
tool_call:
- fname: DatasetCreator
arguments:
dataset_name: my_dataset
cypher_query: "MATCH (e:Entity:ApplicationCollaboration) WHERE e.name_consolidated IN ['CS-EES'] RETURN e"
Tool call with a list-of-objects argument (items)
tool_call:
- fname: DocumentEditor
arguments:
operation: APPEND_TEXT
doc_or_chapter_id: docid-16-c1
elementIds: h1-3
items:
- new_tag: h2
text: New Subheading
- new_tag: p
text: First paragraph under the new subheading.
Internal-thought pre-call (remains JSON)
The generate_internal_thought() method in AgentToolPreparer uses structured JSON output (enforced by the provider via structured_output schema).
This is a separate internal pre-call — not visible to the user — and its format is not changed by the YAML refactoring.
See LLM_HINT_FORMAT_STRUCTURED_OUTPUT in AgentToolPreparer.py and the corresponding instruction label reminder_prompt.
Multi-line strings and HTML content
Use the YAML literal block scalar (|) for any argument value that:
- spans multiple lines, OR
- contains HTML markup.
The | indicator preserves newlines exactly. The content is indented one level beyond the key.
get_yaml_from_text() is fully compatible: _repair_unquoted_colon_values explicitly skips lines whose value starts with |, and the indented block-content lines never match the key-value repair pattern.
Avoid plain unquoted single-line HTML strings: if the HTML contains a colon (e.g.,
href="http://..."orstyle="color: red"), the colon-repair heuristic will wrap the value in double-quotes and escape interior"— this still parses correctly, but|is unambiguous and easier for small LLMs to produce.
Multi-line string argument
tool_call:
- fname: DatasetCreateReport
arguments:
dataset_name: asylum_cases
instructions_to_report: |
For each document, summarize:
1. Applicant background and country of origin
2. Key events and timeline of the case
3. Final outcome of the proceedings
report_excludes_content: true
yaml.safe_load parses the | block as a single string with embedded \n newlines. Keys that follow the block (report_excludes_content) must be de-indented back to the same level as instructions_to_report.
HTML content argument
tool_call:
- fname: DocumentEditor
arguments:
operation: REPLACE_TEXT
doc_or_chapter_id: docid-16-c1
elementIds: p-5
text: |
<p><strong>Important notice:</strong> This policy applies to all active cases.</p>
<ul>
<li>Filed before 2024-01-01</li>
<li>Involving applicants from listed countries</li>
<li>Pending review at cantonal level</li>
</ul>
The HTML <, > characters are ordinary characters inside a | block — no escaping needed. Colons in HTML attributes (e.g., href="http://...") are also safe because the block-content lines are not key-value pairs and are never touched by the colon-repair heuristic.
Coding pattern
from src.core.llm import get_yaml_from_text
# After receiving a string response from the LLM:
if response is not None and isinstance(response, str) and response != "OK":
parsed = get_yaml_from_text(response)
if parsed is not None:
response = parsed
# response is now either "OK" (str), a dict/list (parsed YAML), or the original string