Composizione dell'agente
Questa guida mostra come comporre gli agenti trattando un agente come uno strumento di un altro e spiega come Goa-AI modella le esecuzioni degli agenti come un albero con proiezioni in streaming per diversi destinatari.
Cosa costruirete
- Un agente di pianificazione che esporta strumenti di pianificazione
- Un agente orchestratore che utilizza gli strumenti dell’agente di pianificazione
- Composizione tra processi con esecuzione in linea
Progettazione di agenti composti
Creare design/design.go:
package design
import (
. "goa.design/goa/v3/dsl"
. "goa.design/goa-ai/dsl"
)
var _ = API("orchestrator", func() {})
var PlanRequest = Type("PlanRequest", func() {
Attribute("goal", String, "Goal to plan for")
Required("goal")
})
var PlanResult = Type("PlanResult", func() {
Attribute("plan", String, "Generated plan")
Required("plan")
})
var _ = Service("orchestrator", func() {
// Planning agent that exports tools
Agent("planner", "Planning agent", func() {
Export("planning.tools", func() {
Tool("create_plan", "Create a plan", func() {
Args(PlanRequest)
Return(PlanResult)
})
})
RunPolicy(func() {
DefaultCaps(MaxToolCalls(5))
TimeBudget("1m")
})
})
// Orchestrator agent that uses planning tools
Agent("orchestrator", "Orchestration agent", func() {
Use(AgentToolset("orchestrator", "planner", "planning.tools"))
RunPolicy(func() {
DefaultCaps(MaxToolCalls(10))
TimeBudget("5m")
})
})
})
Generare il codice:
goa gen example.com/tutorial/design
Implementazione dei pianificatori
Il codice generato fornisce gli helper per entrambi gli agenti. Collegarli insieme:
package main
import (
"context"
planner "example.com/tutorial/gen/orchestrator/agents/planner"
orchestrator "example.com/tutorial/gen/orchestrator/agents/orchestrator"
"goa.design/goa-ai/runtime/agent/runtime"
storageinmem "goa.design/goa-ai/runtime/agent/storage/inmem"
)
func main() {
rt := runtime.New(storageinmem.New())
ctx := context.Background()
// Register planning agent
if err := planner.RegisterPlannerAgent(ctx, rt, planner.PlannerAgentConfig{
Planner: &PlanningPlanner{},
}); err != nil {
panic(err)
}
// Register orchestrator agent (automatically uses planning tools)
if err := orchestrator.RegisterOrchestratorAgent(ctx, rt, orchestrator.OrchestratorAgentConfig{
Planner: &OrchestratorPlanner{},
}); err != nil {
panic(err)
}
// Use orchestrator agent
client := orchestrator.NewClient(rt)
// ... run agent ...
}
Concetti chiave:
- Esportazione: Dichiara set di strumenti che altri agenti possono utilizzare
- AgentToolset: Fa riferimento a un set di strumenti esportato da un altro agente
- Esecuzione in linea: Dal punto di vista del chiamante, un agent-as-tool si comporta come una normale chiamata di strumento; il runtime esegue l’agente provider come esecuzione figlia e aggrega il suo output in un singolo
ToolResult(con unRunLinkdi ritorno all’esecuzione figlia) - Cross-Process: Gli agenti possono essere eseguiti su worker diversi, pur mantenendo un albero di esecuzione coerente; gli eventi e gli handle delle esecuzioni
ChildRunLinkedcollegano le chiamate allo strumento genitore alle esecuzioni degli agenti figli per lo streaming e l’osservabilità
Definizioni generate degli agenti
La generazione del codice emette una sola AgentDefinition immutabile per ogni
agente. La definizione contiene il nome del workflow, la coda di attività
predefinita, i contratti degli strumenti, le etichette obbligatorie, la policy
di completion e le definizioni di tutti gli agenti figli raggiungibili tramite
strumenti basati su agenti.
I chiamanti e gli helper generati per registrare i worker usano la stessa
definizione. Il codice scritto a mano fornisce il pianificatore, gli esecutori
degli strumenti e le impostazioni delle activity; non deve ripetere la route,
la coda, l’ID dell’agente figlio, i contratti dei suoi strumenti o le etichette
obbligatorie. In questo modo chiamante e worker interpretano il progetto nello
stesso modo. Una singola esecuzione può scegliere un’altra coda con
WithTaskQueue.
Passthrough: Inoltro deterministico degli strumenti
Per gli strumenti esportati che devono bypassare completamente il pianificatore e inoltrare direttamente a un metodo di servizio, utilizzare Passthrough. Questo è utile quando:
- Si desidera un comportamento deterministico e prevedibile (nessun processo decisionale LLM)
- Lo strumento è un semplice wrapper attorno a un metodo di servizio esistente
- Si ha bisogno di una latenza garantita senza l’overhead del pianificatore
Quando usare il Passthrough rispetto all’esecuzione normale
| Scenario | Utilizzare Passthrough | Utilizzare l’esecuzione normale |
|---|---|---|
| Operazioni CRUD semplici | ✓ | |
| strumenti di registrazione/audit | ✓ | |
| Strumenti che richiedono un ragionamento LLM | ✓ | ✓ |
| Flussi di lavoro in più fasi | ✓ | |
| Strumenti che possono necessitare di tentativi con suggerimenti | ✓ | ✓ |
Dichiarazione DSL
Export("logging-tools", func() {
Tool("log_message", "Log a message", func() {
Args(func() {
Attribute("level", String, "Log level", func() {
Enum("debug", "info", "warn", "error")
})
Attribute("message", String, "Message to log")
Required("level", "message")
})
Return(func() {
Attribute("logged", Boolean, "Whether the message was logged")
Required("logged")
})
// Bypass planner, forward directly to LoggingService.LogMessage
Passthrough("log_message", "LoggingService", "LogMessage")
})
})
Comportamento in fase di esecuzione
Quando un agente consumatore chiama uno strumento passante:
- Il runtime riceve la chiamata allo strumento dal pianificatore del consumatore
- Invece di invocare il pianificatore dell’agente fornitore, chiama direttamente il metodo del servizio target
- Il risultato viene restituito al consumatore senza alcuna elaborazione LLM
Questo fornisce:
- Latenza prevedibile: Nessun ritardo di inferenza LLM
- Comportamento deterministico: Lo stesso input produce sempre lo stesso output
- Efficienza dei costi: Nessun utilizzo di token per operazioni semplici
Eseguire alberi e sessioni
Goa-AI modella l’esecuzione come un albero di esecuzioni e strumenti:
Esecuzione - un’esecuzione di un agente:
- Identificato da un
RunID - Descritta da
run.Context(RunID, SessionID, TurnID, label, caps) - Lo
storage.Storefornito dall’applicazione salva in modo duraturo lo stato dell’esecuzione e i relativi record immutabili nella stessa operazione
- Identificato da un
Sessione - una conversazione o un flusso di lavoro che comprende una o più sessioni:
SessionIDraggruppa le sessioni correlate (ad esempio, chat a più turni)- Le interfacce utente in genere eseguono il rendering di una sessione alla volta
Albero delle sessioni - relazioni padre/figlio tra sessioni e strumenti:
- Esecuzione agente di livello superiore (ad esempio,
chat) - Esecuzioni di agenti figlio (agent-as-tool, ad esempio,
ada,diagnostics) - Strumenti di servizio sotto questi agenti
- Esecuzione agente di livello superiore (ad esempio,
Il runtime mantiene questa struttura ad albero utilizzando:
run.Handle- un handle leggero conRunID,AgentID,ParentRunID,ParentToolCallID- Aiutanti Agent-as-tool e registrazioni di toolset che creano sempre vere esecuzioni figlio per gli agenti annidati (nessun hack nascosto in linea)
Prima dell’avvio di un pianificatore figlio, storage.Store.StartChildRun salva
insieme il collegamento al padre, i metadati del figlio e il suo primo record.
Per un padre senza sessione, StartOneShotChildRun esegue la stessa operazione
senza inventare una sessione. La prima chiamata richiede che il padre esista,
non abbia una sessione e sia ancora attivo. Un retry esatto resta valido dopo
la fine del padre perché il collegamento è già salvato; un retry modificato o
un nuovo figlio dopo quella fine viene rifiutato.
Se la registrazione dello strumento padre renderizza un prompt per il figlio, il runtime prepara quel prompt in una activity prima di avviare il workflow figlio. L’activity restituisce esattamente un successo o un fallimento. Il successo contiene soltanto i messaggi esatti e gli eventi di rendering salvati nella cronologia del workflow. Il workflow ricava l’identità dell’esecuzione figlia, della sessione, del padre, dello strumento e delle etichette dalla chiamata originale già registrata, invece di accettarla dall’activity. Il replay usa quindi il testo originale e non legge mai dallo storage una versione più recente del prompt.
Gli ID dei workflow figli Temporal includono l’ID esatto della chiamata allo strumento assegnato dal runtime. Le chiamate parallele allo stesso agente annidato restano così distinte; una release che cambia questa derivazione non è compatibile con i workflow figli già in esecuzione.
Agente come strumento e RunLink
Quando un agente utilizza un altro agente come strumento:
- Il runtime avvia un’esecuzione figlio per l’agente fornitore con il proprio
RunID - Tiene traccia dei collegamenti genitore/figlio in
run.Context - Esegue un ciclo completo di pianificazione/esecuzione/ripresa nell’esecuzione figlio
Il risultato dello strumento genitore (planner.ToolResult) porta con sé:
RunLink *run.Handle
Questo RunLink permette a:
- Ai pianificatori di ragionare sull’esecuzione figlia (ad esempio, per l’audit/la registrazione)
- Alle interfacce utente di creare “schede agente” nidificate e renderizzare gli eventi filtrando lo stream di sessione per
run_id - Strumenti esterni per navigare da un ciclo genitore ai suoi figli senza indovinare
Stream di proprietà della sessione
Goa-AI pubblica gli eventi stream.Event in un unico stream di proprietà della sessione:
session/<session_id>
Questo stream contiene eventi per tutte le run della sessione, incluse le run annidate degli agenti (agent-as-tool). Ogni evento include run_id e session_id e il runtime emette:
child_run_linked: collega una chiamata allo strumento padre (tool_call_id) con la run figlia (child_run_id)run_stream_end: marcatore esplicito che significa “non appariranno altri eventi visibili per questa run”
I consumatori si iscrivono una volta per sessione e chiudono SSE/WebSocket quando osservano run_stream_end per il run_id attivo.
import "goa.design/goa-ai/runtime/agent/stream"
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
}
case err := <-errs:
panic(err)
}
}
Profili di flusso
stream.StreamProfile descrive quali tipi di eventi vengono emessi per un pubblico.
Struttura di StreamProfile
type StreamProfile struct {
Assistant bool // assistant_reply
AssistantTurns bool // assistant_turn
Thoughts bool // planner_thought
PromptRendered bool // prompt_rendered
ToolStart bool // tool_start
ToolUpdate bool // tool_update
ToolEnd bool // tool_end
AwaitClarification bool // await_clarification
AwaitConfirmation bool // await_confirmation
AwaitQuestions bool // await_questions
AwaitExternalTools bool // await_external_tools
ToolAuthorization bool // tool_authorization
Usage bool // usage
Workflow bool // workflow
ChildRuns bool // child_run_linked (strumento padre → run figlia)
}
Profili integrati
Goa-AI fornisce profili integrati per casi d’uso comuni:
stream.DefaultProfile()emette tutti i tipi di eventi.stream.UserChatProfile()è adatto alle UI utente finali.stream.AgentDebugProfile()è adatto alle viste di debug/sviluppatore.stream.MetricsProfile()emette soloUsageeWorkflow.
Nel modello di streaming di proprietà della sessione, non sono necessarie sottoscrizioni separate per le run figlie. child_run_linked serve a costruire l’albero di run e ad associare gli eventi alla scheda corretta consumando un unico stream session/<session_id>.
Cablaggio dei profili ai sottoscrittori
Applicare i profili quando si creano gli abbonati al flusso:
import "goa.design/goa-ai/runtime/agent/stream"
// Create a subscriber with the user chat profile
chatSub, err := stream.NewSubscriberWithProfile(chatSink, stream.UserChatProfile())
if err != nil {
return err
}
// Create a subscriber with the debug profile
debugSub, err := stream.NewSubscriberWithProfile(debugSink, stream.AgentDebugProfile())
if err != nil {
return err
}
// Create a subscriber with the metrics profile
metricsSub, err := stream.NewSubscriberWithProfile(metricsSink, stream.MetricsProfile())
if err != nil {
return err
}
Creazione di profili personalizzati
Per esigenze specifiche, è possibile creare profili personalizzati impostando singoli campi:
// Custom profile: tools and workflow only, no thoughts or assistant replies
toolsOnlyProfile := stream.StreamProfile{
ToolStart: true,
ToolUpdate: true,
ToolEnd: true,
Workflow: true,
ChildRuns: true,
}
// Custom profile: everything except usage (for privacy-sensitive contexts)
noUsageProfile := stream.DefaultProfile()
noUsageProfile.Usage = false
sub, err := stream.NewSubscriberWithProfile(sink, toolsOnlyProfile)
Linee guida per la selezione dei profili
| Pubblico | Profilo raccomandato | Motivazione |
|---|---|---|
| UI di chat per l’utente finale | UserChatProfile() | Struttura pulita con schede agente espandibili |
| Console di amministrazione/debug | AgentDebugProfile() | Visibilità completa di strumenti, attese e fasi |
| Metriche/fatturazione | MetricsProfile() | Eventi minimi per l’aggregazione |
| Registrazione di audit | DefaultProfile() | Registrazione completa con campi di correlazione per run |
| Cruscotti in tempo reale | Personalizzati (flusso di lavoro + utilizzo) | Solo monitoraggio dello stato e dei costi |
Le applicazioni scelgono il profilo quando cablano i sink e i bridge (ad esempio, Pulse, SSE, WebSocket) in modo che:
- Le interfacce utente della chat rimangono pulite e strutturate (schede annidate guidate da
child_run_linked) - Le console di debug possono vedere il dettaglio completo nello stesso stream di sessione
- Le pipeline di metriche vedono solo quanto basta per aggregare l’utilizzo e gli stati
Progettazione di interfacce utente con gli alberi di esecuzione
Dato il modello run tree + streaming, una tipica interfaccia utente di chat può:
- Sottoscrivere lo stream di sessione (
session/<session_id>) con un profilo chat utente. - Tracciare la run attiva (
active_run_id) e renderizzare:- Risposte dell’assistente (
assistant_reply) - Ciclo di vita degli strumenti (
tool_start/tool_update/tool_end) - Link a run figlie (
child_run_linked) come schede agente annidate perchild_run_id
- Risposte dell’assistente (
- Per ogni scheda, renderizzare la timeline della run figlia filtrando lo stesso stream di sessione per
run_id == child_run_id(nessuna sottoscrizione aggiuntiva). - Chiudere SSE/WebSocket quando si osserva
run_stream_endperactive_run_id.
L’idea chiave: la topologia di esecuzione (albero) è preservata tramite ID ed eventi di link, e lo streaming è un unico log ordinato per sessione che proietti in corsie/schede filtrando per run_id.
Prossimi passi
- Integrazione MCP - Connessione a server di strumenti esterni
- Memoria e sessioni - Gestire lo stato con le trascrizioni e gli archivi di memoria
- Produzione - Distribuire con UI temporale e streaming