diff --git a/go/go_core/internal/acp/routing.go b/go/go_core/internal/acp/routing.go index 22583f57..ca7682ef 100644 --- a/go/go_core/internal/acp/routing.go +++ b/go/go_core/internal/acp/routing.go @@ -33,6 +33,8 @@ func resolveRoutingMetadata(params map[string]any) (router.Result, bool) { ExplicitSkills: parseRoutingStringSlice(routingParams["explicitSkills"]), AllowSkillInstall: parseBool(routingParams["allowSkillInstall"]), AvailableSkills: parseRoutingSkillCandidates(routingParams["availableSkills"]), + AIGatewayBaseURL: strings.TrimSpace(sharedString(params, "aiGatewayBaseUrl")), + AIGatewayAPIKey: strings.TrimSpace(sharedString(params, "aiGatewayApiKey")), }) return result, true } @@ -90,6 +92,37 @@ func recordRoutingSuccess( }) } +func applyResolvedRouting(params map[string]any, result router.Result) map[string]any { + if len(params) == 0 { + return params + } + next := make(map[string]any, len(params)+6) + for key, value := range params { + next[key] = value + } + switch result.ResolvedExecutionTarget { + case router.ExecutionTargetSingleAgent: + next["mode"] = router.ExecutionTargetSingleAgent + case router.ExecutionTargetMultiAgent: + next["mode"] = router.ExecutionTargetMultiAgent + case router.ExecutionTargetGateway: + next["mode"] = router.ExecutionTargetGatewayChat + if strings.TrimSpace(result.ResolvedEndpointTarget) != "" { + next["executionTarget"] = strings.TrimSpace(result.ResolvedEndpointTarget) + } + } + if strings.TrimSpace(result.ResolvedProviderID) != "" { + next["provider"] = strings.TrimSpace(result.ResolvedProviderID) + } + if strings.TrimSpace(result.ResolvedModel) != "" { + next["model"] = strings.TrimSpace(result.ResolvedModel) + } + if len(result.ResolvedSkills) > 0 { + next["selectedSkills"] = append([]string(nil), result.ResolvedSkills...) + } + return next +} + func parseRoutingSkillCandidates(raw any) []skills.Candidate { list, ok := raw.([]any) if !ok { diff --git a/go/go_core/internal/acp/routing_test.go b/go/go_core/internal/acp/routing_test.go index c0c74fb3..6385731a 100644 --- a/go/go_core/internal/acp/routing_test.go +++ b/go/go_core/internal/acp/routing_test.go @@ -1,6 +1,8 @@ package acp import ( + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" @@ -268,3 +270,100 @@ func TestExecuteSessionTaskExplicitRoutingDoesNotRecordProjectMemory(t *testing. } } } + +func TestExecuteSessionTaskAutoRoutingPromotesComplexRequestToMultiAgent(t *testing.T) { + workspaceDir := filepath.Join(t.TempDir(), "workspace") + if err := os.MkdirAll(workspaceDir, 0o755); err != nil { + t.Fatalf("create workspace: %v", err) + } + + aiGateway := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"planner output"}}]}`)) + }), + ) + defer aiGateway.Close() + + server := NewServer() + response, rpcErr := server.executeSessionTask(task{ + req: shared.RPCRequest{ + Params: map[string]any{ + "sessionId": "session-complex", + "threadId": "thread-complex", + "mode": "single-agent", + "provider": "claude", + "taskPrompt": "collect latest news and summarize it into a report for review", + "workingDirectory": workspaceDir, + "aiGatewayBaseUrl": aiGateway.URL, + "aiGatewayApiKey": "test-key", + "routing": map[string]any{ + "routingMode": "auto", + "preferredGatewayTarget": "local", + }, + }, + }, + }) + if rpcErr != nil { + t.Fatalf("expected success, got rpc error: %v", rpcErr) + } + if success, _ := response["success"].(bool); !success { + t.Fatalf("expected success response, got %#v", response) + } + if got := response["mode"]; got != "multi-agent" { + t.Fatalf("expected session mode to be promoted to multi-agent, got %#v", got) + } + if got := response["resolvedExecutionTarget"]; got != "multi-agent" { + t.Fatalf("expected resolved execution target multi-agent, got %#v", got) + } +} + +func TestHandleRoutingResolveAllowsSkillInstallRetry(t *testing.T) { + tempDir := t.TempDir() + finder := filepath.Join(tempDir, "find-skills.sh") + installer := filepath.Join(tempDir, "install-skills.sh") + if err := os.WriteFile( + finder, + []byte("#!/bin/sh\nprintf '%s' '{\"candidates\":[{\"id\":\"video-translator\",\"label\":\"video-translator\",\"description\":\"translate video\",\"installed\":false}]}'\n"), + 0o755, + ); err != nil { + t.Fatalf("write finder: %v", err) + } + if err := os.WriteFile( + installer, + []byte("#!/bin/sh\nprintf '%s' '{\"candidates\":[{\"id\":\"video-translator\",\"label\":\"video-translator\",\"description\":\"translate video\",\"installed\":true}]}'\n"), + 0o755, + ); err != nil { + t.Fatalf("write installer: %v", err) + } + t.Setenv("ACP_FIND_SKILLS_BIN", finder) + t.Setenv("ACP_INSTALL_SKILL_BIN", installer) + + result := handleRoutingResolve(map[string]any{ + "taskPrompt": "translate and dub this video with subtitles", + "workingDirectory": "/tmp/workspace", + "routing": map[string]any{ + "routingMode": "auto", + "allowSkillInstall": true, + "availableSkills": []any{ + map[string]any{ + "id": "docx", + "label": "docx", + "description": "docs", + "installed": true, + }, + }, + }, + }) + + if got := result["skillResolutionSource"]; got != "find_skills" { + t.Fatalf("expected find_skills source, got %#v", got) + } + if got := result["needsSkillInstall"]; got != false { + t.Fatalf("expected install retry to clear needsSkillInstall, got %#v", got) + } + resolvedSkills, _ := result["resolvedSkills"].([]string) + if len(resolvedSkills) != 1 || resolvedSkills[0] != "video-translator" { + t.Fatalf("expected installed skill to resolve, got %#v", result["resolvedSkills"]) + } +} diff --git a/go/go_core/internal/acp/server.go b/go/go_core/internal/acp/server.go index 37203fd6..ec002b12 100644 --- a/go/go_core/internal/acp/server.go +++ b/go/go_core/internal/acp/server.go @@ -17,6 +17,7 @@ import ( "xworkmate/go_core/internal/dispatch" "xworkmate/go_core/internal/gatewayruntime" "xworkmate/go_core/internal/mounts" + "xworkmate/go_core/internal/router" "xworkmate/go_core/internal/shared" ) @@ -491,6 +492,11 @@ func (s *Server) runQueue(queue chan task) { func (s *Server) executeSessionTask(task task) (map[string]any, *shared.RPCError) { params := task.req.Params + resolvedRouting, hasResolvedRouting := resolveRoutingMetadata(params) + if hasResolvedRouting { + params = applyResolvedRouting(params, resolvedRouting) + } + sessionID := strings.TrimSpace(shared.StringArg(params, "sessionId", "")) threadID := strings.TrimSpace(shared.StringArg(params, "threadId", sessionID)) mode := strings.TrimSpace(shared.StringArg(params, "mode", "single-agent")) @@ -510,7 +516,6 @@ func (s *Server) executeSessionTask(task task) (map[string]any, *shared.RPCError session.history = append(session.history, prompt) } turnID := fmt.Sprintf("turn-%d", time.Now().UnixNano()) - resolvedRouting, hasResolvedRouting := resolveRoutingMetadata(params) ctx, cancel := context.WithCancel(context.Background()) s.setSessionCancel(sessionID, cancel) @@ -525,6 +530,21 @@ func (s *Server) executeSessionTask(task task) (map[string]any, *shared.RPCError "error": false, }) + if mode == router.ExecutionTargetGatewayChat || mode == router.ExecutionTargetGateway { + result := taskResult{ + response: map[string]any{ + "success": false, + "error": "gateway execution must be dispatched to a connected gateway ACP endpoint", + "turnId": turnID, + "mode": router.ExecutionTargetGatewayChat, + }, + } + if hasResolvedRouting { + result.response = mergeRoutingResponse(result.response, resolvedRouting) + } + return result.response, nil + } + if mode == "multi-agent" { result := s.runMultiAgent(ctx, session, params, turnID, notify) if result.err != nil { diff --git a/go/go_core/internal/memory/provider.go b/go/go_core/internal/memory/provider.go index 252afdfc..f5aeed17 100644 --- a/go/go_core/internal/memory/provider.go +++ b/go/go_core/internal/memory/provider.go @@ -168,13 +168,13 @@ func parsePreferences(text string) Preferences { } func mergePreferences(dst *Preferences, src Preferences) { - if strings.TrimSpace(dst.PreferredRoute) == "" && strings.TrimSpace(src.PreferredRoute) != "" { + if strings.TrimSpace(src.PreferredRoute) != "" { dst.PreferredRoute = strings.TrimSpace(src.PreferredRoute) } - if strings.TrimSpace(dst.PreferredModel) == "" && strings.TrimSpace(src.PreferredModel) != "" { + if strings.TrimSpace(src.PreferredModel) != "" { dst.PreferredModel = strings.TrimSpace(src.PreferredModel) } - if len(dst.PreferredSkills) == 0 && len(src.PreferredSkills) > 0 { + if len(src.PreferredSkills) > 0 { dst.PreferredSkills = append([]string(nil), src.PreferredSkills...) } } diff --git a/go/go_core/internal/memory/provider_test.go b/go/go_core/internal/memory/provider_test.go index 49ae4af1..d9500b91 100644 --- a/go/go_core/internal/memory/provider_test.go +++ b/go/go_core/internal/memory/provider_test.go @@ -83,3 +83,36 @@ func TestRecordSuccessWritesProjectLevelMemoryFiles(t *testing.T) { } } } + +func TestLoadLetsProjectMemoryOverrideGlobalPreferences(t *testing.T) { + tempDir := t.TempDir() + workingDir := filepath.Join(tempDir, "workspace") + homeDir := filepath.Join(tempDir, "home") + if err := os.MkdirAll(filepath.Join(workingDir, ".xworkmate"), 0o755); err != nil { + t.Fatalf("mkdir workspace: %v", err) + } + if err := os.MkdirAll(filepath.Join(homeDir, "self-improving", "projects"), 0o755); err != nil { + t.Fatalf("mkdir home: %v", err) + } + if err := os.WriteFile(filepath.Join(homeDir, "self-improving", "memory.md"), []byte("preferred-route: single-agent\npreferred-model: gpt-4o\npreferred-skills: docx\n"), 0o644); err != nil { + t.Fatalf("write global memory: %v", err) + } + if err := os.WriteFile(filepath.Join(homeDir, "self-improving", "projects", "workspace.md"), []byte("preferred-route: gateway\npreferred-model: gpt-5.4\n"), 0o644); err != nil { + t.Fatalf("write project home memory: %v", err) + } + if err := os.WriteFile(filepath.Join(workingDir, ".xworkmate", "memory.md"), []byte("preferred-route: multi-agent\npreferred-skills: pptx, pdf\n"), 0o644); err != nil { + t.Fatalf("write project local memory: %v", err) + } + + result := NewService(homeDir).Load(workingDir) + + if result.Preferences.PreferredRoute != "multi-agent" { + t.Fatalf("expected project-local route to win, got %#v", result.Preferences) + } + if result.Preferences.PreferredModel != "gpt-5.4" { + t.Fatalf("expected project-home model to override global, got %#v", result.Preferences) + } + if len(result.Preferences.PreferredSkills) != 2 || result.Preferences.PreferredSkills[0] != "pptx" { + t.Fatalf("expected project-local skills to win, got %#v", result.Preferences.PreferredSkills) + } +} diff --git a/go/go_core/internal/router/classifier.go b/go/go_core/internal/router/classifier.go new file mode 100644 index 00000000..ee144c8c --- /dev/null +++ b/go/go_core/internal/router/classifier.go @@ -0,0 +1,78 @@ +package router + +import ( + "context" + "strings" + "time" + + "xworkmate/go_core/internal/shared" +) + +type ClassificationRequest struct { + Prompt string + AIGatewayBaseURL string + AIGatewayAPIKey string +} + +type Classifier interface { + Classify(req ClassificationRequest) string +} + +type LLMClassifier struct{} + +func (LLMClassifier) Classify(req ClassificationRequest) string { + baseURL := shared.NormalizeBaseURL(strings.TrimSpace(req.AIGatewayBaseURL)) + apiKey := strings.TrimSpace(req.AIGatewayAPIKey) + if baseURL == "" { + baseURL = shared.NormalizeBaseURL( + shared.EnvOrDefault("LLM_BASE_URL", "https://api.openai.com/v1"), + ) + } + if apiKey == "" { + apiKey = strings.TrimSpace(shared.EnvOrDefault("LLM_API_KEY", "")) + } + if baseURL == "" || apiKey == "" { + return "" + } + + model := strings.TrimSpace(shared.EnvOrDefault("ACP_ROUTING_MODEL", "gpt-4o")) + if model == "" { + model = "gpt-4o" + } + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + content, err := shared.CallOpenAICompatibleCtx( + ctx, + baseURL, + apiKey, + model, + []map[string]string{ + { + "role": "system", + "content": "Classify the user task into exactly one label: single-agent, multi-agent, or gateway. Return only the label.", + }, + { + "role": "user", + "content": strings.TrimSpace(req.Prompt), + }, + }, + ) + if err != nil { + return "" + } + return normalizeClassifierLabel(content) +} + +func normalizeClassifierLabel(value string) string { + normalized := strings.ToLower(strings.TrimSpace(value)) + switch { + case strings.Contains(normalized, ExecutionTargetSingleAgent): + return ExecutionTargetSingleAgent + case strings.Contains(normalized, ExecutionTargetMultiAgent): + return ExecutionTargetMultiAgent + case strings.Contains(normalized, ExecutionTargetGateway): + return ExecutionTargetGateway + default: + return "" + } +} diff --git a/go/go_core/internal/router/router.go b/go/go_core/internal/router/router.go index 4459a21b..49d253b9 100644 --- a/go/go_core/internal/router/router.go +++ b/go/go_core/internal/router/router.go @@ -33,6 +33,8 @@ type Request struct { ExplicitSkills []string AllowSkillInstall bool AvailableSkills []skills.Candidate + AIGatewayBaseURL string + AIGatewayAPIKey string } type Result struct { @@ -48,15 +50,19 @@ type Result struct { } type Resolver struct { - SkillFinder skills.Finder - MemoryService memory.Service + SkillFinder skills.Finder + SkillInstaller skills.Installer + MemoryService memory.Service + Classifier Classifier } func NewResolver() Resolver { homeDir, _ := os.UserHomeDir() return Resolver{ - SkillFinder: skills.StaticFinder{}, - MemoryService: memory.NewService(homeDir), + SkillFinder: skills.NewDefaultFinder(), + SkillInstaller: skills.NewDefaultInstaller(), + MemoryService: memory.NewService(homeDir), + Classifier: LLMClassifier{}, } } @@ -69,7 +75,7 @@ func (r Resolver) Resolve(req Request) Result { MemorySources: mem.Sources, } - result.ResolvedExecutionTarget, result.ResolvedEndpointTarget = resolveExecution(req, mem.Preferences) + result.ResolvedExecutionTarget, result.ResolvedEndpointTarget = r.resolveExecution(req, mem.Preferences) if result.ResolvedModel == "" { result.ResolvedModel = strings.TrimSpace(mem.Preferences.PreferredModel) } @@ -80,7 +86,7 @@ func (r Resolver) Resolve(req Request) Result { AvailableSkills: req.AvailableSkills, AllowSkillInstall: req.AllowSkillInstall, } - skillResult := skills.Resolve(skillRequest, r.SkillFinder) + skillResult := skills.Resolve(skillRequest, r.SkillFinder, r.SkillInstaller) result.ResolvedSkills = skillResult.ResolvedSkills result.SkillResolutionSource = skillResult.Source result.SkillCandidates = skillResult.Candidates @@ -104,17 +110,37 @@ func (r Resolver) Resolve(req Request) Result { return result } -func resolveExecution(req Request, prefs memory.Preferences) (string, string) { +func (r Resolver) resolveExecution(req Request, prefs memory.Preferences) (string, string) { explicit := strings.TrimSpace(req.ExplicitExecutionTarget) if strings.EqualFold(strings.TrimSpace(req.RoutingMode), RoutingModeExplicit) && explicit != "" { return mapExplicitTarget(explicit) } prompt := normalize(req.Prompt) - if looksOnline(prompt) { + + localTask := looksLocal(prompt) + onlineTask := looksOnline(prompt) + complexTask := looksComplex(prompt) + + switch { + case localTask && complexTask: + return ExecutionTargetMultiAgent, EndpointTargetSingleAgent + case onlineTask && complexTask: + return ExecutionTargetMultiAgent, EndpointTargetSingleAgent + case localTask: + return ExecutionTargetSingleAgent, EndpointTargetSingleAgent + case onlineTask: return ExecutionTargetGateway, normalizeGatewayTarget(req.PreferredGatewayTarget) + case complexTask: + return ExecutionTargetMultiAgent, EndpointTargetSingleAgent } - if looksLocal(prompt) { + + switch normalizeExecutionTarget(r.classify(req)) { + case ExecutionTargetGateway: + return ExecutionTargetGateway, normalizeGatewayTarget(req.PreferredGatewayTarget) + case ExecutionTargetMultiAgent: + return ExecutionTargetMultiAgent, EndpointTargetSingleAgent + case ExecutionTargetSingleAgent: return ExecutionTargetSingleAgent, EndpointTargetSingleAgent } @@ -127,6 +153,17 @@ func resolveExecution(req Request, prefs memory.Preferences) (string, string) { return ExecutionTargetSingleAgent, EndpointTargetSingleAgent } +func (r Resolver) classify(req Request) string { + if r.Classifier == nil { + return "" + } + return normalizeExecutionTarget(r.Classifier.Classify(ClassificationRequest{ + Prompt: req.Prompt, + AIGatewayBaseURL: req.AIGatewayBaseURL, + AIGatewayAPIKey: req.AIGatewayAPIKey, + })) +} + func mapExplicitTarget(value string) (string, string) { switch strings.TrimSpace(value) { case EndpointTargetLocal: @@ -166,6 +203,49 @@ func looksOnline(prompt string) bool { }) } +func looksComplex(prompt string) bool { + strongSignals := containsAny(prompt, []string{ + "multiple deliverables", "multiple outputs", "多个产物", "多个输出", + "审阅", "复核", "汇编", "end-to-end", "end to end", + }) + if strongSignals { + return true + } + + reviewSignals := containsAny(prompt, []string{ + "review", "audit", "verify", "summarize", "compare", + "审阅", "复核", "汇总", "对比", "整理", "整合", "汇编", + }) + multiStepSignals := containsAny(prompt, []string{ + "workflow", "pipeline", "step by step", "multi-step", "collect and", + "analyze and", "review and", "compare and", "summarize and", + "先", "然后", "之后", + }) + structuredOutputSignals := containsAny(prompt, []string{ + "report", "memo", "table", "spreadsheet", "document", "deck", "slides", + "presentation", "报告", "总结", "表格", "文档", "演示", + }) + onlineCollectionSignals := containsAny(prompt, []string{ + "browser", "search", "news", "research", "crawl", "scrape", + "跨浏览器", "搜索", "资讯", "采集", "检索", + }) + + score := 0 + if reviewSignals { + score++ + } + if multiStepSignals { + score++ + } + if structuredOutputSignals { + score++ + } + if onlineCollectionSignals && structuredOutputSignals { + return true + } + return score >= 2 +} + func containsAny(haystack string, needles []string) bool { for _, needle := range needles { if strings.Contains(haystack, normalize(needle)) { diff --git a/go/go_core/internal/router/router_test.go b/go/go_core/internal/router/router_test.go index 9d8cce3b..94e54b02 100644 --- a/go/go_core/internal/router/router_test.go +++ b/go/go_core/internal/router/router_test.go @@ -7,10 +7,17 @@ import ( "xworkmate/go_core/internal/skills" ) +type fakeClassifier string + +func (f fakeClassifier) Classify(req ClassificationRequest) string { + return string(f) +} + func TestResolveExplicitTargetOverridesAuto(t *testing.T) { resolver := Resolver{ - SkillFinder: skills.StaticFinder{}, - MemoryService: memory.Service{}, + SkillFinder: skills.StaticFinder{}, + SkillInstaller: nil, + MemoryService: memory.Service{}, } result := resolver.Resolve(Request{ @@ -34,8 +41,9 @@ func TestResolveExplicitTargetOverridesAuto(t *testing.T) { func TestResolveAutoLocalTaskToSingleAgent(t *testing.T) { resolver := Resolver{ - SkillFinder: skills.StaticFinder{}, - MemoryService: memory.Service{}, + SkillFinder: skills.StaticFinder{}, + SkillInstaller: nil, + MemoryService: memory.Service{}, } result := resolver.Resolve(Request{ @@ -49,8 +57,9 @@ func TestResolveAutoLocalTaskToSingleAgent(t *testing.T) { func TestResolveAutoOnlineTaskToGateway(t *testing.T) { resolver := Resolver{ - SkillFinder: skills.StaticFinder{}, - MemoryService: memory.Service{}, + SkillFinder: skills.StaticFinder{}, + SkillInstaller: nil, + MemoryService: memory.Service{}, } result := resolver.Resolve(Request{ @@ -66,17 +75,39 @@ func TestResolveAutoOnlineTaskToGateway(t *testing.T) { } } -func TestResolveComplexTaskStaysWithinAutoSingleAgentAndGatewayLanes(t *testing.T) { +func TestResolveComplexTaskUpgradesToMultiAgent(t *testing.T) { resolver := Resolver{ - SkillFinder: skills.StaticFinder{}, - MemoryService: memory.Service{}, + SkillFinder: skills.StaticFinder{}, + SkillInstaller: nil, + MemoryService: memory.Service{}, } result := resolver.Resolve(Request{ Prompt: "analyze these files, review the output, and summarize multiple deliverables", }) - if result.ResolvedExecutionTarget != ExecutionTargetSingleAgent { - t.Fatalf("expected single-agent route, got %#v", result) + if result.ResolvedExecutionTarget != ExecutionTargetMultiAgent { + t.Fatalf("expected multi-agent route, got %#v", result) + } +} + +func TestResolveUsesClassifierForBoundarySamples(t *testing.T) { + resolver := Resolver{ + SkillFinder: skills.StaticFinder{}, + SkillInstaller: nil, + MemoryService: memory.Service{}, + Classifier: fakeClassifier(ExecutionTargetGateway), + } + + result := resolver.Resolve(Request{ + Prompt: "help me handle this ambiguous request", + PreferredGatewayTarget: EndpointTargetLocal, + }) + + if result.ResolvedExecutionTarget != ExecutionTargetGateway { + t.Fatalf("expected classifier to resolve gateway route, got %#v", result) + } + if result.ResolvedEndpointTarget != EndpointTargetLocal { + t.Fatalf("expected local endpoint target, got %#v", result) } } diff --git a/go/go_core/internal/skills/command_io.go b/go/go_core/internal/skills/command_io.go new file mode 100644 index 00000000..365f3599 --- /dev/null +++ b/go/go_core/internal/skills/command_io.go @@ -0,0 +1,209 @@ +package skills + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" + "time" + + "xworkmate/go_core/internal/shared" +) + +type ChainFinder struct { + Primary Finder + Fallback Finder +} + +func (f ChainFinder) Find(prompt string) []Candidate { + if f.Primary != nil { + if resolved := dedupeCandidates(f.Primary.Find(prompt)); len(resolved) > 0 { + return resolved + } + } + if f.Fallback == nil { + return nil + } + return dedupeCandidates(f.Fallback.Find(prompt)) +} + +type CommandFinder struct { + Binary string +} + +func (f CommandFinder) Find(prompt string) []Candidate { + payload, ok := runSkillCommand( + strings.TrimSpace(f.Binary), + map[string]any{"prompt": strings.TrimSpace(prompt)}, + ) + if !ok { + return nil + } + return parseCandidatesPayload(payload) +} + +type CommandInstaller struct { + Binary string +} + +func (i CommandInstaller) Install(candidates []Candidate) ([]Candidate, error) { + payload, ok := runSkillCommand( + strings.TrimSpace(i.Binary), + map[string]any{ + "candidates": routingCandidatesPayload(candidates), + }, + ) + if !ok { + return nil, nil + } + return parseCandidatesPayload(payload), nil +} + +func NewDefaultFinder() Finder { + return ChainFinder{ + Primary: CommandFinder{ + Binary: strings.TrimSpace(shared.EnvOrDefault("ACP_FIND_SKILLS_BIN", "")), + }, + Fallback: StaticFinder{}, + } +} + +func NewDefaultInstaller() Installer { + return CommandInstaller{ + Binary: strings.TrimSpace(shared.EnvOrDefault("ACP_INSTALL_SKILL_BIN", "")), + } +} + +func runSkillCommand(binary string, payload map[string]any) (map[string]any, bool) { + if binary == "" { + return nil, false + } + if _, err := exec.LookPath(binary); err != nil { + return nil, false + } + body, err := json.Marshal(payload) + if err != nil { + return nil, false + } + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, binary) + cmd.Stdin = strings.NewReader(string(body)) + output, err := cmd.Output() + if err != nil { + return nil, false + } + var decoded map[string]any + if err := json.Unmarshal(output, &decoded); err == nil { + return decoded, true + } + var list []map[string]any + if err := json.Unmarshal(output, &list); err == nil { + return map[string]any{"candidates": list}, true + } + return nil, false +} + +func parseCandidatesPayload(payload map[string]any) []Candidate { + if len(payload) == 0 { + return nil + } + if raw, ok := payload["candidates"]; ok { + return parseCandidates(raw) + } + if raw, ok := payload["skills"]; ok { + return parseCandidates(raw) + } + return parseCandidates(payload) +} + +func parseCandidates(raw any) []Candidate { + switch typed := raw.(type) { + case []any: + result := make([]Candidate, 0, len(typed)) + for _, item := range typed { + entry := toMap(item) + if len(entry) == 0 { + continue + } + result = append(result, Candidate{ + ID: strings.TrimSpace(stringValue(entry["id"])), + Label: strings.TrimSpace(stringValue(entry["label"])), + Description: strings.TrimSpace(stringValue(entry["description"])), + Installed: boolValue(entry["installed"]), + }) + } + return dedupeCandidates(result) + case []map[string]any: + values := make([]any, 0, len(typed)) + for _, item := range typed { + values = append(values, item) + } + return parseCandidates(values) + case map[string]any: + entry := Candidate{ + ID: strings.TrimSpace(stringValue(typed["id"])), + Label: strings.TrimSpace(stringValue(typed["label"])), + Description: strings.TrimSpace(stringValue(typed["description"])), + Installed: boolValue(typed["installed"]), + } + if entry.ID == "" && entry.Label == "" { + return nil + } + return []Candidate{entry} + default: + return nil + } +} + +func routingCandidatesPayload(candidates []Candidate) []map[string]any { + result := make([]map[string]any, 0, len(candidates)) + for _, candidate := range candidates { + result = append(result, map[string]any{ + "id": strings.TrimSpace(candidate.ID), + "label": strings.TrimSpace(candidate.Label), + "description": strings.TrimSpace(candidate.Description), + "installed": candidate.Installed, + }) + } + return result +} + +func toMap(value any) map[string]any { + if typed, ok := value.(map[string]any); ok { + return typed + } + if typed, ok := value.(map[string]interface{}); ok { + return typed + } + return nil +} + +func stringValue(value any) string { + if value == nil { + return "" + } + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + default: + return strings.TrimSpace(fmt.Sprint(value)) + } +} + +func boolValue(value any) bool { + switch typed := value.(type) { + case bool: + return typed + case string: + normalized := strings.ToLower(strings.TrimSpace(typed)) + return normalized == "true" || normalized == "1" || normalized == "yes" + case float64: + return typed != 0 + case int: + return typed != 0 + default: + return false + } +} diff --git a/go/go_core/internal/skills/resolver.go b/go/go_core/internal/skills/resolver.go index af5295ea..0c3255ba 100644 --- a/go/go_core/internal/skills/resolver.go +++ b/go/go_core/internal/skills/resolver.go @@ -16,6 +16,10 @@ type Finder interface { Find(prompt string) []Candidate } +type Installer interface { + Install(candidates []Candidate) ([]Candidate, error) +} + type ResolveRequest struct { Prompt string ExplicitSkills []string @@ -48,7 +52,7 @@ func (StaticFinder) Find(prompt string) []Candidate { return dedupeCandidates(candidates) } -func Resolve(req ResolveRequest, finder Finder) ResolveResult { +func Resolve(req ResolveRequest, finder Finder, installer Installer) ResolveResult { available := dedupeCandidates(req.AvailableSkills) explicit := normalizeList(req.ExplicitSkills) if len(explicit) > 0 { @@ -93,10 +97,28 @@ func Resolve(req ResolveRequest, finder Finder) ResolveResult { } } + if req.AllowSkillInstall && installer != nil && len(uninstalled) > 0 { + installedCandidates, err := installer.Install(uninstalled) + if err == nil && len(installedCandidates) > 0 { + mergedAvailable := dedupeCandidates( + append(append([]Candidate(nil), available...), installedCandidates...), + ) + if resolved := installedMatches(fallback, mergedAvailable); len(resolved) > 0 { + return ResolveResult{ + ResolvedSkills: resolved, + Candidates: dedupeCandidates( + append(append([]Candidate(nil), fallback...), installedCandidates...), + ), + Source: "find_skills", + } + } + } + } + return ResolveResult{ Candidates: fallback, Source: "find_skills", - NeedsInstall: !req.AllowSkillInstall && len(uninstalled) > 0, + NeedsInstall: len(uninstalled) > 0, } } @@ -162,6 +184,16 @@ func findInstalledMatch(candidate Candidate, available []Candidate) string { return "" } +func installedMatches(candidates []Candidate, available []Candidate) []string { + resolved := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + if matched := findInstalledMatch(candidate, available); matched != "" { + resolved = append(resolved, matched) + } + } + return dedupeStrings(resolved) +} + func candidateLabel(candidate Candidate) string { if strings.TrimSpace(candidate.Label) != "" { return strings.TrimSpace(candidate.Label) diff --git a/go/go_core/internal/skills/resolver_test.go b/go/go_core/internal/skills/resolver_test.go index 446dd75d..42814794 100644 --- a/go/go_core/internal/skills/resolver_test.go +++ b/go/go_core/internal/skills/resolver_test.go @@ -2,6 +2,20 @@ package skills import "testing" +type fakeFinder []Candidate + +func (f fakeFinder) Find(prompt string) []Candidate { + return append([]Candidate(nil), f...) +} + +type fakeInstaller struct { + installed []Candidate +} + +func (f fakeInstaller) Install(candidates []Candidate) ([]Candidate, error) { + return append([]Candidate(nil), f.installed...), nil +} + func TestResolvePrefersExplicitSkills(t *testing.T) { result := Resolve(ResolveRequest{ Prompt: "make a deck", @@ -9,7 +23,7 @@ func TestResolvePrefersExplicitSkills(t *testing.T) { AvailableSkills: []Candidate{ {ID: "pptx", Label: "pptx", Installed: true}, }, - }, StaticFinder{}) + }, StaticFinder{}, nil) if result.Source != "local_match" { t.Fatalf("expected local_match source, got %q", result.Source) @@ -26,7 +40,7 @@ func TestResolveUsesInstalledLocalMatchesBeforeFallback(t *testing.T) { {ID: "pptx", Label: "PPTX", Installed: true}, {ID: "docx", Label: "DOCX", Installed: true}, }, - }, StaticFinder{}) + }, StaticFinder{}, nil) if result.Source != "local_match" { t.Fatalf("expected local_match source, got %q", result.Source) @@ -41,7 +55,7 @@ func TestResolveFallsBackToFindSkillsCandidates(t *testing.T) { Prompt: "translate and dub this video with subtitles", AvailableSkills: []Candidate{{ID: "docx", Label: "docx", Installed: true}}, AllowSkillInstall: false, - }, StaticFinder{}) + }, StaticFinder{}, nil) if result.Source != "find_skills" { t.Fatalf("expected find_skills source, got %q", result.Source) @@ -56,3 +70,31 @@ func TestResolveFallsBackToFindSkillsCandidates(t *testing.T) { t.Fatalf("unexpected fallback candidates: %#v", result.Candidates) } } + +func TestResolveInstallsMissingSkillsWhenAuthorized(t *testing.T) { + result := Resolve( + ResolveRequest{ + Prompt: "translate and dub this video with subtitles", + AvailableSkills: []Candidate{{ID: "docx", Label: "docx", Installed: true}}, + AllowSkillInstall: true, + }, + fakeFinder{ + {ID: "video-translator", Label: "video-translator", Installed: false}, + }, + fakeInstaller{ + installed: []Candidate{ + {ID: "video-translator", Label: "video-translator", Installed: true}, + }, + }, + ) + + if result.Source != "find_skills" { + t.Fatalf("expected find_skills source, got %q", result.Source) + } + if result.NeedsInstall { + t.Fatalf("expected install retry to resolve the skill, got %#v", result) + } + if len(result.ResolvedSkills) != 1 || result.ResolvedSkills[0] != "video-translator" { + t.Fatalf("unexpected resolved skills after install: %#v", result.ResolvedSkills) + } +} diff --git a/lib/app/app_controller_desktop_single_agent.dart b/lib/app/app_controller_desktop_single_agent.dart index 419960f9..915f111f 100644 --- a/lib/app/app_controller_desktop_single_agent.dart +++ b/lib/app/app_controller_desktop_single_agent.dart @@ -208,7 +208,8 @@ extension AppControllerDesktopSingleAgent on AppController { resolvedRuntimeModel; } final resolvedGatewayEntryState = - result.resolvedExecutionTarget == 'gateway-chat' + (result.resolvedExecutionTarget == 'gateway' || + result.resolvedExecutionTarget == 'gateway-chat') ? (result.resolvedEndpointTarget.trim().isNotEmpty ? result.resolvedEndpointTarget.trim() : AssistantExecutionTarget.local.promptValue) @@ -857,8 +858,7 @@ extension AppControllerDesktopSingleAgent on AppController { final resolvedExplicitExecutionTarget = sessionTarget == AssistantExecutionTarget.auto ? '' - : - explicitExecutionTarget?.trim().isNotEmpty == true + : explicitExecutionTarget?.trim().isNotEmpty == true ? explicitExecutionTarget!.trim() : (thread?.hasExplicitExecutionTargetSelection ?? false) ? _routingExecutionTargetValue( @@ -868,8 +868,7 @@ extension AppControllerDesktopSingleAgent on AppController { final resolvedExplicitProviderId = sessionTarget == AssistantExecutionTarget.auto ? '' - : - thread?.hasExplicitProviderSelection ?? false + : thread?.hasExplicitProviderSelection ?? false ? singleAgentProviderForSession(normalizedSessionKey).providerId : ''; final resolvedExplicitModel = thread?.hasExplicitModelSelection ?? false diff --git a/lib/runtime/go_agent_core_client.dart b/lib/runtime/go_agent_core_client.dart index 57a69d1c..042fdac6 100644 --- a/lib/runtime/go_agent_core_client.dart +++ b/lib/runtime/go_agent_core_client.dart @@ -159,6 +159,7 @@ class GoAgentCoreSessionRequest { return 'multi-agent'; } return switch (target) { + AssistantExecutionTarget.auto => 'single-agent', AssistantExecutionTarget.singleAgent => 'single-agent', AssistantExecutionTarget.local => 'gateway', AssistantExecutionTarget.remote => 'gateway', diff --git a/lib/runtime/go_agent_core_desktop_transport.dart b/lib/runtime/go_agent_core_desktop_transport.dart index 72e720b9..f8204d1d 100644 --- a/lib/runtime/go_agent_core_desktop_transport.dart +++ b/lib/runtime/go_agent_core_desktop_transport.dart @@ -278,28 +278,31 @@ class GoAgentCoreDesktopTransport implements GoAgentCoreClient { final resolvedModel = routingResult['resolvedModel']?.toString().trim() ?? ''; final resolvedSkills = _castStringList(routingResult['resolvedSkills']); + final routedTarget = _targetForRouting(request, routingResult); - if (resolvedExecutionTarget.isNotEmpty) { - params['mode'] = resolvedExecutionTarget; - params['resolvedExecutionTarget'] = resolvedExecutionTarget; - } - if (resolvedEndpointTarget.isNotEmpty) { - params['resolvedEndpointTarget'] = resolvedEndpointTarget; - if (_isGatewayExecutionTarget(resolvedExecutionTarget)) { + if (routedTarget != AssistantExecutionTarget.singleAgent) { + if (resolvedExecutionTarget.isNotEmpty) { + params['mode'] = 'gateway-chat'; + } + if (resolvedEndpointTarget.isNotEmpty) { params['executionTarget'] = resolvedEndpointTarget; + params['resolvedEndpointTarget'] = resolvedEndpointTarget; + } + if (resolvedProviderId.isNotEmpty) { + params['provider'] = resolvedProviderId; + params['resolvedProviderId'] = resolvedProviderId; + } + if (resolvedModel.isNotEmpty) { + params['model'] = resolvedModel; + params['resolvedModel'] = resolvedModel; + } + if (resolvedSkills.isNotEmpty) { + params['selectedSkills'] = resolvedSkills; + params['resolvedSkills'] = resolvedSkills; } } - if (resolvedProviderId.isNotEmpty) { - params['provider'] = resolvedProviderId; - params['resolvedProviderId'] = resolvedProviderId; - } - if (resolvedModel.isNotEmpty) { - params['model'] = resolvedModel; - params['resolvedModel'] = resolvedModel; - } - if (resolvedSkills.isNotEmpty) { - params['selectedSkills'] = resolvedSkills; - params['resolvedSkills'] = resolvedSkills; + if (resolvedExecutionTarget.isNotEmpty) { + params['resolvedExecutionTarget'] = resolvedExecutionTarget; } for (final key in [ 'skillResolutionSource', diff --git a/lib/web/go_agent_core_web_transport.dart b/lib/web/go_agent_core_web_transport.dart index f54f1f1d..7e64b1ac 100644 --- a/lib/web/go_agent_core_web_transport.dart +++ b/lib/web/go_agent_core_web_transport.dart @@ -152,28 +152,31 @@ class GoAgentCoreWebTransport implements GoAgentCoreClient { final resolvedModel = routingResult['resolvedModel']?.toString().trim() ?? ''; final resolvedSkills = _castStringList(routingResult['resolvedSkills']); + final routedTarget = _targetForRouting(request, routingResult); - if (resolvedExecutionTarget.isNotEmpty) { - params['mode'] = resolvedExecutionTarget; - params['resolvedExecutionTarget'] = resolvedExecutionTarget; - } - if (resolvedEndpointTarget.isNotEmpty) { - params['resolvedEndpointTarget'] = resolvedEndpointTarget; - if (_isGatewayExecutionTarget(resolvedExecutionTarget)) { + if (routedTarget != AssistantExecutionTarget.singleAgent) { + if (resolvedExecutionTarget.isNotEmpty) { + params['mode'] = 'gateway-chat'; + } + if (resolvedEndpointTarget.isNotEmpty) { params['executionTarget'] = resolvedEndpointTarget; + params['resolvedEndpointTarget'] = resolvedEndpointTarget; + } + if (resolvedProviderId.isNotEmpty) { + params['provider'] = resolvedProviderId; + params['resolvedProviderId'] = resolvedProviderId; + } + if (resolvedModel.isNotEmpty) { + params['model'] = resolvedModel; + params['resolvedModel'] = resolvedModel; + } + if (resolvedSkills.isNotEmpty) { + params['selectedSkills'] = resolvedSkills; + params['resolvedSkills'] = resolvedSkills; } } - if (resolvedProviderId.isNotEmpty) { - params['provider'] = resolvedProviderId; - params['resolvedProviderId'] = resolvedProviderId; - } - if (resolvedModel.isNotEmpty) { - params['model'] = resolvedModel; - params['resolvedModel'] = resolvedModel; - } - if (resolvedSkills.isNotEmpty) { - params['selectedSkills'] = resolvedSkills; - params['resolvedSkills'] = resolvedSkills; + if (resolvedExecutionTarget.isNotEmpty) { + params['resolvedExecutionTarget'] = resolvedExecutionTarget; } for (final key in [ 'skillResolutionSource',