Merge branch 'main' into bolt-perf-discord-string-builder-16389558094904414313

This commit is contained in:
hobbyistlabs-coder 2026-03-13 16:30:30 -04:00 committed by GitHub
commit a8efcf5666
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 167 additions and 159 deletions

View file

@ -1,139 +0,0 @@
--- pkg/agent/loop.go
+++ pkg/agent/loop.go
@@ -910,121 +910,13 @@
"tools_json": formatToolsForLog(providerToolDefs),
})
- // Call LLM with fallback chain if multiple candidates are configured.
- var response *providers.LLMResponse
- var err error
-
- llmOpts := map[string]any{
- "max_tokens": agent.MaxTokens,
- "temperature": agent.Temperature,
- "prompt_cache_key": agent.ID,
- }
- // parseThinkingLevel guarantees ThinkingOff for empty/unknown values,
- // so checking != ThinkingOff is sufficient.
- if agent.ThinkingLevel != ThinkingOff {
- if tc, ok := agent.Provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() {
- llmOpts["thinking_level"] = string(agent.ThinkingLevel)
- } else {
- logger.WarnCF("agent", "thinking_level is set but current provider does not support it, ignoring",
- map[string]any{"agent_id": agent.ID, "thinking_level": string(agent.ThinkingLevel)})
- }
- }
-
- callLLM := func() (*providers.LLMResponse, error) {
- if len(activeCandidates) > 1 && al.fallback != nil {
- fbResult, fbErr := al.fallback.Execute(
- ctx,
- activeCandidates,
- func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
- return agent.Provider.Chat(ctx, messages, providerToolDefs, model, llmOpts)
- },
- )
- if fbErr != nil {
- return nil, fbErr
- }
- if fbResult.Provider != "" && len(fbResult.Attempts) > 0 {
- logger.InfoCF(
- "agent",
- fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts",
- fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1),
- map[string]any{"agent_id": agent.ID, "iteration": iteration},
- )
- }
- return fbResult.Response, nil
- }
- return agent.Provider.Chat(ctx, messages, providerToolDefs, activeModel, llmOpts)
- }
-
- // Retry loop for context/token errors
- maxRetries := 2
- for retry := 0; retry <= maxRetries; retry++ {
- response, err = callLLM()
- if err == nil {
- break
- }
-
- errMsg := strings.ToLower(err.Error())
-
- // Check if this is a network/HTTP timeout — not a context window error.
- isTimeoutError := errors.Is(err, context.DeadlineExceeded) ||
- strings.Contains(errMsg, "deadline exceeded") ||
- strings.Contains(errMsg, "client.timeout") ||
- strings.Contains(errMsg, "timed out") ||
- strings.Contains(errMsg, "timeout exceeded")
-
- // Detect real context window / token limit errors, excluding network timeouts.
- isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") ||
- strings.Contains(errMsg, "context window") ||
- strings.Contains(errMsg, "maximum context length") ||
- strings.Contains(errMsg, "token limit") ||
- strings.Contains(errMsg, "too many tokens") ||
- strings.Contains(errMsg, "max_tokens") ||
- strings.Contains(errMsg, "invalidparameter") ||
- strings.Contains(errMsg, "prompt is too long") ||
- strings.Contains(errMsg, "request too large"))
-
- if isTimeoutError && retry < maxRetries {
- backoff := time.Duration(retry+1) * 5 * time.Second
- logger.WarnCF("agent", "Timeout error, retrying after backoff", map[string]any{
- "error": err.Error(),
- "retry": retry,
- "backoff": backoff.String(),
- })
- time.Sleep(backoff)
- continue
- }
-
- if isContextError && retry < maxRetries {
- logger.WarnCF(
- "agent",
- "Context window error detected, attempting compression",
- map[string]any{
- "error": err.Error(),
- "retry": retry,
- },
- )
-
- if retry == 0 && !constants.IsInternalChannel(opts.Channel) {
- al.bus.PublishOutbound(ctx, bus.OutboundMessage{
- Channel: opts.Channel,
- ChatID: opts.ChatID,
- Content: "Context window exceeded. Compressing history and retrying...",
- })
- }
-
- al.forceCompression(agent, opts.SessionKey)
- newHistory := agent.Sessions.GetHistory(opts.SessionKey)
- newSummary := agent.Sessions.GetSummary(opts.SessionKey)
- messages = agent.ContextBuilder.BuildMessages(
- newHistory, newSummary, "",
- nil, opts.Channel, opts.ChatID,
- )
- continue
- }
- break
- }
-
- if err != nil {
- logger.ErrorCF("agent", "LLM call failed",
- map[string]any{
- "agent_id": agent.ID,
- "iteration": iteration,
- "error": err.Error(),
- })
- return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err)
- }
+ response, err := al.executeLLMWithRetry(
+ ctx, agent, opts, &messages,
+ providerToolDefs, activeCandidates,
+ activeModel, iteration,
+ )
+ if err != nil {
+ return "", iteration, err
+ }
go al.handleReasoning(

View file

@ -1,18 +0,0 @@
--- AGENT_LOOP_IMPROVEMENTS.md
+++ AGENT_LOOP_IMPROVEMENTS.md
@@ -4,14 +4,14 @@
## Phase 1: Architecture & Maintainability (Refactoring)
-- [ ] **Extract LLM Call & Retry Logic:** Refactor `runLLMIteration` by moving the LLM calling, fallback chain, and context window retry logic into a dedicated method (e.g., `executeLLMWithRetry`).
-- [ ] **Extract Tool Execution Logic:** Refactor `runLLMIteration` by moving the parallel tool execution (`sync.WaitGroup`), channel routing, and async callbacks into a dedicated method (e.g., `executeToolBatch`).
-- [ ] **State Machine / Explicit Flow:** Clean up the main loop logic to reduce nested `if/for` blocks and make the transition between generating, executing tools, and compressing context more explicit.
+- [x] **Extract LLM Call & Retry Logic:** Refactor `runLLMIteration` by moving the LLM calling, fallback chain, and context window retry logic into a dedicated method (e.g., `executeLLMWithRetry`).
+- [x] **Extract Tool Execution Logic:** Refactor `runLLMIteration` by moving the parallel tool execution (`sync.WaitGroup`), channel routing, and async callbacks into a dedicated method (e.g., `executeToolBatch`).
+- [x] **State Machine / Explicit Flow:** Clean up the main loop logic to reduce nested `if/for` blocks and make the transition between generating, executing tools, and compressing context more explicit.
## Phase 2: Reliability & Error Handling
-- [ ] **Graceful Recovery on Tool Panic:** Add `defer recover()` inside the parallel tool execution goroutines to prevent a panicked tool from crashing the entire `AgentLoop`. Return the panic as an error string to the LLM.
-- [ ] **Exponential Backoff:** Replace the linear backoff in LLM retries (`time.Duration(retry+1) * 5 * time.Second`) with exponential backoff and jitter to better handle rate limits.
+- [x] **Graceful Recovery on Tool Panic:** Add `defer recover()` inside the parallel tool execution goroutines to prevent a panicked tool from crashing the entire `AgentLoop`. Return the panic as an error string to the LLM.
+- [x] **Exponential Backoff:** Replace the linear backoff in LLM retries (`time.Duration(retry+1) * 5 * time.Second`) with exponential backoff and jitter to better handle rate limits.
- [ ] **Granular Error Classification:** Update `LLMProvider` interfaces to return structured, typed errors (e.g., `providers.ErrContextLengthExceeded`) instead of relying on fragile string matching.

153
pkg/agent/loop_medical.go Normal file
View file

@ -0,0 +1,153 @@
package agent
import (
"context"
"fmt"
"path/filepath"
"regexp"
"strings"
"jane/pkg/bus"
"jane/pkg/logger"
)
// The medical CoT state machine phases
const (
PhaseExtraction = "extraction"
PhaseTemporalCorrelation = "temporal_correlation"
PhaseTheoryGeneration = "theory_generation"
PhaseVerification = "verification"
PhaseSafetyDisclaimers = "safety_disclaimers"
)
// processMedicalRequest implements the medical persona CoT loop
func (al *AgentLoop) processMedicalRequest(
ctx context.Context,
agent *AgentInstance,
opts processOptions,
) (string, error) {
logger.InfoCF("medical", "Starting clinical CoT loop", map[string]any{
"session_key": opts.SessionKey,
})
// Detect target patient from message
targetPatient := detectTargetPatient(opts.UserMessage)
if targetPatient == "" {
// If no specific patient is explicitly asked for, we ask the user for clarification.
msg := "Please specify the patient you are analyzing (e.g., 'Analyze patient John Doe')."
if opts.SendResponse {
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: opts.Channel,
ChatID: opts.ChatID,
Content: msg,
})
}
return msg, nil
}
// Ensure the session history is isolated to this specific patient and not globally
// shared across patients if the clinician uses the same chat session.
patientSessionKey := opts.SessionKey + ":patient:" + targetPatient
// Lock the workspace path to the patient directory
patientWorkspace := filepath.Join(agent.Workspace, targetPatient)
// We must not modify the shared agent instance directly to avoid data races.
// Instead, we clone the agent instance for this specific request.
clonedAgent := *agent
clonedAgent.Workspace = patientWorkspace
var finalResponse strings.Builder
finalResponse.WriteString(fmt.Sprintf("Clinician Agent initialized for patient: %s\n\n", targetPatient))
// CoT State Machine variables
var currentContext string = opts.UserMessage
phases := []string{
PhaseExtraction,
PhaseTemporalCorrelation,
PhaseTheoryGeneration,
PhaseVerification,
PhaseSafetyDisclaimers,
}
for _, phase := range phases {
logger.DebugCF("medical", "Executing phase", map[string]any{"phase": phase})
prompt := buildPhasePrompt(phase, currentContext)
// Create a temporary opts for this phase
phaseOpts := opts
phaseOpts.SessionKey = patientSessionKey
phaseOpts.UserMessage = prompt
phaseOpts.SendResponse = false // don't send intermediate steps to bus
phaseOpts.EnableSummary = false
// Run a standard single LLM execution for this phase
phaseResult, err := al.runAgentLoop(ctx, &clonedAgent, phaseOpts)
if err != nil {
logger.ErrorCF("medical", "Phase failed", map[string]any{
"phase": phase,
"error": err.Error(),
})
return "", err
}
// Accumulate result
finalResponse.WriteString(fmt.Sprintf("### [%s]\n%s\n\n", phase, phaseResult))
// Pass result as context to next phase
currentContext = currentContext + "\n\n" + fmt.Sprintf("Result of %s:\n%s", phase, phaseResult)
// Mandatory safety check abort
if phase == PhaseSafetyDisclaimers {
if strings.Contains(strings.ToLower(phaseResult), "life-threatening") ||
strings.Contains(strings.ToLower(phaseResult), "red flag") {
finalResponse.WriteString("\n⚠ **RED FLAG DETECTED: This requires immediate emergency medical attention.**\n")
}
}
}
// Send to bus if requested
if opts.SendResponse {
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: opts.Channel,
ChatID: opts.ChatID,
Content: finalResponse.String(),
})
}
return finalResponse.String(), nil
}
func buildPhasePrompt(phase string, context string) string {
basePrompt := "You are The Clinician, an expert medical reasoning agent.\nContext:\n%s\n\nTask:\n"
switch phase {
case PhaseExtraction:
return fmt.Sprintf(basePrompt, context) + "Extract all clinical terminology, symptoms, and vital signs from the context."
case PhaseTemporalCorrelation:
return fmt.Sprintf(basePrompt, context) + "Analyze the temporal correlation of symptoms. Scan for recurring patterns in the patient's history."
case PhaseTheoryGeneration:
return fmt.Sprintf(basePrompt, context) + "Generate a ranked list of potential causes (DDx) and pathophysiological theories based on the extracted symptoms and patterns."
case PhaseVerification:
return fmt.Sprintf(basePrompt, context) + "Verify the proposed theories and any implicit remedies against the patient's Allergies and Medications profile."
case PhaseSafetyDisclaimers:
return fmt.Sprintf(basePrompt, context) + "Provide mandatory clinical disclaimers. Explicitly state whether any 'Life-Threatening' or 'Red Flag' symptoms were detected."
default:
return context
}
}
func detectTargetPatient(message string) string {
// Simple regex to extract patient name (e.g., "patient John Doe" or "patient: John Doe")
re := regexp.MustCompile(`(?i)patient\s*:?\s*([a-zA-Z0-9_]+(?:\s+[a-zA-Z0-9_]+)*)`)
matches := re.FindStringSubmatch(message)
if len(matches) > 1 {
// Clean up the patient name to make it directory-safe
patientName := strings.TrimSpace(matches[1])
patientName = strings.ReplaceAll(patientName, " ", "_")
return patientName
}
return ""
}

View file

@ -136,6 +136,11 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
SendResponse: false,
}
// Medical Persona specific routing interception
if agent != nil && agent.ID == "the-clinician" {
return al.processMedicalRequest(ctx, agent, opts)
}
// context-dependent commands check their own Runtime fields and report
// "unavailable" when the required capability is nil.
if response, handled := al.handleCommand(ctx, msg, agent, &opts); handled {

View file

@ -181,8 +181,8 @@ func TestConfig_BackwardCompat_NoAgentsList(t *testing.T) {
t.Fatalf("unmarshal: %v", err)
}
if len(cfg.Agents.List) != 0 {
t.Errorf("agents.list should be empty for backward compat, got %d", len(cfg.Agents.List))
if len(cfg.Agents.List) != 1 {
t.Errorf("agents.list should have default clinician agent for backward compat, got %d", len(cfg.Agents.List))
}
if len(cfg.Bindings) != 0 {
t.Errorf("bindings should be empty, got %d", len(cfg.Bindings))

View file

@ -36,6 +36,13 @@ func DefaultConfig() *Config {
SummarizeMessageThreshold: 20,
SummarizeTokenPercent: 75,
},
List: []AgentConfig{
{
ID: "the-clinician",
Name: "Medical Persona",
Workspace: filepath.Join(homePath, "Obsidian_Vault", "Patients"),
},
},
},
Bindings: []AgentBinding{},
Session: SessionConfig{