Merge branch 'main' into bolt-perf-discord-string-builder-16389558094904414313
This commit is contained in:
commit
a8efcf5666
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,
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue