Reports
The report is the human-readable narrative produced from a finished simulation. It is
generated by a smart-LLM tool-calling loop that can query the SimulationResult directly.
Sources: simswarm/report.py, simswarm/report_tools.py, simswarm/prompts/report.j2. The
reader-facing overview is in Reports.
Report generation runs off-pod, after the simulation finishes — it consumes a
SimulationResult, not live engine state.
ReportGenerator.generate(result, goal)
class ReportGenerator:
def __init__(self, llm: LLMClient) -> None: ...
async def generate(self, result: SimulationResult, goal: str) -> Report: ...
It instantiates ReportTools(result), seeds a message list with the rendered system prompt
(_render_system_prompt(goal) -> report.j2), and runs a tool loop bounded by
_MAX_ROUNDS = 5. On each turn:
- Call
llm.chat(messages, tools=ReportTools.tool_schemas()). - If the response contains tool calls, append the assistant message, dispatch each call via
tools.dispatch(name, args), and append each JSON result as arole: "tool"message. - If there are no tool calls, treat the response content as the finished markdown and break.
The returned Report has executive_brief, findings, and raw_markdown.
Query tools — ReportTools
ReportTools wraps a SimulationResult and exposes four query tools. Schemas come from
ReportTools.tool_schemas(); dispatch routes by name and JSON-encodes the result (errors
are caught and returned as {"error": ...}).
get_top_posts(limit=10)—extract_posts(result.chat_log)[:limit]. This returns the first N posts in chat-log order; it is not engagement-ranked despite the name.get_coalitions()—_detect_mutual_follow_coalitions, which builds a follow graph fromfollowactions (target read fromaction_args["target"]) and emits a coalition for any agent whose followers it follows back, sized at least 2. Each coalition hasname,description,agents,strength(min(100, size*20)), and a cyclingcolor.Caveat: this depends on a
targetkey being present in eachfollowaction's args. The native social env'sfollowtool emits the target underagent_id(nottarget), so on current native runs the follow graph is empty and this tool returns[]. The working, stance-based coalition detector isstory_signals.name_coalitions— a separate construct; do not conflate the two.get_agent_summary(agent_id)—name,total_actions,total_posts,rounds_active, and up to 3sample_posts(viapost_text). Unknown agents return a zeroed summary withagent_idas the name.get_trajectory(agent_id)— the per-round trajectory list fromextract_agent_trajectories;[]for unknown agents.
Markdown parsing
The finished markdown is parsed into the Report dataclass by regex:
_extract_briefpulls the paragraph under## Executive Summary._extract_findingspulls the## Key Findingssection and splits it on###headers into{title, content}dicts.
The report.j2 template
The template asks for markdown with exact sections — ## Executive Summary, ## Verdict,
## Key Findings (exactly 4, each tagged with a slot from industry / regulator /
intermediary / market / turning_point), ## Agent Coalitions, ## Market Analysis,
## Conclusion. It enforces phase/week language over round numbers and forbids inventing
entities or events.
A note on two render paths
The template is written to receive pre-computed signals and forecast_days (the
deterministic story signals) so the LLM is grounded and needs no tool
calls. However, ReportGenerator._render_system_prompt(goal) renders report.j2 with
only goal — the signals/forecast context is not threaded in on this engine-side path.
The Jinja environment is configured with ChainableUndefined, so unprovided fields like
signals.stakeholder_positions render empty rather than erroring, and the loop relies on the
ReportTools query tools instead. Treat report.j2 as the prompt contract, but be aware the
engine-side renderer passes only the goal; the fully-grounded signals path is wired up by the
SaaS report layer (see simswarm/adapter.py:adapt_structured, which merges an LLM
brief/verdict/findings with build_story_signals(...)).