Implement intent router state machine v2

This commit is contained in:
Haitao Pan 2026-04-03 20:33:16 +08:00
parent 922a287166
commit af808d552d
15 changed files with 733 additions and 70 deletions

View File

@ -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 {

View File

@ -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"])
}
}

View File

@ -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 {

View File

@ -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...)
}
}

View File

@ -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)
}
}

View File

@ -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 ""
}
}

View File

@ -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)) {

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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

View File

@ -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',

View File

@ -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 <String>[
'skillResolutionSource',

View File

@ -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 <String>[
'skillResolutionSource',