Merge pull request #13 from hobbyistlabs-coder/jules-epic-medical-persona-5232913973598266228
feat: JANE-003 integrate medical persona and obsidian path locking
This commit is contained in:
commit
eb7667266b
6 changed files with 167 additions and 159 deletions
139
patch_llm.patch
139
patch_llm.patch
|
|
@ -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(
|
|
||||||
|
|
@ -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
153
pkg/agent/loop_medical.go
Normal 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 ""
|
||||||
|
}
|
||||||
|
|
@ -136,6 +136,11 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
SendResponse: false,
|
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
|
// context-dependent commands check their own Runtime fields and report
|
||||||
// "unavailable" when the required capability is nil.
|
// "unavailable" when the required capability is nil.
|
||||||
if response, handled := al.handleCommand(ctx, msg, agent, &opts); handled {
|
if response, handled := al.handleCommand(ctx, msg, agent, &opts); handled {
|
||||||
|
|
|
||||||
|
|
@ -181,8 +181,8 @@ func TestConfig_BackwardCompat_NoAgentsList(t *testing.T) {
|
||||||
t.Fatalf("unmarshal: %v", err)
|
t.Fatalf("unmarshal: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(cfg.Agents.List) != 0 {
|
if len(cfg.Agents.List) != 1 {
|
||||||
t.Errorf("agents.list should be empty for backward compat, got %d", len(cfg.Agents.List))
|
t.Errorf("agents.list should have default clinician agent for backward compat, got %d", len(cfg.Agents.List))
|
||||||
}
|
}
|
||||||
if len(cfg.Bindings) != 0 {
|
if len(cfg.Bindings) != 0 {
|
||||||
t.Errorf("bindings should be empty, got %d", len(cfg.Bindings))
|
t.Errorf("bindings should be empty, got %d", len(cfg.Bindings))
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,13 @@ func DefaultConfig() *Config {
|
||||||
SummarizeMessageThreshold: 20,
|
SummarizeMessageThreshold: 20,
|
||||||
SummarizeTokenPercent: 75,
|
SummarizeTokenPercent: 75,
|
||||||
},
|
},
|
||||||
|
List: []AgentConfig{
|
||||||
|
{
|
||||||
|
ID: "the-clinician",
|
||||||
|
Name: "Medical Persona",
|
||||||
|
Workspace: filepath.Join(homePath, "Obsidian_Vault", "Patients"),
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Bindings: []AgentBinding{},
|
Bindings: []AgentBinding{},
|
||||||
Session: SessionConfig{
|
Session: SessionConfig{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue