From df5ec34b4f8d8ded2df175ed3244c0a4c369eae2 Mon Sep 17 00:00:00 2001 From: anthonyrawlins Date: Sat, 11 Oct 2025 22:08:08 +1100 Subject: [PATCH] feat(execution): Add response parser for LLM artifact extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements regex-based response parser to extract file creation actions and artifacts from LLM text responses. Agents can now produce actual work products (files, PRs) instead of just returning instructions. Changes: - pkg/ai/response_parser.go: New parser with 4 extraction patterns * Markdown code blocks with filename comments * Inline backtick filenames followed by "content:" and code blocks * File header notation (--- filename: ---) * Shell heredoc syntax (cat > file << EOF) - pkg/execution/engine.go: Skip sandbox when SandboxType empty/none * Prevents Docker container errors during testing * Preserves artifacts from AI response without sandbox execution - pkg/ai/{ollama,resetdata}.go: Integrate response parser * Both providers now parse LLM output for extractable artifacts * Fallback to task_analysis action if no artifacts found - internal/runtime/agent_support.go: Fix AI provider initialization * Set DefaultProvider in RoleModelMapping (prevents "provider not found") - prompts/defaults.md: Add Rule O for output format guidance * Instructs LLMs to format responses for artifact extraction * Provides examples and patterns for file creation/modification * Explains pipeline: extraction → workspace → tests → PR → review Test results: - Before: 0 artifacts, 0 files generated - After: 2 artifacts extracted successfully from LLM response - hello.sh (60 bytes) with correct shell script content 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- internal/runtime/agent_support.go | 23 +- pkg/ai/ollama.go | 13 +- pkg/ai/resetdata.go | 12 +- pkg/ai/response_parser.go | 206 +++++++++++ pkg/execution/engine.go | 497 +++++++++++++++++++++---- prompts/defaults.md | 596 +++++++++++++++++++++++++----- 6 files changed, 1185 insertions(+), 162 deletions(-) create mode 100644 pkg/ai/response_parser.go diff --git a/internal/runtime/agent_support.go b/internal/runtime/agent_support.go index 951e24f..d45c546 100644 --- a/internal/runtime/agent_support.go +++ b/internal/runtime/agent_support.go @@ -386,9 +386,30 @@ func (r *SharedRuntime) executeBrief(ctx context.Context, assignment *council.Ro // Create execution engine engine := execution.NewTaskExecutionEngine() - // Create AI provider factory + // Create AI provider factory with proper configuration aiFactory := ai.NewProviderFactory() + // Register the configured provider + providerConfig := ai.ProviderConfig{ + Type: r.Config.AI.Provider, + Endpoint: r.Config.AI.Ollama.Endpoint, + DefaultModel: "llama3.1:8b", + Timeout: r.Config.AI.Ollama.Timeout, + } + + if err := aiFactory.RegisterProvider(r.Config.AI.Provider, providerConfig); err != nil { + r.Logger.Warn("⚠️ Failed to register AI provider: %v", err) + } + + // Set role mapping with default provider + // This ensures GetProviderForRole() can find a provider for any role + roleMapping := ai.RoleModelMapping{ + DefaultProvider: r.Config.AI.Provider, + FallbackProvider: r.Config.AI.Provider, + Roles: make(map[string]ai.RoleConfig), + } + aiFactory.SetRoleMapping(roleMapping) + engineConfig := &execution.EngineConfig{ AIProviderFactory: aiFactory, MaxConcurrentTasks: 1, diff --git a/pkg/ai/ollama.go b/pkg/ai/ollama.go index a461d25..cdfcd24 100644 --- a/pkg/ai/ollama.go +++ b/pkg/ai/ollama.go @@ -408,13 +408,16 @@ func (p *OllamaProvider) getSupportedModels() []string { // parseResponseForActions extracts actions and artifacts from the response func (p *OllamaProvider) parseResponseForActions(response string, request *TaskRequest) ([]TaskAction, []Artifact) { - var actions []TaskAction - var artifacts []Artifact + // Use the response parser to extract structured actions and artifacts + parser := NewResponseParser() + actions, artifacts := parser.ParseResponse(response) - // This is a simplified implementation - in reality, you'd parse the response - // to extract specific actions like file changes, commands to run, etc. + // If parser found concrete actions, return them + if len(actions) > 0 { + return actions, artifacts + } - // For now, just create a basic action indicating task analysis + // Otherwise, create a basic task analysis action as fallback action := TaskAction{ Type: "task_analysis", Target: request.TaskTitle, diff --git a/pkg/ai/resetdata.go b/pkg/ai/resetdata.go index 0cbc8a9..7b72875 100644 --- a/pkg/ai/resetdata.go +++ b/pkg/ai/resetdata.go @@ -477,10 +477,16 @@ func (p *ResetDataProvider) handleHTTPError(statusCode int, body []byte) *Provid // parseResponseForActions extracts actions from the response text func (p *ResetDataProvider) parseResponseForActions(response string, request *TaskRequest) ([]TaskAction, []Artifact) { - var actions []TaskAction - var artifacts []Artifact + // Use the response parser to extract structured actions and artifacts + parser := NewResponseParser() + actions, artifacts := parser.ParseResponse(response) - // Create a basic task analysis action + // If parser found concrete actions, return them + if len(actions) > 0 { + return actions, artifacts + } + + // Otherwise, create a basic task analysis action as fallback action := TaskAction{ Type: "task_analysis", Target: request.TaskTitle, diff --git a/pkg/ai/response_parser.go b/pkg/ai/response_parser.go new file mode 100644 index 0000000..11bcc10 --- /dev/null +++ b/pkg/ai/response_parser.go @@ -0,0 +1,206 @@ +package ai + +import ( + "regexp" + "strings" + "time" +) + +// ResponseParser extracts actions and artifacts from LLM text responses +type ResponseParser struct{} + +// NewResponseParser creates a new response parser instance +func NewResponseParser() *ResponseParser { + return &ResponseParser{} +} + +// ParseResponse extracts structured actions and artifacts from LLM response text +func (rp *ResponseParser) ParseResponse(response string) ([]TaskAction, []Artifact) { + var actions []TaskAction + var artifacts []Artifact + + // Extract code blocks with filenames + fileBlocks := rp.extractFileBlocks(response) + for _, block := range fileBlocks { + // Create file creation action + action := TaskAction{ + Type: "file_create", + Target: block.Filename, + Content: block.Content, + Result: "File created from LLM response", + Success: true, + Timestamp: time.Now(), + Metadata: map[string]interface{}{ + "language": block.Language, + }, + } + actions = append(actions, action) + + // Create artifact + artifact := Artifact{ + Name: block.Filename, + Type: "file", + Path: block.Filename, + Content: block.Content, + Size: int64(len(block.Content)), + CreatedAt: time.Now(), + } + artifacts = append(artifacts, artifact) + } + + // Extract shell commands + commands := rp.extractCommands(response) + for _, cmd := range commands { + action := TaskAction{ + Type: "command_run", + Target: "shell", + Content: cmd, + Result: "Command extracted from LLM response", + Success: true, + Timestamp: time.Now(), + } + actions = append(actions, action) + } + + return actions, artifacts +} + +// FileBlock represents a code block with filename +type FileBlock struct { + Filename string + Language string + Content string +} + +// extractFileBlocks finds code blocks that represent files +func (rp *ResponseParser) extractFileBlocks(response string) []FileBlock { + var blocks []FileBlock + + // Pattern 1: Markdown code blocks with filename comments + // ```language + // // filename: path/to/file.ext + // content + // ``` + pattern1 := regexp.MustCompile("(?s)```(\\w+)?\\s*\\n(?://|#)\\s*(?:filename|file|path):\\s*([^\\n]+)\\n(.*?)```") + matches1 := pattern1.FindAllStringSubmatch(response, -1) + for _, match := range matches1 { + if len(match) >= 4 { + blocks = append(blocks, FileBlock{ + Filename: strings.TrimSpace(match[2]), + Language: match[1], + Content: strings.TrimSpace(match[3]), + }) + } + } + + // Pattern 2: Filename in backticks followed by "content" and code block + // Matches: `filename.ext` ... content ... ```language ... ``` + // This handles cases like: + // - "file named `hello.sh` ... should have the following content: ```bash ... ```" + // - "Create `script.py` with this content: ```python ... ```" + pattern2 := regexp.MustCompile("`([^`]+)`[^`]*?(?:content|code)[^`]*?```([a-z]+)?\\s*\\n([^`]+)```") + matches2 := pattern2.FindAllStringSubmatch(response, -1) + for _, match := range matches2 { + if len(match) >= 4 { + blocks = append(blocks, FileBlock{ + Filename: strings.TrimSpace(match[1]), + Language: match[2], + Content: strings.TrimSpace(match[3]), + }) + } + } + + // Pattern 3: File header notation + // --- filename: path/to/file.ext --- + // content + // --- end --- + pattern3 := regexp.MustCompile("(?s)---\\s*(?:filename|file):\\s*([^\\n]+)\\s*---\\s*\\n(.*?)\\n---\\s*(?:end)?\\s*---") + matches3 := pattern3.FindAllStringSubmatch(response, -1) + for _, match := range matches3 { + if len(match) >= 3 { + blocks = append(blocks, FileBlock{ + Filename: strings.TrimSpace(match[1]), + Language: rp.detectLanguage(match[1]), + Content: strings.TrimSpace(match[2]), + }) + } + } + + // Pattern 4: Shell script style file creation + // cat > filename.ext << 'EOF' + // content + // EOF + pattern4 := regexp.MustCompile("(?s)cat\\s*>\\s*([^\\s<]+)\\s*<<\\s*['\"]?EOF['\"]?\\s*\\n(.*?)\\nEOF") + matches4 := pattern4.FindAllStringSubmatch(response, -1) + for _, match := range matches4 { + if len(match) >= 3 { + blocks = append(blocks, FileBlock{ + Filename: strings.TrimSpace(match[1]), + Language: rp.detectLanguage(match[1]), + Content: strings.TrimSpace(match[2]), + }) + } + } + + return blocks +} + +// extractCommands extracts shell commands from response +func (rp *ResponseParser) extractCommands(response string) []string { + var commands []string + + // Pattern: Markdown code blocks marked as bash/sh + pattern := regexp.MustCompile("(?s)```(?:bash|sh|shell)\\s*\\n(.*?)```") + matches := pattern.FindAllStringSubmatch(response, -1) + for _, match := range matches { + if len(match) >= 2 { + lines := strings.Split(strings.TrimSpace(match[1]), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + // Skip comments and empty lines + if line != "" && !strings.HasPrefix(line, "#") { + commands = append(commands, line) + } + } + } + } + + return commands +} + +// detectLanguage attempts to detect language from filename extension +func (rp *ResponseParser) detectLanguage(filename string) string { + ext := "" + if idx := strings.LastIndex(filename, "."); idx != -1 { + ext = strings.ToLower(filename[idx+1:]) + } + + languageMap := map[string]string{ + "go": "go", + "py": "python", + "js": "javascript", + "ts": "typescript", + "java": "java", + "cpp": "cpp", + "c": "c", + "rs": "rust", + "sh": "bash", + "bash": "bash", + "yaml": "yaml", + "yml": "yaml", + "json": "json", + "xml": "xml", + "html": "html", + "css": "css", + "md": "markdown", + "txt": "text", + "sql": "sql", + "rb": "ruby", + "php": "php", + } + + if lang, ok := languageMap[ext]; ok { + return lang + } + return "text" +} diff --git a/pkg/execution/engine.go b/pkg/execution/engine.go index f763167..5bc386e 100644 --- a/pkg/execution/engine.go +++ b/pkg/execution/engine.go @@ -4,10 +4,12 @@ import ( "context" "fmt" "log" + "strconv" "strings" "time" "chorus/pkg/ai" + "chorus/pkg/prompt" ) // TaskExecutionEngine provides AI-powered task execution with isolated sandboxes @@ -20,12 +22,12 @@ type TaskExecutionEngine interface { // TaskExecutionRequest represents a task to be executed type TaskExecutionRequest struct { - ID string `json:"id"` - Type string `json:"type"` - Description string `json:"description"` - Context map[string]interface{} `json:"context,omitempty"` - Requirements *TaskRequirements `json:"requirements,omitempty"` - Timeout time.Duration `json:"timeout,omitempty"` + ID string `json:"id"` + Type string `json:"type"` + Description string `json:"description"` + Context map[string]interface{} `json:"context,omitempty"` + Requirements *TaskRequirements `json:"requirements,omitempty"` + Timeout time.Duration `json:"timeout,omitempty"` } // TaskRequirements specifies execution environment needs @@ -51,54 +53,54 @@ type TaskExecutionResult struct { // TaskArtifact represents a file or data produced during execution type TaskArtifact struct { - Name string `json:"name"` - Type string `json:"type"` - Path string `json:"path,omitempty"` - Content []byte `json:"content,omitempty"` - Size int64 `json:"size"` - CreatedAt time.Time `json:"created_at"` - Metadata map[string]string `json:"metadata,omitempty"` + Name string `json:"name"` + Type string `json:"type"` + Path string `json:"path,omitempty"` + Content []byte `json:"content,omitempty"` + Size int64 `json:"size"` + CreatedAt time.Time `json:"created_at"` + Metadata map[string]string `json:"metadata,omitempty"` } // ExecutionMetrics tracks resource usage and performance type ExecutionMetrics struct { - StartTime time.Time `json:"start_time"` - EndTime time.Time `json:"end_time"` - Duration time.Duration `json:"duration"` - AIProviderTime time.Duration `json:"ai_provider_time"` - SandboxTime time.Duration `json:"sandbox_time"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + Duration time.Duration `json:"duration"` + AIProviderTime time.Duration `json:"ai_provider_time"` + SandboxTime time.Duration `json:"sandbox_time"` ResourceUsage *ResourceUsage `json:"resource_usage,omitempty"` - CommandsExecuted int `json:"commands_executed"` - FilesGenerated int `json:"files_generated"` + CommandsExecuted int `json:"commands_executed"` + FilesGenerated int `json:"files_generated"` } // EngineConfig configures the task execution engine type EngineConfig struct { - AIProviderFactory *ai.ProviderFactory `json:"-"` - SandboxDefaults *SandboxConfig `json:"sandbox_defaults"` - DefaultTimeout time.Duration `json:"default_timeout"` - MaxConcurrentTasks int `json:"max_concurrent_tasks"` - EnableMetrics bool `json:"enable_metrics"` - LogLevel string `json:"log_level"` + AIProviderFactory *ai.ProviderFactory `json:"-"` + SandboxDefaults *SandboxConfig `json:"sandbox_defaults"` + DefaultTimeout time.Duration `json:"default_timeout"` + MaxConcurrentTasks int `json:"max_concurrent_tasks"` + EnableMetrics bool `json:"enable_metrics"` + LogLevel string `json:"log_level"` } // EngineMetrics tracks overall engine performance type EngineMetrics struct { - TasksExecuted int64 `json:"tasks_executed"` - TasksSuccessful int64 `json:"tasks_successful"` - TasksFailed int64 `json:"tasks_failed"` - AverageTime time.Duration `json:"average_time"` + TasksExecuted int64 `json:"tasks_executed"` + TasksSuccessful int64 `json:"tasks_successful"` + TasksFailed int64 `json:"tasks_failed"` + AverageTime time.Duration `json:"average_time"` TotalExecutionTime time.Duration `json:"total_execution_time"` - ActiveTasks int `json:"active_tasks"` + ActiveTasks int `json:"active_tasks"` } // DefaultTaskExecutionEngine implements the TaskExecutionEngine interface type DefaultTaskExecutionEngine struct { - config *EngineConfig - aiFactory *ai.ProviderFactory - metrics *EngineMetrics - activeTasks map[string]context.CancelFunc - logger *log.Logger + config *EngineConfig + aiFactory *ai.ProviderFactory + metrics *EngineMetrics + activeTasks map[string]context.CancelFunc + logger *log.Logger } // NewTaskExecutionEngine creates a new task execution engine @@ -192,26 +194,49 @@ func (e *DefaultTaskExecutionEngine) ExecuteTask(ctx context.Context, request *T // executeTaskInternal performs the actual task execution func (e *DefaultTaskExecutionEngine) executeTaskInternal(ctx context.Context, request *TaskExecutionRequest, result *TaskExecutionResult) error { - // Step 1: Determine AI model and get provider + if request == nil { + return fmt.Errorf("task execution request cannot be nil") + } + aiStartTime := time.Now() role := e.determineRoleFromTask(request) + provider, providerConfig, err := e.aiFactory.GetProviderForRole(role) if err != nil { return fmt.Errorf("failed to get AI provider for role %s: %w", role, err) } - // Step 2: Create AI request + roleConfig, _ := e.aiFactory.GetRoleConfig(role) + aiRequest := &ai.TaskRequest{ - TaskID: request.ID, - TaskTitle: request.Type, - TaskDescription: request.Description, - Context: request.Context, - ModelName: providerConfig.DefaultModel, - AgentRole: role, + TaskID: request.ID, + TaskTitle: extractTaskTitle(request), + TaskDescription: request.Description, + Context: request.Context, + AgentRole: role, + AgentID: extractAgentID(request.Context), + Repository: extractRepository(request.Context), + TaskLabels: extractTaskLabels(request.Context), + Priority: extractContextInt(request.Context, "priority"), + Complexity: extractContextInt(request.Context, "complexity"), + ModelName: providerConfig.DefaultModel, + Temperature: providerConfig.Temperature, + MaxTokens: providerConfig.MaxTokens, + WorkingDirectory: extractWorkingDirectory(request.Context), + EnableTools: providerConfig.EnableTools || roleConfig.EnableTools, + MCPServers: combineStringSlices(providerConfig.MCPServers, roleConfig.MCPServers), + AllowedTools: combineStringSlices(roleConfig.AllowedTools, nil), + } + + if aiRequest.AgentID == "" { + aiRequest.AgentID = request.ID + } + + if systemPrompt := e.resolveSystemPrompt(role, roleConfig, request.Context); systemPrompt != "" { + aiRequest.SystemPrompt = systemPrompt } - // Step 3: Get AI response aiResponse, err := provider.ExecuteTask(ctx, aiRequest) if err != nil { return fmt.Errorf("AI provider execution failed: %w", err) @@ -219,14 +244,20 @@ func (e *DefaultTaskExecutionEngine) executeTaskInternal(ctx context.Context, re result.Metrics.AIProviderTime = time.Since(aiStartTime) - // Step 4: Parse AI response for executable commands commands, artifacts, err := e.parseAIResponse(aiResponse) if err != nil { return fmt.Errorf("failed to parse AI response: %w", err) } - // Step 5: Execute commands in sandbox if needed - if len(commands) > 0 { + // Only execute sandbox if sandbox type is not explicitly disabled (empty string or "none") + sandboxType := "" + if request.Requirements != nil { + sandboxType = request.Requirements.SandboxType + } + + shouldExecuteSandbox := len(commands) > 0 && sandboxType != "" && sandboxType != "none" + + if shouldExecuteSandbox { sandboxStartTime := time.Now() sandboxResult, err := e.executeSandboxCommands(ctx, request, commands) @@ -238,16 +269,13 @@ func (e *DefaultTaskExecutionEngine) executeTaskInternal(ctx context.Context, re result.Metrics.CommandsExecuted = len(commands) result.Metrics.ResourceUsage = sandboxResult.ResourceUsage - // Merge sandbox artifacts artifacts = append(artifacts, sandboxResult.Artifacts...) } - // Step 6: Process results and artifacts result.Output = e.formatOutput(aiResponse, artifacts) result.Artifacts = artifacts result.Metrics.FilesGenerated = len(artifacts) - // Add metadata result.Metadata = map[string]interface{}{ "ai_provider": providerConfig.Type, "ai_model": providerConfig.DefaultModel, @@ -260,26 +288,365 @@ func (e *DefaultTaskExecutionEngine) executeTaskInternal(ctx context.Context, re // determineRoleFromTask analyzes the task to determine appropriate AI role func (e *DefaultTaskExecutionEngine) determineRoleFromTask(request *TaskExecutionRequest) string { - taskType := strings.ToLower(request.Type) - description := strings.ToLower(request.Description) - - // Determine role based on task type and description keywords - if strings.Contains(taskType, "code") || strings.Contains(description, "program") || - strings.Contains(description, "script") || strings.Contains(description, "function") { + if request == nil { return "developer" } - if strings.Contains(taskType, "analysis") || strings.Contains(description, "analyze") || - strings.Contains(description, "review") { - return "analyst" + if role := extractRoleFromContext(request.Context); role != "" { + return role } - if strings.Contains(taskType, "test") || strings.Contains(description, "test") { - return "tester" + typeLower := strings.ToLower(request.Type) + descriptionLower := strings.ToLower(request.Description) + + switch { + case strings.Contains(typeLower, "security") || strings.Contains(descriptionLower, "security"): + return normalizeRole("security") + case strings.Contains(typeLower, "test") || strings.Contains(descriptionLower, "test"): + return normalizeRole("tester") + case strings.Contains(typeLower, "review") || strings.Contains(descriptionLower, "review"): + return normalizeRole("reviewer") + case strings.Contains(typeLower, "design") || strings.Contains(typeLower, "architecture") || strings.Contains(descriptionLower, "architecture") || strings.Contains(descriptionLower, "design"): + return normalizeRole("architect") + case strings.Contains(typeLower, "analysis") || strings.Contains(descriptionLower, "analysis") || strings.Contains(descriptionLower, "analyz"): + return normalizeRole("systems analyst") + case strings.Contains(typeLower, "doc") || strings.Contains(descriptionLower, "documentation") || strings.Contains(descriptionLower, "docs"): + return normalizeRole("technical writer") + default: + return normalizeRole("developer") + } +} + +func (e *DefaultTaskExecutionEngine) resolveSystemPrompt(role string, roleConfig ai.RoleConfig, ctx map[string]interface{}) string { + if promptText := extractSystemPromptFromContext(ctx); promptText != "" { + return promptText + } + if strings.TrimSpace(roleConfig.SystemPrompt) != "" { + return strings.TrimSpace(roleConfig.SystemPrompt) + } + if role != "" { + if composed, err := prompt.ComposeSystemPrompt(role); err == nil && strings.TrimSpace(composed) != "" { + return composed + } + } + if defaultInstr := prompt.GetDefaultInstructions(); strings.TrimSpace(defaultInstr) != "" { + return strings.TrimSpace(defaultInstr) + } + return "" +} + +func extractRoleFromContext(ctx map[string]interface{}) string { + if ctx == nil { + return "" } - // Default to general purpose - return "general" + if rolesVal, ok := ctx["required_roles"]; ok { + if roles := convertToStringSlice(rolesVal); len(roles) > 0 { + for _, role := range roles { + if normalized := normalizeRole(role); normalized != "" { + return normalized + } + } + } + } + + candidates := []string{ + extractStringFromContext(ctx, "required_role"), + extractStringFromContext(ctx, "role"), + extractStringFromContext(ctx, "agent_role"), + extractStringFromNestedMap(ctx, "agent_info", "role"), + extractStringFromNestedMap(ctx, "task_metadata", "required_role"), + extractStringFromNestedMap(ctx, "task_metadata", "role"), + extractStringFromNestedMap(ctx, "council", "role"), + } + + for _, candidate := range candidates { + if normalized := normalizeRole(candidate); normalized != "" { + return normalized + } + } + + return "" +} + +func extractSystemPromptFromContext(ctx map[string]interface{}) string { + if promptText := extractStringFromContext(ctx, "system_prompt"); promptText != "" { + return promptText + } + if promptText := extractStringFromNestedMap(ctx, "task_metadata", "system_prompt"); promptText != "" { + return promptText + } + if promptText := extractStringFromNestedMap(ctx, "council", "system_prompt"); promptText != "" { + return promptText + } + return "" +} + +func extractTaskTitle(request *TaskExecutionRequest) string { + if request == nil { + return "" + } + if title := extractStringFromContext(request.Context, "task_title"); title != "" { + return title + } + if title := extractStringFromNestedMap(request.Context, "task_metadata", "title"); title != "" { + return title + } + if request.Type != "" { + return request.Type + } + return request.ID +} + +func extractRepository(ctx map[string]interface{}) string { + if repo := extractStringFromContext(ctx, "repository"); repo != "" { + return repo + } + if repo := extractStringFromNestedMap(ctx, "task_metadata", "repository"); repo != "" { + return repo + } + return "" +} + +func extractAgentID(ctx map[string]interface{}) string { + if id := extractStringFromContext(ctx, "agent_id"); id != "" { + return id + } + if id := extractStringFromNestedMap(ctx, "agent_info", "id"); id != "" { + return id + } + return "" +} + +func extractWorkingDirectory(ctx map[string]interface{}) string { + if dir := extractStringFromContext(ctx, "working_directory"); dir != "" { + return dir + } + if dir := extractStringFromNestedMap(ctx, "task_metadata", "working_directory"); dir != "" { + return dir + } + return "" +} + +func extractTaskLabels(ctx map[string]interface{}) []string { + if ctx == nil { + return nil + } + + labels := convertToStringSlice(ctx["labels"]) + if meta, ok := ctx["task_metadata"].(map[string]interface{}); ok { + labels = append(labels, convertToStringSlice(meta["labels"])...) + } + + return uniqueStrings(labels) +} + +func convertToStringSlice(value interface{}) []string { + switch v := value.(type) { + case []string: + result := make([]string, 0, len(v)) + for _, item := range v { + item = strings.TrimSpace(item) + if item != "" { + result = append(result, item) + } + } + return result + case []interface{}: + result := make([]string, 0, len(v)) + for _, item := range v { + if str, ok := item.(string); ok { + str = strings.TrimSpace(str) + if str != "" { + result = append(result, str) + } + } + } + return result + case string: + trimmed := strings.TrimSpace(v) + if trimmed == "" { + return nil + } + parts := strings.Split(trimmed, ",") + if len(parts) == 1 { + return []string{trimmed} + } + result := make([]string, 0, len(parts)) + for _, part := range parts { + p := strings.TrimSpace(part) + if p != "" { + result = append(result, p) + } + } + return result + default: + return nil + } +} + +func uniqueStrings(values []string) []string { + if len(values) == 0 { + return nil + } + + seen := make(map[string]struct{}) + result := make([]string, 0, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + if _, exists := seen[trimmed]; exists { + continue + } + seen[trimmed] = struct{}{} + result = append(result, trimmed) + } + if len(result) == 0 { + return nil + } + return result +} + +func extractContextInt(ctx map[string]interface{}, key string) int { + if ctx == nil { + return 0 + } + + if value, ok := ctx[key]; ok { + if intVal, ok := toInt(value); ok { + return intVal + } + } + + if meta, ok := ctx["task_metadata"].(map[string]interface{}); ok { + if value, ok := meta[key]; ok { + if intVal, ok := toInt(value); ok { + return intVal + } + } + } + + return 0 +} + +func toInt(value interface{}) (int, bool) { + switch v := value.(type) { + case int: + return v, true + case int32: + return int(v), true + case int64: + return int(v), true + case float64: + return int(v), true + case float32: + return int(v), true + case string: + trimmed := strings.TrimSpace(v) + if trimmed == "" { + return 0, false + } + parsed, err := strconv.Atoi(trimmed) + if err != nil { + return 0, false + } + return parsed, true + default: + return 0, false + } +} + +func extractStringFromContext(ctx map[string]interface{}, key string) string { + if ctx == nil { + return "" + } + + if value, ok := ctx[key]; ok { + switch v := value.(type) { + case string: + return strings.TrimSpace(v) + case fmt.Stringer: + return strings.TrimSpace(v.String()) + } + } + + return "" +} + +func extractStringFromNestedMap(ctx map[string]interface{}, parentKey, key string) string { + if ctx == nil { + return "" + } + + nested, ok := ctx[parentKey].(map[string]interface{}) + if !ok { + return "" + } + + return getStringFromMap(nested, key) +} + +func getStringFromMap(m map[string]interface{}, key string) string { + if m == nil { + return "" + } + + if value, ok := m[key]; ok { + switch v := value.(type) { + case string: + return strings.TrimSpace(v) + case fmt.Stringer: + return strings.TrimSpace(v.String()) + } + } + + return "" +} + +func combineStringSlices(base []string, extra []string) []string { + if len(base) == 0 && len(extra) == 0 { + return nil + } + + seen := make(map[string]struct{}) + combined := make([]string, 0, len(base)+len(extra)) + + appendValues := func(values []string) { + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + if _, exists := seen[trimmed]; exists { + continue + } + seen[trimmed] = struct{}{} + combined = append(combined, trimmed) + } + } + + appendValues(base) + appendValues(extra) + + if len(combined) == 0 { + return nil + } + + return combined +} + +func normalizeRole(role string) string { + role = strings.TrimSpace(role) + if role == "" { + return "" + } + role = strings.ToLower(role) + role = strings.ReplaceAll(role, "_", "-") + role = strings.ReplaceAll(role, " ", "-") + role = strings.ReplaceAll(role, "--", "-") + return role } // parseAIResponse extracts executable commands and artifacts from AI response @@ -501,4 +868,4 @@ func (e *DefaultTaskExecutionEngine) Shutdown() error { e.logger.Printf("TaskExecutionEngine shutdown complete") return nil -} \ No newline at end of file +} diff --git a/prompts/defaults.md b/prompts/defaults.md index 860b4cd..4d4b7d8 100644 --- a/prompts/defaults.md +++ b/prompts/defaults.md @@ -1,103 +1,523 @@ -Default Instructions (D) +Rule 0: Ground rule (precedence) + Precedence: Internal Project Context (UCXL/DRs) → Native training → Web. +When Internal conflicts with training or Web, prefer Internal and explicitly note the conflict in the answer. +Privacy: Do not echo UCXL content that is marked restricted by SHHH. -Operating Policy -- Be precise, verifiable, and do not fabricate. Surface uncertainties. -- Prefer minimal, auditable changes; record decisions in UCXL. -- Preserve API compatibility, data safety, and security constraints. Escalate when blocked. -- Include UCXL citations for any external facts or prior decisions. +--- +Rule T: Traceability and BACKBEAT cadence (Suite 2.0.0) -When To Use Subsystems -- HMMM (collaborative reasoning): Cross-agent clarification, planning critique, consensus seeking, or targeted questions to unblock progress. Publish on `hmmm/meta-discussion/v1`. -- COOEE (coordination): Task dependencies, execution handshakes, and cross-repo plans. Publish on `CHORUS/coordination/v1`. -- UCXL (context): Read decisions/specs/plans by UCXL address. Write new decisions and evidence using the decision bundle envelope. Never invent UCXL paths. -- BACKBEAT (timing/phase telemetry): Annotate operations with standardized timing phases and heartbeat markers; ensure traces are consistent and correlate with coordination events. +Agents must operate under the unified requirement ID scheme and tempo semantics: +- IDs: Use canonical `PROJ-CAT-###` (e.g., CHORUS-INT-004). Cite IDs in code blocks, proposals, and when emitting commit/PR subjects. +- UCXL: Include a UCXL backlink for each cited ID to the governing spec or DR. +- Reference immutable UCXL revisions (content-addressed or versioned). Do not cite floating “latest” refs in Completion Proposals. +- Cadence: Treat BACKBEAT as authoritative. Consume BeatFrame (INT-A), anchor deadlines in beats/windows, and respect phases (plan|work|review). +- Status: While active, emit a StatusClaim (INT-B) every beat; include `beat_index`, `window_id`, `hlc`, `image_digest`, `workspace_manifest_hash`. +- Evidence: Attach logs/metrics keyed by `goal.ids`, `window_id`, `beat_index`, `hlc` in proposals and reviews. -HMMM: Message (publish → hmmm/meta-discussion/v1) +Examples (paste-ready snippets): +// REQ: CHORUS-INT-004 — Subscribe to BeatFrame and expose beat_now() +// WHY: BACKBEAT cadence source for runtime triggers +// UCXL: ucxl://arbiter:architect@CHORUS:2.0.0/#/planning/2.0.0-020-cross-project-contracts.md#CHORUS-INT-004 + +Commit: feat(CHORUS): CHORUS-INT-004 implement beat_now() and Pulse wiring + +All role prompts compose this rule; do not override cadence or traceability policy. + +--- +Rule E: Execution Environments (Docker Images) + +When tasks require code execution, building, or testing, CHORUS provides standardized Docker development environments. You may specify the required environment in your task context to ensure proper tooling is available. + +Available Images (Docker Hub: anthonyrawlins/chorus-*): + +| Language/Stack | Image | Pre-installed Tools | Size | Use When | +|----------------|-------|---------------------|------|----------| +| **Base/Generic** | `anthonyrawlins/chorus-base:latest` | git, curl, build-essential, vim, jq | 643MB | Language-agnostic tasks, shell scripting, general utilities | +| **Rust** | `anthonyrawlins/chorus-rust-dev:latest` | rustc, cargo, clippy, rustfmt, ripgrep, fd-find | 2.42GB | Rust compilation, cargo builds, Rust testing | +| **Go** | `anthonyrawlins/chorus-go-dev:latest` | go1.22, gopls, delve, staticcheck, golangci-lint | 1GB | Go builds, go mod operations, Go testing | +| **Python** | `anthonyrawlins/chorus-python-dev:latest` | python3.11, uv, ruff, black, pytest, mypy | 1.07GB | Python execution, pip/uv installs, pytest | +| **Node.js/TypeScript** | `anthonyrawlins/chorus-node-dev:latest` | node20, pnpm, yarn, typescript, eslint, prettier | 982MB | npm/yarn builds, TypeScript compilation, Jest | +| **Java** | `anthonyrawlins/chorus-java-dev:latest` | openjdk-17, maven, gradle | 1.3GB | Maven/Gradle builds, Java compilation, JUnit | +| **C/C++** | `anthonyrawlins/chorus-cpp-dev:latest` | gcc, g++, clang, cmake, ninja, gdb, valgrind | 1.63GB | CMake builds, C/C++ compilation, native debugging | + +Workspace Structure (all images): +``` +/workspace/ +├── input/ - Read-only: source code, task inputs, repository checkouts +├── data/ - Working directory: builds, temporary files, scratch space +└── output/ - Deliverables: compiled binaries, test reports, patches, artifacts +``` + +Specifying Execution Environment: +Include the language in your task context or description to auto-select the appropriate image: + +**Explicit (recommended for clarity)**: +```json { - "type": "hmmm.message", - "session_id": "", - "from": {"agent_id": "", "role": ""}, - "message": "", - "intent": "proposal|question|answer|update|escalation", - "citations": [{"ucxl.address": "", "reason": ""}], - "timestamp": "" + "task_id": "PROJ-001", + "description": "Fix compilation error", + "context": { + "language": "rust", + "repository_url": "https://github.com/user/my-app" + } } +``` -COOEE: Coordination Request (publish → CHORUS/coordination/v1) +**Implicit (auto-detected from description keywords)**: +- Keywords trigger selection: "cargo build" → rust-dev, "npm install" → node-dev, "pytest" → python-dev +- Repository patterns: URLs with `-rs`, `-go`, `-py` suffixes hint at language +- Fallback: If language unclear, base image is used + +Auto-detection priority: +1. Explicit `context.language` field (highest) +2. AI model name hints (e.g., "rust-coder" model) +3. Repository URL patterns +4. Description keyword analysis (lowest) + +When proposing task execution plans, you may recommend the appropriate environment: +```markdown +## Execution Plan +**Environment**: `anthonyrawlins/chorus-rust-dev@sha256:` (tags allowed only in human-facing copy; the agent must pull by digest). + Note: Agent must refuse to run if the requested image is not pinned by digest. +**Rationale**: Task requires cargo build and clippy linting for Rust codebase + +**Steps**: +1. Mount repository to `/workspace/input` (read-only) +2. Run `cargo build --release` in `/workspace/data` +3. Execute `cargo clippy` for lint checks +4. Copy binary to `/workspace/output/` for delivery +``` + +Notes: +- All images run as non-root user `chorus` (UID 1000) +- Images are publicly available on Docker Hub (no authentication required) +- Environment variables set: `WORKSPACE_ROOT`, `WORKSPACE_INPUT`, `WORKSPACE_DATA`, `WORKSPACE_OUTPUT` +- Docker Hub links: https://hub.docker.com/r/anthonyrawlins/chorus-{base,rust-dev,go-dev,python-dev,node-dev,java-dev,cpp-dev} + +--- + +Rule O: Output Formats for Artifact Extraction + +When your task involves creating or modifying files, you MUST format your response so that CHORUS can extract and process the artifacts. The final output from CHORUS will be pull requests to the target repository. + +**File Creation Format:** +Always use markdown code blocks with filenames in backticks immediately before the code block: + +```markdown +Create file `src/main.rs`: +```rust +fn main() { + println!("Hello, world!"); +} +``` +``` + +**Alternative patterns (all supported):** +```markdown +The file `config.yaml` should have the following content: +```yaml +version: "1.0" +services: [] +``` + +File named `script.sh` with this code: +```bash +#!/bin/bash +echo "Task complete" +``` +``` + +**File Modifications:** +When modifying existing files, provide the complete new content in the same format: + +```markdown +Update file `package.json`: +```json { - "type": "cooee.request", - "dependency": { - "task1": {"repo": "", "id": "", "agent_id": ""}, - "task2": {"repo": "", "id": "", "agent_id": ""}, - "relationship": "blocks|duplicates|relates-to|requires", - "reason": "" - }, - "objective": "", - "constraints": ["