Implement intent router state machine v2
This commit is contained in:
parent
922a287166
commit
af808d552d
@ -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 {
|
||||
|
||||
@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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...)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
78
go/go_core/internal/router/classifier.go
Normal file
78
go/go_core/internal/router/classifier.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
@ -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)) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
209
go/go_core/internal/skills/command_io.go
Normal file
209
go/go_core/internal/skills/command_io.go
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user