Temps d'exécution
Présentation de l’architecture
Le runtime Goa-AI orchestre la boucle planifier/exécuter/reprendre, applique les politiques, gère l’état et se coordonne avec les moteurs, les planificateurs, les outils, la mémoire, les hooks et les modules de fonctionnalités.
| Couche | Responsabilité |
|---|---|
| DSL + Codegen | Produire des registres d’agents, des spécifications/codecs d’outils, des spécifications/codecs de complétion, des flux de travail, des adaptateurs MCP |
| Noyau d’exécution | Orchestre la boucle de planification/démarrage/reprise, l’application des politiques, les hooks, la mémoire, le streaming |
| Adaptateur de moteur de flux de travail | L’adaptateur Temporal implémente engine.Engine ; d’autres moteurs peuvent se brancher |
| Stockage du runtime hôte | Enregistre ensemble la portée de session, l’état d’exécution, les points de reprise et les enregistrements immuables |
| Modules de fonctionnalités | Intégrations facultatives (MCP, Pulse, mémoire et prompts, fournisseurs de modèles) |
Architecture agentique de haut niveau
Au moment de l’exécution, Goa-AI organise votre système autour d’un petit ensemble de constructions composables :
Agents : orchestrateurs de longue durée identifiés par
agent.Ident(par exemple,service.chat). Chaque agent possède un planificateur, une politique d’exécution, des flux de travail générés et des enregistrements d’outils.Exécutions : une seule exécution d’un agent. Les courses sont identifiées par un
RunIDet suivies viarun.Contextetrun.Handle. Les exécutions de session sont regroupées parSessionIDetTurnIDpour former des conversations ; Les exécutions ponctuelles sont explicitement sans session.Ensembles d’outils et outils : collections nommées de fonctionnalités, identifiées par
tools.Ident(service.toolset.tool). Les ensembles d’outils basés sur des services appellent des API ; Les ensembles d’outils soutenus par des agents exécutent d’autres agents en tant qu’outils.Achèvements : Contrats de sortie d’assistant direct dactylographiés appartenant au service générés sous
gen/<service>/completions. Les assistants d’achèvement attachent une sortie structurée imposée par le fournisseur aux requêtes de modèle unaire et en streaming direct, puis décodent la charge utile typée canonique via les codecs générés.Planificateurs : votre couche stratégique basée sur LLM mettant en œuvre
PlanStart/PlanResume. Les planificateurs décident quand appeler les outils plutôt que de répondre directement ; le runtime impose des plafonds et des budgets de temps autour de ces décisions.Arbre d’exécution et agent en tant qu’outil : lorsqu’un agent appelle un autre agent en tant qu’outil, le moteur d’exécution démarre une véritable exécution enfant avec son propre
RunID. Le parentToolResulttransporte unRunLink(*run.Handle) pointant vers l’enfant, et un événement de fluxchild_run_linkedcorrespondant est émis afin que UIs puisse corréler les appels d’outil parent avec les ID d’exécution enfant sans deviner.Flux et profils appartenant à la session : Goa-AI publie les valeurs
stream.Eventsaisies dans un flux appartenant à la session (session/<session_id>). Les événements transportent à la foisRunIDetSessionIDet incluent un marqueur de limite explicite (run_stream_end) afin que les consommateurs puissent fermer SSE/WebSocket de manière déterministe sans minuterie.stream.StreamProfilesélectionne les types d’événements visibles pour une audience donnée (chat UI, débogage, métriques).
Démarrage rapide
package main
import (
"context"
"time"
chat "example.com/assistant/gen/orchestrator/agents/chat"
"goa.design/goa-ai/runtime/agent/model"
"goa.design/goa-ai/runtime/agent/runtime"
storageinmem "goa.design/goa-ai/runtime/agent/storage/inmem"
)
func main() {
// In-memory engine is the default; pass WithEngine for Temporal or custom engines.
store := storageinmem.New()
rt := runtime.New(store)
ctx := context.Background()
err := chat.RegisterChatAgent(ctx, rt, chat.ChatAgentConfig{Planner: newChatPlanner()})
if err != nil {
panic(err)
}
// Sessions are first-class: create a session before starting runs under it.
if _, err := store.CreateSession(ctx, "session-1", time.Now().UTC()); err != nil {
panic(err)
}
client := chat.NewClient(rt)
out, err := client.Run(ctx, "session-1", []*model.Message{{
Role: model.ConversationRoleUser,
Parts: []model.Part{model.TextPart{Text: "Summarize the latest status."}},
}})
if err != nil {
panic(err)
}
// Use out.RunID, out.Final (the assistant message), etc.
}
Complétions directes dactylographiées
Toutes les interactions structurées ne doivent pas être modélisées comme un appel d’outil. Quand votre
Le service a besoin d’une réponse finale de l’assistant tapée, déclarez Completion(...) dans le
DSL et régénérer.
goa gen émet gen/<service>/completions avec :
- les types de résultat et d’union typés ;
- les schémas privés et codecs générés du résultat ;
- les fonctions générées
Complete<Name>(ctx, client, req); - les fonctions typées
StreamComplete<Name>(ctx, client, req); <Name>Example()lorsque le résultat racine possède unExample(...).
Les services peuvent déclarer des achèvements sans déclarer de Agent(...). Agent
un échafaudage de démarrage rapide/exemple est émis uniquement pour les services qui possèdent réellement
agents.
Ces assistants clonent la requête et attachent une sortie structurée indépendante du fournisseur.
métadonnées, appelez le model.Client sous-jacent et décodez le canonique typé
charge utile via le codec généré :
resp, err := taskcompletion.CompleteDraftFromTranscript(ctx, modelClient, &model.Request{
Messages: []*model.Message{{
Role: model.ConversationRoleUser,
Parts: []model.Part{model.TextPart{Text: "Create a startup investigation task."}},
}},
})
if err != nil {
panic(err)
}
fmt.Println(resp.Value.Name)
Chaque model.StructuredOutput de bas niveau exige un nom non vide. Les
fonctions générées le dérivent du DSL validé. Une complétion unaire effectue
exactement un appel au modèle. Un JSON invalide renvoie
planner.OutputContractError, non récupérable, et une réponse nil ; aucune
demande de correction n’est déclenchée. Après un succès,
resp.ModelResponse contient la réponse exacte du fournisseur et son
utilisation des jetons.
Les complétions en continu renvoient un completion.Streamer[T]. Recv
expose des fragments d’aperçu, tandis que Value() reste indisponible jusqu’à
la fin du flux et jusqu’à ce que la réponse terminale concorde avec la
complétion finale :
stream, err := taskcompletion.StreamCompleteDraftFromTranscript(ctx, modelClient, &model.Request{
Messages: []*model.Message{{
Role: model.ConversationRoleUser,
Parts: []model.Part{model.TextPart{Text: "Create a startup investigation task."}},
}},
})
if err != nil {
panic(err)
}
defer stream.Close()
for {
chunk, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
panic(err)
}
// Render preview completion_delta chunks here when useful.
_ = chunk
}
value, ok := stream.Value()
if !ok {
panic("completion stream ended without a typed value")
}
fmt.Println(value.Name)
Les aides à la complétion typées sont intentionnellement strictes :
- Les assistants unaires acceptent uniquement les demandes unaires.
- Les noms de complétion sont validés à la limite DSL : 1 à 64 caractères ASCII,
lettres/chiffres/
_/-uniquement et doit commencer par une lettre ou un chiffre. - Les assistants unaires et en streaming rejettent les requêtes activées par les outils et le
StructuredOutputfourni par l’appelant. - Les fournisseurs de streaming émettent des fragments d’aperçu
completion_deltaet une réponse terminale canonique cohérente, ou refusent explicitement la demande. - Les fournisseurs qui n’implémentent pas la sortie structurée renvoient
model.ErrStructuredOutputUnsupported. - Les schémas générés sont canoniques et indépendants du fournisseur ; les adaptateurs de fournisseur peuvent les normaliser sur un sous-ensemble pris en charge, mais doivent échouer explicitement lorsqu’ils ne peuvent pas préserver le contrat déclaré.
Client uniquement vs travailleur
Deux rôles utilisent le runtime :
- Client uniquement (soumettre des exécutions) : construit un environnement d’exécution avec un moteur compatible client et n’enregistre pas les agents. Utilisez le
<agent>.NewClient(rt)généré, qui transporte l’AgentDefinitiongénérée et partagée avec les workers distants. - Worker (exécutions d’exécution) : construit un environnement d’exécution avec un moteur capable de fonctionner, enregistre les ensembles d’outils et les agents, puis scelle l’enregistrement afin que l’interrogation ne démarre qu’une fois le registre d’exécution local terminé.
Chaque AgentDefinition générée est le contrat complet et immuable d’un agent.
Elle contient le nom du workflow, la file de tâches par défaut, les contrats des
outils générés, les labels obligatoires, la politique de complétion et les
définitions de tous les agents enfants accessibles. Les appelants l’utilisent
pour valider et diriger le travail avant que le moteur accepte le workflow ; les
workers utilisent la même valeur pour l’enregistrer. Une exécution donnée peut
choisir une autre file avec WithTaskQueue, mais un enregistrement écrit à la
main ne doit pas définir une seconde route ni un second graphe d’agents enfants.
Exemple client uniquement
rt := runtime.New(runtimeStore, runtime.WithEngine(temporalClient)) // engine client
// The host session service has already created "s1".
// No agent registration is needed in a caller-only process.
client := chat.NewClient(rt)
out, err := client.Run(ctx, "s1", msgs)
Exécutions ponctuelles sans session
Utilisez StartOneShot et OneShotRun lorsque vous souhaitez un travail durable qui n’est pas attaché à une session existante.
Start/Runsont de type session : ils nécessitent unSessionIDconcret, participent au cycle de vie de la session et émettent des événements de flux à l’échelle de la session.StartOneShot/OneShotRunsont sans session : ils ne prennent pas deSessionIDet n’en créent pas. Avant d’exécuter le travail, le stockage intégré enregistre les métadonnées complètes sans session et l’enregistrementRunStarted, afin que l’exécution soit consultable parRunID.- L’application hôte crée les sessions avant le travail ; les runtimes d’agents ne créent, ne terminent et ne suppriment pas les sessions.
- Le moteur accepte un workflow racine avant que sa première activity enregistre l’exécution. Aucun état
pendingn’est créé avant l’admission. - Les démarrages racine, enfant et ponctuel sont des opérations distinctes. Le démarrage enfant enregistre le lien parent ; le démarrage ponctuel enregistre toutes les métadonnées sans session.
- Le motif d’annulation est écrit une seule fois. Une répétition identique réussit ; un motif différent produit un conflit.
- La suspension et la fin enregistrent le nouvel état avec l’enregistrement immuable correspondant.
StartOneShotrenvoie immédiatement unengine.WorkflowHandle.OneShotRunest le wrapper pratique de blocage qui appellehandle.Wait(ctx)pour vous.
client := chat.NewClient(rt)
handle, err := client.StartOneShot(ctx, msgs,
runtime.WithRunID("run-123"),
runtime.WithLabels(map[string]string{"tenant": "acme"}),
)
if err != nil {
panic(err)
}
out, err := handle.Wait(ctx)
if err != nil {
panic(err)
}
fmt.Println(out.RunID)
La méthode de plus bas niveau Runtime.RunOneShot enregistre l’exécution avant
d’appeler le code de l’application. Lorsque le callback se termine, elle
enregistre les prompts rendus et le résultat final même si le callback a annulé
son contexte. Les erreurs temporaires du stockage relancent l’écriture des
enregistrements déjà préparés sans rappeler le callback.
Exemple de travailleur
eng, err := temporal.NewWorker(temporal.Options{
ClientOptions: &client.Options{HostPort: "temporal:7233", Namespace: "default"},
WorkerOptions: temporal.WorkerOptions{TaskQueue: "orchestrator.chat"},
})
if err != nil {
panic(err)
}
defer eng.Close()
rt := runtime.New(runtimeStore, runtime.WithEngine(eng))
if err := chat.RegisterUsedToolsets(ctx, rt /* executors... */); err != nil {
panic(err)
}
if err := chat.RegisterChatAgent(ctx, rt, chat.ChatAgentConfig{Planner: myPlanner}); err != nil {
panic(err)
}
if err := rt.Seal(ctx); err != nil {
panic(err)
}
Boucle Planifier → Exécuter → Reprendre
- Le moteur accepte un workflow pour l’agent, en mémoire ou dans Temporal.
- La première activité enregistre l’identité et le premier enregistrement
permanent avec
StartRootRun,StartChildRun,StartOneShotRunouStartOneShotChildRun. Chaque workflow accepté enregistreRunStarted. Mémoire et sessions définit les trois façons valides d’enregistrer les motifs et les demandes d’annulation. - Le runtime appelle
PlanStartavec les messages et unrun.ContextcontenantRunID,SessionID,TurnID, les libellés et les limites. - Il planifie les appels d’outils avec les codecs générés.
- Il appelle
PlanResumeavec les résultats d’outils survivants visibles par le planificateur. Les outils budgétisés sont visibles par défaut ; les outils comptables ne sont rejoués qu’après un échec dontToolFailure.Recovery.Actiondemande une récupération. La boucle continue jusqu’à une réponse finale, un résultat d’outil final ou le succès d’un outilTerminalRun. Pendant une finalisation imposée par une limite ou un délai, le planificateur peut terminer avec des outils comptables terminaux. L’exécution progresse dans les valeursrun.Phase(prompted,planning,executing_tools,synthesizinget phases terminales). - Les hooks et les abonnés au flux émettent des événements (pensées du planificateur, démarrage/mise à jour/fin de l’outil, attentes, utilisation, flux de travail, liens exécutés par l’agent) et, une fois configurés, conservent les entrées de transcription et exécutent les métadonnées.
Phases d’exécution
Au fur et à mesure qu’une exécution progresse dans la boucle planifier/exécuter/reprendre, elle passe par une série de phases du cycle de vie. Ces phases offrent une visibilité précise de l’état d’avancement d’une exécution, permettant à UIs d’afficher des indicateurs de progression de haut niveau.
Valeurs de phase
| Phase | Description |
|---|---|
prompted | Les commentaires ont été reçus et l’exécution est sur le point de commencer la planification |
planning | Le planificateur décide si et comment appeler les outils ou répondre directement |
executing_tools | Les outils (y compris les agents imbriqués) sont en cours d’exécution |
synthesizing | Le planificateur synthétise une réponse finale sans planifier d’outils supplémentaires |
completed | L’exécution s’est terminée avec succès |
failed | La course a échoué |
canceled | La course a été annulée |
Transitions de phases
Une exécution réussie typique suit cette progression :
prompted → planning → executing_tools → planning → synthesizing → completed
↑__________________|
(loop while tools needed)
Le runtime émet des événements hook RunPhaseChanged pour les phases non terminales (par exemple, planning, executing_tools, synthesizing) afin que les abonnés au flux puissent suivre la progression en temps réel.
Phase vs Statut
Les phases sont distinctes de run.Status :
- Le statut (
running,suspended,completed,failed,canceled) correspond à l’état du cycle de vie à granularité grossière stocké dans les métadonnées d’exécution durables. Il n’existe aucun étatpendingavant l’admission. - Phase offre une visibilité plus fine sur la boucle d’exécution, destinée aux surfaces de streaming/UX
Événements du cycle de vie : changements de phase ou achèvement du terminal
Le runtime émet :
RunPhaseChangedpour les transitions de phase non terminales.RunCompletedune fois par exécution pour le cycle de vie du terminal (succès/échec/annulation).
Les abonnés au flux traduisent les deux en événements de flux workflow (stream.WorkflowPayload) :
- Mises à jour non terminales (à partir de
RunPhaseChanged) :phaseuniquement. - Mise à jour du terminal (à partir de
RunCompleted) :status+ terminalphase, plus champs d’erreur structurés sur les échecs.
Mappage de l’état du terminal
status="success"→phase="completed"status="failed"→phase="failed"status="canceled"→phase="canceled"
L’annulation n’est pas une erreur
Pour status="canceled", la charge utile du flux ne doit pas inclure un error destiné à l’utilisateur. Les consommateurs devraient considérer l’annulation comme un état final terminal et sans erreur.
Les échecs sont structurés
Pour status="failed", la charge utile du flux comprend :
error_kind: classificateur stable pour l’UX/la décision (types de fournisseurs commerate_limited,unavailableou types d’exécution commetimeout/internal)retryable: si une nouvelle tentative peut réussir sans modifier l’entréeerror: message sécurisé pour l’utilisateur adapté à l’affichage directdebug_error: chaîne d’erreur brute pour les journaux/diagnostics (pas pour UI)
Identité terminale
RunCompleted porte Labels : les étiquettes de portée exécution fournies au
démarrage de l’exécution (RunInput.Labels, définies via
runtime.WithLabels(...)), nil quand l’exécution n’en avait aucune. Les
abonnés à la fin d’exécution peuvent attribuer le résultat terminal — success,
failed ou canceled — sans maintenir leur propre table run-ID vers identité. Les
mêmes étiquettes sont exposées sur run.Snapshot.Labels pour les lecteurs par
sondage, rejouées depuis l’enregistrement durable RunStarted, de sorte que
l’identité de l’exécution survit aux redémarrages du processus sur les deux
moteurs. Les étiquettes fusionnées par des décisions de politique en cours
d’exécution ne sont pas incluses ; elles restent observables via les
événements PolicyDecision.
Politiques, plafonds et étiquettes
Politique d’exécution au moment de la conception
Au moment de la conception, vous configurez les stratégies par agent avec RunPolicy :
Agent("chat", "Conversational runner", func() {
RunPolicy(func() {
DefaultCaps(
MaxToolCalls(8),
MaxRecoveryTurns(3),
)
TimeBudget("2m")
InterruptsAllowed(true)
})
})
Celui-ci devient un runtime.RunPolicy attaché à l’inscription de l’agent :
Caps :
MaxToolCallslimite le nombre total d’appels d’outils budgétisés par exécution.MaxRecoveryTurnslimite les nouveaux appels au planificateur après le rejet d’un résultat d’outil ou d’une réponse du modèle. Un appel d’outil budgétisé réussi réinitialise cette allocation. Les outils déclarésBookkeeping()ne consomment aucun de ces budgets.Budget temps :
TimeBudget– budget d’horloge murale pour la course.FinalizerGrace(exécution uniquement) – fenêtre réservée en option pour la finalisation.Interruptions :
InterruptsAllowed– option pour la pause/reprise.Comportement des champs manquants :
OnMissingFields– régit ce qui se passe lorsque la validation indique des champs manquants.Outils terminaux : les outils déclarés
TerminalRun()deviennent automatiquement comptables et terminent l’exécution après leur succès ; aucun tourPlanResumesupplémentaire n’est planifié. Pendant la finalisation forcée, le runtime n’admet que les appels comptables terminaux, les exécute avant l’échéance absolue et ne ferme l’exécution que si tous leurs effets réussissent. Avant l’appel, il écrit la valeur exacte deplanner.TerminationReasondansruntime.FinalizationReasonLabel(goa-ai.finalization_reason). Les étiquettes de l’exécution ou de la politique, ainsi que les sorties du planificateur ou du modèle, ne peuvent ni choisir ni remplacer cette valeur. Les appels ordinaires ne la reçoivent pas.Les consommateurs d’appels terminaux à limite fixe ou produits par le planificateur, notamment
tool_failure, utilisentruntime.FinalizationReasonLabel. Déployez ensemble les consommateurs et les workers lors d’une modification de ce contrat d’exécution.
Remplacements de stratégie d’exécution
Dans certains environnements, vous souhaiterez peut-être renforcer ou assouplir les politiques sans en modifier la conception. Le rt.OverridePolicy API permet des ajustements de politique au niveau du processus :
err := rt.OverridePolicy(chat.AgentID, runtime.RunPolicy{
MaxToolCalls: 3,
MaxRecoveryTurns: 1,
InterruptsAllowed: true,
})
Portée : les remplacements sont locaux à l’instance d’exécution actuelle et affectent uniquement les exécutions ultérieures. Ils ne persistent pas lors des redémarrages de processus et ne se propagent pas aux autres travailleurs.
Champs remplaçables :
| Champ | Description |
|---|---|
MaxToolCalls | Nombre total maximum d’appels d’outils par exécution |
MaxRecoveryTurns | Nouveaux appels au planificateur après un résultat rejeté |
TimeBudget | Budget horloger pour la course |
FinalizerGrace | Fenêtre réservée à la finalisation |
InterruptsAllowed | Activer la fonctionnalité pause/reprise |
Seuls les champs non nuls sont appliqués (et InterruptsAllowed lorsque true). Cela permet des remplacements sélectifs sans affecter les autres paramètres de stratégie.
Cas d’utilisation :
- Interruptions temporaires pendant la limitation du fournisseur
- Tests A/B de différentes configurations de politiques
- Développement/débogage avec des contraintes assouplies
- Personnalisation des politiques par locataire au moment de l’exécution
Étiquettes et moteurs de politiques
Goa-AI s’intègre aux moteurs de politiques enfichables via policy.Engine. Les
politiques reçoivent les métadonnées de l’outil (ID, balises), le contexte
d’exécution (SessionID, TurnID, étiquettes) et le ToolFailure structuré après
un échec d’exécution.
Les étiquettes arrivent dans :
run.Context.Labels– disponible pour les planificateurs pendant une exécution- entrée d’activité d’outil (
api.ToolInput.Labels) – clonée dans les exécutions distribuées ; les appels de finalisation reçoivent aussi le motif détenu par le runtime dansruntime.FinalizationReasonLabel - Le stockage du runtime (
storage.Store) ajoute les enregistrements immuables parRunID. Les méthodes de cycle de vie enregistrent l’état, le point de reprise ou l’annulation avec l’enregistrement correspondant. - fin d’exécution et instantanés – les étiquettes de départ ressortent à la fin de l’exécution sur
hooks.RunCompletedEvent.Labelsetrun.Snapshot.Labels, si bien que les hooks de fin et les lecteurs deGetRunSnapshotretrouvent l’identité de l’exécution sans suivi hors bande
Filtrage des outils par exécution
Les balises au moment de la conception et les options d’exécution permettent aux appelants de réduire la surface de l’outil avant invite du planificateur et encore avant l’exécution :
out, err := client.Run(ctx, "session-1", messages,
runtime.WithAllowedTags([]string{"read", "safe"}),
runtime.WithDeniedTags([]string{"destructive"}),
runtime.WithTagPolicyClauses([]runtime.TagPolicyClause{
{AllowedAny: []string{"docs", "search"}},
{DeniedAny: []string{"external"}},
}),
)
Utilisez WithRestrictToTool lorsqu’un flux de réparation doit exposer exactement un outil :
out, err := client.Run(ctx, "session-1", messages,
runtime.WithRestrictToTool(searchspecs.Search),
)
Cette stratégie s’applique à toute l’exécution demandée par l’appelant. Les
échecs d’outils utilisent un contrat distinct : ToolFailure.Recovery.Action
choisit la correction, une nouvelle planification ou la fin, puis le runtime
impose le catalogue d’outils correspondant au tour suivant.
Exécution des outils
- Ensembles d’outils natifs : vous écrivez des implémentations ; le runtime gère le décodage des arguments tapés à l’aide des codecs générés
- Agent en tant qu’outil : les ensembles d’outils d’agent générés exécutent les agents fournisseurs en tant qu’exécutions enfants (en ligne du point de vue du planificateur) et adaptent leur
RunOutputen unplanner.ToolResultavec un handleRunLinkvers l’exécution enfant. - Ensembles d’outils MCP : le runtime transmet le JSON canonique aux appelants générés ; les appelants gèrent le transport
Valeurs par défaut de la charge utile de l’outil
Le décodage de la charge utile de l’outil suit le modèle decode-body → transform de Goa et applique les valeurs par défaut de style Goa de manière déterministe pour les charges utiles de l’outil.
Voir Tool Payload Defaults pour les invariants de contrat et de codegen.
Résultats d’outils limités
Les outils qui renvoient des vues partielles d’ensembles de données plus volumineux doivent déclarer BoundedResult(...).
dans le DSL. Le contrat d’exécution de ces outils est :
- généré
tools.ToolSpec.Boundsdéclare le schéma canonique de résultat borné - les exécutions réussies doivent remplir
planner.ToolResult.Bounds - le runtime projette les limites détenues par le fournisseur dans les
tool_resultJSON émis, indice de résultat données de modèle sous.Bounds, charges utiles de hook et événements de flux - pour les outils paginés, le code fournisseur définit
Bounds.NextCursoravec le curseur opaque de la page suivante
tools.ToolSpec.Bounds utilise les noms JSON visibles par le modèle. Une
déclaration DSL peut référencer des attributs Goa en lower-camel comme
NextCursor("nextCursor"), mais les specs générées, les schémas, la projection
du runtime et les codecs de résultat utilisent next_cursor.
Champs projetés canoniques :
returned(obligatoire)truncated(obligatoire)total(facultatif)refinement_hint(facultatif)next_cursor(facultatif lorsqueNextCursor(...)est exposé par un contratCursordirect)
planner.ToolResult.Bounds reste le seul contrat fournisseur lisible par machine.
Les types de résultats Go créés restent sémantiques et spécifiques au domaine ; ils n’ont pas besoin de
dupliquez les champs délimités canoniques juste pour que les modèles puissent les voir.
ContinueWith("continue_tool", "cursor") déclare la continuation mécanique
comme une action distincte. Le runtime ne la propose que lorsque l’historique
contient une seule tête de chaîne active avec un autre curseur. La correspondance
exacte du curseur fait avancer les pages séquentielles. Les appels source parallèles
restent valides, mais plusieurs têtes actives rendent l’action sans argument
indisponible. Le modèle
l’appelle avec {} et le runtime associe le curseur et les champs de requête
conservés avant l’exécution. Un Cursor("cursor") direct garde le contrat
ouvert : le modèle répète les arguments inchangés avec le curseur opaque renvoyé
dans next_cursor.
Pour les outils BindTo basés sur une méthode, le résultat de la méthode de service lié doit toujours
transporter les champs délimités canoniques afin que l’exécuteur généré puisse construire
planner.ToolResult.Bounds avant projection. Return(...) face à l’outil explicite
les formes ne doivent pas dupliquer ces champs canoniques. Dans la méthode liée
Par conséquent, seuls returned et truncated peuvent être requis ; total,
refinement_hint et next_cursor restent facultatifs et sont omis des émissions
JSON chaque fois que les limites d’exécution les omettent.
Lorsqu’une limite de service doit assembler le résultat canonique JSON à l’extérieur
ExecuteToolActivity, utilisez runtime.EncodeCanonicalToolResult(...) plutôt que
appeler le codec de résultat généré et les assistants de projection de résultats limités
séparément.
Contrats d’exécution rapide
La gestion des invites est native du runtime et versionnée :
runtime.PromptRegistrystocke les enregistrementsprompt.PromptSpecde base immuables.runtime.WithPromptStore(prompt.Store)permet une résolution de remplacement étendue (session->facility->org-> global).- Les planificateurs appellent
PlannerContext.RenderPrompt(ctx, id, data)pour résoudre et afficher le contenu des invites. - Le contenu rendu inclut les métadonnées
prompt.PromptRefpour la provenance ; les planificateurs peuvent les joindre àmodel.Request.PromptRefs.
content, err := input.Agent.RenderPrompt(ctx, "assistant.system", map[string]any{
"AssistantName": "Ops Assistant",
})
if err != nil {
return nil, err
}
resp, err := modelClient.Complete(ctx, &model.Request{
RunID: input.RunContext.RunID,
Messages: input.Messages,
PromptRefs: []prompt.PromptRef{content.Ref},
})
PromptRefs indique quelles versions rendues des prompts ont influencé une requête ; il ne fait pas partie du payload du fournisseur. Le runtime le déduit des enregistrements prompt_rendered et des liens parent-enfant, sans maintenir une autre liste susceptible de diverger.
Le rendu n’écrit jamais dans le stockage du runtime. Tous les chemins utilisent
prompt.RenderRecorder pour créer le même prompt.RenderEvent, avec l’ID, la
version et la portée du prompt résolu :
- le code de l’application qui rend les messages initiaux transmet
recorder.Events()avec ces messages viaruntime.WithRenderedPrompts; - les activités du planificateur renvoient leurs événements avec le résultat ;
- la préparation du prompt d’un agent enfant s’exécute dans une activité et renvoie le texte rendu et ses événements dans l’entrée de l’enfant ;
RunOneShotenregistre les rendus effectués par son callback.
Le workflow enregistre chaque événement accepté sous la même forme
PromptRendered. Le chemin initial n’applique pas une règle de rendu
différente ; il transmet seulement un événement créé avant le démarrage du
workflow. La préparation de l’enfant s’exécute dans une activité afin que le
replay Temporal réutilise le texte et les événements déjà présents dans
l’historique, sans lire une version plus récente du prompt.
RenderRecorder.Events renvoie les rendus terminés dans un ordre stable par ID
de prompt, version, session et portée. L’ordre de fin de rendus simultanés ne
peut donc pas modifier la requête exacte de démarrage du workflow.
Mémoire, Streaming, Télémétrie
Hook bus publie des événements de hook structurés pour le cycle de vie complet de l’agent : démarrage/achèvement de l’exécution, changements de phase,
prompt_rendered, planification/résultats/mises à jour des outils, notes du planificateur et blocs de réflexion, attentes, conseils de nouvelle tentative et liens d’agent en tant qu’outil.Les magasins de mémoire (
memory.Store) s’abonnent et ajoutent des événements de mémoire durables (messages utilisateur/assistant, appels d’outils, résultats d’outils, notes de planificateur, réflexion) par(agentID, RunID).Le stockage du runtime (
storage.Store) est unique et appartient à l’application hôte. Il ajoute, pour chaqueRunID, des enregistrements qui ne peuvent plus changer après leur insertion, afin d’alimenter les interfaces d’audit et de débogage et la consultation des exécutions. Ses méthodes de cycle de vie enregistrent l’état, le point de reprise ou la modification d’annulation avec l’enregistrement immuable correspondant en une seule opération.Les récepteurs de flux (
stream.Sink, par exemple Pulse ou SSE/WebSocket personnalisé) reçoivent les valeursstream.Eventtypées produites par lestream.Subscriber. UnStreamProfilecontrôle quels types d’événements sont émis.La transcription durable conserve exactement chaque réponse sélectionnée du fournisseur. Lorsqu’un message assistant contient un appel d’outil, son texte reste dans cette transcription pour la relecture auprès du fournisseur, mais n’est pas émis comme réponse visible par l’utilisateur. Les événements d’outil et d’attente présentent cette étape non terminale. Seuls les messages assistant sans appel d’outil produisent des événements de texte assistant validés.
Télémétrie : les flux de travail et les activités des instruments de journalisation, de métriques et de traçage compatibles OTEL de bout en bout.
Conseils d’affichage des appels d’outils (DisplayHint)
Les appels d’outils peuvent porter un DisplayHint destiné à l’utilisateur (par exemple pour UIs).
Contracter:
- Les constructeurs de hooks ne rendent pas d’indices. Les événements planifiés d’appel d’outil sont par défaut
DisplayHint=="". - Le runtime enrichit et conserve un indice d’appel par défaut durable au moment de la publication à partir du modèle typé lorsque le décodage de la charge utile réussit.
- L’enregistrement des outils exige un titre de métadonnées non vide. Lorsque le décodage typé échoue ou qu’aucun modèle n’est enregistré, le runtime utilise ce titre comme display hint. Les charges utiles mal formées échouent toujours à la frontière de l’outil ; le titre de métadonnées sert seulement à garder le travail tenté affichable. Les indices ne sont jamais rendus à partir des octets JSON bruts.
- Si un producteur définit explicitement
DisplayHint(non vide) avant de publier l’événement hook, le runtime traite comme faisant autorité et ne l’écrase pas. - Pour les modifications de formulation par consommateur, configurez
runtime.WithHintOverridesau moment de l’exécution. Les remplacements prennent priorité sur les modèles créés par DSL pour les événementstool_startdiffusés en streaming.
Consommation d’un flux de session (Pulse)
En production, le modèle courant est le suivant :
- publier des événements de flux d’exécution sur Pulse (flux Redis) à l’aide d’un
stream.Sink - abonnez-vous au flux de session (
session/<session_id>) depuis votre diffusion UI (SSE/WebSocket) - arrêtez de diffuser une analyse lorsque vous observez
type=="run_stream_end"pour l’ID d’exécution actif
import (
pulsestream "goa.design/goa-ai/features/stream/pulse"
"goa.design/goa-ai/runtime/agent/runtime"
"goa.design/goa-ai/runtime/agent/stream"
)
streams, err := pulsestream.NewRuntimeStreams(pulsestream.RuntimeStreamsOptions{
Client: pulseClient,
})
if err != nil {
panic(err)
}
rt := runtime.New(
runtimeStore,
runtime.WithEngine(eng),
runtime.WithStream(streams.Sink()),
)
sub, err := streams.NewSubscriber(pulsestream.SubscriberOptions{SinkName: "ui"})
if err != nil {
panic(err)
}
events, errs, cancel, err := sub.Subscribe(ctx, "session/session-123")
if err != nil {
panic(err)
}
defer cancel()
activeRunID := "run-123"
for {
select {
case evt, ok := <-events:
if !ok {
return
}
if evt.Type() == stream.EventRunStreamEnd && evt.RunID() == activeRunID {
return
}
// evt.SessionID(), evt.RunID(), evt.Type(), evt.Payload()
case err := <-errs:
panic(err)
}
}
Abstraction du moteur
- En mémoire : boucle de développement rapide, pas de dépôts externes
- Temporal : exécution durable, relecture, nouvelles tentatives des activités, signaux et workers ; les adaptateurs relient les activités et propagent le contexte
Les workflows d’agent Goa-AI n’ont qu’une tentative. Le runtime relance les activités individuelles lorsque leur contrat le permet, mais ne redémarre jamais un workflow d’agent complet après un échec. Un tel redémarrage pourrait répéter les effets des outils ou entrer en conflit avec l’enregistrement final déjà sauvegardé par la première tentative.
Synchronisation sémantique vs vivacité du Temporal
Goa-AI maintient le contrat d’exécution public indépendant du moteur :
RunPolicy.Timing.PlanetRunPolicy.Timing.Toolssont des budgets de tentatives sémantiquesruntime.WithTiming(...)remplace ces budgets sémantiques pour une exécution- Les clients générés utilisent la file d’attente par défaut de l’agent. Passez
runtime.WithTaskQueue("orchestrator.chat")à un appelStartouRunlorsqu’une exécution doit utiliser une autre file
Si vous utilisez l’adaptateur Temporal et avez besoin d’un réglage de l’attente en file d’attente ou de l’activité, configurez sur le moteur Temporal lui-même :
eng, err := temporal.NewWorker(temporal.Options{
ClientOptions: &client.Options{
HostPort: "temporal:7233",
Namespace: "default",
},
WorkerOptions: temporal.WorkerOptions{
TaskQueue: "orchestrator.chat",
},
ActivityDefaults: temporal.ActivityDefaults{
Planner: temporal.ActivityTimeoutDefaults{
QueueWaitTimeout: 30 * time.Second,
LivenessTimeout: 20 * time.Second,
},
Tool: temporal.ActivityTimeoutDefaults{
QueueWaitTimeout: 2 * time.Minute,
LivenessTimeout: 20 * time.Second,
},
},
})
if err != nil {
panic(err)
}
Cette division maintient les mécanismes de flux de travail derrière la limite Temporal tandis que le le temps d’exécution générique reste honnête à la fois sur Temporal et sur le moteur en mémoire.
Contrats de l’adaptateur de stockage et de fin
Le runtime enregistre une seule activité typée appelée runtime.store. Chaque
StorageActivityCommand définit exactement l’un des champs Append,
RootStart, ChildStart, OneShotStart, OneShotChildStart, Cancellation,
Suspension ou Terminal. Le StorageActivityResult renvoyé définit exactement le champ
correspondant et aucun autre. Un stockage personnalisé renvoie
storage.ContractError lorsque répéter la même commande ne peut pas réussir.
Les pannes temporaires de base de données ou de réseau restent des erreurs
ordinaires et peuvent être retentées. runtime.WithStorageActivityTimeout
définit le délai Start-to-Close de l’activité et exige une valeur supérieure à
zéro.
Engine.QueryRunCompletion renvoie le Status actuel de l’exécution. Une fois
l’exécution fermée, le même résultat contient aussi son instant stable
CompletedAt et son Output final ou son WorkflowError.
EnsureRunCompletion utilise CompletedAt comme horodatage de l’enregistrement,
afin que chaque nouvelle tentative envoie la même valeur. L’erreur distincte de
la méthode indique que le moteur n’a pas pu récupérer ces informations. Il
n’existe pas de requête de statut séparée.
La préparation du prompt d’un enfant renvoie exactement un Success ou un
Failure. Le succès contient uniquement les messages et les informations sur
les prompts rendus. Le workflow déduit l’identité de l’exécution enfant, de la
session, du parent, de l’outil et des étiquettes depuis l’appel d’outil original
déjà enregistré. Le moteur en mémoire copie et limite l’entrée et la sortie et
applique la même politique de nouvelle tentative que Temporal.
Exécuter des contrats
SessionIDest requis pour les démarrages de session.StartetRunéchouent rapidement lorsqueSessionIDest vide ou un espaceStartOneShotetOneShotRunsont explicitement sans session. Ils ne nécessitent ni ne créent de session et n’émettent pas d’événements de flux à l’échelle de la session.- L’hôte crée les sessions avant de soumettre un travail avec session. Les runtimes d’agents ne créent, ne terminent et ne suppriment pas les sessions
- Le moteur accepte un workflow racine avant que sa première activité enregistre l’exécution. Le runtime ne crée aucun enregistrement
pendingavant cette acceptation - Répéter un démarrage avec le même ID d’exécution et exactement la même requête renvoie le workflow accepté tant que son historique reste interrogeable. Réutiliser l’ID avec une autre entrée est refusé. Après la durée de conservation de l’historique, l’identité permanente de la commande appartient au service produit, pas à Goa-AI
- Les démarrages racine, enfant et ponctuel utilisent des opérations distinctes. Les démarrages d’enfants enregistrent ensemble le lien parent et le démarrage de l’enfant ; les démarrages ponctuels enregistrent les métadonnées complètes sans session
- Temporal termine un workflow enfant si son workflow parent se ferme en premier
- Tout nouvel enfant exige un parent actif.
StartChildRunetStartOneShotChildRunenregistrent chacun le lien parent et le démarrage de l’enfant ensemble. Une nouvelle tentative identique déjà acceptée reste valide après l’arrêt du parent ; une tentative modifiée ou un nouvel enfant est rejeté - Le premier motif d’annulation ne change pas. Une répétition exacte réussit et un autre motif pour la même exécution produit un conflit
- La suspension et la fin enregistrent le nouvel état avec l’enregistrement correspondant, qui ne peut plus être modifié
- Les payloads durables
RunStarted,RunSuspended,RunCompletedetChildRunLinkeddoivent contenir exactement une valeur JSON du type correspondant. Les champs inconnus et les valeurs JSON supplémentaires sont rejetés - Les agents doivent être enregistrés avant la première exécution. Le moteur d’exécution rejette l’enregistrement après la première soumission d’exécution avec
ErrRegistrationClosedpour que les opérateurs du moteur restent déterministes. - Les exécuteurs d’outils reçoivent des métadonnées explicites par appel
(
ToolCallMeta) plutôt que d’extraire des valeurs decontext.Context. Leurs étiquettes copient celles de l’exécution et de la politique et ne contiennentruntime.FinalizationReasonLabelque pour un appel de finalisation terminale - Ne comptez pas sur des solutions de repli implicites ; tous les identifiants de domaine (exécution, session, tour, corrélation) doivent être transmis explicitement
Garantir l’enregistrement final et sa livraison
Les workflows normaux relancent les écritures de suspension et de fin jusqu’à ce que le stockage du runtime les accepte. Un hôte peut utiliser deux commandes explicites après la fermeture de l’historique du moteur :
Runtime.EnsureRunCompletion(ctx, runID)enregistre une suspension ou un résultat final manquant tant que l’exécution reste active dans le stockage. Si elle est déjà terminée, ou si un autre résultat final l’emporte pendant l’exécution de la commande, celle-ci valide et livre exactement le résultat enregistré.Runtime.EnsureChildRunLink(ctx, runID)valide et livre uniquement le lien parent exact d’une exécution enfant associée à une session. Les hôtes peuvent l’appeler dans l’ordre des parents vers les enfants avant de livrer les résultats finaux des enfants imbriqués.
EnsureRunCompletion livre le lien parent avant l’événement final d’un enfant.
Les clés d’événement stables permettent de répéter sans risque la livraison au
flux, et un résultat déjà enregistré ne produit pas une nouvelle notification
locale du cycle de vie. Aucune des deux commandes ne modifie le résultat accepté
par le stockage.
Les deux commandes exigent Runtime.WithStream lorsque le statut de la session
utilisé pour la livraison est actif. EnsureChildRunLink lit le statut actuel
avec LoadSessionStatus. EnsureRunCompletion utilise plutôt le
SessionStatus renvoyé avec l’écriture de l’enregistrement final ou sa nouvelle
tentative identique. Une session nouvellement constatée comme terminée conserve
ses enregistrements et n’émet rien sur le flux. Si le stockage a accepté
l’événement pendant que la session était active, cet événement reste à livrer :
terminer la session pendant les nouvelles tentatives de ce même appel de
livraison ne l’annule pas.
EnsureRunCompletion renvoie ErrRunCompletionNotReady lorsque le moteur
signale encore un workflow actif. Il renvoie ErrRunCompletionCorrupt lorsque
l’historique du moteur ou les données de cycle de vie enregistrées ne peuvent
pas former un résultat valide unique. Une erreur de lecture de l’historique du
moteur est renvoyée au code appelant et n’est jamais enregistrée comme échec
du workflow.
Les méthodes de liste et d’instantané sont en lecture seule et n’appellent jamais ces commandes. Ces commandes n’ajoutent aucune migration du schéma de la base de données et ne changent aucun format public. Elles modifient toutefois l’interface Go des stockages personnalisés, et les enregistrements durables existants doivent respecter les formes JSON typées et strictes décrites dans Mémoire et sessions.
Entrées externes et continuations de workflow
Chaque entrée utilisateur acceptée démarre un workflow de premier niveau pour ce tour. Le workflow se termine soit avec le résultat final de ce tour, soit avec une suspension en attente d’une entrée externe. Les agents imbriqués continuent à s’exécuter dans des workflows enfants liés.
Les clarifications, questions structurées, résultats d’outils externes et
confirmations terminent correctement le workflow courant. Le
RunOutput.Suspension renvoyé contient des demandes Pending visibles et un
point de reprise Checkpoint privé. L’application conserve la suspension complète
dans un stockage serveur fiable et transmet uniquement Suspension.Pending à
l’interface ou au système externe qui doit répondre. Elle ne transmet jamais le
point de reprise privé à un client non fiable. Aucun workflow Temporal ne reste
ouvert pendant la décision d’une personne.
Avant de se terminer, Goa-AI enregistre son point de reprise privé sous l’ID de l’exécution achevée. L’application doit accepter atomiquement une seule réponse afin que deux requêtes concurrentes ne puissent pas continuer le même état. Elle démarre ensuite un nouveau workflow avec l’ID de l’exécution précédente, un nouvel ID d’exécution, un nouvel ID de tour et une réponse typée :
Si l’acceptation de la réponse doit être enregistrée avec des données produit,
appelez PrepareContinuation, puis MarshalBinary, et enregistrez ces octets
avec la réponse dans une seule transaction. Le processus qui lance le workflow
charge ces octets, appelle ParsePreparedRun, puis transmet la valeur restaurée
à StartPrepared. Utilisez Continue uniquement lorsqu’aucune écriture
applicative ne sépare la validation de la soumission au moteur.
next, err := client.Continue(
ctx,
"session-1",
previous.RunID,
"run-124",
"turn-2",
&api.PendingInputResponse{
Clarification: &api.ClarificationAnswer{
ID: "clarify-device",
Answer: "Device ID is ABC-123",
},
},
runtime.WorkflowOptions{},
)
Lors de la préparation de la continuation, l’application transmet uniquement
l’ID de l’exécution terminée et la réponse typée. Goa-AI charge le point de
reprise, valide sa version et la demande en attente, restaure les payloads
enregistrés avec les codecs générés actuels et reprend la planification. Les
octets de PreparedRun peuvent contenir une copie de ce point de reprise et la
transcription complète. Conservez-les uniquement dans un stockage applicatif
fiable avec un accès contrôlé ; ne les envoyez jamais à un client non fiable.
Le seul format accepté est goa-ai.run-suspension.v7. Goa-AI rejette toutes les
versions précédentes au lieu de deviner comment les traduire. Avant d’accepter
des continuations avec le nouveau runtime, l’hôte doit migrer ou supprimer les
exécutions suspendues qui utilisent un ancien format.
Lorsqu’une réponse termine un appel d’outil créé par le modèle dans le workflow
précédent, le nouvel événement tool_end porte deux identités :
- son ID d’exécution normal désigne le nouveau workflow qui a reçu la réponse ;
call_run_iddésigne le workflow précédent qui a émis letool_start.
Les consommateurs du flux doivent associer ces événements avec call_run_id et
l’ID de l’appel. Ils ne doivent ni rechercher les exécutions précédentes, ni
supposer que l’appel et son résultat appartiennent au même workflow.
Confirmation de l’outil
Goa-AI prend en charge les portes de confirmation appliquées au moment de l’exécution pour les outils sensibles (écritures, suppressions, commandes).
Vous pouvez activer la confirmation de deux manières :
- Au moment de la conception (cas courant) : déclarez
Confirmation(...)dans l’outil DSL. Magasins Codegen la politique danstools.ToolSpec.Confirmation. - Runtime (remplacement/dynamique) : passez
runtime.WithToolConfirmation(...)lors de la construction du runtime pour exiger une confirmation pour des outils supplémentaires ou remplacer le comportement au moment de la conception.
Au moment de l’exécution, le workflow émet une demande de confirmation hors bande et exécute uniquement l’outil après qu’une approbation explicite ait été fournie. En cas de refus, le runtime synthétise un outil conforme au schéma résultat afin que la transcription reste valide et que le planificateur puisse réagir de manière déterministe.
Protocole de confirmation
Au moment de l’exécution, la confirmation est implémentée sous la forme d’un protocole d’attente/décision dédié :
Attendre la charge utile (diffusé sous le nom
await_confirmation) :{ "id": "...", "title": "...", "prompt": "...", "tool_name": "facility.commands.change_setpoint", "tool_call_id": "toolcall-1", "payload": { "...": "canonical tool arguments (JSON)" } }
Contracter:
payloadcontient toujours les arguments canoniques de l’outil JSON pour l’appel en attente. S’ils sont approuvés, ce sont les arguments que le runtime exécute.Les remplacements de confirmation peuvent personnaliser le rendu de l’invite et du résultat refusé, mais ils n’introduisent pas de canal de charge utile d’affichage distinct ni ne modifient la signification de
payload.Les produits qui nécessitent une confirmation plus riche UI doivent la matérialiser dans la couche d’application à partir de la charge utile canonique et des lectures appartenant à l’application.
Réponse de continuation :
response := &api.PendingInputResponse{ Confirmation: &api.ConfirmationDecision{ ID: "await-1", Approved: true, // or false RequestedBy: "user:123", Labels: map[string]string{"source": "front-ui"}, Metadata: map[string]any{"ticket_id": "INC-42"}, }, }
Événements d’autorisation d’outil
Lorsqu’une décision est fournie, le runtime émet un événement d’autorisation de première classe :
- Événement crochet :
hooks.ToolAuthorization - Type d’événement de flux :
tool_authorization
Cet événement est l’enregistrement canonique « qui/quand/quoi » pour un appel d’outil confirmé :
tool_name,tool_call_idapproved(vrai/faux)summary(résumé déterministe rendu à l’exécution)approved_by(copié à partir deapi.ConfirmationDecision.RequestedBy, destiné à être un identifiant principal stable)
L’événement est émis immédiatement après la réception de la décision (avant l’exécution de l’outil en cas d’approbation, et avant la synthèse du résultat de l’outil refusé en cas de refus).
Remarques :
- Les consommateurs doivent traiter la confirmation comme un protocole d’exécution :
- Affichez le premier élément en attente lorsque son type est
confirmation, puis envoyez la décision avecAgentClient.Continue. - Ne couplez pas le comportement UI à un nom d’outil de confirmation spécifique ; traitez-le comme un détail de transport interne.
- Affichez le premier élément en attente lorsque son type est
- Les modèles de confirmation (
PromptTemplateetDeniedResultTemplate) sont des chaînes Gotext/templateexécuté avecmissingkey=error. En plus des fonctions de modèle standard (par exempleprintf), Goa-AI fournit :json v→ JSON codev(utile pour les champs de pointeur facultatifs ou l’intégration de valeurs structurées).quote s→ renvoie une chaîne entre guillemets avec échappement Go (commefmt.Sprintf("%q", s)).
Validation d’exécution
Le runtime valide les interactions de confirmation à la limite :
- La confirmation
IDcorrespond à l’identifiant de la demande en attente lorsqu’elle est fournie. - La continuation contient exactement une variante de réponse et une décision bien formée.
Contrat de planificateur
Les planificateurs mettent en œuvre :
type Planner interface {
PlanStart(ctx context.Context, input *planner.PlanInput) (*planner.PlanResult, error)
PlanResume(ctx context.Context, input *planner.PlanResumeInput) (*planner.PlanResult, error)
}
PlanResult contient des appels d’outils, une réponse finale, un résultat d’outil
final, des annotations et la transition choisie après les outils.
PlanResumeInput indique au planificateur pourquoi il est appelé.
Ces contrats sont distincts :
| Contrat | Portée | Signification |
|---|---|---|
ToolSpec.Tags | Un outil, pour chaque exécution | Étiquettes plates disponibles au filtrage générique par les politiques et l’interface utilisateur. |
ToolSpec.Meta | Un outil, pour chaque exécution | Annotations générées et inertes dont la sémantique appartient au consommateur nommé ; les métadonnées seules ne changent pas le runtime. |
ToolSpec.Bookkeeping | Un outil, pour chaque exécution | L’appel est un enregistrement de contrôle durable dont le succès ne requiert pas un autre tour du planificateur. Il ne consomme aucun budget de récupération ou d’échecs consécutifs. |
ToolSpec.TerminalRun | Un outil, pour chaque exécution | Le succès termine lui-même l’exécution et implique automatiquement la comptabilité. |
ToolFailure.Recovery.Action | Un résultat en échec | Définit la prochaine transition autorisée : corriger le même appel, replanifier sans l’outil en échec ou terminer avec les éléments disponibles. |
PlanResult.SynthesizeAfterTools | Un lot sélectionné | Si le lot n’a aucun échec récupérable, le prochain tour du planificateur doit répondre. |
PlanResumeInput.SynthesisOnly | Une activité du planificateur | Renvoyer une réponse finale ; les appels d’outils sont invalides. |
PlanResumeInput.Finalize | Arrêt imposé par le runtime | Un plafond ou une deadline interdit le travail normal. |
Le runtime choisit l’état suivant dans cet ordre :
| Étape terminée | État suivant |
|---|---|
| Un plafond ou une deadline impose la finalisation | Tour Finalize |
Un outil TerminalRun a réussi | Fin immédiate |
Un résultat en échec a ToolFailure.AllowsToolTurn() == true | Tour normal de récupération |
SynthesizeAfterTools vaut true | Tour SynthesisOnly |
| Sinon | Tour normal de continuation |
Ainsi, l’intention du planificateur ne devient pas une seconde politique de
reprise. Un échec récupérable est réparé d’abord ; un lot final réussi ou en
échec terminal passe à la synthèse. Le runtime rejette les appels d’outils
renvoyés depuis un tour SynthesisOnly.
Chaque ToolFailure récupérable sélectionne aussi une Recovery.Action :
correct_callgarde l’outil en échec disponible et transmet au prochain tour du planificateur l’entrée rejetée, les problèmes de validation générés, les indications sur les champs et un exemple. Il n’impose pas un appel de remplacement par échec. Le planificateur peut regrouper le travail, effectuer autant d’appels valides que nécessaire aux outils annoncés, attendre une entrée ou répondre avec les éléments déjà recueillis.replanretire l’outil en échec du prochain tour. Le planificateur peut utiliser un autre outil annoncé, attendre une entrée ou répondre.finishretire tous les outils et exige une réponse finale fondée sur les éléments disponibles.
Le workflow possède les informations de correction visibles par le modèle.
Avant d’enregistrer l’échec, il remplace l’entrée antérieure et l’exemple
fournis par l’exécuteur par l’appel original du fournisseur et la spécification
de l’outil enregistrée. Une continuation créée par le runtime n’a aucune entrée
produite par le modèle et ne peut donc pas demander correct_call ; les
curseurs privés et les champs injectés ne peuvent ainsi pas réapparaître dans
une requête de modèle ultérieure.
Les transcriptions du modèle associent appels et résultats avec
ModelToolCallID. Les activités, tentatives et enregistrements d’exécution
utilisent un ToolCallID distinct attribué par le runtime. Ces deux IDs ne sont
ni interchangeables ni dérivés de l’ordre des appels.
Le runtime enregistre le catalogue exact présenté pendant un tour de récupération et rejette tout appel exécutable qui n’en fait pas partie, y compris un appel intégré à une demande d’entrée utilisateur ou externe. Les codecs générés valident toujours chaque charge utile, et les limites d’outils, d’échecs et de temps arrêtent toujours les travaux invalides répétés. Si un tour de récupération attend une entrée, ses éléments d’échec restent disponibles à la reprise ; le choix d’un appel d’outil ou d’une réponse finale les efface.
Les entrées d’activité de récupération et leur catalogue annoncé font partie de l’historique durable du workflow. Un déploiement qui modifie ce contrat doit drainer ou arrêter les anciens workers et les workflows en cours avant de démarrer le nouveau groupe de workers. Mélanger les versions de workers à cette frontière n’est pas sûr.
Lorsque PlanResumeInput.Finalize est défini, les planificateurs peuvent
renvoyer des outils terminaux de comptabilité ; ces appels ne sont pas rejoués
dans un tour ultérieur et doivent terminer durablement la finalisation.
Les planificateurs reçoivent également un PlannerContext via input.Agent qui expose les services d’exécution :
AdvertisedToolDefinitions()- obtient les définitions d’outils filtrées à l’exécution et visibles par le modèle pour ce tourModelClient(id string)- obtenez un client modèle brut indépendant du fournisseurPlannerModelClient(id string)- obtenez un client modèle à l’échelle du planificateur avec une émission d’événements appartenant au runtimeRenderPrompt(ctx, id, data)- résoudre et afficher le contenu de l’invite pour la portée d’exécution en coursAddReminder(r reminder.Reminder)- enregistrer les rappels système liés à l’exécutionRemoveReminder(id string)- effacer les rappels lorsque les conditions préalables ne sont plus valablesMemory()- accéder à l’historique des conversations
Modules de fonctionnalités
runtime/agent/storage/inmem– stockage intégré en mémoire pour les exemples et les testsruntime/mcp– appelants MCP pour HTTP et stdio ; HTTP accepte les réponses JSON et les flux d’événementsfeatures/memory/mongo– magasin de mémoire durablefeatures/prompt/mongo– Magasin de remplacement d’invite soutenu par Mongofeatures/stream/pulse– Assistants récepteurs/abonnés Pulsefeatures/model/{anthropic,bedrock,openai,vertex}– adaptateurs de fournisseurs qui renvoient des clients de modèle validésfeatures/model/gateway– serveur de fournisseur distant et clients de transport validésfeatures/model/middleware– middleware installé sous la validation du client, notamment la limitation adaptative fondée sur le nombre exact de jetonsfeatures/policy/basic– moteur de stratégie simple avec listes d’autorisation et de blocage et gestion deToolFailure
Modéliser le débit client et la limitation du débit
Goa-AI fournit dans features/model/middleware un limiteur adaptatif fondé
sur les jetons d’entrée. Il demande au client encapsulé le nombre exact de
jetons de la requête, réserve cette capacité avant l’appel et ajuste son budget
effectif de jetons d’entrée par minute lorsque le fournisseur signale une
limitation.
import (
"github.com/aws/aws-sdk-go-v2/service/bedrockruntime"
"goa.design/goa-ai/runtime/agent/runtime"
"goa.design/goa-ai/features/model/bedrock"
mdlmw "goa.design/goa-ai/features/model/middleware"
)
awsClient := bedrockruntime.NewFromConfig(cfg)
bed, err := bedrock.New(awsClient, bedrock.Options{
DefaultModel: "us.anthropic.claude-4-5-sonnet-20251120-v1:0",
})
if err != nil {
panic(err)
}
rl := mdlmw.NewAdaptiveRateLimiter(
ctx,
throughputMap, // *rmap.Map joined earlier (nil for process-local)
"bedrock:sonnet", // key for this model family
80_000, // initial input tokens per minute
1_000_000, // maximum input tokens per minute
)
limited, err := rl.Middleware()(bed)
if err != nil {
panic(err)
}
rt := runtime.New(runtimeStore)
if err := rt.RegisterModel("bedrock", limited); err != nil {
panic(err)
}
La construction du middleware ne teste pas le comptage. Si le fournisseur ou
la requête ne peut pas être compté exactement, le premier appel Complete ou
Stream renvoie model.ErrTokenCountingUnsupported avant l’inférence. Vertex
Gemini prend en charge ce comptage ; Bedrock seulement pour les requêtes et
modèles acceptés par Runtime CountTokens. OpenAI n’a pas de compteur natif.
Le limiteur mesure uniquement les jetons d’entrée. Un succès unaire ou la fin propre d’un flux augmente progressivement le budget ; une limitation unaire ou terminale le réduit. Ouvrir ou fermer un flux ne constitue pas un succès.
Intégration LLM
Les planificateurs Goa-AI interagissent avec de grands modèles de langage via une interface indépendante du fournisseur. Cette conception vous permet d’échanger des fournisseurs (AWS Bedrock, OpenAI, Google Vertex AI — Gemini et Claude-on-Vertex — ou des points de terminaison personnalisés) sans modifier votre code de planificateur.
Le client de modèle validé
Toutes les interactions du planificateur passent par un model.Client opaque :
resp, err := client.Complete(ctx, req)
stream, err := client.Stream(ctx, req) // *model.ValidatedStream
Les intégrations implémentent model.Provider, qui produit les réponses et
fragments de transport bruts. Goa-AI construit model.Client avec
model.NewClient(provider) et valide les requêtes ainsi que les réponses
complètes autour du fournisseur. Les packages externes ne peuvent ni
implémenter model.Client ni exposer des fragments bruts au planificateur.
Avant l’appel, le client valide les noms et schémas d’outils, les parties de messages, les options de raisonnement, la sortie structurée et les valeurs dynamiques. Les requêtes et réponses unaires sont limitées à 16 Mio et 100 000 valeurs visitées ; les métadonnées imbriquées ont une profondeur maximale de 64. Le streaming applique un budget cumulé aux fragments et à la réponse terminale. Toute violation refuse l’opération entière : Goa-AI ne tronque, ne répare et ne convertit jamais les données du modèle.
Un ValidatedStream doit être lu jusqu’à io.EOF. Ce n’est qu’alors que
Response() rend la réponse canonique acceptée. Un flux incomplet, mal formé
ou contradictoire renvoie une erreur sans réponse acceptée.
Adaptateurs de fournisseur
Le Goa-AI est livré avec des adaptateurs pour les fournisseurs LLM populaires :
AWS Bedrock
import (
"github.com/aws/aws-sdk-go-v2/service/bedrockruntime"
"goa.design/goa-ai/features/model/bedrock"
)
awsClient := bedrockruntime.NewFromConfig(cfg)
modelClient, err := bedrock.New(awsClient, bedrock.Options{
DefaultModel: "anthropic.claude-3-5-sonnet-20241022-v2:0",
HighModel: "anthropic.claude-sonnet-4-20250514-v1:0",
SmallModel: "anthropic.claude-3-5-haiku-20241022-v1:0",
MaxTokens: 4096,
Temperature: 0.7,
})
if err != nil {
panic(err)
}
OpenAI
import (
"os"
"goa.design/goa-ai/runtime/agent/runtime"
)
rt := runtime.New(runtimeStore) // stockage du runtime fourni par l'hôte
modelClient, err := rt.NewOpenAIModelClient(runtime.OpenAIConfig{
APIKey: os.Getenv("OPENAI_API_KEY"),
DefaultModel: "gpt-5-mini",
HighModel: "gpt-5",
SmallModel: "gpt-5-nano",
})
if err != nil {
panic(err)
}
Google Vertex AI (Gemini et Claude-on-Vertex)
Le paquet features/model/vertex fournit deux constructeurs qui satisfont
tous deux model.Client : un adaptateur Gemini natif et un assistant de pure
construction qui pointe l’adaptateur Anthropic vers les modèles Claude
hébergés sur Vertex.
import "goa.design/goa-ai/runtime/agent/runtime"
// Gemini sur Vertex, avec les Application Default Credentials.
geminiClient, err := rt.NewVertexGeminiModelClient(ctx, runtime.VertexConfig{
ProjectID: "my-gcp-project",
Location: "us-central1",
DefaultModel: "gemini-2.5-flash",
HighModel: "gemini-3-pro-preview",
SmallModel: "gemini-2.5-flash-lite",
MaxTokens: 4096,
ThinkingBudget: 10000,
})
// Claude sur Vertex. C'est de la pure construction : cela crée un client
// Anthropic SDK sur le transport Vertex du SDK et le confie à
// features/model/anthropic, qui possède la traduction des Messages et la
// classification des erreurs HTTP pour chaque adaptateur hébergé par
// Anthropic (API directe et Vertex) — aucune couche de traduction séparée.
claudeOnVertexClient, err := rt.NewVertexAnthropicModelClient(ctx, runtime.VertexConfig{
ProjectID: "my-gcp-project",
Location: "us-east5",
DefaultModel: "claude-sonnet-4-5@20250929",
})
Les modèles de la génération Gemini 3 attachent une thought signature
opaque aux parties functionCall (pas seulement aux parties
thought/thinking) pour authentifier la chaîne de raisonnement derrière un
appel d’outil. L’adaptateur Vertex fait l’aller-retour de cette signature via
model.ToolCall.ThoughtSignature / model.ToolUsePart.ThoughtSignature en
utilisant la même convention base64 que ThinkingPart.Signature. Le runtime
capture cette signature à la frontière du model-client — avant que l’un ou
l’autre des styles d’intégration ci-dessous ne produise un
planner.ToolRequest — et la rattache par ID d’appel d’outil lors de la
reconstruction du transcript destiné au fournisseur. planner.ToolRequest
ne porte aucun champ signature ; le code du planificateur n’a pas besoin de
savoir que les signatures existent.
Différences de capacités entre fournisseurs
Le type de requête commun reste neutre, mais chaque adaptateur refuse les combinaisons que son API ne peut pas préserver :
| Fournisseur | Contrat appliqué avant ou pendant l’appel |
|---|---|
| OpenAI | La sortie structurée utilise une projection stricte. Elle ne peut pas être combinée aux outils. Les branches oneOf qui se chevauchent sont refusées. Les schémas stricts ont des limites documentées sur les propriétés, les enums, la profondeur et les caractères agrégés. Une requête avec raisonnement refuse la température. |
| Anthropic | Les modèles Claude actuels utilisent la sortie structurée native lorsqu’elle est disponible. Le raisonnement adaptatif permet les outils et le choix forcé normal ; l’ancien raisonnement manuel refuse le choix forcé any ou d’un outil nommé. Un flux doit fermer chaque bloc et fournir une raison d’arrêt. |
| Bedrock | Claude 4.5 et 4.6 utilisent OutputConfig; les autres modèles Claude utilisent un outil privé forcé et valident son résultat avec le même contrat. CountTokens refuse les modèles qui exigent Mantle et les requêtes avec sortie structurée. |
| Vertex Gemini | Gemini 3 utilise des niveaux de raisonnement, refuse les budgets numériques et la désactivation explicite, et conserve les thought signatures des appels d’outils. Les flux exigent exactement un candidat et une raison de fin. |
Pour Claude Opus 4.7+, Sonnet 5+, Haiku 5+, Fable et Mythos, les adaptateurs
Anthropic et Bedrock omettent temperature, top_p et top_k, car ces
modèles les refusent. Les générations antérieures continuent à recevoir les
valeurs configurées.
Les adaptateurs ne conservent aucun historique de conversation. Chaque requête
doit transporter tous les Messages prêts pour le fournisseur ; un RunID ne
leur demande pas de charger des messages antérieurs.
Les principales erreurs sentinelles sont :
model.ErrStructuredOutputUnsupportedlorsqu’un adaptateur ne peut pas représenter le contrat de sortie ;model.ErrTokenCountingUnsupportedlorsque le comptage exact est indisponible ;model.ErrEmptyStreamlorsqu’un fournisseur ferme sans sortie ;model.ErrRateLimitedpour une limitation récupérable du fournisseur.
*planner.OutputContractError est une erreur structurée, pas une sentinelle.
Détectez-la avec errors.As et examinez son origine pour distinguer une sortie
invalide du modèle, du planificateur ou d’un outil. Elle n’est pas récupérable :
une nouvelle requête ne doit pas masquer une violation de contrat.
Métadonnées canoniques et rejeu des citations
model.Message.Meta contient les données produites par le fournisseur
nécessaires au rejeu exact d’une réponse. Les frontières qui persistent ou
transportent ces métadonnées doivent utiliser model.MarshalMetadata et
model.UnmarshalMetadata. Ces codecs exigent un objet JSON unique, conservent
les nombres décodés sous forme de json.Number, rejettent les données
supplémentaires et ramènent nil ou un objet vide à nil.
Le rejeu des citations dépend du fournisseur et ne doit jamais les aplatir en
texte ordinaire. L’adaptateur Bedrock peut rejouer les valeurs CitationsPart
de l’assistant sous forme de blocs de citation natifs, en préservant l’identité
de la source, les extraits et les emplacements du document par caractères,
chunks ou pages. Les citations système Bedrock restent non prises en charge car
son union de contenu système ne possède aucun membre citation. Anthropic et
Vertex rejettent le rejeu lorsqu’une partie canonique ne contient pas les champs
exigés par leur protocole.
Utilisation de clients modèles dans les planificateurs
Les planificateurs obtiennent des clients modèles via le PlannerContext du runtime. Il y a
deux styles d’intégration explicites :
PlannerModelClient(id)pour le streaming à l’échelle du planificateur avec émission d’événements appartenant au runtimeModelClient(id)lorsque vous avez besoin d’un accès direct au modèle validé et que vous drainez le flux renvoyé avecplanner.ConsumeStream
PlannerModelClient (recommandé)
PlannerContext.PlannerModelClient(id) renvoie un client à l’échelle du planificateur qui
possède les émissions AssistantChunk, PlannerThinkingBlock et UsageDelta. C’est
La méthode Stream(...) draine le flux du fournisseur sous-jacent et renvoie un
planner.StreamSummary :
func (p *MyPlanner) PlanStart(ctx context.Context, input *planner.PlanInput) (*planner.PlanResult, error) {
mc, ok := input.Agent.PlannerModelClient("anthropic.claude-3-5-sonnet-20241022-v2:0")
if !ok {
return nil, errors.New("model not configured")
}
req := &model.Request{
Messages: input.Messages,
Tools: input.Agent.AdvertisedToolDefinitions(),
Stream: true,
}
sum, err := mc.Stream(ctx, req)
if err != nil {
return nil, err
}
if len(sum.ToolCalls) > 0 {
return &planner.PlanResult{ToolCalls: sum.ToolCalls}, nil
}
final := sum.FinalResponse()
if final == nil {
return nil, errors.New("model stream ended without a canonical response")
}
return &planner.PlanResult{
FinalResponse: final,
Streamed: true, // Le texte de l'assistant a déjà été diffusé
}, nil
}
Il s’agit du style d’intégration le plus sûr car le client limité au
planificateur n’expose pas de model.Streamer brut et ne peut donc pas être
combiné accidentellement avec planner.ConsumeStream. Le retour de
sum.FinalResponse() sélectionne aussi la réponse exacte du fournisseur
capturée pour cette invocation ; reconstruire un message uniquement textuel
supprimerait le raisonnement, les citations, les signatures, les métadonnées et
les frontières des messages.
Client validé + ConsumeStream
Lorsque vous avez besoin d’accéder directement au model.Client, récupérez-le
sur PlannerContext.ModelClient et associez son flux validé à
planner.ConsumeStream :
mc, ok := input.Agent.ModelClient("anthropic.claude-3-5-sonnet-20241022-v2:0")
if !ok {
return nil, errors.New("model not configured")
}
req := &model.Request{
Messages: input.Messages,
Tools: input.Agent.AdvertisedToolDefinitions(),
Stream: true,
}
stream, err := mc.Stream(ctx, req)
if err != nil {
return nil, err
}
sum, err := planner.ConsumeStream(ctx, stream)
if err != nil {
return nil, err
}
if len(sum.ToolCalls) > 0 {
return &planner.PlanResult{ToolCalls: sum.ToolCalls}, nil
}
final := sum.FinalResponse()
if final == nil {
return nil, errors.New("model stream ended without a canonical response")
}
return &planner.PlanResult{
FinalResponse: final,
Streamed: true,
}, nil
Ce helper se contente de drainer le flux et renvoie un StreamSummary avec le
texte et les appels d’outils accumulés. Le journal des invocations du modèle du
runtime publie ensuite les événements de présentation et d’utilisation acceptés.
Utilisez le client direct lorsque le planificateur doit examiner des fragments
de prévisualisation validés ou effectuer plusieurs appels au modèle pendant un
même tour. Drainez chaque flux sélectionné jusqu’à son résultat terminal : une
fermeture anticipée ne produit pas de réponse acceptée. Le PlanResult renvoyé
doit transmettre un seul résultat exact : l’ensemble complet des ToolCalls du
résumé ou son FinalResponse(). Le runtime rejette les résultats modifiés,
mélangés ou ambigus. Ne combinez pas PlannerModelClient.Stream(...) avec
planner.ConsumeStream ; choisissez un seul propriétaire du flux par tour du
planificateur.
Validation de l’ordre des messages Bedrock
Lors de l’utilisation de AWS Bedrock avec le mode réflexion activé, le moteur d’exécution valide les contraintes d’ordre des messages avant d’envoyer les requêtes. Bedrock nécessite :
- Tout message de l’assistant contenant
tool_usedoit commencer par un bloc de réflexion - Chaque message utilisateur contenant
tool_resultdoit immédiatement suivre un message d’assistant avec les blocstool_usecorrespondants. - Le nombre de blocs
tool_resultne peut pas dépasser le nombretool_useprécédent.
Le client Bedrock valide ces contraintes plus tôt et renvoie une erreur descriptive en cas de violation :
bedrock: invalid message ordering with thinking enabled (run=xxx, model=yyy):
bedrock: assistant message with tool_use must start with thinking
Cette validation garantit que la reconstruction du grand livre de transcription produit des séquences de messages conformes au fournisseur.
Prochaines étapes
- Découvrez les Ensembles d’outils pour comprendre les modèles d’exécution d’outils.
- Explorez Composition d’agent pour les modèles d’agent en tant qu’outil
- En savoir plus sur Mémoire et sessions pour la persistance des transcriptions