Test e risoluzione dei problemi
Questa guida illustra le strategie di test per gli agenti Goa-AI e le soluzioni ai problemi più comuni.
Test degli agenti
Test con il motore in memoria
Il motore in-memory è ideale per i test perché:
- Non richiede dipendenze esterne (no Temporal)
- Esegue in modo sincrono per un comportamento prevedibile nei test
- Fornisce un feedback rapido durante lo sviluppo
func TestChatAgent(t *testing.T) {
// Create runtime with in-memory engine (default)
store := storageinmem.New()
rt := runtime.New(store)
ctx := context.Background()
// Register agent with test planner
err := chat.RegisterChatAgent(ctx, rt, chat.ChatAgentConfig{
Planner: &TestPlanner{},
})
require.NoError(t, err)
_, err = store.CreateSession(ctx, "test-session", time.Now().UTC())
require.NoError(t, err)
// Run agent
client := chat.NewClient(rt)
out, err := client.Run(
ctx,
"test-session",
[]*model.Message{{
Role: model.ConversationRoleUser,
Parts: []model.Part{model.TextPart{Text: "Hello"}},
}},
)
require.NoError(t, err)
// Assert on output
assert.NotEmpty(t, out.RunID)
assert.NotNil(t, out.Final)
}
Test dei planner con provider fittizi
model.Client appartiene al framework e i test double dell’applicazione non
possono implementarlo. Implementare model.Provider, quindi costruire lo
stesso client validato usato in produzione:
type FakeProvider struct {
response *model.Response
}
func (p *FakeProvider) Complete(context.Context, *model.Request) (*model.Response, error) {
return p.response, nil
}
func (p *FakeProvider) Stream(context.Context, *model.Request) (model.Streamer, error) {
return nil, model.ErrStreamingUnsupported
}
func TestValidatedModelResponse(t *testing.T) {
provider := &FakeProvider{response: &model.Response{
Content: []model.Message{{
Role: model.ConversationRoleAssistant,
Parts: []model.Part{model.TextPart{Text: "Hello."}},
}},
StopReason: "stop",
}}
client, err := model.NewClient(provider)
require.NoError(t, err)
resp, err := client.Complete(context.Background(), &model.Request{
Messages: []*model.Message{{
Role: model.ConversationRoleUser,
Parts: []model.Part{model.TextPart{Text: "Hello"}},
}},
})
require.NoError(t, err)
assert.Len(t, resp.Content, 1)
}
Nei test unitari del planner che non richiedono la validazione del modello, iniettare direttamente input deterministici e richieste tipizzate. Usare un provider fittizio quando il test deve provare validazione della richiesta, decodifica dei payload, limiti dell’output o terminazione dello stream.
I provider fittizi per lo streaming devono emettere una sequenza completa e
valida, poi restituire io.EOF; solo allora
ValidatedStream.Response() espone la risposta accettata. I test delle
completion generate devono verificare planner.OutputContractError e una
risposta nil quando l’output viola il codec generato.
Strumenti di test in isolamento
Testare gli esecutori degli strumenti indipendentemente dall’agente:
func TestSearchToolExecutor(t *testing.T) {
// Create executor with mock dependencies
mockSearchService := &MockSearchService{
results: []string{"doc1", "doc2", "doc3"},
}
executor := &SearchExecutor{searchService: mockSearchService}
// Create test tool call
meta := &runtime.ToolCallMeta{
RunID: "test-run",
SessionID: "test-session",
TurnID: "test-turn",
ToolCallID: "call-1",
}
request, err := planner.NewToolRequest(specs.SearchTool(), &specs.SearchPayload{
Query: "test",
Limit: 5,
})
require.NoError(t, err)
// Executors run after validation and execution-ID assignment. Build the
// runtime call from the valid bytes produced by the generated descriptor.
call := &runtime.ToolCall{
Name: request.Name,
Payload: request.Payload,
RunID: meta.RunID,
SessionID: meta.SessionID,
TurnID: meta.TurnID,
ToolCallID: meta.ToolCallID,
}
// Execute tool
result, err := executor.Execute(context.Background(), meta, call)
require.NoError(t, err)
require.NotNil(t, result.ToolResult)
// Assert on result
assert.Nil(t, result.ToolResult.Failure)
assert.NotNil(t, result.ToolResult.Result)
// Unmarshal and verify typed result
searchResult, ok := result.ToolResult.Result.(*specs.SearchResult)
require.True(t, ok)
assert.Len(t, searchResult.Documents, 3)
}
Test della validazione e del recupero degli strumenti
Testare il JSON esterno malformato al confine del codec generato. Le chiamate del modello non valide vengono rifiutate prima di raggiungere planner o executor:
func TestSearchPayloadRequiresQuery(t *testing.T) {
_, err := specs.SearchTool().Payload.FromJSON(
rawjson.Message(`{"limit":5}`),
)
require.Error(t, err)
var validationErr *tools.ValidationError
require.ErrorAs(t, err, &validationErr)
assert.Equal(t, "query", validationErr.Issues()[0].Field)
}
I test diretti degli executor devono creare un planner.ToolRequest valido
con il descrittore tipizzato generato, quindi costruire un runtime.ToolCall
usando nome e payload canonico e assegnare gli ID di esecuzione che il runtime
aggiungerebbe. Verificare gli errori di dominio o del provider tramite
ToolResult.Failure.Kind, Failure.Error e Failure.Recovery.
Verifica della composizione dell’agente
Testare gli scenari dell’agente come strumento:
func TestAgentComposition(t *testing.T) {
store := storageinmem.New()
rt := runtime.New(store)
ctx := context.Background()
// Register provider agent
err := planner.RegisterPlannerAgent(ctx, rt, planner.PlannerAgentConfig{
Planner: &PlanningPlanner{},
})
require.NoError(t, err)
// Register consumer agent that uses provider's tools
err = orchestrator.RegisterOrchestratorAgent(ctx, rt, orchestrator.OrchestratorAgentConfig{
Planner: &OrchestratorPlanner{},
})
require.NoError(t, err)
_, err = store.CreateSession(ctx, "test-session", time.Now().UTC())
require.NoError(t, err)
// Run orchestrator - it should invoke planner agent as a tool
client := orchestrator.NewClient(rt)
out, err := client.Run(
ctx,
"test-session",
[]*model.Message{{
Role: model.ConversationRoleUser,
Parts: []model.Part{model.TextPart{Text: "Create a plan for X"}},
}},
)
require.NoError(t, err)
// Verify child run was created
assert.Greater(t, out.ChildrenCount, 0)
}
Test dell’archiviazione del runtime
Usa runtime/agent/storage/inmem per i test di pianificatori e workflow. Verifica un’implementazione di produzione duratura rispetto allo stesso contratto, inclusi questi casi:
- gli avvii radice, figlio e one-shot senza sessione salvano insieme i metadati e i primi record;
- le nuove chiamate a
StartChildRuneStartOneShotChildRunrichiedono un padre attivo e salvano il collegamento al padre nella stessa operazione dell’avvio del figlio; - un retry identico di uno di questi avvii già accettati resta valido dopo l’arresto del padre, mentre un retry modificato o un nuovo figlio vengono rifiutati;
- un nuovo tentativo identico restituisce l’identificatore del record originale e segnala che non è stato inserito un nuovo record;
- ripetere un cambiamento del ciclo di vita con un record diverso produce un conflitto, anche quando stato e altri campi non cambiano;
- cambiare un valore fissato dalla prima scrittura restituisce un conflitto;
- una chiamata esplicita a
CancelRunaccettata da un workflow attivo salva insieme il primo motivo e il recordstorage.CancellationRecordTypecorrispondente; il suo tipo serializzato èruntime.cancellation_intent. Un retry identico riesce e un motivo successivo diverso produce un conflitto; - l’avvio di un’esecuzione con una sessione già terminata salva
session_endedcon il record terminale annullato e senza alcun recordstorage.CancellationRecordType; - un annullamento avviato dal motore lascia vuoto il motivo salvato e non ha un
record
storage.CancellationRecordType, mentre il record terminale contieneengine_canceled; - la sospensione salva insieme checkpoint, stato sospeso e record corrispondente;
- il completamento salva insieme stato finale e record corrispondente;
- l’avvio di una continuazione richiede un’esecuzione precedente sospesa che esista e abbia la stessa sessione, lo stesso agente e la stessa esecuzione padre;
- una continuazione non corrispondente non scrive né l’avvio del successore né
un collegamento al padre, mentre un successore accettato conserva
PredecessorRunIDinRunStarted, non inRunMeta; - una sessione terminata impedisce il lavoro di pianificatore e strumenti, ma registra come annullato un workflow già accettato;
- l’eliminazione fallisce mentre è attiva un’esecuzione e, al termine di tutte le esecuzioni, rimuove metadati, checkpoint e record della sessione terminata.
Questi test dello storage devono eseguire le transazioni reali del database. Un mock che controlla solo le chiamate ai metodi non può dimostrare che stato e record diventino visibili insieme.
Verifica separatamente l’adattatore Temporal: la chiusura di un workflow padre deve terminare il workflow figlio.
Verifica separatamente i comandi espliciti del runtime per consegnare il completamento:
EnsureRunCompletionsalva il risultato mancante di un’esecuzione attiva e convalida e riconsegna un risultato già salvato senza cambiarlo;- il collegamento di un figlio viene consegnato prima del suo evento finale,
mentre
EnsureChildRunLinkconsegna soltanto l’esatto collegamento salvato; - una sessione attiva senza
Runtime.WithStreamnon riesce, mentre una sessione appena rilevata come terminata conserva il risultato salvato e ne sopprime la consegna; LoadSessionStatusrestituisce lo stato corrente della sessione, mentreEnsureRunCompletionusa ilSessionStatusrestituito insieme alla scrittura del record finale o al suo tentativo identico e conserva quello stato durante i tentativi di consegna allo stream;- un evento accettato mentre la sessione è attiva resta da consegnare se la sessione termina durante quella chiamata di consegna;
- un workflow ancora attivo nel motore restituisce
ErrRunCompletionNotReady, mentre dati malformati o contraddittori del motore o dello storage restituisconoErrRunCompletionCorrupt.
Verifica separatamente il codec degli hook: i decoder di RunStarted,
RunSuspended, RunCompleted e ChildRunLinked devono rifiutare null, i
campi sconosciuti e un secondo valore JSON finale.
I test delle continuazioni devono accettare goa-ai.run-suspension.v7 e
rifiutare tutte le versioni precedenti prima di ripristinare i payload o
chiamare il planner.
Risoluzione dei problemi
Errori comuni
Errore “registrazione chiusa
Sintomo:
error: registration closed: cannot register agent after runtime start
Causa: Tentativo di registrare un agente dopo che il runtime ha iniziato l’elaborazione delle esecuzioni.
Soluzione: Registrare tutti gli agenti prima di avviare qualsiasi esecuzione:
store := storageinmem.New()
rt := runtime.New(store)
// ✓ Register all agents first
chat.RegisterChatAgent(ctx, rt, chatConfig)
planner.RegisterPlannerAgent(ctx, rt, plannerConfig)
// ✓ Then create a session and start runs
client := chat.NewClient(rt)
if _, err := store.CreateSession(ctx, "session-123", time.Now().UTC()); err != nil {
panic(err)
}
out, err := client.Run(ctx, "session-123", messages, opts...)
Errore “ID sessione mancante
Sintomo:
error: missing session ID: session ID is required for run
Causa: Avvio di un’esecuzione senza fornire un ID di sessione.
Soluzione: Fornire sempre un ID di sessione come argomento posizionale richiesto:
// ✗ Wrong - no session ID
out, err := client.Run(ctx, "", messages)
// ✓ Correct - session ID provided
if _, err := store.CreateSession(ctx, "session-123", time.Now().UTC()); err != nil {
panic(err)
}
out, err := client.Run(ctx, "session-123", messages)
Suggerimento: Per i test, utilizzare un ID di sessione fisso. Per la produzione, generare ID di sessione unici per ogni conversazione.
Errori di violazione dei criteri
Sintomo:
error: policy violation: max tool calls exceeded (10/10)
Cause: L’agente ha superato il limite configurato MaxToolCalls per gli strumenti con budget. Gli strumenti dichiarati Bookkeeping() non consumano questo cap.
Soluzioni:
- Aumentare il limite se il caso d’uso richiede legittimamente più chiamate allo strumento:
RunPolicy(func() {
DefaultCaps(MaxToolCalls(20)) // Increase from default
})
Migliorare l’efficienza del pianificatore per utilizzare meno chiamate agli strumenti:
- Operazioni in batch, se possibile
- Utilizzare chiamate di utensili più specifiche
- Migliorare la tempestività della progettazione
Controllare i loop infiniti nella logica del pianificatore che richiama ripetutamente lo stesso strumento.
Esentare i record di controllo strutturati dai budget di retrieval e di errori dichiarandoli
Bookkeeping()nel DSL. Marker di stato e dichiarazioni di transizione appartengono a questa categoria; i risultati di lookup il cui successo deve pianificare altro ragionamento no. Un batch misto prodotto dal modello resta atomico ed è rifiutato interamente se le chiamate con budget non rientrano. Per un commit terminale usa soloTerminalRun(): gli strumenti terminali diventano automaticamente bookkeeping e possono essere ammessi dopo l’esaurimento del budget.
Sintomo:
error: bookkeeping-only tool batch requires a terminal tool or terminal planner payload
Causa: Il planner ha emesso solo strumenti di bookkeeping. Chiamate e risultati restano nella trascrizione del provider, ma i risultati riusciti non attivano un altro PlanResume e non entrano nei futuri ToolOutputs tipizzati. Lo stesso turno deve quindi risolversi in modo terminale oppure attendere input.
Soluzioni:
- Concludi nello stesso turno con
TerminalRun(),FinalResponseoFinalToolResultquando il batch bookkeeping è già terminale. - Metti esplicitamente in pausa con una handshake di attesa/pausa se il run sta aspettando input umano o esterno.
- Sposta lo stato del turno successivo in un input esplicito del planner invece di dipendere da un risultato bookkeeping riuscito per riprendere la pianificazione.
Sintomo:
error: policy violation: max consecutive failed tool calls exceeded (3/3)
Causa: Più chiamate consecutive allo strumento non sono andate a buon fine.
Soluzioni:
- Correggere gli errori dello strumento sottostante - controllare i log dell’esecutore dello strumento
- Correggere il contratto strutturato dell’errore affinché
Failure.Recoveryfornisca al planner l’azione corretta e prove esatte per la correzione - Aumentare il limite se si prevedono fallimenti transitori:
RunPolicy(func() {
DefaultCaps(MaxRecoveryTurns(5))
})
Sintomo:
error: policy violation: time budget exceeded (2m0s)
Causa: L’esecuzione dell’agente ha superato il valore configurato TimeBudget.
Soluzioni:
- Aumentare il budget per le operazioni di lunga durata:
RunPolicy(func() {
TimeBudget("10m")
})
- Usare
Timingper un controllo a grana fine:
RunPolicy(func() {
Timing(func() {
Budget("10m") // Overall budget
Plan("1m") // Per-plan timeout
Tools("2m") // Per-tool timeout
})
})
- Ottimizzare l’esecuzione degli strumenti per completarli più velocemente.
Errore “strumento sconosciuto
Sintomo:
error: unknown tool: orchestrator.helpers.search
Causa: Il pianificatore ha richiesto uno strumento non registrato.
Soluzioni:
- Verificare la registrazione del set di strumenti - assicurarsi che il set di strumenti sia registrato presso l’agente:
Agent("chat", "Chat agent", func() {
Use(HelpersToolset) // Make sure this is included
})
Controllo dell’ortografia dei nomi degli strumenti - i nomi degli strumenti sono sensibili alle maiuscole e minuscole e utilizzano nomi qualificati.
Regenerare il codice dopo le modifiche al DSL:
goa gen example.com/project/design
Errore “carico utile non valido
Sintomo:
error: invalid payload: json: cannot unmarshal string into Go struct field SearchPayload.limit of type int
Causa: L’LLM ha fornito un payload che non corrisponde allo schema dello strumento.
Soluzioni:
- Testare il codec generato affinché il confine riporti problemi precisi dei campi:
_, err := specs.SearchTool().Payload.FromJSON(
rawjson.Message(`{"query":"example","limit":"ten"}`),
)
var validationErr *tools.ValidationError
require.ErrorAs(t, err, &validationErr)
assert.Equal(t, "invalid_field_type", validationErr.Issues()[0].Constraint)
Quando un provider emette questo payload, il client del modello validato
restituisce model.OutputValidationError. Il planner/runtime lo espone come
planner.OutputContractError prima che venga eseguito il codice
dell’executor o del servizio. Usare errors.As per verificare l’errore
strutturato al confine del test; non viene registrato alcun ToolFailure.
Testare RecoveryCorrectCall separatamente con una chiamata prodotta dal
modello che supera la validazione dello schema e il cui executor o confine di
dominio restituisce un ToolFailure recuperabile.
Migliorare le descrizioni degli strumenti per chiarire i tipi previsti.
Aggiungere esempi al DSL:
Args(func() {
Attribute("limit", Int, "Maximum results", func() {
Example(10)
Minimum(1)
Maximum(100)
})
})
Suggerimenti per il debug
Abilitare la registrazione del debug
import "goa.design/goa-ai/runtime/agent/runtime"
rt := runtime.New(
storageinmem.New(),
runtime.WithLogger(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))),
)
Sottoscrivere gli eventi per il debug
type DebugSink struct{}
func (s *DebugSink) Send(ctx context.Context, event stream.Event) error {
fmt.Printf("[%s] %s run=%s session=%s payload=%v\n",
time.Now().Format(time.RFC3339),
event.Type(),
event.RunID(),
event.SessionID(),
event.Payload(),
)
return nil
}
func (s *DebugSink) Close(ctx context.Context) error { return nil }
// Wire the sink into the runtime to observe all stream events.
rt := runtime.New(storageinmem.New(), runtime.WithStream(&DebugSink{}))
Ispezionare le specifiche dello strumento in fase di esecuzione
// List all registered tools
for _, spec := range rt.ToolSpecsForAgent(chat.AgentID) {
fmt.Printf("Tool: %s\n", spec.Name)
fmt.Printf(" Description: %s\n", spec.Description)
fmt.Printf(" Payload Schema: %s\n", spec.Payload.Schema)
}
Passi successivi
- Riferimento DSL - Riferimenti completi alle funzioni DSL
- Runtime - Comprendere l’architettura di runtime
- Produzione - Distribuzione con UI temporale e streaming