Changer de couche d'interaction¶
Le cœur de flower ignore l'existence de toute UI. Chaque chose qui se produit dans une exécution — le modèle parle, appelle un outil, le contexte est presque plein, il faut poser une question à un humain — est aplatie vers une seule et même structure de données : Event. La couche d'interaction ne connaît que Event et n'importe aucun type du SDK. C'est la frontière qui permet de changer d'UI sans toucher au cœur : terminal, Web, service HTTP, entièrement automatique et sans surveillance — ce qui change, c'est le consommateur d'Event, rien d'autre, pas une ligne.
Quel problème cela résout¶
Le flux de messages du SDK expose des types internes : AssistantMessage, ToolUseBlock, ToolResultBlock, ResultMessage, SystemMessage… Les consommer directement dans l'UI a deux conséquences : dès que le SDK monte de version, le front doit suivre ; et comme chaque forme de message est différente, chaque UI doit réécrire la logique « est-ce du corps de texte ou un appel d'outil ».
normalize(message) transforme un message du SDK en 0 à N Event (core/events.py). Le coût est une conversion ; ce qu'on obtient, c'est l'absence de dépendance de type entre la couche d'interaction et le SDK.
Cette frontière règle au passage quatre choses moins évidentes, toutes les quatre dans normalize() :
- Les prises de parole des subagents sont marquées (
payload["subagent"]). Sans cela, le brief de tâche envoyé et les paroles intermédiaires du subagent se mélangeraient au corps de texte du thread principal, et pollueraient ensuite, le long du workflow, le prompt de l'étape suivante. - Les messages d'erreur synthétiques produits lors d'une coupure réseau sont aiguillés vers
kind="error". Lors d'une coupure, le SDK écritAPI Error: …dans le transcript comme s'il s'agissait d'un message assistant ; ça ressemble à une parole du modèle (modelvaut"<synthetic>"). Si on ne l'intercepte pas ici, ça finit dansStepResult.text, puis est transmis à l'étape suivante. - Les frontières de compact sont signalées explicitement (
kind="reset"). Après la frontière, le modèle ne « se souvient » que du résumé, et le cache de prompt est rompu à cet endroit — une exécution à long horizon doit pouvoir le voir. - Le niveau de contexte sort avec chaque message (
payload["context"]=input_tokens+cache_read_input_tokens+cache_creation_input_tokens). C'est la seule source du critère de handoff.
Comment s'en servir (code minimal)¶
Une couche d'interaction doit brancher trois choses : la sortie d'événements (où rendre), le canal de questions (qui répond), l'interruption (comment crier stop). Le bloc ci-dessous branche tout, il tourne tel quel :
import asyncio
from flower import Event, HumanChannel, Runtime, starter_flow
def sink(ev: Event) -> None:
"""Rend l'Event dans votre propre UI — c'est la seule chose à remplacer."""
if ev.kind == "step":
print(f"\n=== {ev.text} ({ev.payload['index']}/{ev.payload['total']}) ===")
elif ev.kind == "text" and not ev.payload.get("subagent"):
print(ev.text)
elif ev.kind == "tool_call":
print(f" [{ev.tool}] {ev.text}")
elif ev.kind == "handoff":
print(f" ~ 换代/{ev.payload.get('phase')}: {ev.text}")
elif ev.kind == "retry":
print(f" ~ 重试: {ev.text}")
elif ev.kind == "ask" and ev.payload.get("kind") == "mail":
print(f" ~ 人主动说:{ev.text}")
# kind == "ask" qui n'est pas du mail : traité plus bas par answerer (mode pull)
async def answerer(ch: HumanChannel) -> None:
"""Récupération des questions en mode pull. Pour du Web / HTTP, c'est l'autre endroit à changer."""
while True:
ask = await ch.next_ask() # sans timeout, on attend indéfiniment
if ask is None:
continue
print(f"\n?? {ask.question} 选项={ask.options}")
ch.answer(ask.id, "按你的判断来") # ou ch.decline(ask.id, "先跳过")
async def main() -> None:
wf = starter_flow("帮我做一个 X", workspace=".", run_dir="runs", timeout_s=60)
# Runtime utilise le workbench déjà créé par le workflow — n'en fabriquez pas un autre
rt = Runtime(workspace=".", run_dir="runs", workbench=wf.workbench)
task = asyncio.create_task(answerer(wf.channel))
try:
ctx = await wf.run(rt, on_event=sink)
finally:
task.cancel()
rt.close() # ferme la connexion SQLite
print(rt.total_cost(), ctx.get("_failed_at"))
asyncio.run(main())
Deux finitions faciles à oublier : rt.close() doit impérativement être dans finally ; si ctx["_failed_at"] a une valeur, c'est que l'exécution s'est arrêtée en route (on_fail="stop") — ne la prenez pas pour un succès.
Il n'y a qu'un seul workbench, n'en fabriquez pas un autre
Le workbench créé par Runtime(workbench=True) se trouve dans <run_dir>/workbench, alors que Workbench(ws) est par défaut dans <ws>/.flower — ce ne sont pas le même répertoire. Un programme pilote qui fabrique lui-même le chemin pour aller chercher 需求.md obtient « le brief est écrit dans le répertoire A, l'index injecté scanne le répertoire B » — sans la moindre erreur. Soit vous passez à Runtime celui que le workflow a créé (l'écriture ci-dessus), soit vous utilisez la sonde en lecture seule wake_state() pour lui demander où il est.
Trois sorties d'événements¶
await rt.run(spec, "…", on_event=sink) # 1. un seul agent
await wf.run(rt, on_event=sink, on_step=progress) # 2. tout le workflow, transmis à chaque étape
wf = Workflow(steps=[...], channel=ch) # 3. canal de questions, branché sur la même sortie
Le branchement du troisième cas se fait dans Workflow.run : il n'est automatique que si on_event est non None et que channel.on_event est encore None. Si vous l'avez branché vous-même, il n'est pas écrasé :
on_step(step, result) est un autre callback, appelé une fois à la fin de chaque étape (y compris en cas d'échec), et il reçoit le StepResult complet. Barre de progression, écriture sur disque, alertes : accrochez-les ici, n'essayez pas de les reconstituer depuis le flux d'Event — le corps de texte est découpé en plusieurs morceaux par les handoffs et les retries.
Terminal : celui par défaut¶
Il y en a un même sans écrire de code. flower "帮我做一个 X" passe par flower/cli.py, qui est une implémentation de référence de la couche d'interaction, pas une partie du framework ; elle peut être remplacée intégralement. Les options sont dans la référence CLI. Disons la taille telle qu'elle est. cli.py en entier fait 1264 lignes, 57KB — mais ce n'est pas l'ensemble qu'il faut remplacer. Le vrai point de remplacement, c'est la class Render à l'intérieur (cli.py:489-687, 197 lignes), dont la docstring dit justement : « Event → terminal. Changer d'UI, c'est changer cette seule classe. » Les mille et quelques lignes restantes sont l'interruption, l'oracle, les accusés de réception de la boîte de réception, le sauvetage sur signal — de l'outillage spécifique au terminal, qu'il n'y a de toute façon pas lieu de recopier pour du Web ou du HTTP.
Donc l'affirmation « environ 200 lignes remplaçables en bloc » tient — à condition qu'elle désigne Render, pas cli.py.
Si vous écrivez votre propre UI terminal, le point clé est le thread qui lit l'entrée standard :
import select
import sys
import threading
def start_input(ch: HumanChannel) -> threading.Event:
"""Lit l'entrée standard en continu : s'il y a une question en attente c'est une réponse, sinon ça va dans la boîte de réception. Renvoie le drapeau d'arrêt."""
stop = threading.Event()
def loop() -> None:
while not stop.is_set():
if not select.select([sys.stdin], [], [], 0.2)[0]:
continue # polling, seul moyen de réagir au drapeau d'arrêt
line = sys.stdin.readline()
if not line: # EOF
return
raw = line.strip()
if not raw:
continue
pend = ch.pending()
if pend:
ch.answer(pend[0].id, raw) # sûr entre threads
else:
ch.send(raw) # va dans la boîte de réception, n'interrompt pas le travail en vol
threading.Thread(target=loop, daemon=True, name="stdin").start()
return stop
Les trois points ont été appris à la dure :
- Utiliser un thread daemon, pas
asyncio.to_thread(input, ...).input()bloqué ne peut pas être annulé, etasyncio.rundoit joindre les threads de l'exécuteur par défaut avant de sortir — résultat : le travail est fini, mais il faut encore appuyer une fois sur Entrée pour quitter. - Faire du polling avec
select, pas uninput()direct dans la boucle. Même problème d'annulation : un thread bloqué surinput()ne sera jamais réveillé parstop.set(). - Lire en continu, pas seulement quand il y a une question. Si on ne lit que quand il y a une question, tout ce qui a été tapé pendant les heures de travail reste dans le tampon du terminal et sera avalé comme réponse à la question suivante — l'humain n'a même pas encore vu la question qu'elle est déjà « répondue ».
Web : file d'attente + WebSocket¶
events: asyncio.Queue[dict] = asyncio.Queue()
def sink(ev: Event) -> None: # synchrone, dans le thread de la boucle d'événements, ne doit pas bloquer
try:
events.put_nowait({"kind": ev.kind, "text": ev.text,
"tool": ev.tool, "payload": ev.payload})
except Exception: # une erreur du front ne doit pas emporter trois heures de travail
pass
async def pump(ws) -> None:
while True:
await ws.send_json(await events.get())
@app.post("/answer") # thread de traitement des requêtes — un autre thread, c'est la norme
def answer(ask_id: str, text: str) -> dict:
return {"ok": ch.answer(ask_id, text)}
ev.raw est l'objet SDK d'origine (un Ask dans les événements ask) : non sérialisable en JSON, et à ne pas transmettre au front — utiliser raw, c'est réattacher le front aux types du SDK, et toute cette couche n'aura servi à rien. Les quatre champs kind / text / tool / payload suffisent.
HTTP : numéro de séquence + polling¶
Sans connexion longue, numérotez les événements pour que le client les tire :
import itertools
from collections import deque
seq = itertools.count(1)
log: deque[dict] = deque(maxlen=2000) # on ne garde que les plus récents, la mémoire ne croît pas avec la durée de l'exécution
def sink(ev: Event) -> None:
log.append({"seq": next(seq), "kind": ev.kind, "text": ev.text,
"tool": ev.tool, "payload": ev.payload})
@app.get("/events") # GET /events?after=128
def events(after: int = 0) -> list[dict]:
return [e for e in log if e["seq"] > after]
@app.get("/asks") # ce qui est en attente de réponse en ce moment
def asks() -> list[dict]:
return [{"id": a.id, "question": a.question, "options": a.options,
"waited_s": a.waited_s} for a in ch.pending()]
@app.post("/answer")
def answer(ask_id: str, text: str) -> dict:
return {"ok": ch.answer(ask_id, text)} # False = cette question n'est plus en attente
Deux limites à reconnaître : quand maxlen est plein, les plus anciens sont perdus, et un client qui revient avec un after très vieux n'obtiendra pas tout — l'intervalle de polling doit être cohérent avec cette longueur ; et il faut absolument donner une valeur finie à timeout_s — quand personne ne fait de polling, une question ne se termine pas d'elle-même, et timeout_s=None laisse toute l'exécution suspendue pour toujours. La valeur par défaut de 1800.0 secondes est appropriée.
Entièrement automatique, sans surveillance : personne¶
wf = starter_flow("帮我做一个 X", workspace=".", run_dir="runs", timeout_s=0)
rt = Runtime(workspace=".", run_dir="runs", workbench=wf.workbench)
ctx = await wf.run(rt, on_event=None) # tous les événements sont jetés
L'équivalent en ligne de commande est flower "帮我做一个 X" --timeout 0.
timeout_s=0 (idem pour les négatifs) est le mode entièrement automatique : la question n'entre pas dans la file d'attente et n'émet pas d'événement asked, elle est immédiatement soldée en state="timeout", et l'outil renvoie ce texte fixe —
— et l'exécution continue sans encombre. Les questions-réponses restent ajoutées à HumanChannel(log_path=...) (starter_flow branche par défaut <工作台>/notes/问答记录.md), ce qui permet de voir après coup ce qui a été demandé et quelles hypothèses ont été prises.
Si vous ne voulez pas qu'il ouvre la bouche, utilisez max_asks=0 : la question est directement refusée (state="over_budget"), sans bloquer non plus. Attention, ce n'est pas « retirer l'outil » — allowed_tools n'est pas exclusif : dès qu'un channel est branché sur le coordinateur, les deux outils mcp__human__ask et mcp__human__inbox sont donnés ensemble, et restent appelables qu'ils soient listés ou non. Seuls le quota et le timeout peuvent empêcher une question.
Sans surveillance, ne laissez jamais une question attendre indéfiniment
timeout_s=None veut dire « attendre pour toujours ». Quand personne ne regarde, une seule question suffit à figer sur place une exécution de dix heures, sans erreur, sans timeout, et sans que les logs montrent la moindre différence. Sans surveillance, il n'y a que deux valeurs correctes : 0 (échec immédiat) ou un nombre fini de secondes.
Ce qu'il fait réellement¶
La forme d'Event¶
@dataclass
class Event:
kind: EventKind # 15 valeurs, voir le tableau ci-dessous
text: str = ""
tool: str = "" # renseigné uniquement pour tool_call
payload: dict[str, Any] = field(default_factory=dict)
raw: Any = None # objet SDK d'origine / Ask, y toucher c'est se réattacher au SDK
str(ev) : pour tool_call c'est [nom d'outil] résumé, sinon c'est text ; si text est vide, c'est <kind>.
Les 15 EventKind¶
kind | Émis par | Quand il apparaît | text | payload |
|---|---|---|---|---|
text | normalize() | Corps de texte du modèle | Le texte | subagent, parent_tool_use_id?, context? |
thinking | normalize() | Bloc de réflexion | Le contenu de la réflexion | idem |
prompt | normalize() | Entrée : votre prompt, le brief de tâche envoyé à un subagent | Le texte d'entrée | idem |
tool_call | normalize() | Le modèle lance un appel d'outil | Résumé (file_path / command / pattern, tronqué à 200 caractères) | id, input + idem ; tool est le nom de l'outil |
tool_result | normalize() | Retour d'un outil | Les 500 premiers caractères (vide si le contenu n'est pas une chaîne) | tool_use_id, is_error + idem |
result | normalize() | Fin d'une requête SDK | subtype | session_id, cost_usd, num_turns, is_error |
error | normalize() | Message synthétique lors d'une coupure réseau | Le texte d'erreur | synthetic: True |
reset | normalize() | Frontière de compact ou réinitialisation de session | 压缩(trigger) 167000 → 42000 tokens ; pour une réinitialisation de session, conversation reset | trigger, pre_tokens, post_tokens, micro, subtype (vide lors d'une réinitialisation de session) |
system | normalize() | Les autres messages système du SDK | subtype | data transmis tel quel |
task | normalize() | Message de progression d'une tâche | vide | kind = nom de la classe du message SDK |
unknown | normalize() | Type de message non reconnu | Nom de la classe | — |
Le corps de task est vide, ne l'affichez pas tel quel
TaskProgressMessage et consorts sont des types de messages internes au SDK. Auparavant, normalize() émettait le nom de la classe comme corps de texte ; à l'écran, c'est du bruit pur, et mélangé au corps de texte de l'agent ça donne l'impression d'une erreur (constaté en pratique). Aujourd'hui, c'est un événement sans corps de texte, le nom de la classe étant placé dans payload["kind"] — c'est à la couche d'interaction de décider de l'afficher ou non (events.py).
| ask | HumanChannel | Il faut une réponse humaine, une question a trouvé son issue, ou un humain a parlé de lui-même | La question / la parole de l'humain | Deux identités, voir plus bas | | retry | Runtime | Retry en cours / en attente du réseau | Une phrase d'explication | step, attempt | | step | Workflow.run | Frontière d'étape | Nom de l'étape | index, total, resumed, woke | | handoff | Runtime | Handoff : approche / écriture en cours / terminé | Une phrase avec le niveau de contexte | phase, step, context, window + voir plus bas |
Quatre kinds ne sont pas produits par normalize() : ask vient de HumanChannel, retry et handoff viennent de Runtime, step vient de Workflow.run. Les mettre dans le même EventKind est délibéré — l'UI ne connaît qu'un seul jeu d'Event, sans avoir à ouvrir une voie séparée pour « il faut une réponse humaine » ou « frontière d'étape ».
En écrivant une UI, laissez une branche else. EventKind gagnera de nouveaux membres, et une vieille UI ne devrait pas planter pour autant.
Les trois phases de handoff¶
phase | Quand c'est émis | Extras dans payload |
|---|---|---|
near | Le niveau a dépassé warn_at. Émis une seule fois par génération, pas de spam | at (le seuil de handoff) |
writing | Début de l'écriture du document de handoff. L'écriture prend une dizaine de secondes ; sans cet événement l'interface a l'air figée | — |
done | Handoff écrit, bascule vers une nouvelle session | degraded (version dégradée ou non), path (où c'est écrit, chaîne vide sans workbench), sections |
Pour le mécanisme lui-même, voir handoff.
Les deux identités d'ask¶
Event("ask") porte à la fois « une question » et « une parole spontanée de l'humain ». L'UI doit d'abord regarder payload["kind"] :
| Identité | Comment la reconnaître | payload |
|---|---|---|
| Une question | Pas de clé kind | id, options, state, answer, remaining, asked_at ; raw est l'objet Ask |
| Une parole spontanée de l'humain | payload["kind"] == "mail" | kind, state (queued déposé / delivered récupéré), id, amended (dans quel fichier c'est ajouté, chaîne vide si non configuré). Pas d'options ni de remaining |
Une question émet au moins deux événements : un au moment de la question (state="asked"), un autre à l'issue (answered / timeout / declined / over_budget / invalid). L'UI n'a qu'à mettre à jour la même entrée d'après payload["id"].
Demander à l'humain : Ask et HumanChannel¶
@dataclass
class Ask:
id: str # "q1", "q2"…
question: str
options: list[str] = field(default_factory=list)
asked_at: float = field(default_factory=time.time)
state: str = "asked" # voir les cinq issues ci-dessus
answer: str = ""
@property
def waited_s(self) -> float: ... # secondes d'attente, une décimale
def event(self, remaining: int = 0) -> Event: ...
HumanChannel est un serveur MCP in-process plus un jeu de méthodes destinées à l'UI. Côté modèle, seuls deux outils sont visibles : mcp__human__ask (poser une question, ça suspend et attend) et mcp__human__inbox (consulter la boîte de réception, sans blocage ; si elle est vide, ça renvoie immédiatement une phrase d'explication). Construction complète :
HumanChannel(
*, # tout en keyword-only
on_event=None, # sortie en mode push. Workflow ne la branche automatiquement que si elle vaut None
max_asks=None, # None = illimité ; 0 = interdit de demander. Au-delà du quota, refus direct, sans blocage
timeout_s=1800.0, # None = attendre pour toujours ; <= 0 = échec immédiat
log_path=None, # les questions-réponses sont ajoutées à ce fichier, sans occuper le contexte
amend_path=None, # ce que l'humain dit en cours d'exécution est ajouté à ce fichier, en général le brief
over_budget_text=OVER_BUDGET, # trois réponses fixes, remplaçables par les vôtres
timeout_text=TIMEOUT,
declined_text=DECLINED,
)
amend_path est le paramètre le plus facile à oublier, et c'est lui qui détermine si « les exigences modifiées en cours de route par l'humain » survivent aux frontières d'étape. Chaque étape est une nouvelle session avec un instantané figé en lecture seule : ce qui est dit en cours d'exécution n'entre que dans le contexte de l'agent du moment ; l'étape suivante (par exemple le verdict) est une session toute neuve qui lit 需求.md et 目标.md, et ne voit pas ce que vous avez dit — elle juge donc encore selon les anciennes limites et déclare hors périmètre ce qui vient d'être corrigé. amend_path ajoute chaque message au brief — en ajout et non en écrasement : l'exigence initiale est de l'histoire, et voir ce qui a changé vaut mieux que ne pas le voir. starter_flow branche par défaut <工作台>/notes/需求.md.
Constaté en pratique ($0.6767), ce mécanisme marche encore mieux que prévu : l'humain dit « au passage, rapporte le nombre total d'octets » ; le coordinateur consulte la boîte de réception et rapporte — « hand l'a déjà lu dans le complément en cours d'exécution de .flower/notes/需求.md et l'a calculé, pas besoin de redéléguer ». Le subagent l'a lu dans le fichier, sans que personne le lui répète.
Membres publics :
| Membre | Signature | Sémantique |
|---|---|---|
tool_name | -> str | "mcp__human__ask" |
inbox_name | -> str | "mcp__human__inbox" |
mcp_servers | () -> dict | À passer directement à AgentSpec.mcp_servers. Le nom de la clé doit correspondre au nom du serveur, d'où le fait qu'il soit fourni ici |
ask | async (question, options=None) -> Ask | Suspend et attend l'humain. Ne lève jamais d'exception hormis CancelledError — l'absence de réponse est aussi une réponse, distinguée par ask.state |
pending | () -> list[Ask] | Les questions actuellement en attente de réponse |
next_ask | async (timeout=None) -> Ask \| None | Pour les UI en mode pull. Renvoie None en cas de timeout, lève si annulé |
answer | (ask_id, text) -> bool | Répondre. False = cette question n'est plus en attente (timeout / déjà répondue) |
decline | (ask_id, reason="") -> bool | Passer, en laissant le modèle juger lui-même et écrire son hypothèse dans « Inconnues et hypothèses » |
send | (text) -> Mail \| None | L'humain dit spontanément une phrase, qui va dans la boîte de réception. N'interrompt pas l'agent ; appelle automatiquement amend() en interne |
amend | (text, *, label="运行中补充") -> bool | Ajoute à amend_path. Renvoie si l'écriture a vraiment eu lieu (chemin non configuré / texte vide / OSError donnent tous False) |
pending_mail | () -> list[Mail] | Les paroles pas encore récupérées |
remaining | -> int | Nombre de questions encore possibles. Avec max_asks=None, renvoie -1, pas 0 |
transcript | () -> str | Le markdown de l'historique des questions-réponses |
asks / mail / ui_errors | list | Toutes les questions / toutes les paroles de l'humain / les exceptions levées par les callbacks de l'UI |
answer, decline et send peuvent être appelés depuis n'importe quel thread. Le thread de traitement des requêtes d'un backend Web, le thread d'entrée d'une TUI sont dans d'autres threads — c'est la norme, pas un cas limite. En interne, ça passe par loop.call_soon_threadsafe, car asyncio.Future.set_result n'est pas thread-safe.
Push et pull sont deux modes de récupération : choisissez-en un :
| Comment récupérer | Convient à | |
|---|---|---|
| Push | HumanChannel(on_event=…), réception d'un kind == "ask" avec payload["state"] == "asked" | UI événementielles (push Web, redessin de TUI) |
| Pull | await channel.next_ask() | Une tâche d'entrée indépendante |
Trois sémantiques « 0 / None » toutes différentes ; les confondre, c'est un blocage définitif sans surveillance ou pas la moindre question posée :
| Écriture | Signification |
|---|---|
max_asks=None | Nombre illimité (défaut) |
max_asks=0 | Questions interdites, refus direct |
timeout_s=None | Attendre pour toujours |
timeout_s<=0 | Ne pas attendre, la question échoue immédiatement |
remaining renvoie -1 | La valeur quand max_asks=None, pas 0 |
Interruption : n'importe quel thread peut crier stop¶
rt.interrupt("别改 Makefile,那两行直接改") ; une chaîne vide interrompt sans rien dire. Trois propriétés :
- Reprise de la même session (
resume), pas un redémarrage à zéro — le travail déjà fait et le contexte sont conservés. On réutilise la voie existante du retry après coupure réseau, en remplaçant simplement la « cause de l'échec » par « l'humain a interrompu » et leresume_promptpar la parole de l'humain. - Ne consomme pas
max_attempts. C'est un quota pour les pannes, pas pour les humains. - Coopératif : on coupe à une frontière de message, sans annuler brutalement la tâche. Le coût, c'est un délai jusqu'au message suivant (si un subagent est en cours, il faut attendre son retour) ; ce qu'on gagne, c'est de ne pas déchirer l'état en plein milieu.
Le coût, dit tel quel : une interruption fait perdre le travail à moitié fait d'un subagent en vol (constaté lors de la coupure réseau HT001, voir issue #2). L'implémentation de référence du terminal l'écrit explicitement dans son invite, pour qu'on le sache avant d'appuyer ; votre propre UI devrait faire pareil.
Si vous ne voulez pas interrompre mais simplement ajouter une exigence, utilisez la boîte de réception (ch.send(...)) — elle n'interrompt rien, et le délai est le prochain point de contrôle de l'agent.
Oracle : poser une question sans déranger l'exécution¶
Pour savoir « où on en est », inutile d'interrompre, et il ne faut pas demander au coordinateur : cet échange occuperait définitivement le contexte du thread principal (qui contient des décisions, pas un historique de questions-réponses), et il devrait interrompre ce qu'il est en train de faire. Sur une exécution de dix heures, trois questions posées en passant suffisent à payer ces deux coûts.
L'oracle est une voie latérale en lecture seule. Ses seuls outils sont Read / Glob / Grep, il a le workbench ouvert, et il vient avec des garde-fous par défaut : max_turns=12, max_budget_usd=0.5. Dans le terminal, une ligne commençant par ? le déclenche ; il répond à partir de deux choses : la fenêtre d'événements récents (60 entrées, fixe) et le brief, les objectifs, les notes et les livrables du workbench. Il utilise son propre Runtime (<run_dir>/aside), de sorte que le coût et le lignage de session ne se mélangent pas au manifeste principal — ce manifeste consigne « quelles étapes cette exécution a effectuées », et une question posée en passant n'est pas une étape.
Constaté en pratique : deux questions pour $0.5190 au total, sans un octet de plus dans le manifeste de l'exécution principale.
Deux règles dures¶
on_event ne doit ni bloquer, ni laisser échapper d'exception
Un. on_event est une fonction synchrone, appelée dans le thread de la boucle d'événements. Donc asyncio.Queue.put_nowait() est sûr, await ne l'est pas (ce n'est pas une coroutine), et la bloquer, c'est bloquer toute l'exécution. Si vous avez du travail lent à faire, déposez-le dans une file et laissez une autre tâche s'en charger.
Deux. Lever une exception dans on_event abîme l'exécution elle-même. Les événements de type corps de texte sont émis à l'intérieur du bloc try de Runtime._attempt : l'exception est enregistrée comme result.error — l'étape est déclarée en échec ; les événements retry sont émis en dehors de ce bloc, et l'exception remonte directement hors de Runtime.run. Le front ne doit pas emporter trois heures de travail : enveloppez vous-même dans un try.
Exception : les événements ask émis par HumanChannel lui-même sont déjà enveloppés, les exceptions étant collectées dans channel.ui_errors, sans interrompre l'exécution.
Quand ne pas s'en servir¶
Ce qu'il ne faut pas faire dans la couche d'interaction¶
| À ne pas faire | Pourquoi | Que faire à la place |
|---|---|---|
from claude_agent_sdk import ... | Dès que la couche d'interaction dépend des types du SDK, le front doit suivre à chaque montée de version, et cette couche n'aura servi à rien | N'utiliser que kind / text / tool / payload d'Event |
Lire ev.raw | Idem, et en plus ce n'est pas sérialisable en JSON | S'il manque un détail, ajoutez-le au payload dans normalize(), ne contournez pas la frontière |
await, requêtes réseau ou écritures disque lentes dans on_event | C'est synchrone, appelé sur le thread de la boucle d'événements ; le bloquer, c'est bloquer toute l'exécution | put_nowait() dans une file, une autre tâche consomme |
Laisser on_event propager une exception | Une exception sur un événement de corps de texte devient result.error, et l'étape est déclarée en échec | Envelopper tout le corps du callback dans un try |
Utiliser disallowed_tools pour couper les questions | C'est au niveau de la session, ce qui désactive aussi l'outil homonyme des subagents (message d'erreur constaté : "Bash is disabled for this session, in subagents as well as here") | max_asks=0 ou timeout_s=0 |
Retirer mcp__human__ask d'allowed_tools en croyant interdire les questions | allowed_tools n'est pas exclusif : c'est une liste d'exemption d'approbation, pas une liste blanche ; brancher un channel donne les deux outils ensemble | Idem |
Fabriquer soi-même le chemin pour trouver 需求.md / 目标.md | Il y a deux emplacements possibles pour le workbench ; se tromper ne produit aucune erreur, juste un échec silencieux | wake_state() ou wf.workbench |
Reconstituer progression et résultat depuis le flux d'Event | Le corps de texte est découpé en plusieurs morceaux par les handoffs et les retries | on_step(step, result) pour obtenir le StepResult complet |
timeout_s=None sans surveillance | Personne ne répond, l'exécution reste suspendue pour toujours, sans erreur ni timeout | 0, ou un nombre fini de secondes |
Quand il n'y a tout simplement rien à remplacer¶
- Vous voulez seulement changer les couleurs, afficher une ligne de plus ou de moins — il suffit de modifier la fonction de rendu. Dans l'implémentation de référence du terminal, l'interruption, l'oracle, les accusés de réception de la boîte de réception, le sauvetage sur SIGHUP / SIGTERM, l'attente de la fin de la voie latérale avant de quitter : tout réécrire coûte cher.
- Vous voulez seulement exécuter une étape, sans interaction — utilisez
flower once. Il ne passe pas par le pilote interactif : il n'a de toute façon ni interruption Ctrl+C, ni thread de réponse sur l'entrée standard, ni oracle, ni sauvetage sur signal. - Ce que vous voulez changer, c'est en fait le workflow, pas l'UI — voir Concevoir un workflow. La couche d'interaction décide seulement qui regarde et qui répond ; c'est
Workflowqui décide du nombre d'étapes, de la manière de juger et du moment de sortir par anticipation. - Ce que vous voulez changer, c'est le stockage de session, le modèle ou le budget — aucun de ces trois éléments n'est sur cette frontière, voir la référence de l'API Python.