From 26725286d53f76cd4c4dfd146142401121945c2a Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Fri, 19 Dec 2025 10:24:44 +0100 Subject: [PATCH] feat: introduce phase configuration module and enhance agent profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds a new phase configuration module that manages model and thinking level settings for different execution phases. It reads configurations from `task_metadata.json` and provides resolved model IDs for various phases, including spec creation, planning, coding, and QA. Key changes include: - New `phase_config.py` file to handle model ID mappings and thinking budgets. - Updates to agent files (`coder.py`, `planner.py`, `loop.py`) to utilize phase-specific models and thinking levels. - Modifications to the CLI and UI components to support per-phase configuration, enhancing the user experience for task creation and editing. The new structure allows for optimized model selection and thinking depth based on the phase, improving overall task execution efficiency. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../src/main/agent/agent-manager.ts | 16 + auto-claude-ui/src/main/agent/types.ts | 17 + .../components/AgentProfileSelector.tsx | 370 ++++++++++++++++++ .../components/TaskCreationWizard.tsx | 113 +++--- .../renderer/components/TaskEditDialog.tsx | 109 +++++- .../components/task-detail/TaskLogs.tsx | 83 +++- auto-claude-ui/src/renderer/hooks/useIpc.ts | 80 ++-- auto-claude-ui/src/shared/constants/config.ts | 4 +- auto-claude-ui/src/shared/constants/models.ts | 30 +- auto-claude-ui/src/shared/types/settings.ts | 26 +- auto-claude-ui/src/shared/types/task.ts | 12 +- auto-claude/agents/coder.py | 18 +- auto-claude/agents/planner.py | 2 +- auto-claude/cli/build_commands.py | 17 +- auto-claude/core/client.py | 11 + auto-claude/phase_config.py | 290 ++++++++++++++ auto-claude/qa/loop.py | 40 +- auto-claude/runners/spec_runner.py | 27 +- auto-claude/spec/compaction.py | 155 ++++++++ auto-claude/spec/phases/planning_phases.py | 6 +- .../spec/phases/requirements_phases.py | 1 + auto-claude/spec/phases/spec_phases.py | 7 +- auto-claude/spec/pipeline/agent_runner.py | 30 +- auto-claude/spec/pipeline/orchestrator.py | 65 ++- auto-claude/task_logger/logger.py | 95 ++++- run.py/agent.py | 0 26 files changed, 1493 insertions(+), 131 deletions(-) create mode 100644 auto-claude-ui/src/renderer/components/AgentProfileSelector.tsx create mode 100644 auto-claude/phase_config.py create mode 100644 auto-claude/spec/compaction.py create mode 100644 run.py/agent.py diff --git a/auto-claude-ui/src/main/agent/agent-manager.ts b/auto-claude-ui/src/main/agent/agent-manager.ts index 1ed3455f..1166d183 100644 --- a/auto-claude-ui/src/main/agent/agent-manager.ts +++ b/auto-claude-ui/src/main/agent/agent-manager.ts @@ -128,6 +128,20 @@ export class AgentManager extends EventEmitter { args.push('--auto-approve'); } + // Pass model and thinking level configuration + // For auto profile, use phase-specific config; otherwise use single model/thinking + if (metadata?.isAutoProfile && metadata.phaseModels && metadata.phaseThinking) { + // Pass the spec phase model and thinking level to spec_runner + args.push('--model', metadata.phaseModels.spec); + args.push('--thinking-level', metadata.phaseThinking.spec); + } else if (metadata?.model) { + // Non-auto profile: use single model and thinking level + args.push('--model', metadata.model); + if (metadata.thinkingLevel) { + args.push('--thinking-level', metadata.thinkingLevel); + } + } + // Store context for potential restart this.storeTaskContext(taskId, projectPath, '', {}, true, taskDescription, specDir, metadata); @@ -183,6 +197,8 @@ export class AgentManager extends EventEmitter { // Note: --parallel was removed from run.py CLI - parallel execution is handled internally by the agent // The options.parallel and options.workers are kept for future use or logging purposes + // Note: Model configuration is read from task_metadata.json by the Python scripts, + // which allows per-phase configuration for planner, coder, and QA phases // Store context for potential restart this.storeTaskContext(taskId, projectPath, specId, options, false); diff --git a/auto-claude-ui/src/main/agent/types.ts b/auto-claude-ui/src/main/agent/types.ts index ddf680e8..141e4124 100644 --- a/auto-claude-ui/src/main/agent/types.ts +++ b/auto-claude-ui/src/main/agent/types.ts @@ -48,6 +48,23 @@ export interface TaskExecutionOptions { export interface SpecCreationMetadata { requireReviewBeforeCoding?: boolean; + // Auto profile - phase-based model and thinking configuration + isAutoProfile?: boolean; + phaseModels?: { + spec: 'haiku' | 'sonnet' | 'opus'; + planning: 'haiku' | 'sonnet' | 'opus'; + coding: 'haiku' | 'sonnet' | 'opus'; + qa: 'haiku' | 'sonnet' | 'opus'; + }; + phaseThinking?: { + spec: 'none' | 'low' | 'medium' | 'high' | 'ultrathink'; + planning: 'none' | 'low' | 'medium' | 'high' | 'ultrathink'; + coding: 'none' | 'low' | 'medium' | 'high' | 'ultrathink'; + qa: 'none' | 'low' | 'medium' | 'high' | 'ultrathink'; + }; + // Non-auto profile - single model and thinking level + model?: 'haiku' | 'sonnet' | 'opus'; + thinkingLevel?: 'none' | 'low' | 'medium' | 'high' | 'ultrathink'; } export interface IdeationProgressData { diff --git a/auto-claude-ui/src/renderer/components/AgentProfileSelector.tsx b/auto-claude-ui/src/renderer/components/AgentProfileSelector.tsx new file mode 100644 index 00000000..85f0f4c8 --- /dev/null +++ b/auto-claude-ui/src/renderer/components/AgentProfileSelector.tsx @@ -0,0 +1,370 @@ +/** + * AgentProfileSelector - Reusable component for selecting agent profile in forms + * + * Provides a dropdown for quick profile selection (Auto, Complex, Balanced, Quick) + * with an inline "Custom" option that reveals model and thinking level selects. + * The "Auto" profile shows per-phase model configuration. + * + * Used in TaskCreationWizard and TaskEditDialog. + */ +import { useState } from 'react'; +import { Brain, Scale, Zap, Sliders, Sparkles, ChevronDown, ChevronUp } from 'lucide-react'; +import { Label } from './ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from './ui/select'; +import { + DEFAULT_AGENT_PROFILES, + AVAILABLE_MODELS, + THINKING_LEVELS, + DEFAULT_PHASE_MODELS, + DEFAULT_PHASE_THINKING +} from '../../shared/constants'; +import type { ModelType, ThinkingLevel } from '../../shared/types'; +import type { PhaseModelConfig, PhaseThinkingConfig } from '../../shared/types/settings'; +import { cn } from '../lib/utils'; + +interface AgentProfileSelectorProps { + /** Currently selected profile ID ('auto', 'complex', 'balanced', 'quick', or 'custom') */ + profileId: string; + /** Current model value (fallback for non-auto profiles) */ + model: ModelType | ''; + /** Current thinking level value (fallback for non-auto profiles) */ + thinkingLevel: ThinkingLevel | ''; + /** Phase model configuration (for auto profile) */ + phaseModels?: PhaseModelConfig; + /** Phase thinking configuration (for auto profile) */ + phaseThinking?: PhaseThinkingConfig; + /** Called when profile selection changes */ + onProfileChange: (profileId: string, model: ModelType, thinkingLevel: ThinkingLevel) => void; + /** Called when model changes (in custom mode) */ + onModelChange: (model: ModelType) => void; + /** Called when thinking level changes (in custom mode) */ + onThinkingLevelChange: (level: ThinkingLevel) => void; + /** Called when phase models change (in auto mode) */ + onPhaseModelsChange?: (phaseModels: PhaseModelConfig) => void; + /** Called when phase thinking changes (in auto mode) */ + onPhaseThinkingChange?: (phaseThinking: PhaseThinkingConfig) => void; + /** Whether the selector is disabled */ + disabled?: boolean; +} + +const iconMap: Record = { + Brain, + Scale, + Zap, + Sparkles +}; + +const PHASE_LABELS: Record = { + spec: { label: 'Spec Creation', description: 'Discovery, requirements, context gathering' }, + planning: { label: 'Planning', description: 'Implementation planning and architecture' }, + coding: { label: 'Coding', description: 'Actual code implementation' }, + qa: { label: 'QA Review', description: 'Quality assurance and validation' } +}; + +export function AgentProfileSelector({ + profileId, + model, + thinkingLevel, + phaseModels, + phaseThinking, + onProfileChange, + onModelChange, + onThinkingLevelChange, + onPhaseModelsChange, + onPhaseThinkingChange, + disabled +}: AgentProfileSelectorProps) { + const [showPhaseDetails, setShowPhaseDetails] = useState(false); + + const isCustom = profileId === 'custom'; + const isAuto = profileId === 'auto'; + + // Use provided phase configs or defaults + const currentPhaseModels = phaseModels || DEFAULT_PHASE_MODELS; + const currentPhaseThinking = phaseThinking || DEFAULT_PHASE_THINKING; + + const handleProfileSelect = (selectedId: string) => { + if (selectedId === 'custom') { + // Keep current model/thinking level, just mark as custom + onProfileChange('custom', model as ModelType || 'sonnet', thinkingLevel as ThinkingLevel || 'medium'); + } else if (selectedId === 'auto') { + // Auto profile - set defaults + const autoProfile = DEFAULT_AGENT_PROFILES.find(p => p.id === 'auto'); + if (autoProfile) { + onProfileChange('auto', autoProfile.model, autoProfile.thinkingLevel); + // Initialize phase configs with defaults if callback provided + if (onPhaseModelsChange && autoProfile.phaseModels) { + onPhaseModelsChange(autoProfile.phaseModels); + } + if (onPhaseThinkingChange && autoProfile.phaseThinking) { + onPhaseThinkingChange(autoProfile.phaseThinking); + } + } + } else { + const profile = DEFAULT_AGENT_PROFILES.find(p => p.id === selectedId); + if (profile) { + onProfileChange(profile.id, profile.model, profile.thinkingLevel); + } + } + }; + + const handlePhaseModelChange = (phase: keyof PhaseModelConfig, value: ModelType) => { + if (onPhaseModelsChange) { + onPhaseModelsChange({ + ...currentPhaseModels, + [phase]: value + }); + } + }; + + const handlePhaseThinkingChange = (phase: keyof PhaseThinkingConfig, value: ThinkingLevel) => { + if (onPhaseThinkingChange) { + onPhaseThinkingChange({ + ...currentPhaseThinking, + [phase]: value + }); + } + }; + + // Get profile display info + const getProfileDisplay = () => { + if (isCustom) { + return { + icon: Sliders, + label: 'Custom Configuration', + description: 'Choose model & thinking level' + }; + } + const profile = DEFAULT_AGENT_PROFILES.find(p => p.id === profileId); + if (profile) { + return { + icon: iconMap[profile.icon || 'Scale'] || Scale, + label: profile.name, + description: profile.description + }; + } + // Default to balanced + return { + icon: Scale, + label: 'Balanced', + description: 'Good balance of speed and quality' + }; + }; + + const display = getProfileDisplay(); + + return ( +
+ {/* Agent Profile Selection */} +
+ + +

+ {display.description} +

+
+ + {/* Auto Profile - Phase Configuration */} + {isAuto && ( +
+ {/* Phase Summary */} +
+ + + {/* Compact summary when collapsed */} + {!showPhaseDetails && ( +
+ {(Object.keys(PHASE_LABELS) as Array).map((phase) => { + const modelLabel = AVAILABLE_MODELS.find(m => m.value === currentPhaseModels[phase])?.label?.replace('Claude ', '') || currentPhaseModels[phase]; + return ( +
+ {PHASE_LABELS[phase].label}: + {modelLabel} +
+ ); + })} +
+ )} +
+ + {/* Detailed Phase Configuration */} + {showPhaseDetails && ( +
+ {(Object.keys(PHASE_LABELS) as Array).map((phase) => ( +
+
+ + + {PHASE_LABELS[phase].description} + +
+
+ + +
+
+ ))} +
+ )} +
+ )} + + {/* Custom Configuration (shown only when custom is selected) */} + {isCustom && ( +
+ {/* Model Selection */} +
+ + +
+ + {/* Thinking Level Selection */} +
+ + +
+
+ )} +
+ ); +} diff --git a/auto-claude-ui/src/renderer/components/TaskCreationWizard.tsx b/auto-claude-ui/src/renderer/components/TaskCreationWizard.tsx index 5565601f..a15ca40e 100644 --- a/auto-claude-ui/src/renderer/components/TaskCreationWizard.tsx +++ b/auto-claude-ui/src/renderer/components/TaskCreationWizard.tsx @@ -40,10 +40,12 @@ import { } from './ImageUpload'; import { ReferencedFilesSection } from './ReferencedFilesSection'; import { TaskFileExplorerDrawer } from './TaskFileExplorerDrawer'; +import { AgentProfileSelector } from './AgentProfileSelector'; import { createTask, saveDraft, loadDraft, clearDraft, isDraftEmpty } from '../stores/task-store'; import { useProjectStore } from '../stores/project-store'; import { cn } from '../lib/utils'; import type { TaskCategory, TaskPriority, TaskComplexity, TaskImpact, TaskMetadata, ImageAttachment, TaskDraft, ModelType, ThinkingLevel, ReferencedFile } from '../../shared/types'; +import type { PhaseModelConfig, PhaseThinkingConfig } from '../../shared/types/settings'; import { TASK_CATEGORY_LABELS, TASK_PRIORITY_LABELS, @@ -53,8 +55,8 @@ import { MAX_REFERENCED_FILES, ALLOWED_IMAGE_TYPES_DISPLAY, DEFAULT_AGENT_PROFILES, - AVAILABLE_MODELS, - THINKING_LEVELS + DEFAULT_PHASE_MODELS, + DEFAULT_PHASE_THINKING } from '../../shared/constants'; import { useSettingsStore } from '../stores/settings-store'; @@ -73,7 +75,7 @@ export function TaskCreationWizard({ const { settings } = useSettingsStore(); const selectedProfile = DEFAULT_AGENT_PROFILES.find( p => p.id === settings.selectedAgentProfile - ) || DEFAULT_AGENT_PROFILES.find(p => p.id === 'balanced')!; + ) || DEFAULT_AGENT_PROFILES.find(p => p.id === 'auto')!; const [title, setTitle] = useState(''); const [description, setDescription] = useState(''); @@ -97,8 +99,16 @@ export function TaskCreationWizard({ const [impact, setImpact] = useState(''); // Model configuration (initialized from selected agent profile) + const [profileId, setProfileId] = useState(settings.selectedAgentProfile || 'auto'); const [model, setModel] = useState(selectedProfile.model); const [thinkingLevel, setThinkingLevel] = useState(selectedProfile.thinkingLevel); + // Auto profile - per-phase configuration + const [phaseModels, setPhaseModels] = useState( + selectedProfile.phaseModels || DEFAULT_PHASE_MODELS + ); + const [phaseThinking, setPhaseThinking] = useState( + selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING + ); // Image attachments const [images, setImages] = useState([]); @@ -161,9 +171,12 @@ export function TaskCreationWizard({ setPriority(draft.priority); setComplexity(draft.complexity); setImpact(draft.impact); - // Load model/thinkingLevel from draft if present, otherwise use profile defaults + // Load model/thinkingLevel/profileId from draft if present, otherwise use profile defaults + setProfileId(draft.profileId || settings.selectedAgentProfile || 'balanced'); setModel(draft.model || selectedProfile.model); setThinkingLevel(draft.thinkingLevel || selectedProfile.thinkingLevel); + setPhaseModels(draft.phaseModels || selectedProfile.phaseModels || DEFAULT_PHASE_MODELS); + setPhaseThinking(draft.phaseThinking || selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING); setImages(draft.images); setReferencedFiles(draft.referencedFiles ?? []); setRequireReviewBeforeCoding(draft.requireReviewBeforeCoding ?? false); @@ -178,12 +191,15 @@ export function TaskCreationWizard({ } // Note: Referenced Files section is always visible, no need to expand } else { - // No draft - initialize model/thinkingLevel from selected profile + // No draft - initialize from selected profile + setProfileId(settings.selectedAgentProfile || 'balanced'); setModel(selectedProfile.model); setThinkingLevel(selectedProfile.thinkingLevel); + setPhaseModels(selectedProfile.phaseModels || DEFAULT_PHASE_MODELS); + setPhaseThinking(selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING); } } - }, [open, projectId, selectedProfile.model, selectedProfile.thinkingLevel]); + }, [open, projectId, settings.selectedAgentProfile, selectedProfile.model, selectedProfile.thinkingLevel]); /** * Get current form state as a draft @@ -196,13 +212,16 @@ export function TaskCreationWizard({ priority, complexity, impact, + profileId, model, thinkingLevel, + phaseModels, + phaseThinking, images, referencedFiles, requireReviewBeforeCoding, savedAt: new Date() - }), [projectId, title, description, category, priority, complexity, impact, model, thinkingLevel, images, referencedFiles, requireReviewBeforeCoding]); + }), [projectId, title, description, category, priority, complexity, impact, profileId, model, thinkingLevel, phaseModels, phaseThinking, images, referencedFiles, requireReviewBeforeCoding]); /** * Handle paste event for screenshot support */ @@ -532,6 +551,12 @@ export function TaskCreationWizard({ if (impact) metadata.impact = impact; if (model) metadata.model = model; if (thinkingLevel) metadata.thinkingLevel = thinkingLevel; + // Auto profile - per-phase configuration + if (profileId === 'auto') { + metadata.isAutoProfile = true; + if (phaseModels) metadata.phaseModels = phaseModels; + if (phaseThinking) metadata.phaseThinking = phaseThinking; + } if (images.length > 0) metadata.attachedImages = images; if (allReferencedFiles.length > 0) metadata.referencedFiles = allReferencedFiles; if (requireReviewBeforeCoding) metadata.requireReviewBeforeCoding = true; @@ -561,9 +586,12 @@ export function TaskCreationWizard({ setPriority(''); setComplexity(''); setImpact(''); - // Reset model/thinkingLevel to selected profile defaults + // Reset to selected profile defaults + setProfileId(settings.selectedAgentProfile || 'balanced'); setModel(selectedProfile.model); setThinkingLevel(selectedProfile.thinkingLevel); + setPhaseModels(selectedProfile.phaseModels || DEFAULT_PHASE_MODELS); + setPhaseThinking(selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING); setImages([]); setReferencedFiles([]); setRequireReviewBeforeCoding(false); @@ -765,57 +793,24 @@ export function TaskCreationWizard({

- {/* Model Selection */} -
- - -

- The Claude model to use for this task. Defaults to your selected agent profile. -

-
- - {/* Thinking Level Selection */} -
- - -

- Extended thinking depth for complex reasoning. Higher levels use more tokens but provide deeper analysis. -

-
+ {/* Agent Profile Selection */} + { + setProfileId(newProfileId); + setModel(newModel); + setThinkingLevel(newThinkingLevel); + }} + onModelChange={setModel} + onThinkingLevelChange={setThinkingLevel} + onPhaseModelsChange={setPhaseModels} + onPhaseThinkingChange={setPhaseThinking} + disabled={isCreating} + /> {/* Paste Success Indicator */} {pasteSuccess && ( diff --git a/auto-claude-ui/src/renderer/components/TaskEditDialog.tsx b/auto-claude-ui/src/renderer/components/TaskEditDialog.tsx index d50c7961..d87ae977 100644 --- a/auto-claude-ui/src/renderer/components/TaskEditDialog.tsx +++ b/auto-claude-ui/src/renderer/components/TaskEditDialog.tsx @@ -54,17 +54,23 @@ import { isValidImageMimeType, resolveFilename } from './ImageUpload'; +import { AgentProfileSelector } from './AgentProfileSelector'; import { persistUpdateTask } from '../stores/task-store'; import { cn } from '../lib/utils'; -import type { Task, ImageAttachment, TaskCategory, TaskPriority, TaskComplexity, TaskImpact } from '../../shared/types'; +import type { Task, ImageAttachment, TaskCategory, TaskPriority, TaskComplexity, TaskImpact, ModelType, ThinkingLevel } from '../../shared/types'; import { TASK_CATEGORY_LABELS, TASK_PRIORITY_LABELS, TASK_COMPLEXITY_LABELS, TASK_IMPACT_LABELS, MAX_IMAGES_PER_TASK, - ALLOWED_IMAGE_TYPES_DISPLAY + ALLOWED_IMAGE_TYPES_DISPLAY, + DEFAULT_AGENT_PROFILES, + DEFAULT_PHASE_MODELS, + DEFAULT_PHASE_THINKING } from '../../shared/constants'; +import type { PhaseModelConfig, PhaseThinkingConfig } from '../../shared/types/settings'; +import { useSettingsStore } from '../stores/settings-store'; /** * Props for the TaskEditDialog component @@ -81,6 +87,12 @@ interface TaskEditDialogProps { } export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDialogProps) { + // Get selected agent profile from settings for defaults + const { settings } = useSettingsStore(); + const selectedProfile = DEFAULT_AGENT_PROFILES.find( + p => p.id === settings.selectedAgentProfile + ) || DEFAULT_AGENT_PROFILES.find(p => p.id === 'auto')!; + const [title, setTitle] = useState(task.title); const [description, setDescription] = useState(task.description); const [isSaving, setIsSaving] = useState(false); @@ -95,6 +107,36 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi const [complexity, setComplexity] = useState(task.metadata?.complexity || ''); const [impact, setImpact] = useState(task.metadata?.impact || ''); + // Agent profile / model configuration + const [profileId, setProfileId] = useState(() => { + // Check if task uses Auto profile + if (task.metadata?.isAutoProfile) { + return 'auto'; + } + // Determine profile ID from task metadata or default to 'auto' + const taskModel = task.metadata?.model; + const taskThinking = task.metadata?.thinkingLevel; + if (taskModel && taskThinking) { + // Check if it matches a known profile + const matchingProfile = DEFAULT_AGENT_PROFILES.find( + p => p.model === taskModel && p.thinkingLevel === taskThinking && !p.isAutoProfile + ); + return matchingProfile?.id || 'custom'; + } + return settings.selectedAgentProfile || 'auto'; + }); + const [model, setModel] = useState(task.metadata?.model || selectedProfile.model); + const [thinkingLevel, setThinkingLevel] = useState( + task.metadata?.thinkingLevel || selectedProfile.thinkingLevel + ); + // Auto profile - per-phase configuration + const [phaseModels, setPhaseModels] = useState( + task.metadata?.phaseModels || selectedProfile.phaseModels || DEFAULT_PHASE_MODELS + ); + const [phaseThinking, setPhaseThinking] = useState( + task.metadata?.phaseThinking || selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING + ); + // Image attachments const [images, setImages] = useState(task.metadata?.attachedImages || []); @@ -118,6 +160,35 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi setPriority(task.metadata?.priority || ''); setComplexity(task.metadata?.complexity || ''); setImpact(task.metadata?.impact || ''); + + // Reset model configuration + const taskModel = task.metadata?.model; + const taskThinking = task.metadata?.thinkingLevel; + const isAutoProfile = task.metadata?.isAutoProfile; + + if (isAutoProfile) { + setProfileId('auto'); + setModel(taskModel || selectedProfile.model); + setThinkingLevel(taskThinking || selectedProfile.thinkingLevel); + setPhaseModels(task.metadata?.phaseModels || DEFAULT_PHASE_MODELS); + setPhaseThinking(task.metadata?.phaseThinking || DEFAULT_PHASE_THINKING); + } else if (taskModel && taskThinking) { + const matchingProfile = DEFAULT_AGENT_PROFILES.find( + p => p.model === taskModel && p.thinkingLevel === taskThinking && !p.isAutoProfile + ); + setProfileId(matchingProfile?.id || 'custom'); + setModel(taskModel); + setThinkingLevel(taskThinking); + setPhaseModels(DEFAULT_PHASE_MODELS); + setPhaseThinking(DEFAULT_PHASE_THINKING); + } else { + setProfileId(settings.selectedAgentProfile || 'auto'); + setModel(selectedProfile.model); + setThinkingLevel(selectedProfile.thinkingLevel); + setPhaseModels(selectedProfile.phaseModels || DEFAULT_PHASE_MODELS); + setPhaseThinking(selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING); + } + setImages(task.metadata?.attachedImages || []); setRequireReviewBeforeCoding(task.metadata?.requireReviewBeforeCoding ?? false); setError(null); @@ -130,7 +201,7 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi setShowImages((task.metadata?.attachedImages || []).length > 0); setPasteSuccess(false); } - }, [open, task]); + }, [open, task, settings.selectedAgentProfile, selectedProfile.model, selectedProfile.thinkingLevel]); /** * Handle paste event for screenshot support @@ -328,6 +399,8 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi priority !== (task.metadata?.priority || '') || complexity !== (task.metadata?.complexity || '') || impact !== (task.metadata?.impact || '') || + model !== (task.metadata?.model || '') || + thinkingLevel !== (task.metadata?.thinkingLevel || '') || requireReviewBeforeCoding !== (task.metadata?.requireReviewBeforeCoding ?? false) || JSON.stringify(images) !== JSON.stringify(task.metadata?.attachedImages || []); @@ -346,6 +419,17 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi if (priority) metadataUpdates.priority = priority; if (complexity) metadataUpdates.complexity = complexity; if (impact) metadataUpdates.impact = impact; + if (model) metadataUpdates.model = model as ModelType; + if (thinkingLevel) metadataUpdates.thinkingLevel = thinkingLevel as ThinkingLevel; + // Auto profile - per-phase configuration + if (profileId === 'auto') { + metadataUpdates.isAutoProfile = true; + if (phaseModels) metadataUpdates.phaseModels = phaseModels; + if (phaseThinking) metadataUpdates.phaseThinking = phaseThinking; + } else { + // Clear auto profile fields if switching away from auto + metadataUpdates.isAutoProfile = false; + } if (images.length > 0) metadataUpdates.attachedImages = images; metadataUpdates.requireReviewBeforeCoding = requireReviewBeforeCoding; @@ -429,6 +513,25 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi

+ {/* Agent Profile Selection */} + { + setProfileId(newProfileId); + setModel(newModel); + setThinkingLevel(newThinkingLevel); + }} + onModelChange={setModel} + onThinkingLevelChange={setThinkingLevel} + onPhaseModelsChange={setPhaseModels} + onPhaseThinkingChange={setPhaseThinking} + disabled={isSaving} + /> + {/* Paste Success Indicator */} {pasteSuccess && (
diff --git a/auto-claude-ui/src/renderer/components/task-detail/TaskLogs.tsx b/auto-claude-ui/src/renderer/components/task-detail/TaskLogs.tsx index c457117c..9f5a9ed9 100644 --- a/auto-claude-ui/src/renderer/components/task-detail/TaskLogs.tsx +++ b/auto-claude-ui/src/renderer/components/task-detail/TaskLogs.tsx @@ -14,12 +14,15 @@ import { Search, FolderSearch, Wrench, - Info + Info, + Brain, + Cpu } from 'lucide-react'; import { Badge } from '../ui/badge'; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../ui/collapsible'; import { cn } from '../../lib/utils'; -import type { Task, TaskLogs, TaskLogPhase, TaskPhaseLog, TaskLogEntry } from '../../../shared/types'; +import type { Task, TaskLogs, TaskLogPhase, TaskPhaseLog, TaskLogEntry, TaskMetadata } from '../../../shared/types'; +import type { PhaseModelConfig, PhaseThinkingConfig, ThinkingLevel, ModelTypeShort } from '../../../shared/types/settings'; interface TaskLogsProps { task: Task; @@ -51,6 +54,60 @@ const PHASE_COLORS: Record = { validation: 'text-purple-500 bg-purple-500/10 border-purple-500/30' }; +// Map log phases to config phase keys +// Note: 'planning' log phase covers both spec creation and implementation planning +const LOG_PHASE_TO_CONFIG_PHASE: Record = { + planning: 'spec', // Planning log phase primarily shows spec creation + coding: 'coding', + validation: 'qa' +}; + +// Short labels for models +const MODEL_SHORT_LABELS: Record = { + opus: 'Opus', + sonnet: 'Sonnet', + haiku: 'Haiku' +}; + +// Short labels for thinking levels +const THINKING_SHORT_LABELS: Record = { + none: 'None', + low: 'Low', + medium: 'Med', + high: 'High', + ultrathink: 'Ultra' +}; + +// Helper to get model and thinking info for a log phase +function getPhaseConfig( + metadata: TaskMetadata | undefined, + logPhase: TaskLogPhase +): { model: string; thinking: string } | null { + if (!metadata) return null; + + const configPhase = LOG_PHASE_TO_CONFIG_PHASE[logPhase]; + + // Auto profile with per-phase config + if (metadata.isAutoProfile && metadata.phaseModels && metadata.phaseThinking) { + const model = metadata.phaseModels[configPhase]; + const thinking = metadata.phaseThinking[configPhase]; + return { + model: MODEL_SHORT_LABELS[model] || model, + thinking: THINKING_SHORT_LABELS[thinking] || thinking + }; + } + + // Non-auto profile with single model/thinking + if (metadata.model && metadata.thinkingLevel) { + return { + model: MODEL_SHORT_LABELS[metadata.model] || metadata.model, + thinking: THINKING_SHORT_LABELS[metadata.thinkingLevel] || metadata.thinkingLevel + }; + } + + return null; +} + export function TaskLogs({ task, phaseLogs, @@ -84,6 +141,7 @@ export function TaskLogs({ isExpanded={expandedPhases.has(phase)} onToggle={() => onTogglePhase(phase)} isTaskStuck={isStuck} + phaseConfig={getPhaseConfig(task.metadata, phase)} /> ))}
@@ -113,9 +171,10 @@ interface PhaseLogSectionProps { isExpanded: boolean; onToggle: () => void; isTaskStuck?: boolean; + phaseConfig?: { model: string; thinking: string } | null; } -function PhaseLogSection({ phase, phaseLog, isExpanded, onToggle, isTaskStuck }: PhaseLogSectionProps) { +function PhaseLogSection({ phase, phaseLog, isExpanded, onToggle, isTaskStuck, phaseConfig }: PhaseLogSectionProps) { const Icon = PHASE_ICONS[phase]; const status = phaseLog?.status || 'pending'; const hasEntries = (phaseLog?.entries.length || 0) > 0; @@ -190,7 +249,23 @@ function PhaseLogSection({ phase, phaseLog, isExpanded, onToggle, isTaskStuck }: )}
- {getStatusBadge()} +
+ {/* Model and thinking level indicator */} + {phaseConfig && ( +
+
+ + {phaseConfig.model} +
+ | +
+ + {phaseConfig.thinking} +
+
+ )} + {getStatusBadge()} +
diff --git a/auto-claude-ui/src/renderer/hooks/useIpc.ts b/auto-claude-ui/src/renderer/hooks/useIpc.ts index ba1103a4..7291ce23 100644 --- a/auto-claude-ui/src/renderer/hooks/useIpc.ts +++ b/auto-claude-ui/src/renderer/hooks/useIpc.ts @@ -48,69 +48,93 @@ export function useIpcListeners(): void { ); // Roadmap event listeners - const setGenerationStatus = useRoadmapStore.getState().setGenerationStatus; - const setRoadmap = useRoadmapStore.getState().setRoadmap; + // Helper to check if event is for the currently viewed project + const isCurrentProject = (eventProjectId: string): boolean => { + const currentProjectId = useRoadmapStore.getState().currentProjectId; + return currentProjectId === eventProjectId; + }; const cleanupRoadmapProgress = window.electronAPI.onRoadmapProgress( - (_projectId: string, status: RoadmapGenerationStatus) => { + (projectId: string, status: RoadmapGenerationStatus) => { // Debug logging if (window.DEBUG) { console.log('[Roadmap] Progress update:', { - projectId: _projectId, + projectId, + currentProjectId: useRoadmapStore.getState().currentProjectId, phase: status.phase, progress: status.progress, message: status.message }); } - setGenerationStatus(status); + // Only update if this is for the currently viewed project + if (isCurrentProject(projectId)) { + useRoadmapStore.getState().setGenerationStatus(status); + } } ); const cleanupRoadmapComplete = window.electronAPI.onRoadmapComplete( - (_projectId: string, roadmap: Roadmap) => { + (projectId: string, roadmap: Roadmap) => { // Debug logging if (window.DEBUG) { console.log('[Roadmap] Generation complete:', { - projectId: _projectId, + projectId, + currentProjectId: useRoadmapStore.getState().currentProjectId, featuresCount: roadmap.features?.length || 0, phasesCount: roadmap.phases?.length || 0 }); } - setRoadmap(roadmap); - setGenerationStatus({ - phase: 'complete', - progress: 100, - message: 'Roadmap ready' - }); + // Only update if this is for the currently viewed project + if (isCurrentProject(projectId)) { + useRoadmapStore.getState().setRoadmap(roadmap); + useRoadmapStore.getState().setGenerationStatus({ + phase: 'complete', + progress: 100, + message: 'Roadmap ready' + }); + } } ); const cleanupRoadmapError = window.electronAPI.onRoadmapError( - (_projectId: string, error: string) => { + (projectId: string, error: string) => { // Debug logging if (window.DEBUG) { - console.error('[Roadmap] Error received:', { projectId: _projectId, error }); + console.error('[Roadmap] Error received:', { + projectId, + currentProjectId: useRoadmapStore.getState().currentProjectId, + error + }); + } + // Only update if this is for the currently viewed project + if (isCurrentProject(projectId)) { + useRoadmapStore.getState().setGenerationStatus({ + phase: 'error', + progress: 0, + message: 'Generation failed', + error + }); } - setGenerationStatus({ - phase: 'error', - progress: 0, - message: 'Generation failed', - error - }); } ); const cleanupRoadmapStopped = window.electronAPI.onRoadmapStopped( - (_projectId: string) => { + (projectId: string) => { // Debug logging if (window.DEBUG) { - console.log('[Roadmap] Generation stopped:', { projectId: _projectId }); + console.log('[Roadmap] Generation stopped:', { + projectId, + currentProjectId: useRoadmapStore.getState().currentProjectId + }); + } + // Only update if this is for the currently viewed project + if (isCurrentProject(projectId)) { + useRoadmapStore.getState().setGenerationStatus({ + phase: 'idle', + progress: 0, + message: 'Generation stopped' + }); } - setGenerationStatus({ - phase: 'idle', - progress: 0, - message: 'Generation stopped' - }); } ); diff --git a/auto-claude-ui/src/shared/constants/config.ts b/auto-claude-ui/src/shared/constants/config.ts index 8ca5443b..213882cd 100644 --- a/auto-claude-ui/src/shared/constants/config.ts +++ b/auto-claude-ui/src/shared/constants/config.ts @@ -25,8 +25,8 @@ export const DEFAULT_APP_SETTINGS = { // Global API keys (used as defaults for all projects) globalClaudeOAuthToken: undefined as string | undefined, globalOpenAIApiKey: undefined as string | undefined, - // Selected agent profile - defaults to 'balanced' for good speed/quality balance - selectedAgentProfile: 'balanced', + // Selected agent profile - defaults to 'auto' for per-phase optimized model selection + selectedAgentProfile: 'auto', // Changelog preferences (persisted between sessions) changelogFormat: 'keep-a-changelog' as const, changelogAudience: 'user-facing' as const, diff --git a/auto-claude-ui/src/shared/constants/models.ts b/auto-claude-ui/src/shared/constants/models.ts index b853a3dc..8e9e5b89 100644 --- a/auto-claude-ui/src/shared/constants/models.ts +++ b/auto-claude-ui/src/shared/constants/models.ts @@ -3,7 +3,7 @@ * Claude models, thinking levels, memory backends, and agent profiles */ -import type { AgentProfile } from '../types/settings'; +import type { AgentProfile, PhaseModelConfig } from '../types/settings'; // ============================================ // Available Models @@ -48,8 +48,36 @@ export const THINKING_LEVELS = [ // Agent Profiles // ============================================ +// Default phase model configuration for Auto profile +// Optimized for each phase: fast discovery, quality planning, balanced coding, thorough QA +export const DEFAULT_PHASE_MODELS: PhaseModelConfig = { + spec: 'sonnet', // Good quality specs without being too slow + planning: 'opus', // Complex architecture decisions benefit from Opus + coding: 'sonnet', // Good balance of speed and quality for implementation + qa: 'sonnet' // Thorough but not overly slow QA +}; + +// Default phase thinking configuration for Auto profile +export const DEFAULT_PHASE_THINKING: import('../types/settings').PhaseThinkingConfig = { + spec: 'medium', // Moderate thinking for spec creation + planning: 'high', // Deep thinking for planning complex features + coding: 'medium', // Standard thinking for coding + qa: 'high' // Thorough analysis for QA review +}; + // Default agent profiles for preset model/thinking configurations export const DEFAULT_AGENT_PROFILES: AgentProfile[] = [ + { + id: 'auto', + name: 'Auto (Optimized)', + description: 'Uses different models per phase for optimal speed & quality', + model: 'sonnet', // Fallback/default model + thinkingLevel: 'medium', + icon: 'Sparkles', + isAutoProfile: true, + phaseModels: DEFAULT_PHASE_MODELS, + phaseThinking: DEFAULT_PHASE_THINKING + }, { id: 'complex', name: 'Complex Tasks', diff --git a/auto-claude-ui/src/shared/types/settings.ts b/auto-claude-ui/src/shared/types/settings.ts index 7c002167..9b5d0748 100644 --- a/auto-claude-ui/src/shared/types/settings.ts +++ b/auto-claude-ui/src/shared/types/settings.ts @@ -8,14 +8,38 @@ import type { ChangelogFormat, ChangelogAudience, ChangelogEmojiLevel } from './ // Thinking level for Claude model (budget token allocation) export type ThinkingLevel = 'none' | 'low' | 'medium' | 'high' | 'ultrathink'; +// Model type shorthand +export type ModelTypeShort = 'haiku' | 'sonnet' | 'opus'; + +// Phase-based model configuration for Auto profile +// Each phase can use a different model optimized for that task type +export interface PhaseModelConfig { + spec: ModelTypeShort; // Spec creation (discovery, requirements, context) + planning: ModelTypeShort; // Implementation planning + coding: ModelTypeShort; // Actual coding implementation + qa: ModelTypeShort; // QA review and fixing +} + +// Thinking level configuration per phase +export interface PhaseThinkingConfig { + spec: ThinkingLevel; + planning: ThinkingLevel; + coding: ThinkingLevel; + qa: ThinkingLevel; +} + // Agent profile for preset model/thinking configurations export interface AgentProfile { id: string; name: string; description: string; - model: 'haiku' | 'sonnet' | 'opus'; + model: ModelTypeShort; thinkingLevel: ThinkingLevel; icon?: string; // Lucide icon name + // Auto profile specific - per-phase configuration + isAutoProfile?: boolean; + phaseModels?: PhaseModelConfig; + phaseThinking?: PhaseThinkingConfig; } export interface AppSettings { diff --git a/auto-claude-ui/src/shared/types/task.ts b/auto-claude-ui/src/shared/types/task.ts index c4896278..33d6e15c 100644 --- a/auto-claude-ui/src/shared/types/task.ts +++ b/auto-claude-ui/src/shared/types/task.ts @@ -2,7 +2,7 @@ * Task-related types */ -import type { ThinkingLevel } from './settings'; +import type { ThinkingLevel, PhaseModelConfig, PhaseThinkingConfig } from './settings'; export type TaskStatus = 'backlog' | 'in_progress' | 'ai_review' | 'human_review' | 'done'; @@ -136,8 +136,12 @@ export interface TaskDraft { priority: TaskPriority | ''; complexity: TaskComplexity | ''; impact: TaskImpact | ''; + profileId?: string; // Agent profile ID ('auto', 'complex', 'balanced', 'quick', 'custom') model: ModelType | ''; thinkingLevel: ThinkingLevel | ''; + // Auto profile - per-phase configuration + phaseModels?: PhaseModelConfig; + phaseThinking?: PhaseThinkingConfig; images: ImageAttachment[]; referencedFiles: ReferencedFile[]; requireReviewBeforeCoding?: boolean; @@ -209,8 +213,12 @@ export interface TaskMetadata { requireReviewBeforeCoding?: boolean; // Require human review of spec/plan before coding starts // Agent configuration (from agent profile or manual selection) - model?: ModelType; // Claude model to use (haiku, sonnet, opus) + model?: ModelType; // Claude model to use (haiku, sonnet, opus) - used when not auto profile thinkingLevel?: ThinkingLevel; // Thinking budget level (none, low, medium, high, ultrathink) + // Auto profile - per-phase model configuration + isAutoProfile?: boolean; // True when using Auto (Optimized) profile + phaseModels?: PhaseModelConfig; // Per-phase model configuration + phaseThinking?: PhaseThinkingConfig; // Per-phase thinking configuration // Archive status archivedAt?: string; // ISO date when task was archived diff --git a/auto-claude/agents/coder.py b/auto-claude/agents/coder.py index 2af5233f..d0e99a53 100644 --- a/auto-claude/agents/coder.py +++ b/auto-claude/agents/coder.py @@ -9,7 +9,7 @@ import asyncio import logging from pathlib import Path -from client import create_client +from core.client import create_client from linear_updater import ( LinearTaskState, is_linear_enabled, @@ -17,6 +17,7 @@ from linear_updater import ( linear_task_started, linear_task_stuck, ) +from phase_config import get_phase_model from progress import ( count_subtasks, count_subtasks_detailed, @@ -244,8 +245,19 @@ async def run_autonomous_agent( commit_before = get_latest_commit(project_dir) commit_count_before = get_commit_count(project_dir) - # Create client (fresh context) - client = create_client(project_dir, spec_dir, model) + # Get the phase-specific model (respects task_metadata.json configuration) + # first_run means we're in planning phase, otherwise coding phase + current_phase = "planning" if first_run else "coding" + phase_model = get_phase_model(spec_dir, current_phase, model) + + # Create client (fresh context) with phase-specific model + # Coding phase uses no extended thinking for fast iteration + client = create_client( + project_dir, + spec_dir, + phase_model, + max_thinking_tokens=None, # No extended thinking for coding + ) # Generate appropriate prompt if first_run: diff --git a/auto-claude/agents/planner.py b/auto-claude/agents/planner.py index 3bd34e67..7f6f3f98 100644 --- a/auto-claude/agents/planner.py +++ b/auto-claude/agents/planner.py @@ -8,7 +8,7 @@ Handles follow-up planner sessions for adding new subtasks to completed specs. import logging from pathlib import Path -from client import create_client +from core.client import create_client from task_logger import ( LogPhase, get_task_logger, diff --git a/auto-claude/cli/build_commands.py b/auto-claude/cli/build_commands.py index 6aa7399e..0de13e58 100644 --- a/auto-claude/cli/build_commands.py +++ b/auto-claude/cli/build_commands.py @@ -68,7 +68,7 @@ def handle_build_command( Args: project_dir: Project root directory spec_dir: Spec directory path - model: Model to use + model: Model to use (used as default; may be overridden by task_metadata.json) max_iterations: Maximum number of iterations (None for unlimited) verbose: Enable verbose output force_isolated: Force isolated workspace mode @@ -86,14 +86,27 @@ def handle_build_command( debug_section, debug_success, ) + from phase_config import get_phase_model from qa_loop import run_qa_validation_loop, should_run_qa from .utils import print_banner, validate_environment + # Get the resolved model for the planning phase (first phase of build) + # This respects task_metadata.json phase configuration from the UI + planning_model = get_phase_model(spec_dir, "planning", model) + coding_model = get_phase_model(spec_dir, "coding", model) + qa_model = get_phase_model(spec_dir, "qa", model) + print_banner() print(f"\nProject directory: {project_dir}") print(f"Spec: {spec_dir.name}") - print(f"Model: {model}") + # Show phase-specific models if they differ + if planning_model != coding_model or coding_model != qa_model: + print(f"Models: Planning={planning_model.split('-')[1] if '-' in planning_model else planning_model}, " + f"Coding={coding_model.split('-')[1] if '-' in coding_model else coding_model}, " + f"QA={qa_model.split('-')[1] if '-' in qa_model else qa_model}") + else: + print(f"Model: {planning_model}") if max_iterations: print(f"Max iterations: {max_iterations}") diff --git a/auto-claude/core/client.py b/auto-claude/core/client.py index 32e1d016..1b7c11db 100644 --- a/auto-claude/core/client.py +++ b/auto-claude/core/client.py @@ -132,6 +132,7 @@ def create_client( spec_dir: Path, model: str, agent_type: str = "coder", + max_thinking_tokens: int | None = None, ) -> ClaudeSDKClient: """ Create a Claude Agent SDK client with multi-layered security. @@ -142,6 +143,11 @@ def create_client( model: Claude model to use agent_type: Type of agent - 'planner', 'coder', 'qa_reviewer', or 'qa_fixer' This determines which custom auto-claude tools are available. + max_thinking_tokens: Token budget for extended thinking (None = disabled) + - ultrathink: 16000 (spec creation) + - high: 10000 (QA review) + - medium: 5000 (planning, validation) + - None: disabled (coding) Returns: Configured ClaudeSDKClient @@ -237,6 +243,10 @@ def create_client( print(" - Sandbox enabled (OS-level bash isolation)") print(f" - Filesystem restricted to: {project_dir.resolve()}") print(" - Bash commands restricted to allowlist") + if max_thinking_tokens: + print(f" - Extended thinking: {max_thinking_tokens:,} tokens") + else: + print(" - Extended thinking: disabled") mcp_servers_list = ["puppeteer (browser automation)", "context7 (documentation)"] if linear_enabled: @@ -320,5 +330,6 @@ def create_client( cwd=str(project_dir.resolve()), settings=str(settings_file.resolve()), env=sdk_env, # Pass ANTHROPIC_BASE_URL etc. to subprocess + max_thinking_tokens=max_thinking_tokens, # Extended thinking budget ) ) diff --git a/auto-claude/phase_config.py b/auto-claude/phase_config.py new file mode 100644 index 00000000..dd9e2759 --- /dev/null +++ b/auto-claude/phase_config.py @@ -0,0 +1,290 @@ +""" +Phase Configuration Module +=========================== + +Handles model and thinking level configuration for different execution phases. +Reads configuration from task_metadata.json and provides resolved model IDs. +""" + +import json +from pathlib import Path +from typing import Literal, TypedDict + +# Model shorthand to full model ID mapping +MODEL_ID_MAP: dict[str, str] = { + "opus": "claude-opus-4-5-20251101", + "sonnet": "claude-sonnet-4-5-20250929", + "haiku": "claude-haiku-4-5-20251001", +} + +# Thinking level to budget tokens mapping (None = no extended thinking) +# Values calibrated for Claude Opus 4.5 extended thinking +THINKING_BUDGET_MAP: dict[str, int | None] = { + "none": None, + "low": 1024, + "medium": 5000, # Balanced thinking for light phases + "high": 10000, # Deep thinking for QA review + "ultrathink": 16000, # Maximum thinking for spec creation +} + +# Spec runner phase-specific thinking levels +# Heavy phases use ultrathink for deep analysis +# Light phases use medium after compaction +SPEC_PHASE_THINKING_LEVELS: dict[str, str] = { + # Heavy phases - ultrathink (discovery, spec creation, self-critique) + "discovery": "ultrathink", + "spec_writing": "ultrathink", + "self_critique": "ultrathink", + # Light phases - medium (after first invocation with compaction) + "requirements": "medium", + "research": "medium", + "context": "medium", + "planning": "medium", + "validation": "medium", + "quick_spec": "medium", + "historical_context": "medium", + "complexity_assessment": "medium", +} + +# Default phase configuration (matches UI defaults) +DEFAULT_PHASE_MODELS: dict[str, str] = { + "spec": "sonnet", + "planning": "opus", + "coding": "sonnet", + "qa": "sonnet", +} + +DEFAULT_PHASE_THINKING: dict[str, str] = { + "spec": "medium", + "planning": "high", + "coding": "medium", + "qa": "high", +} + + +class PhaseModelConfig(TypedDict, total=False): + spec: str + planning: str + coding: str + qa: str + + +class PhaseThinkingConfig(TypedDict, total=False): + spec: str + planning: str + coding: str + qa: str + + +class TaskMetadataConfig(TypedDict, total=False): + """Structure of model-related fields in task_metadata.json""" + isAutoProfile: bool + phaseModels: PhaseModelConfig + phaseThinking: PhaseThinkingConfig + model: str + thinkingLevel: str + + +Phase = Literal["spec", "planning", "coding", "qa"] + + +def resolve_model_id(model: str) -> str: + """ + Resolve a model shorthand (haiku, sonnet, opus) to a full model ID. + If the model is already a full ID, return it unchanged. + + Args: + model: Model shorthand or full ID + + Returns: + Full Claude model ID + """ + # Check if it's a shorthand + if model in MODEL_ID_MAP: + return MODEL_ID_MAP[model] + + # Already a full model ID + return model + + +def get_thinking_budget(thinking_level: str) -> int | None: + """ + Get the thinking budget for a thinking level. + + Args: + thinking_level: Thinking level (none, low, medium, high, ultrathink) + + Returns: + Token budget or None for no extended thinking + """ + return THINKING_BUDGET_MAP.get(thinking_level, THINKING_BUDGET_MAP["medium"]) + + +def load_task_metadata(spec_dir: Path) -> TaskMetadataConfig | None: + """ + Load task_metadata.json from the spec directory. + + Args: + spec_dir: Path to the spec directory + + Returns: + Parsed task metadata or None if not found + """ + metadata_path = spec_dir / "task_metadata.json" + if not metadata_path.exists(): + return None + + try: + with open(metadata_path) as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + return None + + +def get_phase_model( + spec_dir: Path, + phase: Phase, + cli_model: str | None = None, +) -> str: + """ + Get the resolved model ID for a specific execution phase. + + Priority: + 1. CLI argument (if provided) + 2. Phase-specific config from task_metadata.json (if auto profile) + 3. Single model from task_metadata.json (if not auto profile) + 4. Default phase configuration + + Args: + spec_dir: Path to the spec directory + phase: Execution phase (spec, planning, coding, qa) + cli_model: Model from CLI argument (optional) + + Returns: + Resolved full model ID + """ + # CLI argument takes precedence + if cli_model: + return resolve_model_id(cli_model) + + # Load task metadata + metadata = load_task_metadata(spec_dir) + + if metadata: + # Check for auto profile with phase-specific config + if metadata.get("isAutoProfile") and metadata.get("phaseModels"): + phase_models = metadata["phaseModels"] + model = phase_models.get(phase, DEFAULT_PHASE_MODELS[phase]) + return resolve_model_id(model) + + # Non-auto profile: use single model + if metadata.get("model"): + return resolve_model_id(metadata["model"]) + + # Fall back to default phase configuration + return resolve_model_id(DEFAULT_PHASE_MODELS[phase]) + + +def get_phase_thinking( + spec_dir: Path, + phase: Phase, + cli_thinking: str | None = None, +) -> str: + """ + Get the thinking level for a specific execution phase. + + Priority: + 1. CLI argument (if provided) + 2. Phase-specific config from task_metadata.json (if auto profile) + 3. Single thinking level from task_metadata.json (if not auto profile) + 4. Default phase configuration + + Args: + spec_dir: Path to the spec directory + phase: Execution phase (spec, planning, coding, qa) + cli_thinking: Thinking level from CLI argument (optional) + + Returns: + Thinking level string + """ + # CLI argument takes precedence + if cli_thinking: + return cli_thinking + + # Load task metadata + metadata = load_task_metadata(spec_dir) + + if metadata: + # Check for auto profile with phase-specific config + if metadata.get("isAutoProfile") and metadata.get("phaseThinking"): + phase_thinking = metadata["phaseThinking"] + return phase_thinking.get(phase, DEFAULT_PHASE_THINKING[phase]) + + # Non-auto profile: use single thinking level + if metadata.get("thinkingLevel"): + return metadata["thinkingLevel"] + + # Fall back to default phase configuration + return DEFAULT_PHASE_THINKING[phase] + + +def get_phase_thinking_budget( + spec_dir: Path, + phase: Phase, + cli_thinking: str | None = None, +) -> int | None: + """ + Get the thinking budget tokens for a specific execution phase. + + Args: + spec_dir: Path to the spec directory + phase: Execution phase (spec, planning, coding, qa) + cli_thinking: Thinking level from CLI argument (optional) + + Returns: + Token budget or None for no extended thinking + """ + thinking_level = get_phase_thinking(spec_dir, phase, cli_thinking) + return get_thinking_budget(thinking_level) + + +def get_phase_config( + spec_dir: Path, + phase: Phase, + cli_model: str | None = None, + cli_thinking: str | None = None, +) -> tuple[str, str, int | None]: + """ + Get the full configuration for a specific execution phase. + + Args: + spec_dir: Path to the spec directory + phase: Execution phase (spec, planning, coding, qa) + cli_model: Model from CLI argument (optional) + cli_thinking: Thinking level from CLI argument (optional) + + Returns: + Tuple of (model_id, thinking_level, thinking_budget) + """ + model_id = get_phase_model(spec_dir, phase, cli_model) + thinking_level = get_phase_thinking(spec_dir, phase, cli_thinking) + thinking_budget = get_thinking_budget(thinking_level) + + return model_id, thinking_level, thinking_budget + + +def get_spec_phase_thinking_budget(phase_name: str) -> int | None: + """ + Get the thinking budget for a specific spec runner phase. + + This maps granular spec phases (discovery, spec_writing, etc.) to their + appropriate thinking budgets based on SPEC_PHASE_THINKING_LEVELS. + + Args: + phase_name: Name of the spec phase (e.g., 'discovery', 'spec_writing') + + Returns: + Token budget for extended thinking, or None for no extended thinking + """ + thinking_level = SPEC_PHASE_THINKING_LEVELS.get(phase_name, "medium") + return get_thinking_budget(thinking_level) diff --git a/auto-claude/qa/loop.py b/auto-claude/qa/loop.py index bfd04a60..df1845ba 100644 --- a/auto-claude/qa/loop.py +++ b/auto-claude/qa/loop.py @@ -9,8 +9,9 @@ approval or max iterations. import time as time_module from pathlib import Path -from client import create_client +from core.client import create_client from debug import debug, debug_error, debug_section, debug_success, debug_warning +from phase_config import get_phase_model, get_thinking_budget from linear_updater import ( LinearTaskState, is_linear_enabled, @@ -151,9 +152,22 @@ async def run_qa_validation_loop( print(f"\n--- QA Iteration {qa_iteration}/{MAX_QA_ITERATIONS} ---") - # Run QA reviewer - debug("qa_loop", "Creating client for QA reviewer session...") - client = create_client(project_dir, spec_dir, model) + # Run QA reviewer with phase-specific model and high thinking budget + qa_model = get_phase_model(spec_dir, "qa", model) + qa_thinking_budget = get_thinking_budget("high") # 10,000 tokens for thorough review + debug( + "qa_loop", + "Creating client for QA reviewer session...", + model=qa_model, + thinking_budget=qa_thinking_budget, + ) + client = create_client( + project_dir, + spec_dir, + qa_model, + agent_type="qa_reviewer", + max_thinking_tokens=qa_thinking_budget, + ) async with client: debug("qa_loop", "Running QA reviewer agent session...") @@ -278,11 +292,23 @@ async def run_qa_validation_loop( print("Escalating to human review.") break - # Run fixer - debug("qa_loop", "Starting QA fixer session...") + # Run fixer with medium thinking budget + fixer_thinking_budget = get_thinking_budget("medium") # 5,000 tokens for focused fixes + debug( + "qa_loop", + "Starting QA fixer session...", + model=qa_model, + thinking_budget=fixer_thinking_budget, + ) print("\nRunning QA Fixer Agent...") - fix_client = create_client(project_dir, spec_dir, model) + fix_client = create_client( + project_dir, + spec_dir, + qa_model, + agent_type="qa_fixer", + max_thinking_tokens=fixer_thinking_budget, + ) async with fix_client: fix_status, fix_response = await run_qa_fixer_session( diff --git a/auto-claude/runners/spec_runner.py b/auto-claude/runners/spec_runner.py index f04eec55..b0605412 100644 --- a/auto-claude/runners/spec_runner.py +++ b/auto-claude/runners/spec_runner.py @@ -92,6 +92,7 @@ elif dev_env_file.exists(): load_dotenv(dev_env_file) from debug import debug, debug_error, debug_section, debug_success +from phase_config import get_phase_config, resolve_model_id from review import ReviewState from spec import SpecOrchestrator from ui import Icons, highlight, icon, muted, print_section, print_status @@ -161,8 +162,15 @@ Examples: parser.add_argument( "--model", type=str, - default="claude-opus-4-5-20251101", - help="Model to use for agent phases", + default="sonnet", + help="Model to use for agent phases (haiku, sonnet, opus, or full model ID)", + ) + parser.add_argument( + "--thinking-level", + type=str, + default="medium", + choices=["none", "low", "medium", "high", "ultrathink"], + help="Thinking level for extended thinking (none, low, medium, high, ultrathink)", ) parser.add_argument( "--no-ai-assessment", @@ -234,12 +242,16 @@ Examples: f"\n{icon(Icons.GEAR)} Note: --dev flag is deprecated. All specs now go to .auto-claude/specs/\n" ) + # Resolve model shorthand to full model ID + resolved_model = resolve_model_id(args.model) + debug( "spec_runner", "Creating spec orchestrator", project_dir=str(project_dir), task_description=task_description[:200] if task_description else None, - model=args.model, + model=resolved_model, + thinking_level=args.thinking_level, complexity_override=args.complexity, use_ai_assessment=not args.no_ai_assessment, interactive=args.interactive or not task_description, @@ -251,7 +263,8 @@ Examples: task_description=task_description, spec_name=args.continue_spec, spec_dir=args.spec_dir, - model=args.model, + model=resolved_model, + thinking_level=args.thinking_level, complexity_override=args.complexity, use_ai_assessment=not args.no_ai_assessment, dev_mode=args.dev, @@ -321,9 +334,9 @@ Examples: if args.dev: run_cmd.append("--dev") - # Pass through model if not default - if args.model != "claude-opus-4-5-20251101": - run_cmd.extend(["--model", args.model]) + # Note: Model configuration for subsequent phases (planning, coding, qa) + # is read from task_metadata.json by run.py, so we don't pass it here. + # This allows per-phase configuration when using Auto profile. debug( "spec_runner", diff --git a/auto-claude/spec/compaction.py b/auto-claude/spec/compaction.py new file mode 100644 index 00000000..7075c7d7 --- /dev/null +++ b/auto-claude/spec/compaction.py @@ -0,0 +1,155 @@ +""" +Conversation Compaction Module +============================== + +Summarizes phase outputs to maintain continuity between phases while +reducing token usage. After each phase completes, key findings are +summarized and passed as context to subsequent phases. +""" + +from pathlib import Path + +from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient + +from core.auth import get_sdk_env_vars, require_auth_token + + +async def summarize_phase_output( + phase_name: str, + phase_output: str, + model: str = "claude-sonnet-4-5-20250929", + target_words: int = 500, +) -> str: + """ + Summarize phase output to a concise summary for subsequent phases. + + Uses Sonnet for cost efficiency since this is a simple summarization task. + + Args: + phase_name: Name of the completed phase (e.g., 'discovery', 'requirements') + phase_output: Full output content from the phase (file contents, decisions) + model: Model to use for summarization (defaults to Sonnet for efficiency) + target_words: Target summary length in words (~500-1000 recommended) + + Returns: + Concise summary of key findings, decisions, and insights from the phase + """ + # Validate auth token + require_auth_token() + + # Limit input size to avoid token overflow + max_input_chars = 15000 + truncated_output = phase_output[:max_input_chars] + if len(phase_output) > max_input_chars: + truncated_output += "\n\n[... output truncated for summarization ...]" + + prompt = f"""Summarize the key findings from the "{phase_name}" phase in {target_words} words or less. + +Focus on extracting ONLY the most critical information that subsequent phases need: +- Key decisions made and their rationale +- Critical files, components, or patterns identified +- Important constraints or requirements discovered +- Actionable insights for implementation + +Be concise and use bullet points. Skip boilerplate and meta-commentary. + +## Phase Output: +{truncated_output} + +## Summary: +""" + + client = ClaudeSDKClient( + options=ClaudeAgentOptions( + model=model, + system_prompt=( + "You are a concise technical summarizer. Extract only the most " + "critical information from phase outputs. Use bullet points. " + "Focus on decisions, discoveries, and actionable insights." + ), + allowed_tools=[], # No tools needed for summarization + max_turns=1, + env=get_sdk_env_vars(), + ) + ) + + try: + async with client: + await client.query(prompt) + response_text = "" + async for msg in client.receive_response(): + if hasattr(msg, "content"): + for block in msg.content: + if hasattr(block, "text"): + response_text += block.text + return response_text.strip() + except Exception as e: + # Fallback: return truncated raw output on error + # This ensures we don't block the pipeline if summarization fails + fallback = phase_output[:2000] + if len(phase_output) > 2000: + fallback += "\n\n[... truncated ...]" + return f"[Summarization failed: {e}]\n\n{fallback}" + + +def format_phase_summaries(summaries: dict[str, str]) -> str: + """ + Format accumulated phase summaries for injection into agent context. + + Args: + summaries: Dict mapping phase names to their summaries + + Returns: + Formatted string suitable for agent context injection + """ + if not summaries: + return "" + + formatted_parts = ["## Context from Previous Phases\n"] + for phase_name, summary in summaries.items(): + formatted_parts.append(f"### {phase_name.replace('_', ' ').title()}\n{summary}\n") + + return "\n".join(formatted_parts) + + +async def gather_phase_outputs(spec_dir: Path, phase_name: str) -> str: + """ + Gather output files from a completed phase for summarization. + + Args: + spec_dir: Path to the spec directory + phase_name: Name of the completed phase + + Returns: + Concatenated content of phase output files + """ + outputs = [] + + # Map phases to their expected output files + phase_outputs: dict[str, list[str]] = { + "discovery": ["context.json"], + "requirements": ["requirements.json"], + "research": ["research.json"], + "context": ["context.json"], + "quick_spec": ["spec.md"], + "spec_writing": ["spec.md"], + "self_critique": ["spec.md", "critique_notes.md"], + "planning": ["implementation_plan.json"], + "validation": [], # No output files to summarize + } + + output_files = phase_outputs.get(phase_name, []) + + for filename in output_files: + file_path = spec_dir / filename + if file_path.exists(): + try: + content = file_path.read_text() + # Limit individual file size + if len(content) > 10000: + content = content[:10000] + "\n\n[... file truncated ...]" + outputs.append(f"**{filename}**:\n```\n{content}\n```") + except Exception: + pass # Skip files that can't be read + + return "\n\n".join(outputs) if outputs else "" diff --git a/auto-claude/spec/phases/planning_phases.py b/auto-claude/spec/phases/planning_phases.py index 8d057ed1..7cbd81d8 100644 --- a/auto-claude/spec/phases/planning_phases.py +++ b/auto-claude/spec/phases/planning_phases.py @@ -73,7 +73,10 @@ class PlanningPhaseMixin: f"Running planner agent (attempt {attempt + 1})...", "progress" ) - success, output = await self.run_agent_fn("planner.md") + success, output = await self.run_agent_fn( + "planner.md", + phase_name="planning", + ) if success and plan_file.exists(): result = self.spec_validator.validate_implementation_plan() @@ -161,6 +164,7 @@ Read the failed files, understand the errors, and fix them. success, output = await self.run_agent_fn( "validation_fixer.md", additional_context=context_str, + phase_name="validation", ) if not success: diff --git a/auto-claude/spec/phases/requirements_phases.py b/auto-claude/spec/phases/requirements_phases.py index a1eb9628..05a72679 100644 --- a/auto-claude/spec/phases/requirements_phases.py +++ b/auto-claude/spec/phases/requirements_phases.py @@ -221,6 +221,7 @@ Output your findings to research.json. success, output = await self.run_agent_fn( "spec_researcher.md", additional_context=context_str, + phase_name="research", ) if success and research_file.exists(): diff --git a/auto-claude/spec/phases/spec_phases.py b/auto-claude/spec/phases/spec_phases.py index d8ee3117..29689c31 100644 --- a/auto-claude/spec/phases/spec_phases.py +++ b/auto-claude/spec/phases/spec_phases.py @@ -50,6 +50,7 @@ Create: success, output = await self.run_agent_fn( "spec_quick.md", additional_context=context_str, + phase_name="quick_spec", ) if success and spec_file.exists(): @@ -85,7 +86,10 @@ Create: f"Running spec writer (attempt {attempt + 1})...", "progress" ) - success, output = await self.run_agent_fn("spec_writer.md") + success, output = await self.run_agent_fn( + "spec_writer.md", + phase_name="spec_writing", + ) if success and spec_file.exists(): result = self.spec_validator.validate_spec_document() @@ -162,6 +166,7 @@ Output critique_report.json with: success, output = await self.run_agent_fn( "spec_critic.md", additional_context=context_str, + phase_name="self_critique", ) if success: diff --git a/auto-claude/spec/pipeline/agent_runner.py b/auto-claude/spec/pipeline/agent_runner.py index 5dbc7edb..57e08f06 100644 --- a/auto-claude/spec/pipeline/agent_runner.py +++ b/auto-claude/spec/pipeline/agent_runner.py @@ -12,7 +12,7 @@ from ui.capabilities import configure_safe_encoding configure_safe_encoding() -from client import create_client +from core.client import create_client from debug import debug, debug_detailed, debug_error, debug_section, debug_success from task_logger import ( LogEntryType, @@ -49,6 +49,8 @@ class AgentRunner: prompt_file: str, additional_context: str = "", interactive: bool = False, + thinking_budget: int | None = None, + prior_phase_summaries: str | None = None, ) -> tuple[bool, str]: """Run an agent with the given prompt. @@ -56,6 +58,8 @@ class AgentRunner: prompt_file: The prompt file to use (relative to prompts directory) additional_context: Additional context to add to the prompt interactive: Whether to run in interactive mode + thinking_budget: Token budget for extended thinking (None = disabled) + prior_phase_summaries: Summaries from previous phases for context Returns: Tuple of (success, response_text) @@ -88,6 +92,15 @@ class AgentRunner: prompt += f"\n\n---\n\n**Spec Directory**: {self.spec_dir}\n" prompt += f"**Project Directory**: {self.project_dir}\n" + # Add summaries from previous phases (compaction) + if prior_phase_summaries: + prompt += f"\n{prior_phase_summaries}\n" + debug_detailed( + "agent_runner", + "Added prior phase summaries", + summaries_length=len(prior_phase_summaries), + ) + if additional_context: prompt += f"\n{additional_context}\n" debug_detailed( @@ -96,9 +109,18 @@ class AgentRunner: context_length=len(additional_context), ) - # Create client - debug("agent_runner", "Creating Claude SDK client...") - client = create_client(self.project_dir, self.spec_dir, self.model) + # Create client with thinking budget + debug( + "agent_runner", + "Creating Claude SDK client...", + thinking_budget=thinking_budget, + ) + client = create_client( + self.project_dir, + self.spec_dir, + self.model, + max_thinking_tokens=thinking_budget, + ) current_tool = None message_count = 0 diff --git a/auto-claude/spec/pipeline/orchestrator.py b/auto-claude/spec/pipeline/orchestrator.py index 4db90d9a..fbfbffc0 100644 --- a/auto-claude/spec/pipeline/orchestrator.py +++ b/auto-claude/spec/pipeline/orchestrator.py @@ -9,6 +9,7 @@ import json from collections.abc import Callable from pathlib import Path +from phase_config import get_spec_phase_thinking_budget from review import run_review_checkpoint from task_logger import ( LogEntryType, @@ -27,6 +28,7 @@ from ui import ( ) from .. import complexity, phases, requirements +from ..compaction import format_phase_summaries, gather_phase_outputs, summarize_phase_output from ..validate_pkg.spec_validator import SpecValidator from .agent_runner import AgentRunner from .models import ( @@ -48,7 +50,8 @@ class SpecOrchestrator: spec_name: str | None = None, spec_dir: Path | None = None, # Use existing spec directory (for UI integration) - model: str = "claude-opus-4-5-20251101", + model: str = "claude-sonnet-4-5-20250929", + thinking_level: str = "medium", # Thinking level for extended thinking complexity_override: str | None = None, # Force a specific complexity use_ai_assessment: bool = True, # Use AI for complexity assessment (vs heuristics) dev_mode: bool = False, # Dev mode: specs in gitignored folder, code changes to auto-claude/ @@ -61,6 +64,7 @@ class SpecOrchestrator: spec_name: Optional spec name (for existing specs) spec_dir: Optional existing spec directory (for UI integration) model: The model to use for agent execution + thinking_level: Thinking level (none, low, medium, high, ultrathink) complexity_override: Force a specific complexity level use_ai_assessment: Whether to use AI for complexity assessment dev_mode: Deprecated, kept for API compatibility @@ -68,6 +72,7 @@ class SpecOrchestrator: self.project_dir = Path(project_dir) self.task_description = task_description self.model = model + self.thinking_level = thinking_level self.complexity_override = complexity_override self.use_ai_assessment = use_ai_assessment self.dev_mode = dev_mode @@ -96,6 +101,10 @@ class SpecOrchestrator: # Agent runner (initialized when needed) self._agent_runner: AgentRunner | None = None + # Phase summaries for conversation compaction + # Stores summaries from completed phases to provide context to subsequent phases + self._phase_summaries: dict[str, str] = {} + def _get_agent_runner(self) -> AgentRunner: """Get or create the agent runner. @@ -114,6 +123,7 @@ class SpecOrchestrator: prompt_file: str, additional_context: str = "", interactive: bool = False, + phase_name: str | None = None, ) -> tuple[bool, str]: """Run an agent with the given prompt. @@ -121,12 +131,55 @@ class SpecOrchestrator: prompt_file: The prompt file to use additional_context: Additional context to add interactive: Whether to run in interactive mode + phase_name: Name of the phase (for thinking budget lookup) Returns: Tuple of (success, response_text) """ runner = self._get_agent_runner() - return await runner.run_agent(prompt_file, additional_context, interactive) + + # Get thinking budget for this phase + thinking_budget = None + if phase_name: + thinking_budget = get_spec_phase_thinking_budget(phase_name) + + # Format prior phase summaries for context + prior_summaries = format_phase_summaries(self._phase_summaries) + + return await runner.run_agent( + prompt_file, + additional_context, + interactive, + thinking_budget=thinking_budget, + prior_phase_summaries=prior_summaries if prior_summaries else None, + ) + + async def _store_phase_summary(self, phase_name: str) -> None: + """Summarize and store phase output for subsequent phases. + + Args: + phase_name: Name of the completed phase + """ + try: + # Gather outputs from this phase + phase_output = await gather_phase_outputs(self.spec_dir, phase_name) + if not phase_output: + return + + # Summarize the output + summary = await summarize_phase_output( + phase_name, + phase_output, + model="claude-sonnet-4-5-20250929", # Use Sonnet for efficiency + target_words=500, + ) + + if summary: + self._phase_summaries[phase_name] = summary + + except Exception as e: + # Don't fail the pipeline if summarization fails + print_status(f"Phase summarization skipped: {e}", "warning") async def run(self, interactive: bool = True, auto_approve: bool = False) -> bool: """Run the spec creation process with dynamic phase selection. @@ -199,6 +252,8 @@ class SpecOrchestrator: LogPhase.PLANNING, success=False, message="Discovery failed" ) return False + # Store summary for subsequent phases (compaction) + await self._store_phase_summary("discovery") # === PHASE 2: REQUIREMENTS GATHERING === result = await run_phase( @@ -213,6 +268,8 @@ class SpecOrchestrator: message="Requirements gathering failed", ) return False + # Store summary for subsequent phases (compaction) + await self._store_phase_summary("requirements") # Rename spec folder with better name from requirements rename_spec_dir_from_requirements(self.spec_dir) @@ -275,6 +332,10 @@ class SpecOrchestrator: results.append(result) phases_executed.append(phase_name) + # Store summary for subsequent phases (compaction) + if result.success: + await self._store_phase_summary(phase_name) + if not result.success: print() print_status( diff --git a/auto-claude/task_logger/logger.py b/auto-claude/task_logger/logger.py index 9f5bad00..e0f84fa0 100644 --- a/auto-claude/task_logger/logger.py +++ b/auto-claude/task_logger/logger.py @@ -5,6 +5,8 @@ Main TaskLogger class for logging task execution. from datetime import datetime, timezone from pathlib import Path +from core.debug import debug, debug_error, debug_info, debug_success, is_debug_enabled + from .models import LogEntry, LogEntryType, LogPhase from .storage import LogStorage from .streaming import emit_marker @@ -62,6 +64,49 @@ class TaskLogger: """Add an entry to the current phase.""" self.storage.add_entry(entry) + def _debug_log( + self, + content: str, + entry_type: LogEntryType = LogEntryType.TEXT, + phase: str | None = None, + tool_name: str | None = None, + **kwargs, + ) -> None: + """ + Output a log entry to the terminal via the debug logging system. + + Only outputs when DEBUG=true is set in the environment. + + Args: + content: The message content + entry_type: Type of entry for formatting + phase: Current phase name + tool_name: Tool name if this is a tool log + **kwargs: Additional key-value pairs for debug output + """ + if not is_debug_enabled(): + return + + module = "task_logger" + prefix = f"[{phase or 'unknown'}]" if phase else "" + + if tool_name: + prefix = f"{prefix}[{tool_name}]" + + message = f"{prefix} {content}" if prefix else content + + # Route to appropriate debug function based on entry type + if entry_type == LogEntryType.ERROR: + debug_error(module, message, **kwargs) + elif entry_type == LogEntryType.SUCCESS: + debug_success(module, message, **kwargs) + elif entry_type in (LogEntryType.INFO, LogEntryType.PHASE_START, LogEntryType.PHASE_END): + debug_info(module, message, **kwargs) + elif entry_type in (LogEntryType.TOOL_START, LogEntryType.TOOL_END): + debug(module, message, level=2, **kwargs) + else: + debug(module, message, **kwargs) + def set_session(self, session: int) -> None: """Set the current session number.""" self.current_session = session @@ -110,15 +155,19 @@ class TaskLogger: self._emit("PHASE_START", {"phase": phase_key, "timestamp": self._timestamp()}) # Add phase start entry + phase_message = message or f"Starting {phase_key} phase" entry = LogEntry( timestamp=self._timestamp(), type=LogEntryType.PHASE_START.value, - content=message or f"Starting {phase_key} phase", + content=phase_message, phase=phase_key, session=self.current_session, ) self._add_entry(entry) + # Debug log (when DEBUG=true) + self._debug_log(phase_message, LogEntryType.PHASE_START, phase_key) + # Also print the message if message: print(message, flush=True) @@ -147,16 +196,20 @@ class TaskLogger: ) # Add phase end entry + phase_message = message or f"{'Completed' if success else 'Failed'} {phase_key} phase" entry = LogEntry( timestamp=self._timestamp(), type=LogEntryType.PHASE_END.value, - content=message - or f"{'Completed' if success else 'Failed'} {phase_key} phase", + content=phase_message, phase=phase_key, session=self.current_session, ) self._add_entry(entry) + # Debug log (when DEBUG=true) + entry_type = LogEntryType.SUCCESS if success else LogEntryType.ERROR + self._debug_log(phase_message, entry_type, phase_key) + if message: print(message, flush=True) @@ -205,6 +258,9 @@ class TaskLogger: }, ) + # Debug log (when DEBUG=true) + self._debug_log(content, entry_type, phase_key, subtask=self.current_subtask) + # Also print to console (unless caller handles printing) if print_to_console: print(content, flush=True) @@ -272,6 +328,16 @@ class TaskLogger: }, ) + # Debug log (when DEBUG=true) - include detail for verbose mode + self._debug_log( + content, + entry_type, + phase_key, + subtask=self.current_subtask, + subphase=subphase, + detail=detail[:500] + "..." if len(detail) > 500 else detail, + ) + if print_to_console: print(content, flush=True) @@ -308,6 +374,9 @@ class TaskLogger: {"subphase": subphase, "phase": phase_key, "timestamp": self._timestamp()}, ) + # Debug log (when DEBUG=true) + self._debug_log(f"Starting {subphase}", LogEntryType.INFO, phase_key, subphase=subphase) + if print_to_console: print(f"\n--- {subphase} ---", flush=True) @@ -352,6 +421,14 @@ class TaskLogger: {"name": tool_name, "input": display_input, "phase": phase_key}, ) + # Debug log (when DEBUG=true) + self._debug_log( + display_input or "started", + LogEntryType.TOOL_START, + phase_key, + tool_name=tool_name, + ) + if print_to_console: print(f"\n[Tool: {tool_name}]", flush=True) @@ -419,6 +496,18 @@ class TaskLogger: }, ) + # Debug log (when DEBUG=true) + debug_kwargs = {"status": status} + if display_result: + debug_kwargs["result"] = display_result + self._debug_log( + content, + LogEntryType.SUCCESS if success else LogEntryType.ERROR, + phase_key, + tool_name=tool_name, + **debug_kwargs, + ) + if print_to_console: if result: print(f" [{status}] {display_result}", flush=True) diff --git a/run.py/agent.py b/run.py/agent.py new file mode 100644 index 00000000..e69de29b