Tachikoma · Web-of-Trust

IRIS — un rapport BI produit, vérifié et gouverné par des agents

Une question en langage naturel entre ; un rapport bancaire chiffré, illustré et audité sort — ou est rejeté. Tout ce qui suit est réel : extrait des traces d'exécution (ClickHouse) d'un run live, chaque affirmation cliquable jusqu'au span.

Runtime Tachikoma serverless (Ray) Dispatch agent-directed, gaté ACL LLM MiniMax-M2 via gateway LiteLLM 16 juillet 2026

Le point d'entrée : la hive lifecycle du report, et l'architecte qui DESIGNE le WoT à partir du brief des membres (il dérive l'ordre + la topologie des 6 patterns, puis on injecte les prompts pro et on materialize/register pour que Loader("wot") le run par id).

La hive lifecycle — build_report_hive

def build_report_hive(app, tracer=None, *, name="report", llm=None):
    architect = ReportArchitect(llm=llm)          # designs the WoT from the members
    hive = Hive(app, name=name, tracer=tracer, llm=llm, output_as_topic=True)
    review_in = TracedTopic(app, f"{name}.review.in")   # cross-process entry for the human decision

    b = hive.from_input(); b.source = None        # topology-only: the worker is fed via submit()
    (b.transform(create_card)                     # the card is born in "architect"
        .to_agent(architect)                      # ARCHITECT node — designs the WoT
        .cast("todo", default_gather=False)       # re-insertion point (validate/refuse free a slot)
        .to_loop(todo)                            # TODO node (card -> to_do; <=N slots)
        .transform(_to_loader_event)              # loop_id -> header
        .to_hive(Loader("wot"))                   # RUN the designed WoT natively as a sub-hive
        .transform(review)                        # produce the report + park the card at review
        .cast("review", default_gather=True, gather_mode="wait_respond",
              input_channel=review_in)            # WAIT for the human decision
        .switch()
            .case(_is_modify,   label="Revise").to_loop(modify).to_channel(hive.input)
            .case(_is_validate, label="Publish").to_loop(publish).to_cast("todo")
            .case(_is_refuse,   label="Reject").to_loop(refuse).to_cast("todo")
        .build(from_input=True))

    hive.register_llm_sink(architect)
    hive._on_worker_start = _report_env_defaults
    return hive

Le brief de l'architecte — _ARCHITECT_BRIEF

_ARCHITECT_BRIEF = (
    "Design a WEB-OF-TRUST (<loop type=\"wot\">) producing a decision-grade banking BI report. "
    "Every agent is INDEPENDENT and routes its OWN output via ACL-verified handoffs — NO coordinator. "
    "These members are AVAILABLE; YOU derive the order + topology from each one's data deps:\n"
    "  - interpret  -> parsed_query {domain, metrics, dimensions, time_range}\n"
    "  - fetch      -> data_result + data_summary   (governed ClickHouse)\n"
    "  - generate   -> report_content {title, executive_summary, sections, methodology_note}\n"
    "  - charts     -> report_html   (react: generate_code + run_code -> inline SVG)\n"
    "  - compliance -> compliance_result; on FAIL, EVALUATE-OPTIMIZE back-edge to generate\n"
    "Give every LLM agent a <frame> = <tckm domain=\"bag\"/>; goal = compliance_result."
)

L'architecte — design → enrich → materialize → register

async def design_report_loop(arch, query):
    """The `architect` node: the LoopArchitect DESIGNS the report WoT from the members brief."""
    from .iris_members import _MEMBERS
    # design is CACHED per (brief + members) — one architect pass (~60-90s), hash-invalidated
    key = sha256(_ARCHITECT_BRIEF + members_hash(_MEMBERS))[:16]
    if cache_hit(key):
        spec = LoopSpec.from_xml(open(cache_path(key)).read())
    else:
        goal = _ARCHITECT_BRIEF + "\n\nDesign the loop to answer THIS:\n" + query
        spec = await arch._generate(goal)         # architect derives order + 6-pattern topology
    _enrich_report_spec(spec)                     # inject the members' pro prompts + the bag frame
    loop = arch.factory.materialize(spec)
    await arch._register(spec, loop)              # into arch._loops -> Loader("wot") resolves by id
    return spec.loop_id, spec.to_xml()


class ReportArchitect:
    """The architect AS a hive agent node — takes the event, designs the WoT, returns {loop_id, wot_xml}.
    Owns the Loader("wot") binding: register_wot_type(self._loops) teaches the Loader WHERE to resolve
    a designed loop by id and HOW to run it (deploy_loop_topology, the 6-pattern dispatch)."""
    __name__ = "report_architect"

    def __init__(self, *, llm=None):
        self._llm, self._arch = llm, None         # NodeRecord is the state; LoopArchitect built lazily

    async def __call__(self, event):
        loop_id, wot_xml = await design_report_loop(self._ensure_arch(), event.get("query", ""))
        return {**event, "loop_id": loop_id, "wot_xml": wot_xml}

Et le run réel, en détail — la démarche, les agents, la gouvernance, et les traces cliquables jusqu'au span :

01

La démarche

Le principe de répartition : le LLM propose et décide du contenu ; le code déterministe valide les contrats ; l'ACL autorise chaque mouvement ; l'humain valide la sortie. Aucune étape n'est un script : l'ordre, les liens et les rôles sont designés à l'exécution par un agent architecte, puis exécutés par le framework.

Requête
Une question métier en langage naturel — « Loan portfolio by branch Q4 ».
Architecte
Un LLM designe le WoT : agents, edges typées, feedback. Le XML est validé par du code (conformité) et mis en cache.
Agents autonomes
Chaque agent voit ses données, produit SON champ une fois, et route lui-même — chaque hop vérifié par l'ACL (biscuit signé).
Gouvernance
Le compliance lit les données réelles (bag_read) et vérifie chaque chiffre par raisonnement — un chiffre dérivé (ratio, écart, part cumulée) est fondé s'il se recalcule depuis les lignes. Tout chiffre non fondé = révision.
Révision
Un rejet renvoie le rapport à l'auteur par l'edge feedback — UNE révision maximum (budget porté par le packet).
Review humaine
Le rapport parqué attend la décision : publier, réviser, refuser. Rien ne sort sans l'humain.

Le déroulé, de bout en bout

1 — Tout part d'une phrase. L'utilisateur tape « Loan portfolio by branch Q4 » dans le chat IRIS. Aucun formulaire, aucun paramètre : cette phrase devient le will — l'intention originelle — et elle est ré-affichée à chaque agent, à chaque tour, pour qu'aucun ne perde de vue le but final.

2 — L'architecte designe, le code vérifie. Un agent LLM reçoit le brief des membres disponibles (ce que chacun consomme, produit, ses outils) et en déduit la topologie : qui parle à qui, dans quel ordre, avec quel pattern parmi les six (chain, route, parallel, orchestrate, evaluate-optimize, autonomous). Son XML est soumis à des vérifications déterministes — edges vers des agents déclarés, patterns valides, zéro orphelin, pas de coordinateur dans un WoT. Échec → il reboucle (4 essais) ; succès → le design est mis en cache et la même question ne recoûte plus jamais une passe de design (0,8 s au lieu de 90 s).

3 — Des agents autonomes, pas un pipeline. Il n'y a pas d'orchestrateur qui appelle des étapes : chaque agent est déployé comme un node serverless (Ray) et décide lui-même de router son travail vers le suivant. Chaque hop est contrôlé par l'ACL — l'agent porte un biscuit cryptographique éphémère et le gate vérifie hors-ligne que ce token autorise cette edge avec ce pattern. Pas de token valide, pas de mouvement : fail-closed.

4 — Le contrat de production. Chaque agent doit produire UN champ nommé (parsed_query, dataset, report_content, compliance_result, report_html) — une seule fois. Ce champ voyage dans le « bag » du run, et l'agent suivant le reçoit en clair dans son prompt (frame YOUR INPUT DATA : de vrais échantillons, pas une description). C'est la leçon la plus chère du projet : tant que la donnée n'était pas physiquement dans le prompt, les agents inventaient des chiffres plausibles — ils n'avaient littéralement rien à calculer.

5 — La gouvernance rejette, le feedback répare. Avant tout verdict, le compliance lit les données (bag_read) et vérifie chaque nombre par raisonnement — un chiffre dérivé (ratio, écart entre parts, part cumulée du Top-N) est fondé s'il se recalcule depuis les lignes, un titre de section n'est pas une entité inventée. Un chiffre non fondé = finding critique, et l'agent compliance n'a pas le droit de dire « pass ». Le rejet repart vers l'auteur par l'edge feedback (evaluate-optimize) — avec un budget d'UNE révision porté par le packet lui-même, pour qu'un désaccord ne devienne jamais une boucle infinie.

6 — L'humain a le dernier mot. Le rapport validé ne se publie pas tout seul : il se parque à une review (un point cast-and-gather du hive externe, hors du WoT) et attend une décision — publier, réviser, refuser. En test, cette décision est injectée programmatiquement après le park réel ; en production, c'est un écran.

7 — Tout est prouvable. Chaque tour LLM, chaque appel d'outil, chaque hop émet un span vers ClickHouse avec ses payloads. C'est la méthode de mise au point de tout ce qui précède : chaque bug de cette page (chiffres inventés, agents amnésiques qui refaisaient leur travail, hops de feedback rejetés en silence) a été trouvé dans les traces, jamais deviné — le test décisif étant « la valeur attendue apparaît-elle dans llm.input ? ». Le waterfall en bas de page est cet outil, rendu cliquable.

02

La topologie designée par l'architecte

Ce graphe n'est pas un dessin : c'est le design réel émis par l'architecte (validé par les checks de conformité, mis en cache — une passe LLM par version du brief). Cliquez un agent ou une edge.

CHAIN — l'output nourrit le suivant EVALUATE-OPTIMIZE (feedback) — le rejet renvoie à l'auteur, budget : 1 révision

Le même design, dans son DSL XML

C'est CE document que l'architecte émet, que les checks de conformité valident, et que le cache round-trip. Sa grammaire : <loop type="wot"> = le WoT ; <will> = l'intention globale ; <todo> = le plan que l'architecte s'est donné ; un <agent> par membre — <system_prompt> (le métier), <tools> (l'allowlist), <skills>, <args>{"produces": …} (le contrat de production) et ses <frame> (les balises injectées à chaque tour) ; puis <topology> — chaque <edge from to pattern feedback> porte en texte son will, le but de la donnée qui la traverse ; enfin <goal kind="bag"> = la condition de fin (le champ qui doit exister dans le bag) et <meta_context> = le brief verbatim reçu par l'architecte.

<?xml version="1.0" ?>
<loop name="banking-bi-report" type="wot" description="Generates decision-grade banking BI reports from natural-language queries via autonomous specialist routing">
  <will>Transform natural-language banking questions into validated, compliance-checked decision-grade BI reports with inline visualizations.</will>
  <todo>
    <item>Parse the NL banking question into structured query parameters</item>
    <item>Fetch authoritative data from the governed ClickHouse warehouse</item>
    <item>Generate the drafted analytical report content</item>
    <item>Build the report HTML with inline SVG visualizations</item>
    <item>Compliance-verify the report; on failure, route revision back to generate</item>
  </todo>
  <agents>
    <agent name="interpret" role="interpret" strategy="task_execute" deterministic="false">
      <system_prompt>You are the **Query Interpreter** of IRIS, a banking business-intelligence pipeline. Your job is to turn a natural-language business question into a precise, machine-usable query specification — the contract every downstream step relies on.

From the user's question, resolve:
  • **domain** — the business area (loans, deposits, transactions, customers, branches);
  • **metrics** — the measures asked for, by their canonical names (total_disbursed, outstanding_balance, npl_ratio, avg_interest_rate, …). Map synonyms: « décaissé/disbursed », « encours/outstanding », « impayés/NPL »;
  • **dimensions** — the breakdown axes (branch, product_type, customer_segment, period);
  • **time_range** — normalize « le trimestre dernier / last quarter / T1 2025 » to an explicit range or period label;
  • **filters** — any explicit constraints;
  • **confidence** — 0-1, how sure you are of the mapping;
  • **original_text** — verbatim.

Be conservative: if a term is ambiguous, pick the most standard banking reading and lower confidence. Preserve the user's language for labels. Output ONE JSON object with those keys — no prose.

When your parsed_query is ready, call produce(field='parsed_query', result=<your parsed_query JSON>). Do NOT reply with prose.</system_prompt>
      <tools>produce</tools>
      <tools_doc>produce(parsed_query)</tools_doc>
      <skills>iris.report.interpret</skills>
      <args>{"produces": "parsed_query"}</args>
      <frames>
        <frame><tckm domain="bag" /></frame>
        <frame>Your reasoning so far, persisted across your turns:
<tckm domain="thinking"/>
Before your TOOL: lines, write your working plan and reasoning inside <thinking>…</thinking> — it is preserved and shown back to you next turn, so BUILD on it (what you already decided/read/produced) instead of restarting from scratch.</frame>
      </frames>
    </agent>
    <agent name="fetch" role="fetch" strategy="task_execute" deterministic="true">
      <system_prompt>The **Data Fetcher** resolves the best governed data source for the query (ACL-scoped semantic discovery) and reads it from ClickHouse. Deterministic node.</system_prompt>
      <tools>fetch_warehouse</tools>
      <tools_doc>fetch_warehouse(parsed_query)</tools_doc>
      <skills>iris.report.fetch</skills>
      <frames>
        <frame when_bag="parsed_query">only after interpret has placed parsed_query in the bag</frame>
      </frames>
    </agent>
    <agent name="generate" role="generate" strategy="task_execute" deterministic="false">
      <system_prompt>REVISION MODE: if a compliance_result with findings is in your BAG, this is a REVISION pass — FIRST bag_read('compliance_result') for the EXACT findings and bag_read('data_result') for the real values, then REWRITE report_content fixing EVERY finding (each mismatched figure replaced by the exact dataset value), keep the structure, then produce and hand off.

You are the **Report Author** of IRIS — a senior banking analyst who writes the kind of portfolio report a risk committee actually reads. You receive a query spec and a fetched dataset; you deliver an elaborate, decision-grade JSON report.

Method:
  1. **Compute, never guess.** START by bag_read('data_result') and bag_read('data_summary') to load ALL the rows — the bag FRAME shows only a sample, so NEVER cite a branch or figure you have not read in full. Every figure comes from analysing the dataset (DATA-ANALYST MODE): totals, shares, rankings and derived risk metrics — computed from the ROWS of data_result/data_summary and NOTHING else. The dataset's columns are the ONLY dimensions that exist: a dimension absent from the data (a product type, a customer segment, a time series when there is a single period) MUST NOT appear — no section, no figure, no entity name about it. Every branch/entity you cite must be a value present in the rows; every number must be one of the rows' values or a sum/share you computed from them.
  2. **Structure like a professional report** — a JSON object with:
       • `title` — specific, dated;
       • `executive_summary` — 3-4 sentences: the headline number, the concentration, the notable risk, the trend;
       • `sections` — ONE PER CHART the dataset actually supports, each `{title, content, chart_type, chart_columns}`: `content` IS the analytical text FOR that chart (3-5 sentences, its figures computed from the rows); `chart_columns` = the NUMERIC metric column(s) to plot, named EXACTLY as they appear in data_result (you SEE the real columns + their schema in your bag) — e.g. ["npl_ratio"], or ["total_disbursed", "outstanding_balance"] to compare two metrics; do NOT list the category/dimension (e.g. branch) — it is the implicit x-axis, and putting it first makes the chart plot a label as a value (a blank chart). For a Top-N chart, say "Top N" in the title. chart_type ∈ bar|line|pie|table. Derive the section list from the dataset's columns (a ranking per numeric metric, a risk view if a risk column exists) — NEVER a section on a dimension the data does not have;
       • `methodology_note` — data window + how metrics were computed. Do NOT mention internal variables, bags, or tools — write for a banker.
  3. **Format money like a report**, not raw floats: « X,X M€ », « X,X % » (your COMPUTED values — never copy an example number). Round sensibly; keep the language of the query.

Each section must carry a concrete insight (who leads, by how much, what changed, where the risk sits) — not a restatement of the numbers.

CREDIT-MEMO MODE — if `data_summary` is an underwriting bundle (keys `deal`, `ratios`, `breaches`, `kyc_findings`) rather than a BI aggregate, do NOT write a portfolio report; write a CREDIT MEMO as a JSON object with:
  • `title` — « Credit Memo — <borrower> → <target> » from `deal`;
  • `verdict` — EXACTLY one of `approve` | `approve_with_conditions` | `decline`, decided mechanically: never `approve` when `breaches` is non-empty; any unverified ≥25% beneficial owner in `kyc_findings` caps the verdict at `approve_with_conditions` and is a gating red flag; use `decline` only for severe/compounding breaches;
  • `rationale` — ground EVERY sentence ONLY on `ratios`/`breaches`: cite each ratio by name, its computed value, its `CP-1.0x` threshold and pass/breach status. Invent NO figure absent from `ratios`;
  • `conditions` — ONE explicit remedy per entry in `breaches` (e.g. « Debt/EBITDA 3.51 vs CP-1.02 max 3.5 → covenant step-down / additional equity »); [] if none;
  • `kyc_red_flags` — cite EACH entry in `kyc_findings` verbatim (owner, ownership %, KYC status) and state that a pending/unverified ≥25% owner blocks unconditional approval; if empty, state KYC is clear;
  • `sections` — optional supporting detail, same `{title, content, chart_type}` shape.

When your report_content is ready, call produce(field='report_content', result=<your report_content JSON>). Do NOT reply with prose.</system_prompt>
      <tools>produce,bag_read</tools>
      <tools_doc>produce(report_content) | bag_read()</tools_doc>
      <skills>iris.report.generate</skills>
      <args>{"produces": "report_content"}</args>
      <frames>
        <frame><tckm domain="bag" /></frame>
        <frame>Your reasoning so far, persisted across your turns:
<tckm domain="thinking"/>
Before your TOOL: lines, write your working plan and reasoning inside <thinking>…</thinking> — it is preserved and shown back to you next turn, so BUILD on it (what you already decided/read/produced) instead of restarting from scratch.</frame>
      </frames>
    </agent>
    <agent name="charts" role="charts" strategy="react" deterministic="false">
      <system_prompt>You are the **Chart Builder** of IRIS. You turn the authored report into report_html — the COMPLETE report document a risk committee reads (title, executive summary, every section's narrative WITH its chart, the data table, a sources footer), NOT a bare chart. Your `main(args)` receives args['report_content'] (the authored narrative), args['data_result'], args['data_summary'], args['parsed_query']. Mechanism: `generate_code` a `main(args) -> list[dict]` returning [{'html': <the FULL document>}], then `run_code(from_bag='code', produces='report_html')`. Follow your SKILL for HOW to build the document and draw each chart; never ship without the per-section narrative and the sources footer.</system_prompt>
      <tools>generate_code,run_code,produce,bag_read</tools>
      <tools_doc>generate_code(instruction) | run_code(from_bag,produces) | produce(report_html) | bag_read()</tools_doc>
      <skills>iris.report.charts</skills>
      <args>{"produces": "report_html"}</args>
      <frames>
        <frame><tckm domain="bag" /></frame>
        <frame>Your reasoning so far, persisted across your turns:
<tckm domain="thinking"/>
Before your TOOL: lines, write your working plan and reasoning inside <thinking>…</thinking> — it is preserved and shown back to you next turn, so BUILD on it (what you already decided/read/produced) instead of restarting from scratch.</frame>
      </frames>
    </agent>
    <agent name="compliance" role="compliance" strategy="task_execute" deterministic="false">
      <system_prompt>You are the **Compliance Reviewer** of IRIS. You audit a drafted report against banking governance before it can be published, and you are accountable for what ships.

Check, and cite the rule for each finding (look them up in your corpus rather than guessing):
  • **PII / confidentiality** — no individual customer is identifiable; only aggregates;
  • **figure integrity** — verify grounding YOURSELF: `bag_read('data_result')` + `bag_read('data_summary')`, then check EVERY figure in the report is COMPUTABLE from the rows. A raw value, a column sum, a share, AND a DERIVED figure (a ratio, a spread / difference between two shares, a cumulative Top-N %, an average, a min–max band) is GROUNDED when its components are in the data — do NOT flag a correctly-derived figure, and section titles / chart labels are NOT entities. Flag ONLY a number that CANNOT be reconstructed from the rows, or a branch/entity name absent from the data — that is a critical finding (rule=figure-integrity) and status can NOT be pass. Also check shares sum to ~100% and section totals match;
  • **disclosure** — the data window and methodology are stated; no unsupported claims;
  • **fair presentation** — risk (NPL, concentration) is not omitted or downplayed.

Output ONE JSON object: `{status: "pass"|"revise"|"reject", findings: [{severity, rule, issue, fix}], summary}`. Be strict but specific: a `revise` must say exactly what to change; a `reject` must name a hard violation.

ROUTING (EVALUATE-OPTIMIZE): after producing compliance_result, IF status is revise/reject AND no revision has happened yet (no prior compliance_result in the bag), send the report BACK for revision — handoff_agent('generate', 'evaluate-optimize') — the author rewrites with your findings. If a revision already happened or status is pass, do NOT send back: your verdict is final, end your node.

UNDERWRITING POLICY (HARD) — if `data_summary` is an underwriting bundle (has `breaches` and `kyc_findings`), enforce, in ADDITION to the above:
  • **breach coverage** — EVERY string in `data_summary.breaches` MUST appear as its own finding (severity ≥ high, `rule` = the `CP-1.0x` policy it names). A memo that omits or softens a breach is a `revise` at minimum;
  • **KYC trap (hard)** — for EVERY entry in `data_summary.kyc_findings` (a ≥25% beneficial owner whose KYC ≠ verified) emit a `critical` finding; set `status` = `reject` UNLESS the memo's verdict is `approve_with_conditions` or `decline` AND it names that owner as a blocking condition. An unverified ≥25% owner may NEVER ship as an unconditional `approve`;
  • **verdict consistency** — reject if the memo's `verdict` is `approve` while `data_summary.breaches` is non-empty.
Cite `data_summary` figures verbatim; never recompute.

When your compliance_result is ready, call produce(field='compliance_result', result=<your compliance_result JSON>). Do NOT reply with prose.</system_prompt>
      <tools>produce,bag_read,verdict_failed</tools>
      <tools_doc>produce(compliance_result) | bag_read()</tools_doc>
      <skills>iris.report.compliance</skills>
      <args>{"produces": "compliance_result"}</args>
      <frames>
        <frame><tckm domain="bag" /></frame>
        <frame>Your reasoning so far, persisted across your turns:
<tckm domain="thinking"/>
Before your TOOL: lines, write your working plan and reasoning inside <thinking>…</thinking> — it is preserved and shown back to you next turn, so BUILD on it (what you already decided/read/produced) instead of restarting from scratch.</frame>
        <frame name="declared-feedback"><condition domain="self.tool" name="verdict_failed">
Your verdict FAILED the report — the revision routes back NOW (declared rule):
<tckm domain="self.tool" name="handoff_agent" params='{"agent_name": "generate", "pattern": "evaluate_optimize"}'/>
</condition></frame>
      </frames>
    </agent>
  </agents>
  <topology>
    <edge from="interpret" to="fetch" pattern="chain">parsed_query</edge>
    <edge from="fetch" to="generate" pattern="chain">data_result, data_summary</edge>
    <edge from="generate" to="charts" pattern="chain">report_content</edge>
    <edge from="charts" to="compliance" pattern="chain">report_html</edge>
    <edge from="compliance" to="generate" feedback="true" pattern="evaluate_optimize">revision notes (findings)</edge>
  </topology>
  <meta_context>RAW REQUEST: Design a WEB-OF-TRUST (<loop type="wot">) that produces a decision-grade banking BI report from a natural-language banking question. In a WoT every agent is INDEPENDENT and routes its OWN output via ACL-verified handoffs along the <topology> edges — declare NO dispatching coordinator (at most <coordinator strategy="supervise"> as a schema watchdog that never dispatches nor collects). These member sub-agents are AVAILABLE — YOU decide the workflow: the order, the todo_items and the topology, INFERRED from each member's data dependencies (what it consumes vs produces). Do not assume an order; derive it.
- interpret — LLM, tools=produce. Consumes: the NL question. Produces: parsed_query {domain, metrics, dimensions, time_range, filters}.
- fetch — deterministic, tools=fetch_warehouse. Consumes: parsed_query. Produces: data_result + data_summary (reads the governed ClickHouse warehouse).
- generate — LLM, tools=produce,bag_read. Consumes: data_result + data_summary (bag_read them in FULL — the frame is only a sample). Produces: report_content {title, executive_summary, sections, methodology_note}.
- charts — LLM, strategy=react, tools=generate_code,run_code,produce,bag_read. Consumes: report_content + data_result + data_summary. Produces: report_html (builds inline-SVG charts by code-generation).
- compliance — LLM, tools=produce,bag_read. Consumes: the drafted report + data_result (bag_read them to verify figures GENERATIVELY — grounding is reasoned, not a deterministic tool). Produces: compliance_result {status, findings, summary}. On a FAILED check it sends the report BACK to generate for revision — wire an EVALUATE-OPTIMIZE back-edge <edge from="compliance" to="generate" pattern="EVALUATE-OPTIMIZE" feedback="true">revision notes (the findings)</edge>.
Give EVERY LLM agent a <frame> whose text is EXACTLY <tckm domain="bag"/> so it sees the live bag each turn. Keep each <system_prompt> to ONE short line — the real professional prompts are injected separately by the caller. Add <goal kind="bag"><key>compliance_result</key></goal>.

CONCRETE REQUEST — design the loop to answer THIS:
Loan portfolio by branch Q4</meta_context>
  <goal kind="bag">
    <key>compliance_result</key>
  </goal>
</loop>
03

Chaque étape, expliquée

Cinq agents spécialistes. Chacun est autonome : il lit ses données, produit son champ de contrat, et route lui-même son travail — le rail applique les contrats qu'il oublierait.

Query Interpreterinterpret

Traduit la question en spécification machine : domaine, métriques, dimensions, période.

Garde-fous : produce(parsed_query) puis handoff — 1 à 2 appels LLM (~7 s).

produce
Warehouse Fetcherfetch

Exécute la requête gouvernée sur ClickHouse (l'outil est déterministe, le LLM décide seulement de l'appeler).

Garde-fous : SQL par métrique, filtres validés contre le schéma réel.

fetch_warehouse
Report Authorgenerate

Écrit le rapport (executive summary, sections 1:1 avec leurs charts, note de méthode) — depuis les données réelles qu'il lit dans le bag (bag_read), pas une description tronquée.

Garde-fous : produce-once : le champ s'écrit UNE fois ; le rail route à sa place s'il insiste.

bag_readproduce
Compliance Reviewercompliance

Audite le rapport en raisonnant sur les données (bag_read) : chaque chiffre est-il fondé — y compris dérivé (ratio, écart, part cumulée) —, PII, présentation. Rend pass / revise / reject.

Garde-fous : Grounding génératif : un chiffre non fondé = finding ; reject → hop feedback vers l'auteur (budget : 1).

bag_readproduce
Chart Buildercharts

Écrit puis exécute du code Python qui rend le rapport en HTML + SVG (un chart par section, colonnes réelles ; la dimension est lue depuis les données, jamais codée en dur).

Garde-fous : Contrat gen→run→consume ; shapes réelles injectées dans le prompt du codegen ; sandbox.

bag_readgenerate_coderun_code
04

Ce que voit un agent — les couches de son prompt

Un agent n'est pas un prompt : c'est un empilement gouverné, assemblé à chaque tour.

Skill

Le corpus métier du rôle (un SKILL.md par agent), greffé au system prompt via le backend.

Prompt métier

La mission professionnelle du membre (« senior banking analyst… »), injectée depuis le registre des membres.

Frame WoT

Ses parents, ses next agents permis (balises résolues par SON biscuit), la règle par pattern, la règle terminale.

Données réelles

« YOUR INPUT DATA » — un échantillon réel de chaque champ reçu. Sans ça, l'agent hallucinait : il n'avait rien à calculer.

Mémoire d'actions

« YOUR RECENT ACTIONS » — ses 3 dernières actions, commentées par lui-même. Sans ça, il refaisait ce qu'il avait déjà fait.

Tools

Les outils déclarés dans le design + les rails (bag_read, reply). Multi-TOOL : plusieurs actions par réponse, exécutées en ordre.

05

Le rapport, rendu comme dans l'application

Le report_html réellement produit par l'agent charts (code écrit puis exécuté par lui) : sections narratives, charts SVG aux colonnes réelles, table précise, pied sources & provenance.

06

Qui valide quoi

La qualité ne repose sur aucun prompt : chaque étage a son valideur, et un étage LLM qui ignore son contrat se fait refuser par l'étage déterministe au-dessus — au bout de deux refus, le rail exécute lui-même l'obligation du schéma.

QuoiQui valideComment
Le design (XML)code déterministe_check_loop : wot ⇒ zéro coordinator, edges → agents déclarés, pattern ∈ les 6, zéro orphelin. Refus → l'architecte re-boucle.
Chaque hopl'ACL (biscuit signé)gate_handoff : token + edge + pattern, offline, fail-closed. Aucun agent ne route sans autorisation.
Le comportement d'un nodele rail (code)produce-once, refus mains-vides, budget _rev (1 révision), auto-run / auto-route quand l'agent déraille.
Le contenu (chiffres, entités)grounding génératifLe compliance lit le dataset (bag_read) et vérifie que chaque chiffre — même dérivé — se recalcule depuis les lignes ; sinon il liste le finding.
Le verdict métiercompliance (agent LLM)pass / revise / reject — contraint : un mismatch interdit le « pass ».
La publicationl'humainvalidate / modify / refuse à la review. Rien ne se publie sans cette décision.

Vu en production, attrapé par ces gardes

Des chiffres inventés (« 735,0 M€ »), des agences fictives (« Lyon Centre »), des colonnes inexistantes (« amount ») — tous détectés par le raisonnement du compliance sur les données réelles, rejetés, renvoyés en révision par l'edge feedback. Et la cause racine, trouvée dans les traces : la donnée n'était pas dans le prompt — corrigée, l'invention d'entités a disparu.

07

Les traces — chaque affirmation est cliquable

Le waterfall du run : barres au temps réel, une couleur par trace de node. Cliquez une ligne pour les payloads (args, résultat, tour LLM). Repérez le span tool:handoff_agent de compliance : le hop de révision accepté par le gate ACL.