Skip to content

Commit ddabeb2

Browse files
committed
feat: implement LLM robustness features including text-to-tool-call recovery, empty response nudges, output truncation, and API timeouts.
1 parent 65838bf commit ddabeb2

12 files changed

Lines changed: 707 additions & 8 deletions

File tree

adk/connection/local_connection.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,20 @@ func NewLocalConnection(ctx context.Context, cfg LocalConfig, logger *slog.Logge
230230
return nil, fmt.Errorf("unexpected message type received during initialization: %T", initResp.Payload)
231231
}
232232

233-
// 10. Launch background read loop
233+
// 10. Configure WebSocket keepalive.
234+
// The server sends periodic pings; gorilla/websocket auto-responds with pongs.
235+
// Set a read deadline that resets on each ping to detect server death.
236+
// 45s pong timeout matches the server's wsPongTimeout constant.
237+
const wsPongTimeout = 45 * time.Second
238+
wsConn.SetReadDeadline(time.Now().Add(wsPongTimeout))
239+
wsConn.SetPingHandler(func(appData string) error {
240+
// Reset read deadline on each ping from server
241+
wsConn.SetReadDeadline(time.Now().Add(wsPongTimeout))
242+
// Send pong response (gorilla requires explicit handler to reset deadline)
243+
return wsConn.WriteControl(websocket.PongMessage, []byte(appData), time.Now().Add(5*time.Second))
244+
})
245+
246+
// 11. Launch background read loop
234247
go c.readLoop(ctx)
235248

236249
return c, nil
@@ -452,11 +465,21 @@ func (c *LocalConnection) handleStepUpdate(su *pb.StepUpdate) {
452465
}
453466

454467
func (c *LocalConnection) handleTrajectoryState(ts *pb.TrajectoryState) {
468+
c.logger.Debug("received trajectory state",
469+
"state", ts.State.String(),
470+
"parent", ts.ParentTrajectoryId,
471+
"trajectory_id", ts.TrajectoryId,
472+
)
455473
// Root trajectory state change to idle/done/error signals the end of the Chat turn.
456474
if ts.ParentTrajectoryId == "" {
457475
if ts.State == pb.TrajectoryState_TRAJ_IDLE ||
458476
ts.State == pb.TrajectoryState_TRAJ_COMPLETED ||
459477
ts.State == pb.TrajectoryState_TRAJ_ERROR {
478+
if ts.State == pb.TrajectoryState_TRAJ_ERROR {
479+
c.logger.Warn("trajectory ended with ERROR state",
480+
"trajectory_id", ts.TrajectoryId,
481+
)
482+
}
460483
c.closeStepsCh()
461484
}
462485
}

agents/zenith/config.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,10 +159,11 @@ func (z *ZenithConfig) ApplyTo(cfg *adk.LocalAgentConfig) error {
159159
cfProvider = "workers-ai" // default to Workers AI
160160
}
161161
cfg.CloudflareProvider = cfProvider
162+
cfg.CloudflareCacheTTL = 3600 // Cache responses for 1 hour
162163
if z.Model != "" {
163164
cfg.CloudflareModel = z.Model
164165
} else {
165-
cfg.CloudflareModel = "@cf/zai-org/glm-4.7-flash" // sensible default
166+
cfg.CloudflareModel = "@cf/google/gemma-4-26b-a4b-it" // best free model for agentic work
166167
}
167168

168169
default:

internal/engine/engine.go

Lines changed: 124 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ type Engine struct {
8080
msgCtx MessageContextConfig // Per-message context enrichment config
8181
notifyCh <-chan tools.SystemMessage // System notifications (timers, task completions)
8282
running atomic.Int32 // 1 = engine is running a turn, 0 = idle
83+
preCompletionHook func() // Called before TRAJ_IDLE so session can save state
8384

8485
// Subagent support
8586
depth int // Nesting depth (0 = root)
@@ -337,6 +338,14 @@ func (e *Engine) SetSettingsChanges(changes []SettingsChange) {
337338
e.msgCtx.SettingsChanges = append(e.msgCtx.SettingsChanges, changes...)
338339
}
339340

341+
// SetPreCompletionHook registers a callback that runs after the engine's
342+
// final response but BEFORE TRAJ_IDLE is emitted over the WebSocket.
343+
// The session uses this to save conversation state before the SDK can
344+
// kill the harness process in response to TRAJ_IDLE.
345+
func (e *Engine) SetPreCompletionHook(fn func()) {
346+
e.preCompletionHook = fn
347+
}
348+
340349
// RunWithContext executes the agentic loop with optional per-message host context.
341350
// The host context (active file, cursor, etc.) is injected into the enriched message
342351
// sent to the LLM, while the raw userMessage is preserved for step updates.
@@ -398,6 +407,7 @@ drained:
398407
toolDecls := e.buildToolDeclarations()
399408

400409
// Agentic loop
410+
emptyRetries := 0
401411
for turn := 0; turn < e.maxTurns; turn++ {
402412
select {
403413
case <-ctx.Done():
@@ -478,11 +488,16 @@ drained:
478488
var err error
479489
start := time.Now()
480490

491+
// Per-call timeout prevents hanging when the LLM API stalls
492+
// (e.g., Cloudflare worker dies mid-stream without closing the connection).
493+
callCtx, callCancel := context.WithTimeout(ctx, llmCallTimeout)
494+
481495
if sp, ok := e.provider.(llm.StreamingProvider); ok {
482-
resp, err = e.streamGenerate(ctx, sp, req)
496+
resp, err = e.streamGenerate(callCtx, sp, req)
483497
} else {
484-
resp, err = e.provider.Generate(ctx, req)
498+
resp, err = e.provider.Generate(callCtx, req)
485499
}
500+
callCancel()
486501
latency := time.Since(start)
487502

488503
// Trace the response
@@ -529,6 +544,43 @@ drained:
529544
continue // Retry the turn
530545
}
531546

547+
// ── Text-to-tool-call recovery ──
548+
// Some models (e.g., Llama 3.3 via Workers AI) output tool calls as text
549+
// instead of structured tool_calls. Detect and recover so the agentic
550+
// loop continues instead of stalling.
551+
if len(resp.ToolCalls) == 0 && resp.Content != "" {
552+
recovered, remaining := tryExtractToolCallsFromText(resp.Content, e.knownToolNames(), e.logger)
553+
if len(recovered) > 0 {
554+
resp.ToolCalls = recovered
555+
resp.Content = remaining
556+
resp.FinishReason = "tool_calls"
557+
}
558+
}
559+
560+
// Empty model response recovery — model returned no text AND no tool calls
561+
// with a non-max_tokens finish reason. This happens with smaller models
562+
// (Workers AI, Ollama) that occasionally produce empty completions.
563+
if resp.Content == "" && len(resp.ToolCalls) == 0 && resp.FinishReason != "max_tokens" {
564+
emptyRetries++
565+
if emptyRetries <= 2 {
566+
e.logger.Warn("model returned empty response, retrying with nudge",
567+
"finish_reason", resp.FinishReason,
568+
"retry", emptyRetries,
569+
"turn", turn,
570+
)
571+
e.history = append(e.history, llm.Message{
572+
Role: "model",
573+
Content: "[Empty response]",
574+
})
575+
e.history = append(e.history, llm.Message{
576+
Role: "user",
577+
Content: "Your previous response was empty. Please continue with the task. If you need to use a tool, call it. If you're done, provide your final answer.",
578+
})
579+
continue
580+
}
581+
// After 2 retries, fall through to handleFinalResponse with empty text
582+
}
583+
532584
// If model returned text with no tool calls → final response
533585
if resp.FinishReason == "stop" || len(resp.ToolCalls) == 0 {
534586
return e.handleFinalResponse(resp)
@@ -567,10 +619,28 @@ drained:
567619
return fmt.Errorf("engine: exceeded max turns (%d)", e.maxTurns)
568620
}
569621

622+
// llmCallTimeout is the maximum time allowed for a single LLM API call
623+
// (including streaming). Prevents indefinite hangs when the API stalls.
624+
const llmCallTimeout = 120 * time.Second
625+
626+
// maxToolResultSize is the maximum size (in bytes) of a single tool result
627+
// added to the conversation history. Results exceeding this are truncated to
628+
// prevent overwhelming smaller LLMs (e.g., Workers AI free-tier models).
629+
// 32KB ≈ 8K tokens — enough for meaningful content while staying within
630+
// context budget limits for most models.
631+
const maxToolResultSize = 32_000
632+
570633
// toolResultMsg builds a tool result message, propagating the ThoughtSignature
571634
// from the ToolCall. Gemini 3.5+ requires thought_signature on functionResponse
572635
// parts to maintain chain integrity.
573636
func toolResultMsg(tc llm.ToolCall, content string, isError bool) llm.Message {
637+
// Truncate oversized tool results to prevent context blowup
638+
if len(content) > maxToolResultSize {
639+
content = content[:maxToolResultSize] + fmt.Sprintf(
640+
"\n\n... [output truncated, showing %d/%d bytes. Use more specific queries or line ranges to get targeted results.]",
641+
maxToolResultSize, len(content),
642+
)
643+
}
574644
return llm.Message{
575645
Role: "tool",
576646
ToolResult: &llm.ToolCallResult{
@@ -660,6 +730,13 @@ func (e *Engine) handleFinalResponse(resp *llm.GenerateResponse) error {
660730
Content: resp.Content,
661731
})
662732

733+
// Run pre-completion hook (e.g., session saves conversation state)
734+
// BEFORE emitting TRAJ_IDLE, because the SDK will kill the process
735+
// as soon as it receives TRAJ_IDLE.
736+
if e.preCompletionHook != nil {
737+
e.preCompletionHook()
738+
}
739+
663740
e.emitTrajectoryState(pb.TrajectoryState_TRAJ_IDLE)
664741
return nil
665742
}
@@ -1468,7 +1545,10 @@ func (e *Engine) extractToolResult(step *pb.StepUpdate) string {
14681545
"size_bytes": e.SizeBytes, "child_count": e.ChildCount,
14691546
})
14701547
}
1471-
result = map[string]interface{}{"entries": entries}
1548+
result = map[string]interface{}{
1549+
"entries": entries,
1550+
"total_entries": len(a.ListDir.Entries),
1551+
}
14721552
case *pb.StepUpdate_GrepSearch:
14731553
var matches []map[string]interface{}
14741554
for _, m := range a.GrepSearch.Matches {
@@ -1701,3 +1781,44 @@ func (e *Engine) buildToolDeclarations() []llm.FunctionDeclaration {
17011781
func (e *Engine) History() []llm.Message {
17021782
return e.history
17031783
}
1784+
1785+
// knownToolNames returns a set of all tool names the engine knows about.
1786+
// This includes built-in tools, engine-intercepted tools, host tools, and MCP tools.
1787+
// Used by text-to-tool-call recovery to validate extracted tool names.
1788+
func (e *Engine) knownToolNames() map[string]bool {
1789+
names := make(map[string]bool)
1790+
1791+
// Built-in + engine-intercepted tools from the registry
1792+
for _, s := range e.toolRegistry.Schemas() {
1793+
names[s.Name] = true
1794+
}
1795+
1796+
// Host-side tools
1797+
for name := range e.hostToolNames {
1798+
names[name] = true
1799+
}
1800+
1801+
// Host tool declarations (some may not be in hostToolNames)
1802+
for _, d := range e.hostToolDecls {
1803+
names[d.Name] = true
1804+
}
1805+
1806+
// MCP tools
1807+
if e.mcpMgr != nil {
1808+
for _, d := range e.mcpMgr.ToolDeclarations() {
1809+
names[d.Name] = true
1810+
}
1811+
}
1812+
1813+
// Engine-intercepted tools (not in registry)
1814+
for _, name := range []string{
1815+
"invoke_subagent", "define_subagent", "manage_subagents",
1816+
"send_message", "ask_question", "ask_permission", "list_permissions",
1817+
"knowledge_write", "knowledge_replace", "knowledge_delete",
1818+
"publish", "finish",
1819+
} {
1820+
names[name] = true
1821+
}
1822+
1823+
return names
1824+
}

0 commit comments

Comments
 (0)