diff --git a/patch_llm.patch b/patch_llm.patch deleted file mode 100644 index f7aea1a6b..000000000 --- a/patch_llm.patch +++ /dev/null @@ -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( diff --git a/patch_readme.patch b/patch_readme.patch deleted file mode 100644 index 5779565f4..000000000 --- a/patch_readme.patch +++ /dev/null @@ -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. diff --git a/pkg/agent/loop_medical.go b/pkg/agent/loop_medical.go new file mode 100644 index 000000000..befee126b --- /dev/null +++ b/pkg/agent/loop_medical.go @@ -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 "" +} diff --git a/pkg/agent/loop_process.go b/pkg/agent/loop_process.go index 9983d935a..fff717c91 100644 --- a/pkg/agent/loop_process.go +++ b/pkg/agent/loop_process.go @@ -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 { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 1c93028c7..fbe038a57 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -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)) diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 2a3e66043..a4b734769 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -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{