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 <noreply@anthropic.com>
872 lines
25 KiB
Go
872 lines
25 KiB
Go
package execution
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"chorus/pkg/ai"
|
|
"chorus/pkg/prompt"
|
|
)
|
|
|
|
// TaskExecutionEngine provides AI-powered task execution with isolated sandboxes
|
|
type TaskExecutionEngine interface {
|
|
ExecuteTask(ctx context.Context, request *TaskExecutionRequest) (*TaskExecutionResult, error)
|
|
Initialize(ctx context.Context, config *EngineConfig) error
|
|
Shutdown() error
|
|
GetMetrics() *EngineMetrics
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// TaskRequirements specifies execution environment needs
|
|
type TaskRequirements struct {
|
|
AIModel string `json:"ai_model,omitempty"`
|
|
SandboxType string `json:"sandbox_type,omitempty"`
|
|
RequiredTools []string `json:"required_tools,omitempty"`
|
|
EnvironmentVars map[string]string `json:"environment_vars,omitempty"`
|
|
ResourceLimits *ResourceLimits `json:"resource_limits,omitempty"`
|
|
SecurityPolicy *SecurityPolicy `json:"security_policy,omitempty"`
|
|
}
|
|
|
|
// TaskExecutionResult contains the results of task execution
|
|
type TaskExecutionResult struct {
|
|
TaskID string `json:"task_id"`
|
|
Success bool `json:"success"`
|
|
Output string `json:"output"`
|
|
ErrorMessage string `json:"error_message,omitempty"`
|
|
Artifacts []TaskArtifact `json:"artifacts,omitempty"`
|
|
Metrics *ExecutionMetrics `json:"metrics"`
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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"`
|
|
ResourceUsage *ResourceUsage `json:"resource_usage,omitempty"`
|
|
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"`
|
|
}
|
|
|
|
// 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"`
|
|
TotalExecutionTime time.Duration `json:"total_execution_time"`
|
|
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
|
|
}
|
|
|
|
// NewTaskExecutionEngine creates a new task execution engine
|
|
func NewTaskExecutionEngine() *DefaultTaskExecutionEngine {
|
|
return &DefaultTaskExecutionEngine{
|
|
metrics: &EngineMetrics{},
|
|
activeTasks: make(map[string]context.CancelFunc),
|
|
logger: log.Default(),
|
|
}
|
|
}
|
|
|
|
// Initialize configures and prepares the execution engine
|
|
func (e *DefaultTaskExecutionEngine) Initialize(ctx context.Context, config *EngineConfig) error {
|
|
if config == nil {
|
|
return fmt.Errorf("engine config cannot be nil")
|
|
}
|
|
|
|
if config.AIProviderFactory == nil {
|
|
return fmt.Errorf("AI provider factory is required")
|
|
}
|
|
|
|
e.config = config
|
|
e.aiFactory = config.AIProviderFactory
|
|
|
|
// Set default values
|
|
if e.config.DefaultTimeout == 0 {
|
|
e.config.DefaultTimeout = 5 * time.Minute
|
|
}
|
|
if e.config.MaxConcurrentTasks == 0 {
|
|
e.config.MaxConcurrentTasks = 10
|
|
}
|
|
|
|
e.logger.Printf("TaskExecutionEngine initialized with %d max concurrent tasks", e.config.MaxConcurrentTasks)
|
|
return nil
|
|
}
|
|
|
|
// ExecuteTask executes a task using AI providers and isolated sandboxes
|
|
func (e *DefaultTaskExecutionEngine) ExecuteTask(ctx context.Context, request *TaskExecutionRequest) (*TaskExecutionResult, error) {
|
|
if e.config == nil {
|
|
return nil, fmt.Errorf("engine not initialized")
|
|
}
|
|
|
|
startTime := time.Now()
|
|
|
|
// Create task context with timeout
|
|
timeout := request.Timeout
|
|
if timeout == 0 {
|
|
timeout = e.config.DefaultTimeout
|
|
}
|
|
|
|
taskCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
|
|
// Track active task
|
|
e.activeTasks[request.ID] = cancel
|
|
defer delete(e.activeTasks, request.ID)
|
|
|
|
e.metrics.ActiveTasks++
|
|
defer func() { e.metrics.ActiveTasks-- }()
|
|
|
|
result := &TaskExecutionResult{
|
|
TaskID: request.ID,
|
|
Metrics: &ExecutionMetrics{StartTime: startTime},
|
|
}
|
|
|
|
// Execute the task
|
|
err := e.executeTaskInternal(taskCtx, request, result)
|
|
|
|
// Update metrics
|
|
result.Metrics.EndTime = time.Now()
|
|
result.Metrics.Duration = result.Metrics.EndTime.Sub(result.Metrics.StartTime)
|
|
|
|
e.metrics.TasksExecuted++
|
|
e.metrics.TotalExecutionTime += result.Metrics.Duration
|
|
|
|
if err != nil {
|
|
result.Success = false
|
|
result.ErrorMessage = err.Error()
|
|
e.metrics.TasksFailed++
|
|
e.logger.Printf("Task %s failed: %v", request.ID, err)
|
|
} else {
|
|
result.Success = true
|
|
e.metrics.TasksSuccessful++
|
|
e.logger.Printf("Task %s completed successfully in %v", request.ID, result.Metrics.Duration)
|
|
}
|
|
|
|
e.metrics.AverageTime = e.metrics.TotalExecutionTime / time.Duration(e.metrics.TasksExecuted)
|
|
|
|
return result, err
|
|
}
|
|
|
|
// executeTaskInternal performs the actual task execution
|
|
func (e *DefaultTaskExecutionEngine) executeTaskInternal(ctx context.Context, request *TaskExecutionRequest, result *TaskExecutionResult) error {
|
|
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)
|
|
}
|
|
|
|
roleConfig, _ := e.aiFactory.GetRoleConfig(role)
|
|
|
|
aiRequest := &ai.TaskRequest{
|
|
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
|
|
}
|
|
|
|
aiResponse, err := provider.ExecuteTask(ctx, aiRequest)
|
|
if err != nil {
|
|
return fmt.Errorf("AI provider execution failed: %w", err)
|
|
}
|
|
|
|
result.Metrics.AIProviderTime = time.Since(aiStartTime)
|
|
|
|
commands, artifacts, err := e.parseAIResponse(aiResponse)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse AI response: %w", err)
|
|
}
|
|
|
|
// 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)
|
|
if err != nil {
|
|
return fmt.Errorf("sandbox execution failed: %w", err)
|
|
}
|
|
|
|
result.Metrics.SandboxTime = time.Since(sandboxStartTime)
|
|
result.Metrics.CommandsExecuted = len(commands)
|
|
result.Metrics.ResourceUsage = sandboxResult.ResourceUsage
|
|
|
|
artifacts = append(artifacts, sandboxResult.Artifacts...)
|
|
}
|
|
|
|
result.Output = e.formatOutput(aiResponse, artifacts)
|
|
result.Artifacts = artifacts
|
|
result.Metrics.FilesGenerated = len(artifacts)
|
|
|
|
result.Metadata = map[string]interface{}{
|
|
"ai_provider": providerConfig.Type,
|
|
"ai_model": providerConfig.DefaultModel,
|
|
"role": role,
|
|
"commands": len(commands),
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// determineRoleFromTask analyzes the task to determine appropriate AI role
|
|
func (e *DefaultTaskExecutionEngine) determineRoleFromTask(request *TaskExecutionRequest) string {
|
|
if request == nil {
|
|
return "developer"
|
|
}
|
|
|
|
if role := extractRoleFromContext(request.Context); role != "" {
|
|
return role
|
|
}
|
|
|
|
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 ""
|
|
}
|
|
|
|
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
|
|
func (e *DefaultTaskExecutionEngine) parseAIResponse(response *ai.TaskResponse) ([]string, []TaskArtifact, error) {
|
|
var commands []string
|
|
var artifacts []TaskArtifact
|
|
|
|
// Parse response content for commands and files
|
|
// This is a simplified parser - in reality would need more sophisticated parsing
|
|
|
|
if len(response.Actions) > 0 {
|
|
for _, action := range response.Actions {
|
|
switch action.Type {
|
|
case "command", "command_run":
|
|
// Extract command from content or target
|
|
if action.Content != "" {
|
|
commands = append(commands, action.Content)
|
|
} else if action.Target != "" {
|
|
commands = append(commands, action.Target)
|
|
}
|
|
case "file", "file_create", "file_edit":
|
|
// Create artifact from file action
|
|
if action.Target != "" && action.Content != "" {
|
|
artifact := TaskArtifact{
|
|
Name: action.Target,
|
|
Type: "file",
|
|
Content: []byte(action.Content),
|
|
Size: int64(len(action.Content)),
|
|
CreatedAt: time.Now(),
|
|
}
|
|
artifacts = append(artifacts, artifact)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return commands, artifacts, nil
|
|
}
|
|
|
|
// SandboxExecutionResult contains results from sandbox command execution
|
|
type SandboxExecutionResult struct {
|
|
Output string
|
|
Artifacts []TaskArtifact
|
|
ResourceUsage *ResourceUsage
|
|
}
|
|
|
|
// executeSandboxCommands runs commands in an isolated sandbox
|
|
func (e *DefaultTaskExecutionEngine) executeSandboxCommands(ctx context.Context, request *TaskExecutionRequest, commands []string) (*SandboxExecutionResult, error) {
|
|
// Create sandbox configuration
|
|
sandboxConfig := e.createSandboxConfig(request)
|
|
|
|
// Initialize sandbox
|
|
sandbox := NewDockerSandbox()
|
|
err := sandbox.Initialize(ctx, sandboxConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to initialize sandbox: %w", err)
|
|
}
|
|
defer sandbox.Cleanup()
|
|
|
|
var outputs []string
|
|
var artifacts []TaskArtifact
|
|
|
|
// Execute each command
|
|
for _, cmdStr := range commands {
|
|
cmd := &Command{
|
|
Executable: "/bin/sh",
|
|
Args: []string{"-c", cmdStr},
|
|
WorkingDir: "/workspace",
|
|
Timeout: 30 * time.Second,
|
|
}
|
|
|
|
cmdResult, err := sandbox.ExecuteCommand(ctx, cmd)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("command execution failed: %w", err)
|
|
}
|
|
|
|
outputs = append(outputs, fmt.Sprintf("$ %s\n%s", cmdStr, cmdResult.Stdout))
|
|
|
|
if cmdResult.ExitCode != 0 {
|
|
outputs = append(outputs, fmt.Sprintf("Error (exit %d): %s", cmdResult.ExitCode, cmdResult.Stderr))
|
|
}
|
|
}
|
|
|
|
// Get resource usage
|
|
resourceUsage, _ := sandbox.GetResourceUsage(ctx)
|
|
|
|
// Collect any generated files as artifacts
|
|
files, err := sandbox.ListFiles(ctx, "/workspace")
|
|
if err == nil {
|
|
for _, file := range files {
|
|
if !file.IsDir && file.Size > 0 {
|
|
content, err := sandbox.ReadFile(ctx, "/workspace/"+file.Name)
|
|
if err == nil {
|
|
artifact := TaskArtifact{
|
|
Name: file.Name,
|
|
Type: "generated_file",
|
|
Content: content,
|
|
Size: file.Size,
|
|
CreatedAt: file.ModTime,
|
|
}
|
|
artifacts = append(artifacts, artifact)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return &SandboxExecutionResult{
|
|
Output: strings.Join(outputs, "\n"),
|
|
Artifacts: artifacts,
|
|
ResourceUsage: resourceUsage,
|
|
}, nil
|
|
}
|
|
|
|
// createSandboxConfig creates a sandbox configuration from task requirements
|
|
func (e *DefaultTaskExecutionEngine) createSandboxConfig(request *TaskExecutionRequest) *SandboxConfig {
|
|
// Use image selector to choose appropriate development environment
|
|
imageSelector := NewImageSelector()
|
|
selectedImage := imageSelector.SelectImageForTask(request)
|
|
|
|
config := &SandboxConfig{
|
|
Type: "docker",
|
|
Image: selectedImage, // Auto-selected based on task language
|
|
Architecture: "amd64",
|
|
WorkingDir: "/workspace/data", // Use standardized workspace structure
|
|
Timeout: 5 * time.Minute,
|
|
Environment: make(map[string]string),
|
|
}
|
|
|
|
// Add standardized workspace environment variables
|
|
config.Environment["WORKSPACE_ROOT"] = "/workspace"
|
|
config.Environment["WORKSPACE_INPUT"] = "/workspace/input"
|
|
config.Environment["WORKSPACE_DATA"] = "/workspace/data"
|
|
config.Environment["WORKSPACE_OUTPUT"] = "/workspace/output"
|
|
|
|
// Apply defaults from engine config
|
|
if e.config.SandboxDefaults != nil {
|
|
if e.config.SandboxDefaults.Image != "" {
|
|
config.Image = e.config.SandboxDefaults.Image
|
|
}
|
|
if e.config.SandboxDefaults.Resources.MemoryLimit > 0 {
|
|
config.Resources = e.config.SandboxDefaults.Resources
|
|
}
|
|
if e.config.SandboxDefaults.Security.NoNewPrivileges {
|
|
config.Security = e.config.SandboxDefaults.Security
|
|
}
|
|
}
|
|
|
|
// Apply task-specific requirements
|
|
if request.Requirements != nil {
|
|
if request.Requirements.SandboxType != "" {
|
|
config.Type = request.Requirements.SandboxType
|
|
}
|
|
|
|
if request.Requirements.EnvironmentVars != nil {
|
|
for k, v := range request.Requirements.EnvironmentVars {
|
|
config.Environment[k] = v
|
|
}
|
|
}
|
|
|
|
if request.Requirements.ResourceLimits != nil {
|
|
config.Resources = *request.Requirements.ResourceLimits
|
|
}
|
|
|
|
if request.Requirements.SecurityPolicy != nil {
|
|
config.Security = *request.Requirements.SecurityPolicy
|
|
}
|
|
}
|
|
|
|
return config
|
|
}
|
|
|
|
// formatOutput creates a formatted output string from AI response and artifacts
|
|
func (e *DefaultTaskExecutionEngine) formatOutput(aiResponse *ai.TaskResponse, artifacts []TaskArtifact) string {
|
|
var output strings.Builder
|
|
|
|
output.WriteString("AI Response:\n")
|
|
output.WriteString(aiResponse.Response)
|
|
output.WriteString("\n\n")
|
|
|
|
if len(artifacts) > 0 {
|
|
output.WriteString("Generated Artifacts:\n")
|
|
for _, artifact := range artifacts {
|
|
output.WriteString(fmt.Sprintf("- %s (%s, %d bytes)\n",
|
|
artifact.Name, artifact.Type, artifact.Size))
|
|
}
|
|
}
|
|
|
|
return output.String()
|
|
}
|
|
|
|
// GetMetrics returns current engine metrics
|
|
func (e *DefaultTaskExecutionEngine) GetMetrics() *EngineMetrics {
|
|
return e.metrics
|
|
}
|
|
|
|
// Shutdown gracefully shuts down the execution engine
|
|
func (e *DefaultTaskExecutionEngine) Shutdown() error {
|
|
e.logger.Printf("Shutting down TaskExecutionEngine...")
|
|
|
|
// Cancel all active tasks
|
|
for taskID, cancel := range e.activeTasks {
|
|
e.logger.Printf("Canceling active task: %s", taskID)
|
|
cancel()
|
|
}
|
|
|
|
// Wait for tasks to finish (with timeout)
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
for len(e.activeTasks) > 0 {
|
|
select {
|
|
case <-shutdownCtx.Done():
|
|
e.logger.Printf("Shutdown timeout reached, %d tasks may still be active", len(e.activeTasks))
|
|
return nil
|
|
case <-time.After(100 * time.Millisecond):
|
|
// Continue waiting
|
|
}
|
|
}
|
|
|
|
e.logger.Printf("TaskExecutionEngine shutdown complete")
|
|
return nil
|
|
}
|