feat(chain): fail over on empty/degenerate responses
CI / Tidy (push) Successful in 9m26s
CI / Build & Test (push) Successful in 10m29s

A failover chain previously treated a successful-but-empty completion (no
content parts and no tool calls — a "stop with nothing") as a valid result
and returned it. The agent loop then ended the run with empty output, and
the configured backup models were never tried because no error was raised.
This let a single flaky model silently terminate an agent/skill run with
no answer (observed in the wild with ollama-cloud/glm-5.2 returning empty
completions right after a large tool/think turn).

- Add llm.ErrEmptyResponse (classified transient) and Response.IsEmpty():
  true only when there are no tool calls and no meaningful content (no
  parts, or whitespace-only text). A media/image part counts as content,
  so image-only responses are NOT empty.
- chain.Generate converts an empty completion into ErrEmptyResponse so the
  chain fails over to the next target. Unlike an ordinary transient it is
  NOT retried on the same target (the model just produced it; these calls
  are expensive) — the chain penalizes health (so a persistently-empty
  target benches) and advances immediately.
- When every target returns empty the call fails with ErrChainExhausted
  joined to ErrEmptyResponse — a visible error instead of a hollow success.
  Single-element chains therefore also surface empties as errors.

Stream path is unchanged (can't inspect content before the consumer reads
it). Tests: Response.IsEmpty table; chain fails over past an empty head;
all-empty chain returns ErrChainExhausted/ErrEmptyResponse; repeated
empties bench the target across requests. Full suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 10:35:07 -04:00
parent 3e81fbd540
commit 74474c6da0
6 changed files with 217 additions and 1 deletions
+26
View File
@@ -86,3 +86,29 @@ func (r *Response) Text() string {
func (r *Response) Message() Message {
return Message{Role: RoleAssistant, Parts: r.Parts, ToolCalls: r.ToolCalls}
}
// IsEmpty reports whether the response carries no usable output: no tool
// calls and no meaningful content (no parts at all, or only whitespace
// text). A media/image part — or any non-text part — counts as content, so
// an image-only response is NOT empty. A "stop with nothing" like this is
// never a valid completion for an agent step or a Generate call; failover
// chains treat it as a per-target failure (see ErrEmptyResponse).
func (r *Response) IsEmpty() bool {
if r == nil {
return true
}
if len(r.ToolCalls) > 0 {
return false
}
for _, p := range r.Parts {
if t, ok := p.(TextPart); ok {
if strings.TrimSpace(t.Text) != "" {
return false
}
continue
}
// Any non-text part (image/media) is meaningful output.
return false
}
return true
}