Build and deploy / test-python (push) Successful in 11m12s
Build and deploy / test-js (push) Successful in 53s
Build and deploy / lint-python (push) Successful in 3m56s
Build and deploy / lint-js (push) Successful in 3m1s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 3m58s
Prepare la scission a venir entre l'editeur Jeu 2D et le futur editeur Support de formation (voir docs/plan/PLAN.md), sans toucher a l'architecture en couches existante : - screens/ renomme en game_engine/ (nom clair pour le moteur du jeu 2D, avant l'arrivee d'un second "moteur" cote document) : ~85 imports corriges, contrat import-linter mis a jour, meme forme de couches. - routes/, scripts/, static/, templates/, tests/ : tout ce qui est propre au jeu 2D deplace dans un sous-dossier game/ de chacun (routes/game/, static/game/, templates/game/, tests/game/, scripts/game/) ; ce qui est partage par le site (auth, onboarding, dashboard, uploads, db/) reste a la racine de chaque dossier. Un sous-dossier document/ (vide) cree dans chacun pour le futur chantier. - styles/ volontairement inchange : les 3 fichiers sources sont concatenes en un seul static/style.css charge par tout le site, scinder leur CONTENU (editeur vs partage) serait un refactor CSS distinct, pas un deplacement mecanique. - Chaine d'export SCORM (publish/build_scorm_package.py) mise a jour en profondeur : copie des assets, URLs d'icones relatives a static/style.css (qui ne bouge pas), manifeste, wrapper SCORM. - Deux regressions d'un sweep de renommage anterieur corrigees au passage (screens.js/screens/scene-objects incorrectement convertis en game_engine.js/game_engine/scene-objects dans des commentaires). - Effet de bord Windows decouvert et corrige : git mv + Path.write_text convertissent des fichiers en CRLF (core.autocrlf=true) - ~189 fichiers normalises en LF. - .eslintrc.json/package.json : uniquement les chemins de glob mis a jour (static/game/js/...) ; la preparation eslint-plugin-unicorn du lot 7 reste volontairement non committee (package-lock.json restaure a la version precedente). Verifications : ruff, mypy --strict (391 fichiers), vulture, bandit, lint-imports tous verts ; 591/591 tests Python, 276/276 tests JS ; demarrage serveur + requetes HTTP manuelles confirmant que les assets deplaces repondent en 200 au nouvel emplacement et 404 a l'ancien. SKIP=djlint : backlog H021 (styles inline) deja documente comme dette assumee dans CODE_QUALITY.md section 6, aucun template touche par ce commit au-dela d'un deplacement de fichier. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
340 lines
19 KiB
Python
340 lines
19 KiB
Python
"""Boucle tool-use (voir plan Phase 2, §5) — Claude Sonnet 5 pilote
|
|
UNIQUEMENT les tools de ai/tools.py, jamais d'écriture directe en base :
|
|
tout ce qu'un tour de chat produit est donc TOUJOURS relisable/
|
|
modifiable dans l'éditeur normal (même garantie que la Phase 1)."""
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
import db
|
|
import game_engine
|
|
|
|
from .client import MODEL, get_client
|
|
from .tools import TOOLS, dispatch_tool
|
|
|
|
# Borne dure : jamais une boucle sans fin qui dépenserait sans fin si
|
|
# Claude s'entête à rappeler des outils (bug de prompt, tool qui échoue
|
|
# en boucle, etc.) — un écran complet (fond, personnages, widgets,
|
|
# déclencheur, ajustements) peut légitimement dépasser 8 appels.
|
|
_MAX_TOOL_ITERATIONS = 12
|
|
|
|
_SYSTEM_PROMPT = (
|
|
"Tu t'appelles Ruby, l'assistante IA de Forge Engine — présente-toi "
|
|
"sous ce nom si on te le demande. "
|
|
"Tu aides un créateur à construire UN SEUL écran d'un jeu de "
|
|
"formation professionnelle 2D (Forge Engine), via les outils fournis "
|
|
"— jamais autrement. Ne touche qu'à cet écran, jamais aux autres. "
|
|
"Réutilise une variable globale déjà existante UNIQUEMENT si elle "
|
|
"représente EXACTEMENT la même information (ex. un score total "
|
|
"partagé par tout le jeu) — les variables sont globales à TOUT le "
|
|
'jeu, visibles depuis n\'importe quel écran, donc un drapeau "terminé" '
|
|
"propre à UN dialogue/quiz précis ne doit JAMAIS être partagé avec un "
|
|
"autre dialogue/quiz, même similaire, même sur un autre écran (bug "
|
|
'corrigé : un quiz réutilisait par erreur le drapeau "terminé" d\'un '
|
|
"AUTRE quiz sans lien, les rendant mutuellement incohérents). Donne à "
|
|
"chaque nouveau drapeau un nom qui identifie clairement CE qu'il "
|
|
"suit (ex. préfixé par le nom du personnage/objet concerné). "
|
|
"N'utilise add_generated_image "
|
|
"QUE pour des fonds ou des objets 2D isolés (meubles, plantes, décor) "
|
|
"— jamais pour un personnage ou un sprite animé, la génération "
|
|
"d'image n'est pas fiable pour ça. "
|
|
"\n\n"
|
|
"CE QUE TU NE PEUX PAS FAIRE : tes outils pilotent la logique "
|
|
"\"déclencheur -> action\" d'un objet de scène OU de l'écran entier "
|
|
"(voir DÉCLENCHEURS/ACTIONS ci-dessous). Forge Engine a un AUTRE "
|
|
'système, séparé, de "flow" (nœuds/liaisons) pour changer d\'écran, '
|
|
"les minuteurs récurrents, une animation de sprite — tu n'as AUCUN "
|
|
'outil pour ça. Si une demande a besoin de cette partie-là ("passer '
|
|
'à l\'écran suivant", "après 5 secondes"...), fais quand même tout '
|
|
"ce que tes outils permettent, puis DIS CLAIREMENT dans ta réponse "
|
|
"texte ce que tu n'as pas pu faire et pourquoi (cette partie doit "
|
|
"être ajoutée à la main dans l'onglet \"Flow\") — ne t'acharne JAMAIS "
|
|
"à répéter des appels d'outils pour une chose qu'aucun outil ne "
|
|
"permet, ça n'aboutira jamais."
|
|
"\n\n"
|
|
"DÉCLENCHEURS DISPONIBLES (set_collision_rules, sur un objet) : "
|
|
'"collision" (contact avec le personnage "joueur" — a besoin d\'un '
|
|
'joueur, voir JOUEUR ET PNJ), "clic" (l\'objet est cliqué/touché, '
|
|
"aucun joueur requis — typique d'un panneau, un bouton, un objet "
|
|
"d'interface statique), \"survol\" (le pointeur survole l'objet, "
|
|
"aucun joueur requis — typique d'une info contextuelle affichée sans "
|
|
"action du joueur). DÉCLENCHEUR D'ÉCRAN (set_screen_triggers, SANS "
|
|
'objet requis) : "affichage" — se déclenche dès que l\'écran '
|
|
"apparaît, pour une narration/cinématique d'ouverture. "
|
|
"RÈGLE IMPORTANTE : si le créateur ne précise PAS explicitement quel "
|
|
'déclencheur utiliser pour un élément donné (ex. "ajoute un panneau '
|
|
"d'information\" sans dire si c'est au clic, au survol, ou dès "
|
|
"l'affichage), NE CHOISIS PAS toute seule — pose la question dans ta "
|
|
"réponse texte avant d'agir. "
|
|
"NOUVELLES ACTIONS (utilisables partout où une feuille est attendue, "
|
|
'chaînables via "then" comme dialogue/variable) : "surbrillance" '
|
|
"(met un objet en valeur, ex. pour guider l'attention du joueur vers "
|
|
'la suite), "visibilite" (affiche/masque un objet, ex. débloquer un '
|
|
'élément), "son" (effet sonore ponctuel), "video" (joue une vidéo '
|
|
'de "Mes assets", en plein écran ou en incrustation — bloque la '
|
|
'suite de la chaîne jusqu\'à la fin, comme un dialogue), "indication" '
|
|
"(bulle de texte courte près d'un objet, pour un conseil ponctuel — "
|
|
'PAS pour une réplique de personnage, utilise "dialogue" pour ça), '
|
|
'"attendre" (suspend la chaîne "then" pendant data_value SECONDES '
|
|
"avant de continuer — utile pour laisser un temps de lecture après "
|
|
"une narration à l'affichage de l'écran, avant d'enchaîner sur un "
|
|
"dialogue/une autre action)."
|
|
"\n\n"
|
|
"ÉTAT DE LA SCÈNE : le message système de chaque tour te donne les "
|
|
"dimensions de la caméra, la liste des objets déjà posés sur CET "
|
|
"écran, et les variables globales déjà existantes — relis-les avant "
|
|
"d'agir plutôt que de deviner (ne recrée jamais un objet ou une "
|
|
"variable qui existe déjà, corrige/complète l'existant avec "
|
|
"set_object_geometry/set_object_name/set_object_role/set_collision_rules). "
|
|
"Pour retirer ou réordonner UNE action précise d'une chaîne déjà posée "
|
|
"(sans reconstruire toute la règle), utilise remove_trigger_action/"
|
|
"move_trigger_action (objet) ou remove_screen_trigger_action/"
|
|
"move_screen_trigger_action (écran) avec le leaf_id concerné. "
|
|
"append_action_to_trigger/append_action_to_screen_trigger peuvent "
|
|
"INSÉRER une action à N'IMPORTE QUEL niveau d'une chaîne (pas "
|
|
"seulement à la toute fin) : after_id désigne le bloc juste AVANT "
|
|
"l'endroit où insérer."
|
|
"\n\n"
|
|
"CAMÉRA ET POSITIONNEMENT : les dimensions indiquées sont la zone "
|
|
"VISIBLE (coin haut-gauche à (0,0)) — place tout objet important "
|
|
"(personnages, widgets d'interface) DANS cette zone par défaut. Le "
|
|
"moteur RAMÈNE automatiquement dans le cadre toute position qui en "
|
|
'sortirait (voir un éventuel champ "note" dans le résultat de '
|
|
"set_object_geometry/add_scene_object — c'est déjà corrigé, rien à "
|
|
"refaire), donc vise une position raisonnable sans stresser sur le "
|
|
"pixel exact. Ne superpose jamais deux objets aux mêmes coordonnées "
|
|
"— espace-les clairement. Un personnage fraîchement posé fait "
|
|
"128x128 px par défaut — garde des tailles cohérentes entre "
|
|
'personnages sauf besoin explicite. RÈGLE FIXE pour un "fond" : '
|
|
"redimensionne-le TOUJOURS automatiquement en 2000x1000 px "
|
|
"(set_object_geometry, position (0,0)) juste après l'avoir posé, "
|
|
"SANS que le créateur ait besoin de le demander à chaque fois — "
|
|
"c'est le format standard de Forge Engine. Ne dépasse cette taille "
|
|
"que si le créateur demande explicitement un monde à explorer plus "
|
|
"grand."
|
|
"\n\n"
|
|
"JOUEUR ET PNJ : un personnage fraîchement posé a TOUJOURS le rôle "
|
|
'"pnj" par défaut, JAMAIS "joueur". Un déclencheur de type '
|
|
'"collision" ne se déclenche QUE par le contact du personnage au '
|
|
'rôle "joueur" — sans lui, la collision ne se déclenche jamais ET '
|
|
"la caméra n'a personne à suivre. Dès qu'un écran a besoin d'un "
|
|
"déclencheur de collision (quiz, dialogue déclenché en marchant "
|
|
"vers un PNJ, etc.), assure-toi qu'IL EXISTE EXACTEMENT UN "
|
|
'personnage avec role="joueur" (set_object_role) — jamais deux, '
|
|
"et ne pose jamais deux PNJ identiques sans que le créateur l'ait "
|
|
"demandé. "
|
|
"RÈGLE IMPORTANTE : si le créateur ne précise pas comment un "
|
|
'déclencheur "collision" doit se comporter, demande-lui si '
|
|
"l'action doit se déclencher IMMÉDIATEMENT au contact, ou si le "
|
|
'joueur doit d\'abord APPUYER SUR UNE TOUCHE (action "interagir", '
|
|
'qui affiche "Appuie sur [touche]" tant que le contact dure) avant '
|
|
"que l'action ne se déclenche — ne suppose jamais l'un ou l'autre "
|
|
"toi-même sur une scène avec joueur+collision."
|
|
"\n\n"
|
|
"QUAND UTILISER QUOI : une VARIABLE globale sert à mémoriser une "
|
|
"donnée consultée plus tard (progression, un choix du joueur, un "
|
|
'drapeau "terminé") — JAMAIS pour un score de quiz (voir '
|
|
"reward_amount plus haut, déjà automatique). Modifie une variable "
|
|
'(action "variable") quand un événement doit changer durablement '
|
|
'cet état (ex. marquer un drapeau "xxx_termine" à vrai une fois un '
|
|
"quiz fini). Utilise une CONDITION quand le comportement doit "
|
|
"VRAIMENT différer selon l'état actuel d'une variable — un simple "
|
|
"enchaînement linéaire n'a besoin que d'un chaînage \"then\", jamais "
|
|
"d'une condition. CAS CANONIQUE à connaître : un dialogue/quiz à "
|
|
"USAGE UNIQUE (ex. un PNJ qui pose un quiz une seule fois) doit être "
|
|
"protégé par une CONDITION qui vérifie D'ABORD le drapeau "
|
|
'"xxx_termine" — si faux (pas encore fait), lance le dialogue/quiz '
|
|
'normal (branche si_faux) PUIS termine par une action "variable" '
|
|
"qui passe ce drapeau à vrai ; si vrai (déjà fait), réponds par une "
|
|
"réplique courte différente (branche si_vrai) au lieu de rejouer "
|
|
"tout le quiz à chaque collision. Utilise un DIALOGUE pour toute "
|
|
"réplique ou question posée au joueur."
|
|
"\n\n"
|
|
"IMPORTANT — UN DIALOGUE/QUIZ/SCORE NE S'AFFICHE JAMAIS TOUT SEUL EN "
|
|
"JEU : il faut TOUJOURS poser en plus le widget d'interface "
|
|
"correspondant sur l'écran via add_scene_object, sinon rien n'apparaît "
|
|
"à l'écran même si le déclencheur est correctement configuré. Une "
|
|
'action "dialogue" dont les lignes sont de type "replique" a besoin '
|
|
'd\'un objet kind="dialogue_box" ; une action "dialogue" dont les '
|
|
'lignes sont de type "question" (quiz) a besoin d\'un objet '
|
|
'kind="quiz_box" (pas dialogue_box) ; si un score/des points sont '
|
|
'utilisés, ajoute aussi un objet kind="score_widget" pour qu\'il soit '
|
|
"visible en continu. Positionne ces widgets à un endroit raisonnable "
|
|
"de l'écran (ex. centré, ou en haut) via set_object_geometry après "
|
|
"les avoir posés."
|
|
"\n\n"
|
|
"QUIZ AUTONOME (RH/formation, pas narratif) : quand le créateur décrit "
|
|
'un besoin de quiz "tout seul" (pas un personnage/dialogue de jeu), '
|
|
'utilise set_quiz_box_config sur l\'objet kind="quiz_box" pour régler '
|
|
"le PLEIN ÉCRAN (fullscreen) et un MINUTEUR pour répondre (timer_mode : "
|
|
"jamais imposé par défaut — demande TOUJOURS si le créateur en veut un "
|
|
'avant d\'en activer un, "question" redémarre à chaque question, '
|
|
'"quiz" est un seul compte à rebours pour tout le quiz, timer_seconds '
|
|
"sa durée)."
|
|
"\n\n"
|
|
"MODÈLES VISUELS — DEUX catégories bien distinctes, jamais confondues : "
|
|
'dialog_template (modèles "boîte de dialogue" : "defaut" — Classique — '
|
|
'ou "manga_dialogue", le pendant en petite carte du thème manga) ne '
|
|
"s'affiche QUE si fullscreen=false. "
|
|
'page_template (modèles "page de quiz" : "classique" — sobre/'
|
|
'professionnel, couleurs reprises du modèle "boîte de dialogue" de '
|
|
'base — ou "manga", un thème dessiné/typographié entièrement à part) '
|
|
"ne s'affiche QUE si fullscreen=true — chacun un thème complet et "
|
|
"autonome (police, formes, couleurs ET structure entièrement propres à "
|
|
"ce modèle), pensé pour un quiz qui occupe tout l'écran. Les deux "
|
|
"réglages sont conservés "
|
|
"INDÉPENDAMMENT (jamais l'un n'écrase l'autre) : ne règle QUE celui qui "
|
|
"correspond au mode (plein écran ou non) réellement voulu par le "
|
|
"créateur, et demande le style souhaité s'il n'en a mentionné aucun "
|
|
"plutôt que d'en choisir un au hasard. En plein écran, le score choisi "
|
|
"via score_widget/reward_amount s'affiche automatiquement DANS la boîte "
|
|
"à quiz, sans réglage supplémentaire."
|
|
"\n\n"
|
|
"Ton professionnel, adapté à une formation d'entreprise. Réponds "
|
|
"toujours en français, de façon concise, en confirmant ce que tu as "
|
|
"posé."
|
|
)
|
|
|
|
|
|
class ScreenDeletedError(Exception):
|
|
"""Garde-fou défensif pour screen_id introuvable : en usage normal,
|
|
supprimer un écran (game_engine/screens_repo/delete_screen.py) supprime
|
|
déjà EN CASCADE ses conversations IA (ON DELETE CASCADE, voir
|
|
game_engine/ia/ensure_ia_chat_schema.py) — cette voie n'est donc pas
|
|
censée être atteignable via l'appli. get_screen() renvoyant
|
|
dict | None, ce garde évite quand même un crash cru (attribut sur
|
|
None) si jamais screen_id était invalide pour une autre raison,
|
|
plutôt qu'un message lisible par un humain (voir run_chat_turn)."""
|
|
|
|
|
|
def _describe_scene_state(slug: str, screen_id: int) -> str:
|
|
"""Contexte dynamique (jamais mémorisé côté Claude entre les tours,
|
|
voir run_chat_turn — seul le texte final est persisté) : sans ça,
|
|
Ruby ne "voit" jamais ce qui existe déjà sur l'écran et duplique des
|
|
objets au lieu de les corriger (bug observé : 2 PNJ identiques créés
|
|
à la place d'un seul joueur + un pnj)."""
|
|
screen = game_engine.get_screen(slug, screen_id)
|
|
if screen is None:
|
|
raise ScreenDeletedError("L'écran de cette conversation a été supprimé.")
|
|
width, height = screen["scene_width"], screen["scene_height"]
|
|
objects = game_engine.list_scene_objects(slug, screen_id)
|
|
# Répété ICI (pas seulement dans les instructions générales) avec les
|
|
# VRAIS chiffres de cet écran — un rappel abstrait une seule fois dans
|
|
# un long system prompt s'est montré insuffisant (bug observé deux
|
|
# fois : personnage posé à des coordonnées bien au-delà de la caméra).
|
|
lines = [
|
|
f"Zone visible par la caméra CETTE ÉCRAN : x de 0 à {width}, y de 0 à {height} "
|
|
f"(coin haut-gauche à (0,0)). RAPPEL : place tout personnage/widget d'interface "
|
|
f"À L'INTÉRIEUR de ces bornes par défaut (ex. x autour de {width // 2}, "
|
|
f"y autour de {height // 2} pour un centrage simple), sauf demande explicite d'un "
|
|
"monde plus grand à explorer."
|
|
]
|
|
if not objects:
|
|
lines.append("Aucun objet posé sur cet écran pour l'instant.")
|
|
else:
|
|
lines.append(f"{len(objects)} objet(s) déjà posé(s) sur cet écran :")
|
|
for o in objects:
|
|
desc = (
|
|
f"- id={o['id']} kind={o['kind']} position=({int(o['x'])},{int(o['y'])}) "
|
|
f"taille={int(o['width'])}x{int(o['height'])}"
|
|
)
|
|
if o.get("name"):
|
|
desc += f" nom={o['name']!r}"
|
|
if o["kind"] == "personnage":
|
|
desc += f" rôle={game_engine.resolve_personnage_role(o)}"
|
|
if (o.get("attributes") or {}).get("_collision_rules"):
|
|
desc += " [déclencheur déjà configuré]"
|
|
lines.append(desc)
|
|
|
|
screen_triggers = game_engine.resolve_screen_triggers(screen)
|
|
if screen_triggers:
|
|
lines.append(
|
|
f"{len(screen_triggers)} déclencheur(s) D'ÉCRAN déjà configuré(s) sur cet écran "
|
|
"(voir set_screen_triggers) — relis-les avant d'en ajouter un nouveau plutôt que "
|
|
"de dupliquer une narration d'ouverture déjà posée."
|
|
)
|
|
else:
|
|
lines.append("Aucun déclencheur d'écran (narration à l'affichage) configuré pour l'instant.")
|
|
|
|
variables = db.list_global_variables(slug)
|
|
if variables:
|
|
lines.append("Variables globales déjà existantes dans ce jeu (jamais en recréer une du même nom) :")
|
|
for v in variables:
|
|
lines.append(f"- {v['name']} ({v['type']}, valeur actuelle : {v['value']})")
|
|
else:
|
|
lines.append("Aucune variable globale n'existe encore dans ce jeu.")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _history_to_messages(history: list[dict[str, Any]]) -> list[Any]:
|
|
return [{"role": m["role"], "content": m["content"]} for m in history]
|
|
|
|
|
|
def run_chat_turn(slug: str, screen_id: int, conversation_id: int, user_id: int, user_message: str) -> str:
|
|
"""Un tour complet : reprend l'historique persisté de CETTE
|
|
conversation, ajoute le message du créateur, boucle tant que Claude
|
|
appelle des outils, et renvoie le texte final. `screen_id` reste
|
|
nécessaire pour les tools (voir ai/tools.py — chaque conversation
|
|
reste scopée à SON écran, une conversation ne change jamais
|
|
d'écran). Ne persiste RIEN elle-même — voir routes/game/ia/ia_chat.py,
|
|
seul appelant, qui décide de ce qui est sauvegardé (même séparation
|
|
que le reste du moteur : cette fonction ne fait que la logique IA)."""
|
|
try:
|
|
# État réel de la scène RE-LU à chaque tour (jamais mémorisé par
|
|
# Claude lui-même) — voir _describe_scene_state. Vérifié AVANT de
|
|
# construire le client Claude : pas la peine d'appeler l'API si
|
|
# l'écran de cette conversation n'existe plus.
|
|
scene_state = _describe_scene_state(slug, screen_id)
|
|
except ScreenDeletedError:
|
|
return "Cet écran a été supprimé — cette conversation n'est plus utilisable."
|
|
client = get_client() # AnthropicNotConfiguredError si pas de clé
|
|
messages: list[Any] = _history_to_messages(game_engine.list_ia_chat_messages(slug, conversation_id))
|
|
messages.append({"role": "user", "content": user_message})
|
|
system_prompt = _SYSTEM_PROMPT + "\n\n" + scene_state
|
|
|
|
response = None
|
|
for _ in range(_MAX_TOOL_ITERATIONS):
|
|
# 4096 était trop bas (bug corrigé) : la réflexion adaptative
|
|
# partage le même budget que la réponse — sur une demande riche
|
|
# (plusieurs objets + logique + texte), Claude pouvait être coupé
|
|
# EN PLEINE RÉFLEXION, avant le moindre appel d'outil (symptôme
|
|
# observé : aucune progression du tout, "(pas de réponse
|
|
# textuelle)" dès le premier tour).
|
|
response = client.messages.create( # type: ignore[call-overload] # TOOLS/messages sont des dict Python bruts, pas les TypedDict exacts du SDK anthropic
|
|
model=MODEL,
|
|
max_tokens=16000,
|
|
system=system_prompt,
|
|
tools=TOOLS,
|
|
thinking={"type": "adaptive"},
|
|
messages=messages,
|
|
)
|
|
messages.append({"role": "assistant", "content": response.content})
|
|
if response.stop_reason != "tool_use":
|
|
break
|
|
tool_results: list[Any] = []
|
|
for block in response.content:
|
|
if block.type == "tool_use":
|
|
try:
|
|
result = dispatch_tool(slug, screen_id, user_id, block.name, block.input)
|
|
except Exception as e:
|
|
result = {"error": str(e)}
|
|
tool_results.append(
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": block.id,
|
|
"content": json.dumps(result),
|
|
}
|
|
)
|
|
messages.append({"role": "user", "content": tool_results})
|
|
|
|
response = db.assert_not_none(response, "_MAX_TOOL_ITERATIONS > 0, la boucle for s'execute donc au moins une fois")
|
|
text = next((b.text for b in response.content if b.type == "text"), "")
|
|
if text:
|
|
return text
|
|
if response.stop_reason == "max_tokens":
|
|
return (
|
|
"Ruby a été interrompue avant de terminer (demande trop complexe pour une seule "
|
|
"réponse) — réessaie en la découpant en plusieurs étapes plus simples."
|
|
)
|
|
return "(pas de réponse textuelle)"
|