@@ -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.
573636func 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 {
17011781func (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