diff --git a/.gitignore b/.gitignore index 4a28c084..1f46a14f 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,4 @@ POSTGRESQL_COMPAT.md openapi/setting/*.md agent/docs/design/*.md tools/README.md +tools/TOOL-REGISTRATION.md diff --git a/agent/assistant/agent.go b/agent/assistant/agent.go index ccb82d90..214e15d1 100644 --- a/agent/assistant/agent.go +++ b/agent/assistant/agent.go @@ -14,9 +14,7 @@ import ( "github.com/yaoapp/yao/agent/llm" "github.com/yaoapp/yao/agent/output/message" agentsandbox "github.com/yaoapp/yao/agent/sandbox" - sandboxTypes "github.com/yaoapp/yao/agent/sandbox/v2/types" "github.com/yaoapp/yao/llmprovider" - infraV2 "github.com/yaoapp/yao/sandbox/v2" ) // Stream stream the agent @@ -168,30 +166,25 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa var sandboxLoadingMsgID string // V2 sandbox state - var v2Runner sandboxTypes.Runner - var v2Computer infraV2.Computer - var v2LoadingMsgID string - - var v2Cfg *sandboxTypes.SandboxConfig + var v2Init *sandboxV2InitResult if ast.HasSandboxV2() { ctx.Logger.Phase("Sandbox V2") var err error - var v2Cleanup func() - v2Runner, v2Computer, v2Cfg, v2Cleanup, v2LoadingMsgID, err = ast.initSandboxV2(ctx, opts) + v2Init, err = ast.initSandboxV2(ctx, opts) if err != nil { ast.traceAgentFail(agentNode, err) ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err) return nil, err } - sandboxCleanup = v2Cleanup + sandboxCleanup = v2Init.Cleanup ctx.Logger.PhaseComplete("Sandbox V2") - if v2Computer != nil { - ci := v2Computer.ComputerInfo() + if v2Init.Computer != nil { + ci := v2Init.Computer.ComputerInfo() ctx.Logger.Trace("Node: %s (%s)", ci.NodeID, ci.Kind) if ci.BoxID != "" { ctx.Logger.Trace("Computer: %s", ci.BoxID) } - ctx.Logger.Trace("Workspace: %s", v2Cfg.WorkspaceID) + ctx.Logger.Trace("Workspace: %s", v2Init.Config.WorkspaceID) if conn, _, err := ast.GetConnector(ctx, opts); err == nil && conn != nil { ctx.Logger.Trace("Connector: %s", conn.ID()) } @@ -331,22 +324,23 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa // Execute the LLM streaming call // Choose between sandbox execution or direct LLM execution - if ast.HasSandboxV2() && v2Runner != nil && v2Computer != nil && v2Runner.Name() != "yao" { + if ast.HasSandboxV2() && v2Init != nil && v2Init.Runner != nil && v2Init.Computer != nil && v2Init.Runner.Name() != "yao" { // V2 Sandbox execution path (non-yao runners replace LLM.Stream) completionResponse, err = ast.executeSandboxV2Stream(ctx, &sandboxV2StreamParams{ Messages: completionMessages, AgentNode: agentNode, Handler: streamHandler, - Runner: v2Runner, - Computer: v2Computer, - Config: v2Cfg, - LoadingMsgID: v2LoadingMsgID, + Runner: v2Init.Runner, + Computer: v2Init.Computer, + Config: v2Init.Config, + LoadingMsgID: v2Init.LoadingMsgID, Options: opts, + Roles: v2Init.Roles, }) - } else if ast.HasSandboxV2() && v2Runner != nil && v2Runner.Name() == "yao" { + } else if ast.HasSandboxV2() && v2Init != nil && v2Init.Runner != nil && v2Init.Runner.Name() == "yao" { // V2 yao runner: Prepare is done, close loading, fall through to LLM - if v2LoadingMsgID != "" { - closeLoadingV2(ctx, v2LoadingMsgID, "") + if v2Init.LoadingMsgID != "" { + closeLoadingV2(ctx, v2Init.LoadingMsgID, "") } completionResponse, err = ast.executeLLMStream(ctx, completionMessages, completionOptions, agentNode, streamHandler, opts) } else if ast.HasSandbox() { diff --git a/agent/assistant/sandbox_v2.go b/agent/assistant/sandbox_v2.go index 28c7cf45..94e3a124 100644 --- a/agent/assistant/sandbox_v2.go +++ b/agent/assistant/sandbox_v2.go @@ -8,6 +8,7 @@ import ( "path/filepath" "time" + "github.com/yaoapp/gou/connector" "github.com/yaoapp/yao/agent/context" "github.com/yaoapp/yao/agent/i18n" "github.com/yaoapp/yao/agent/output/message" @@ -15,6 +16,7 @@ import ( sandboxTypes "github.com/yaoapp/yao/agent/sandbox/v2/types" store "github.com/yaoapp/yao/agent/store/types" "github.com/yaoapp/yao/config" + "github.com/yaoapp/yao/llmprovider" infraV2 "github.com/yaoapp/yao/sandbox/v2" traceTypes "github.com/yaoapp/yao/trace/types" "github.com/yaoapp/yao/workspace" @@ -25,15 +27,22 @@ func (ast *Assistant) HasSandboxV2() bool { return ast.SandboxV2 != nil } +// sandboxV2InitResult bundles everything returned by initSandboxV2. +type sandboxV2InitResult struct { + Runner sandboxTypes.Runner + Computer infraV2.Computer + Config *sandboxTypes.SandboxConfig + Cleanup func() + LoadingMsgID string + Roles map[string]connector.Connector +} + // initSandboxV2 initializes the V2 sandbox: obtains a Computer, gets a Runner, -// runs Prepare, and returns the runner, computer, a per-request copy of the -// SandboxConfig, cleanup closure, loading message ID, and any error. +// resolves the role matrix, runs Prepare, and returns the result. // // A shallow copy of ast.SandboxV2 is made so that concurrent requests to the // same assistant each get their own mutable config (Owner, ID, NodeID, etc.). -func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options) ( - sandboxTypes.Runner, infraV2.Computer, *sandboxTypes.SandboxConfig, func(), string, error, -) { +func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options) (*sandboxV2InitResult, error) { cfgCopy := *ast.SandboxV2 cfg := &cfgCopy manager := infraV2.M() @@ -52,9 +61,12 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options) conn, _, err := ast.GetConnector(ctx, opts) if err != nil && cfg.Runner.Name != "yao" { closeLoadingV2(ctx, loadingMsgID, "sandbox.failed") - return nil, nil, nil, nil, "", fmt.Errorf("get connector: %w", err) + return nil, fmt.Errorf("get connector: %w", err) } + // 1b. Resolve role matrix once; passed to both Prepare and Stream. + roles := resolveRoles(conn, ctx.Authorized) + // 2. Build human-readable DisplayName from real Agent name + Workspace name. cfg.DisplayName = buildBoxDisplayName(ctx, ast.ID, ast.Name) @@ -89,7 +101,7 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options) computer, identifier, err := sandboxv2.GetComputer(ctx, cfg, manager) if err != nil { closeLoadingV2(ctx, loadingMsgID, "sandbox.failed") - return nil, nil, nil, nil, "", fmt.Errorf("getComputer failed: %w", err) + return nil, fmt.Errorf("getComputer failed: %w", err) } _ = identifier @@ -98,7 +110,7 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options) if err != nil { sandboxv2.LifecycleAction(stdCtx, cfg, computer, manager) closeLoadingV2(ctx, loadingMsgID, "sandbox.failed") - return nil, nil, nil, nil, "", fmt.Errorf("get runner %q: %w", cfg.Runner.Name, err) + return nil, fmt.Errorf("get runner %q: %w", cfg.Runner.Name, err) } // 5. Resolve assistant directory and skills subdirectory. @@ -129,6 +141,7 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options) Computer: computer, Config: cfg, Connector: conn, + Roles: roles, AssistantID: ast.ID, SkillsDir: skillsDir, AssistantDir: assistantDir, @@ -140,11 +153,9 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options) runner.Cleanup(stdCtx, computer) sandboxv2.LifecycleAction(stdCtx, cfg, computer, manager) closeLoadingV2(ctx, loadingMsgID, "sandbox.failed") - return nil, nil, nil, nil, "", fmt.Errorf("runner.Prepare: %w", err) + return nil, fmt.Errorf("runner.Prepare: %w", err) } - // Inject computer + workspace into context so Create/Next hooks - // can access ctx.computer and ctx.workspace. ctx.SetComputer(computer) cleanup := func() { @@ -154,7 +165,14 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options) sandboxv2.LifecycleAction(cleanCtx, cfg, computer, manager) } - return runner, computer, cfg, cleanup, loadingMsgID, nil + return &sandboxV2InitResult{ + Runner: runner, + Computer: computer, + Config: cfg, + Cleanup: cleanup, + LoadingMsgID: loadingMsgID, + Roles: roles, + }, nil } // sandboxV2StreamParams groups arguments for executeSandboxV2Stream. @@ -167,6 +185,7 @@ type sandboxV2StreamParams struct { Config *sandboxTypes.SandboxConfig LoadingMsgID string Options *context.Options + Roles map[string]connector.Connector } // executeSandboxV2Stream calls the V2 Runner.Stream and wraps it in the @@ -208,6 +227,7 @@ func (ast *Assistant) executeSandboxV2Stream( Computer: p.Computer, Config: cfg, Connector: conn, + Roles: p.Roles, AssistantID: ast.ID, Messages: p.Messages, SystemPrompt: systemPrompt, @@ -229,6 +249,25 @@ func (ast *Assistant) executeSandboxV2Stream( return sandboxv2.ExecuteSandboxStream(ctx, execReq, p.Handler) } +// resolveRoles builds the role → connector map using the llmprovider role system. +// The primary connector (user-selected or system default) becomes "default"; +// other roles (heavy, light, vision) are fetched from llmprovider settings. +func resolveRoles(conn connector.Connector, identity llmprovider.Identity) map[string]connector.Connector { + roles := map[string]connector.Connector{} + if conn != nil { + roles["default"] = conn + } + if llmprovider.Global == nil || identity == nil { + return roles + } + for _, role := range []string{"heavy", "light", "vision"} { + if c, err := llmprovider.Global.GetRoleModelBy(role, identity); err == nil { + roles[role] = c + } + } + return roles +} + // initStandaloneWorkspace loads the workspace FS into context when no sandbox // is configured but the user selected a workspace (metadata["workspace_id"]). func (ast *Assistant) initStandaloneWorkspace(ctx *context.Context) { diff --git a/agent/sandbox/v2/claude/command.go b/agent/sandbox/v2/claude/command.go index d5b66bb5..f363aa96 100644 --- a/agent/sandbox/v2/claude/command.go +++ b/agent/sandbox/v2/claude/command.go @@ -12,6 +12,7 @@ import ( "github.com/yaoapp/gou/connector" goullm "github.com/yaoapp/gou/llm" "github.com/yaoapp/gou/store" + "github.com/yaoapp/kun/log" "github.com/yaoapp/kun/str" agentContext "github.com/yaoapp/yao/agent/context" "github.com/yaoapp/yao/agent/sandbox/v2/types" @@ -159,64 +160,14 @@ func buildEnv(req *types.StreamRequest, p platform) map[string]string { model, _ = setting["model"].(string) } - roleConnectors := getRoleConnectors(req) - getConn := func(id string) connector.Connector { - c, _ := connector.Connectors[id] - return c - } + isAnthropic := req.Connector.Is(connector.ANTHROPIC) - if req.Connector.Is(connector.ANTHROPIC) { - env["ANTHROPIC_BASE_URL"] = host - env["ANTHROPIC_API_KEY"] = key - if model != "" { - env["ANTHROPIC_MODEL"] = model - env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = model - env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = model - env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = model - env["CLAUDE_CODE_SUBAGENT_MODEL"] = model - } - - if len(roleConnectors) > 0 { - primaryHost := host - for role, rm := range claudeRoleEnvMap { - if role == "default" { - continue - } - rc := resolveRoleConnector(role, roleConnectors, req.UserExplicit, getConn) - if rc == nil { - continue - } - rcHost := connectorHost(rc) - if rcHost == primaryHost && supportsProtocol(rc, "anthropic") { - rcModel, _ := rc.Setting()["model"].(string) - if rcModel != "" { - env[rm.EnvVar] = rcModel - } - } - } - } + if isAnthropic { + setAnthropicModelEnv(env, host, key, model, req.Connector) + applyAnthropicRoleOverrides(env, host, req.Roles) } else { - connectorID := req.Connector.ID() - env["ANTHROPIC_BASE_URL"] = fmt.Sprintf("http://127.0.0.1:%d/c/%s", defaultA2OPort, connectorID) - env["ANTHROPIC_API_KEY"] = "dummy" - env["ANTHROPIC_MODEL"] = "claude-sonnet-4-6" - env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = "claude-sonnet-4-6" - env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = "claude-sonnet-4-6" - env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = "claude-sonnet-4-6" - env["CLAUDE_CODE_SUBAGENT_MODEL"] = "claude-sonnet-4-6" - - if len(roleConnectors) > 0 { - for role, rm := range claudeRoleEnvMap { - if role == "default" { - continue - } - rc := resolveRoleConnector(role, roleConnectors, req.UserExplicit, getConn) - if rc == nil { - continue - } - env[rm.EnvVar] = rm.ModelName - } - } + setA2OModelEnv(env, req.Connector.ID(), model, req.Connector) + applyA2ORoleOverrides(env, req.Roles) } if lc, ok := req.Connector.(goullm.LLMConnector); ok { @@ -261,6 +212,25 @@ func buildEnv(req *types.StreamRequest, p platform) map[string]string { } } + logger := req.Logger + if logger == nil { + logger = agentContext.NoopLogger() + } + connectorID := "" + if req.Connector != nil { + connectorID = req.Connector.ID() + } + logger.Debug("claude-env: connector=%s isAnthropic=%v", connectorID, req.Connector != nil && req.Connector.Is(connector.ANTHROPIC)) + logger.Debug("claude-env: ANTHROPIC_MODEL=%s", env["ANTHROPIC_MODEL"]) + logger.Debug("claude-env: OPUS_MODEL=%s SONNET_MODEL=%s HAIKU_MODEL=%s", + env["ANTHROPIC_DEFAULT_OPUS_MODEL"], + env["ANTHROPIC_DEFAULT_SONNET_MODEL"], + env["ANTHROPIC_DEFAULT_HAIKU_MODEL"]) + logger.Debug("claude-env: CUSTOM_MODEL_OPTION=%s CAPABILITIES=%s", + env["ANTHROPIC_CUSTOM_MODEL_OPTION"], + env["ANTHROPIC_CUSTOM_MODEL_OPTION_SUPPORTED_CAPABILITIES"]) + logger.Debug("claude-env: MAX_THINKING_TOKENS=%s", env["MAX_THINKING_TOKENS"]) + return env } @@ -331,26 +301,13 @@ func buildSandboxEnvPrompt(p platform, workDir string) string { shellNote := p.EnvPromptNote() - envVarSyntax := "$VAR_NAME" - if osName == "windows" { - envVarSyntax = "$env:VAR_NAME" - } - return fmt.Sprintf(`## Sandbox Environment - **Operating System**: %[2]s - **Shell**: %[3]s - **Working Directory**: %[1]s - **File Access**: You have full read/write access to %[1]s -- **Environment variable syntax**: `+"`%[5]s`"+` (e.g. `+"`$CTX_SKILLS_DIR`"+` on POSIX, `+"`$env:CTX_SKILLS_DIR`"+` on Windows)%[4]s - -## User Attachments - -User-uploaded files (images, documents, code files, etc.) are placed in %[1]s/.attachments/{chatID}/ -Each chat session has its own subdirectory to avoid conflicts. -When the user references an attached file, read it from this directory using the Read or Bash tool. -For image files, you can view them directly as Claude supports vision on local files. -`, workDir, osName, shell, shellNote, envVarSyntax) +%[4]s`, workDir, osName, shell, shellNote) } func hasExistingSession(ctx context.Context, computer infra.Computer, p platform, assistantID string) bool { @@ -427,18 +384,12 @@ func buildLastUserMessageJSONL(messages []agentContext.Message) string { } // claudeRoleEnvMap maps abstract Yao model roles to Claude CLI environment -// variables and virtual model name identifiers used as A2O route keys. -// Only roles with matching Claude CLI env vars are listed here. -// ANTHROPIC_DEFAULT_SONNET_MODEL and CLAUDE_CODE_SUBAGENT_MODEL are set to -// the primary model in buildEnv (Claude CLI doesn't have vision/subagent as -// independent role concepts). -var claudeRoleEnvMap = map[string]struct { - EnvVar string - ModelName string -}{ - "default": {EnvVar: "ANTHROPIC_MODEL", ModelName: "claude-sonnet-4-6"}, - "heavy": {EnvVar: "ANTHROPIC_DEFAULT_OPUS_MODEL", ModelName: "claude-opus-4-6"}, - "light": {EnvVar: "ANTHROPIC_DEFAULT_HAIKU_MODEL", ModelName: "claude-haiku-4-5"}, +// variables. Only roles with matching Claude CLI env vars are listed here. +// ANTHROPIC_DEFAULT_SONNET_MODEL is set to the primary model in buildEnv. +var claudeRoleEnvMap = map[string]struct{ EnvVar string }{ + "default": {EnvVar: "ANTHROPIC_MODEL"}, + "heavy": {EnvVar: "ANTHROPIC_DEFAULT_OPUS_MODEL"}, + "light": {EnvVar: "ANTHROPIC_DEFAULT_HAIKU_MODEL"}, } func connectorHost(c connector.Connector) string { @@ -477,33 +428,149 @@ func supportsProtocol(c connector.Connector, proto string) bool { return false } -// resolveRoleConnector determines which connector to use for a given role. -// Returns nil when the role should use the primary connector (caller decides). -func resolveRoleConnector( - role string, - roleConnectors map[string]*types.RoleConnector, - userExplicit bool, - getConnector func(id string) connector.Connector, -) connector.Connector { - rc, ok := roleConnectors[role] - if !ok || rc == nil { - return nil - } - if rc.Override == "user" && userExplicit { - return nil - } - return getConnector(rc.Connector) -} - -func getRoleConnectors(req *types.StreamRequest) map[string]*types.RoleConnector { - if req.Config == nil { - return nil - } - return req.Config.Runner.Connectors -} - var claudeArgWhitelist = map[string]string{ "max_turns": "--max-turns", "disallowed_tools": "--disallowed-tools", "allowed_tools": "--allowedTools", } + +func isStandardAnthropicModel(model string) bool { + return strings.HasPrefix(model, "claude-") || strings.HasPrefix(model, "anthropic.") +} + +func buildClaudeCodeCapabilities(conn connector.Connector) string { + if conn == nil { + return "" + } + setting := conn.Setting() + if setting == nil { + return "" + } + var caps []string + if thinking, ok := setting["thinking"].(map[string]interface{}); ok { + if thinkType, _ := thinking["type"].(string); thinkType == "enabled" { + caps = append(caps, "thinking") + } + } + return strings.Join(caps, ",") +} + +func setAnthropicModelEnv(env map[string]string, host, key, model string, conn connector.Connector) { + env["ANTHROPIC_BASE_URL"] = host + env["ANTHROPIC_API_KEY"] = key + if model == "" { + return + } + env["ANTHROPIC_MODEL"] = model + env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = model + env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = model + env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = model + + if isStandardAnthropicModel(model) { + return + } + caps := buildClaudeCodeCapabilities(conn) + env["ANTHROPIC_CUSTOM_MODEL_OPTION"] = model + env["ANTHROPIC_CUSTOM_MODEL_OPTION_NAME"] = model + env["ANTHROPIC_DEFAULT_OPUS_MODEL_NAME"] = model + env["ANTHROPIC_DEFAULT_SONNET_MODEL_NAME"] = model + env["ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME"] = model + if caps != "" { + env["ANTHROPIC_CUSTOM_MODEL_OPTION_SUPPORTED_CAPABILITIES"] = caps + env["ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES"] = caps + env["ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES"] = caps + env["ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES"] = caps + } +} + +func applyAnthropicRoleOverrides( + env map[string]string, + primaryHost string, + roles map[string]connector.Connector, +) { + for role, rm := range claudeRoleEnvMap { + if role == "default" { + continue + } + rc, ok := roles[role] + if !ok || rc == nil { + continue + } + roleHost := connectorHost(rc) + if roleHost != primaryHost { + log.Warn("[claude] role %s: host mismatch (%s != %s), falling back to primary", role, roleHost, primaryHost) + continue + } + if !supportsProtocol(rc, "anthropic") { + log.Warn("[claude] role %s: not anthropic protocol, falling back to primary", role) + continue + } + rcModel, _ := rc.Setting()["model"].(string) + if rcModel == "" { + continue + } + env[rm.EnvVar] = rcModel + if isStandardAnthropicModel(rcModel) { + continue + } + env[rm.EnvVar+"_NAME"] = rcModel + if caps := buildClaudeCodeCapabilities(rc); caps != "" { + env[rm.EnvVar+"_SUPPORTED_CAPABILITIES"] = caps + } + } +} + +func setA2OModelEnv(env map[string]string, connectorID, model string, conn connector.Connector) { + env["ANTHROPIC_BASE_URL"] = fmt.Sprintf("http://127.0.0.1:%d/c/%s", defaultA2OPort, connectorID) + env["ANTHROPIC_API_KEY"] = "dummy" + env["ANTHROPIC_MODEL"] = model + env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = model + env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = model + env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = model + + if !isStandardAnthropicModel(model) { + env["ANTHROPIC_CUSTOM_MODEL_OPTION"] = model + env["ANTHROPIC_CUSTOM_MODEL_OPTION_NAME"] = model + env["ANTHROPIC_DEFAULT_OPUS_MODEL_NAME"] = model + env["ANTHROPIC_DEFAULT_SONNET_MODEL_NAME"] = model + env["ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME"] = model + if caps := buildClaudeCodeCapabilities(conn); caps != "" { + env["ANTHROPIC_CUSTOM_MODEL_OPTION_SUPPORTED_CAPABILITIES"] = caps + env["ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES"] = caps + env["ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES"] = caps + env["ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES"] = caps + } + } +} + +func applyA2ORoleOverrides( + env map[string]string, + roles map[string]connector.Connector, +) { + for role, rm := range claudeRoleEnvMap { + if role == "default" { + continue + } + rc, ok := roles[role] + if !ok || rc == nil { + continue + } + var rcModel string + if lc, ok := rc.(goullm.LLMConnector); ok { + rcModel = lc.GetModel() + } + if rcModel == "" { + rcModel, _ = rc.Setting()["model"].(string) + } + if rcModel == "" { + continue + } + env[rm.EnvVar] = rcModel + if !isStandardAnthropicModel(rcModel) { + env[rm.EnvVar+"_NAME"] = rcModel + if caps := buildClaudeCodeCapabilities(rc); caps != "" { + env[rm.EnvVar+"_SUPPORTED_CAPABILITIES"] = caps + } + } + } +} diff --git a/agent/sandbox/v2/claude/command_test.go b/agent/sandbox/v2/claude/command_test.go index 6171e233..e5984a76 100644 --- a/agent/sandbox/v2/claude/command_test.go +++ b/agent/sandbox/v2/claude/command_test.go @@ -308,7 +308,6 @@ func TestBuildSandboxEnvPrompt(t *testing.T) { assert.Contains(t, prompt, "darwin") assert.Contains(t, prompt, "bash") assert.Contains(t, prompt, "Sandbox Environment") - assert.Contains(t, prompt, ".attachments") } func TestBuildSandboxEnvPrompt_WindowsPlatform(t *testing.T) { @@ -525,64 +524,6 @@ func TestSupportsProtocol(t *testing.T) { assert.True(t, supportsProtocol(oai, "openai")) } -func TestResolveRoleConnector_Undeclared(t *testing.T) { - roles := map[string]*types.RoleConnector{} - result := resolveRoleConnector("heavy", roles, false, func(id string) connector.Connector { return nil }) - assert.Nil(t, result) -} - -func TestResolveRoleConnector_Force(t *testing.T) { - heavyConn := newOpenAIConnector("thinking", "https://api.thinking.com", "think-model", "k") - roles := map[string]*types.RoleConnector{ - "heavy": {Connector: "thinking", Override: "force"}, - } - result := resolveRoleConnector("heavy", roles, true, func(id string) connector.Connector { - if id == "thinking" { - return heavyConn - } - return nil - }) - assert.Equal(t, heavyConn, result) -} - -func TestResolveRoleConnector_UserExplicit(t *testing.T) { - roles := map[string]*types.RoleConnector{ - "heavy": {Connector: "thinking", Override: "user"}, - } - result := resolveRoleConnector("heavy", roles, true, func(id string) connector.Connector { - return newOpenAIConnector("thinking", "h", "m", "k") - }) - assert.Nil(t, result, "override=user + userExplicit=true => use user's connector") -} - -func TestResolveRoleConnector_UserNotExplicit(t *testing.T) { - heavyConn := newOpenAIConnector("thinking", "h", "m", "k") - roles := map[string]*types.RoleConnector{ - "heavy": {Connector: "thinking", Override: "user"}, - } - result := resolveRoleConnector("heavy", roles, false, func(id string) connector.Connector { - if id == "thinking" { - return heavyConn - } - return nil - }) - assert.Equal(t, heavyConn, result, "override=user + userExplicit=false => use sandbox connector") -} - -// --- buildEnv with multi-connector --- - -func registerTestConnectors(t *testing.T, connectors map[string]connector.Connector) func() { - t.Helper() - for id, c := range connectors { - connector.Connectors[id] = c - } - return func() { - for id := range connectors { - delete(connector.Connectors, id) - } - } -} - func TestBuildEnv_OpenAI_SingleConnector(t *testing.T) { oai := newOpenAIConnector("kimi", "https://api.moonshot.cn", "kimi-k2.5", "sk-test") req := &types.StreamRequest{ @@ -595,37 +536,32 @@ func TestBuildEnv_OpenAI_SingleConnector(t *testing.T) { env := buildEnv(req, p) assert.Contains(t, env["ANTHROPIC_BASE_URL"], "127.0.0.1") assert.Contains(t, env["ANTHROPIC_BASE_URL"], "kimi") - assert.Equal(t, "claude-sonnet-4-6", env["ANTHROPIC_MODEL"]) - assert.Equal(t, "claude-sonnet-4-6", env["ANTHROPIC_DEFAULT_OPUS_MODEL"]) + assert.Equal(t, "kimi-k2.5", env["ANTHROPIC_MODEL"]) + assert.Equal(t, "kimi-k2.5", env["ANTHROPIC_DEFAULT_OPUS_MODEL"]) } func TestBuildEnv_OpenAI_MultiConnector(t *testing.T) { primary := newOpenAIConnector("kimi", "https://api.moonshot.cn", "kimi-k2.5", "sk-test") heavyConn := newOpenAIConnector("heavy-conn", "https://api.heavy.com", "heavy-model", "sk-h") - cleanup := registerTestConnectors(t, map[string]connector.Connector{ - "heavy-conn": heavyConn, - }) - defer cleanup() - req := &types.StreamRequest{ - Config: &types.SandboxConfig{ - Runner: types.RunnerConfig{ - Connectors: map[string]*types.RoleConnector{ - "heavy": {Connector: "heavy-conn", Override: "force"}, - }, - }, - }, + Config: &types.SandboxConfig{}, Connector: primary, + Roles: map[string]connector.Connector{ + "default": primary, + "heavy": heavyConn, + }, } req.Computer = newFakeComputer("/workspace") p := testPlatform() env := buildEnv(req, p) - assert.Equal(t, "claude-opus-4-6", env["ANTHROPIC_DEFAULT_OPUS_MODEL"], - "heavy role should get its virtual model name for A2O routing") - assert.Equal(t, "claude-sonnet-4-6", env["ANTHROPIC_MODEL"], - "primary should keep default virtual model") + assert.Equal(t, "heavy-model", env["ANTHROPIC_DEFAULT_OPUS_MODEL"], + "heavy role should use actual model name from connector") + assert.Equal(t, "kimi-k2.5", env["ANTHROPIC_MODEL"], + "primary should use actual model name from connector") + assert.Equal(t, "kimi-k2.5", env["ANTHROPIC_CUSTOM_MODEL_OPTION"], + "non-standard model should set custom model option") } func TestBuildEnv_Anthropic_SingleConnector(t *testing.T) { @@ -647,20 +583,13 @@ func TestBuildEnv_Anthropic_MultiConnector_Compatible(t *testing.T) { primary := newAnthropicConnector("claude", "https://api.yao.run", "claude-sonnet-4-20250514", "sk-ant") lightConn := newDualProtoConnector("light-conn", "https://api.yao.run", "claude-haiku-3-5-20241022", "sk-light") - cleanup := registerTestConnectors(t, map[string]connector.Connector{ - "light-conn": lightConn, - }) - defer cleanup() - req := &types.StreamRequest{ - Config: &types.SandboxConfig{ - Runner: types.RunnerConfig{ - Connectors: map[string]*types.RoleConnector{ - "light": {Connector: "light-conn", Override: "force"}, - }, - }, - }, + Config: &types.SandboxConfig{}, Connector: primary, + Roles: map[string]connector.Connector{ + "default": primary, + "light": lightConn, + }, } req.Computer = newFakeComputer("/workspace") p := testPlatform() @@ -695,7 +624,7 @@ func TestInjectA2OConfigWithRoutes_BuildsCorrectJSON(t *testing.T) { heavyConn := newOpenAIConnector("heavy", "https://api.heavy.com", "heavy-model", "sk-h") roleConnectors := map[string]connector.Connector{ - "claude-opus-4-6": heavyConn, + "heavy-model": heavyConn, } primaryCfg := buildSingleA2OConfig(primary) @@ -720,7 +649,7 @@ func TestInjectA2OConfigWithRoutes_BuildsCorrectJSON(t *testing.T) { require.True(t, ok, "routes should be present in JSON") assert.Len(t, routesMap, 1) - heavyRoute, ok := routesMap["claude-opus-4-6"].(map[string]interface{}) + heavyRoute, ok := routesMap["heavy-model"].(map[string]interface{}) require.True(t, ok) assert.Equal(t, "heavy-model", heavyRoute["model"]) assert.Contains(t, heavyRoute["backend"], "api.heavy.com") @@ -736,43 +665,34 @@ func TestResolveAllRoleConnectors_Empty(t *testing.T) { } func TestResolveAllRoleConnectors_WithRoles(t *testing.T) { + primaryConn := newOpenAIConnector("primary", "https://primary.com", "primary-m", "k") heavyConn := newOpenAIConnector("hvy", "https://heavy.com", "heavy-m", "sk") - cleanup := registerTestConnectors(t, map[string]connector.Connector{"hvy": heavyConn}) - defer cleanup() req := &types.StreamRequest{ - Config: &types.SandboxConfig{ - Runner: types.RunnerConfig{ - Connectors: map[string]*types.RoleConnector{ - "heavy": {Connector: "hvy", Override: "force"}, - }, - }, + Config: &types.SandboxConfig{}, + Connector: primaryConn, + Roles: map[string]connector.Connector{ + "default": primaryConn, + "heavy": heavyConn, }, - Connector: newOpenAIConnector("primary", "h", "m", "k"), } result := resolveAllRoleConnectors(req) - assert.Len(t, result, 1) - assert.Equal(t, heavyConn, result["claude-opus-4-6"]) + assert.Len(t, result, 2) + assert.Equal(t, primaryConn, result["primary-m"]) + assert.Equal(t, heavyConn, result["heavy-m"]) } func TestBuildEnv_Anthropic_MultiConnector_Incompatible(t *testing.T) { primary := newAnthropicConnector("claude", "https://api.anthropic.com", "claude-sonnet-4-20250514", "sk-ant") heavyConn := newOpenAIConnector("heavy-oai", "https://api.openai.com", "gpt-4o", "sk-oai") - cleanup := registerTestConnectors(t, map[string]connector.Connector{ - "heavy-oai": heavyConn, - }) - defer cleanup() - req := &types.StreamRequest{ - Config: &types.SandboxConfig{ - Runner: types.RunnerConfig{ - Connectors: map[string]*types.RoleConnector{ - "heavy": {Connector: "heavy-oai", Override: "force"}, - }, - }, - }, + Config: &types.SandboxConfig{}, Connector: primary, + Roles: map[string]connector.Connector{ + "default": primary, + "heavy": heavyConn, + }, } req.Computer = newFakeComputer("/workspace") p := testPlatform() diff --git a/agent/sandbox/v2/claude/runner.go b/agent/sandbox/v2/claude/runner.go index cbeed7f8..c1035729 100644 --- a/agent/sandbox/v2/claude/runner.go +++ b/agent/sandbox/v2/claude/runner.go @@ -12,8 +12,10 @@ import ( "github.com/yaoapp/kun/log" agentContext "github.com/yaoapp/yao/agent/context" "github.com/yaoapp/yao/agent/output/message" + "github.com/yaoapp/yao/agent/sandbox/v2/shared" "github.com/yaoapp/yao/agent/sandbox/v2/types" infra "github.com/yaoapp/yao/sandbox/v2" + "github.com/yaoapp/yao/tools" ) // Runner implements the sandbox Runner interface for Claude CLI (mode=cli). @@ -49,6 +51,18 @@ func (r *Runner) Prepare(ctx context.Context, req *types.PrepareRequest) error { steps := append([]types.PrepareStep{}, req.Config.Prepare...) + if ws := req.Computer.Workplace(); ws != nil { + if err := shared.InjectSystemSkills(ws, tools.SkillsFS, ".claude/skills"); err != nil { + r.logger.Warn("inject system skills: %v", err) + } + if err := shared.AppendSystemPrompt(ws, "CLAUDE.md", tools.SystemPrompt); err != nil { + r.logger.Warn("append CLAUDE.md: %v", err) + } + if err := shared.AppendSystemPrompt(ws, "AGENTS.md", tools.SystemPrompt); err != nil { + r.logger.Warn("append AGENTS.md: %v", err) + } + } + if req.SkillsDir != "" { ws := req.Computer.Workplace() if ws != nil { @@ -243,27 +257,25 @@ func buildSingleA2OConfig(conn connector.Connector) *a2oConnectorConfig { return cfg } -// resolveAllRoleConnectors resolves all declared role connectors and returns -// a map of virtual model name -> connector for roles that have independent connectors. +// resolveAllRoleConnectors maps pre-resolved role connectors from req.Roles +// to actual model names used as A2O proxy route keys. func resolveAllRoleConnectors(req *types.StreamRequest) map[string]connector.Connector { - roleConns := getRoleConnectors(req) - if len(roleConns) == 0 { + if len(req.Roles) == 0 { return nil } result := make(map[string]connector.Connector) - for role, rm := range claudeRoleEnvMap { - if role == "default" { - continue + for _, rc := range req.Roles { + var model string + if lc, ok := rc.(goullm.LLMConnector); ok { + model = lc.GetModel() } - rc := resolveRoleConnector(role, roleConns, req.UserExplicit, func(id string) connector.Connector { - c, _ := connector.Connectors[id] - return c - }) - if rc == nil { - continue + if model == "" { + model, _ = rc.Setting()["model"].(string) + } + if model != "" { + result[model] = rc } - result[rm.ModelName] = rc } return result } diff --git a/agent/sandbox/v2/opencode/command.go b/agent/sandbox/v2/opencode/command.go index 23b44dcb..50035213 100644 --- a/agent/sandbox/v2/opencode/command.go +++ b/agent/sandbox/v2/opencode/command.go @@ -122,7 +122,7 @@ func buildEnv(req *types.StreamRequest, p platform) map[string]string { // like browsers should be nohup'd; this prevents accidental 2-min hangs. env["OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"] = "30000" - primaryConn := resolvePrimaryConnector(req.Connector, req.Config) + primaryConn := resolvePrimaryConnector(req.Connector, req.Roles) if primaryConn != nil { setting := primaryConn.Setting() key, _ := setting["key"].(string) @@ -176,7 +176,7 @@ func buildArgs(req *types.StreamRequest, r *Runner, isContinuation bool, chatID args = append(args, "--continue", "--session", sessionID) } - primaryConn := resolvePrimaryConnector(req.Connector, req.Config) + primaryConn := resolvePrimaryConnector(req.Connector, req.Roles) if primaryConn != nil { if mid := connectorModelID(primaryConn); mid != "" { args = append(args, "--model", mid) @@ -258,26 +258,13 @@ func buildSandboxEnvPrompt(p platform, workDir string) string { shell = "bash" } - envVarSyntax := "$VAR_NAME" - if osName == "windows" { - envVarSyntax = "$env:VAR_NAME" - } - return fmt.Sprintf(`## Sandbox Environment - **Operating System**: %[2]s - **Shell**: %[3]s - **Working Directory**: %[1]s - **File Access**: You have full read/write access to %[1]s -- **Environment variable syntax**: `+"`%[4]s`"+` - -## User Attachments - -User-uploaded files are placed in %[1]s/.attachments/{chatID}/ -Each chat session has its own subdirectory. -When the user attaches files, their paths are listed at the top of the message. -**Read these files yourself** using the Read or Bash tool — they are NOT passed as CLI arguments. -`, workDir, osName, shell, envVarSyntax) +`, workDir, osName, shell) } func getProviderPrefix(conn connector.Connector) string { @@ -287,30 +274,6 @@ func getProviderPrefix(conn connector.Connector) string { return "openai" } -// resolveRoleConnector determines which connector to use for a given role. -func resolveRoleConnector( - role string, - roleConnectors map[string]*types.RoleConnector, - userExplicit bool, - getConnector func(id string) connector.Connector, -) connector.Connector { - rc, ok := roleConnectors[role] - if !ok || rc == nil { - return nil - } - if rc.Override == "user" && userExplicit { - return nil - } - return getConnector(rc.Connector) -} - -func getRoleConnectors(req *types.StreamRequest) map[string]*types.RoleConnector { - if req.Config == nil { - return nil - } - return req.Config.Runner.Connectors -} - // shellQuoteForPlatform builds a shell-safe command string. On Windows // (PowerShell) it uses single quotes with ” escaping; on POSIX it uses // single quotes with '\” escaping. @@ -377,19 +340,15 @@ func connectorModelID(c connector.Connector) string { // consumed by opencode.json provider blocks (via {env:...} references) and // by the custom read.ts tool (for vision API calls). func injectRoleEnvVars(env map[string]string, req *types.StreamRequest) { - if req.Config == nil || req.Config.Runner.Connectors == nil { + if len(req.Roles) == 0 { return } for role, spec := range openCodeRoleMap { if spec.EnvKeyPrefix == "" { continue } - rc, ok := req.Config.Runner.Connectors[role] - if !ok || rc == nil || rc.Connector == "" { - continue - } - c, exists := connector.Connectors[rc.Connector] - if !exists || c == nil { + c, ok := req.Roles[role] + if !ok || c == nil { continue } setting := c.Setting() diff --git a/agent/sandbox/v2/opencode/config.go b/agent/sandbox/v2/opencode/config.go index 267f468c..4eaff1a3 100644 --- a/agent/sandbox/v2/opencode/config.go +++ b/agent/sandbox/v2/opencode/config.go @@ -27,23 +27,13 @@ var openCodeRoleMap = map[string]roleSpec{ }, } -// resolvePrimaryConnector returns the heavy connector if configured, -// otherwise falls back to the caller-supplied primary (typically the -// assistant's default connector). This aligns with OpenCode's semantics -// where the top-level "model" handles complex coding tasks. -func resolvePrimaryConnector(primary connector.Connector, cfg *types.SandboxConfig) connector.Connector { - if cfg == nil || cfg.Runner.Connectors == nil { - return primary +// resolvePrimaryConnector returns the heavy role connector if present in the +// pre-resolved roles map, otherwise falls back to the caller-supplied primary. +func resolvePrimaryConnector(primary connector.Connector, roles map[string]connector.Connector) connector.Connector { + if c, ok := roles["heavy"]; ok && c != nil { + return c } - rc, ok := cfg.Runner.Connectors["heavy"] - if !ok || rc == nil || rc.Connector == "" { - return primary - } - c, exists := connector.Connectors[rc.Connector] - if !exists || c == nil { - return primary - } - return c + return primary } // buildOpenCodeConfig generates the opencode.json project configuration. @@ -58,7 +48,7 @@ func buildOpenCodeConfig(req *types.PrepareRequest, mcpServers []types.MCPServer "permission": map[string]any{"*": "allow"}, } - primaryConn := resolvePrimaryConnector(req.Connector, req.Config) + primaryConn := resolvePrimaryConnector(req.Connector, req.Roles) if primaryConn != nil { providerID, providerCfg, modelStr := buildProviderConfig(primaryConn) cfg["provider"] = map[string]any{providerID: providerCfg} @@ -195,7 +185,7 @@ func normalizeBaseURL(host string) string { // also sets the top-level "small_model" field. primaryConn is the resolved // primary connector (may be heavy or default) used for sameProvider checks. func injectRoleProviders(cfg map[string]any, req *types.PrepareRequest, primaryConn connector.Connector) { - if req.Config == nil || req.Config.Runner.Connectors == nil { + if len(req.Roles) == 0 { return } @@ -223,13 +213,8 @@ func injectRoleProviders(cfg map[string]any, req *types.PrepareRequest, primaryC } for role, spec := range openCodeRoleMap { - rc, ok := req.Config.Runner.Connectors[role] - if !ok || rc == nil || rc.Connector == "" { - continue - } - - c, exists := connector.Connectors[rc.Connector] - if !exists || c == nil { + c, ok := req.Roles[role] + if !ok || c == nil { continue } diff --git a/agent/sandbox/v2/opencode/platform_test.go b/agent/sandbox/v2/opencode/platform_test.go index 36a89cc3..683923b0 100644 --- a/agent/sandbox/v2/opencode/platform_test.go +++ b/agent/sandbox/v2/opencode/platform_test.go @@ -185,8 +185,6 @@ func TestBuildSandboxEnvPrompt_Linux(t *testing.T) { assert.Contains(t, prompt, "linux") assert.Contains(t, prompt, "bash") assert.Contains(t, prompt, "/workspace") - assert.Contains(t, prompt, "$VAR_NAME") - assert.NotContains(t, prompt, "$env:") } func TestBuildSandboxEnvPrompt_Windows(t *testing.T) { @@ -195,7 +193,6 @@ func TestBuildSandboxEnvPrompt_Windows(t *testing.T) { assert.Contains(t, prompt, "windows") assert.Contains(t, prompt, "pwsh") assert.Contains(t, prompt, `C:\workspace`) - assert.Contains(t, prompt, "$env:VAR_NAME") } // --------------------------------------------------------------------------- diff --git a/agent/sandbox/v2/opencode/roles_test.go b/agent/sandbox/v2/opencode/roles_test.go index 7a2ceb58..c036fb2b 100644 --- a/agent/sandbox/v2/opencode/roles_test.go +++ b/agent/sandbox/v2/opencode/roles_test.go @@ -50,26 +50,12 @@ func newFakeAnthropic(id, host, model, key string) *fakeConn { } } -func registerFakeConnectors(t *testing.T, conns map[string]connector.Connector) func() { - t.Helper() - for id, c := range conns { - connector.Connectors[id] = c - } - return func() { - for id := range conns { - delete(connector.Connectors, id) - } - } -} - // --------------------------------------------------------------------------- // injectRoleProviders tests // --------------------------------------------------------------------------- func TestInjectRoleProviders_VisionCustomProvider(t *testing.T) { visionConn := newFakeOpenAI("vis", "https://api.mymaas.com/v1", "gpt-4o-mini", "sk-vis") - cleanup := registerFakeConnectors(t, map[string]connector.Connector{"vision-conn": visionConn}) - defer cleanup() primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds") cfg := map[string]any{ @@ -80,12 +66,10 @@ func TestInjectRoleProviders_VisionCustomProvider(t *testing.T) { req := &types.PrepareRequest{ Connector: primaryConn, - Config: &types.SandboxConfig{ - Runner: types.RunnerConfig{ - Connectors: map[string]*types.RoleConnector{ - "vision": {Connector: "vision-conn", Override: "force"}, - }, - }, + Config: &types.SandboxConfig{}, + Roles: map[string]connector.Connector{ + "default": primaryConn, + "vision": visionConn, }, } @@ -117,8 +101,6 @@ func TestInjectRoleProviders_VisionCustomProvider(t *testing.T) { func TestInjectRoleProviders_VisionNativeOpenAI(t *testing.T) { visionConn := newFakeOpenAI("vis", "", "gpt-4o-mini", "sk-oai") - cleanup := registerFakeConnectors(t, map[string]connector.Connector{"oai-vision": visionConn}) - defer cleanup() primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds") cfg := map[string]any{ @@ -129,12 +111,10 @@ func TestInjectRoleProviders_VisionNativeOpenAI(t *testing.T) { req := &types.PrepareRequest{ Connector: primaryConn, - Config: &types.SandboxConfig{ - Runner: types.RunnerConfig{ - Connectors: map[string]*types.RoleConnector{ - "vision": {Connector: "oai-vision", Override: "force"}, - }, - }, + Config: &types.SandboxConfig{}, + Roles: map[string]connector.Connector{ + "default": primaryConn, + "vision": visionConn, }, } @@ -148,8 +128,6 @@ func TestInjectRoleProviders_VisionNativeOpenAI(t *testing.T) { func TestInjectRoleProviders_LightWithDifferentHost(t *testing.T) { lightConn := newFakeOpenAI("moonshot", "https://api.moonshot.cn/v1", "moonshot-v1-8k", "sk-moon") - cleanup := registerFakeConnectors(t, map[string]connector.Connector{"moonshot-conn": lightConn}) - defer cleanup() primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds") cfg := map[string]any{ @@ -160,12 +138,10 @@ func TestInjectRoleProviders_LightWithDifferentHost(t *testing.T) { req := &types.PrepareRequest{ Connector: primaryConn, - Config: &types.SandboxConfig{ - Runner: types.RunnerConfig{ - Connectors: map[string]*types.RoleConnector{ - "light": {Connector: "moonshot-conn", Override: "force"}, - }, - }, + Config: &types.SandboxConfig{}, + Roles: map[string]connector.Connector{ + "default": primaryConn, + "light": lightConn, }, } @@ -198,8 +174,6 @@ func TestInjectRoleProviders_LightWithDifferentHost(t *testing.T) { func TestInjectRoleProviders_LightSameHostAsPrimary(t *testing.T) { lightConn := newFakeOpenAI("ds-light", "https://api.deepseek.com", "deepseek-chat", "sk-ds") - cleanup := registerFakeConnectors(t, map[string]connector.Connector{"ds-light-conn": lightConn}) - defer cleanup() primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds") primaryProviderID, primaryCfg, modelStr := buildProviderConfig(primaryConn) @@ -212,12 +186,10 @@ func TestInjectRoleProviders_LightSameHostAsPrimary(t *testing.T) { req := &types.PrepareRequest{ Connector: primaryConn, - Config: &types.SandboxConfig{ - Runner: types.RunnerConfig{ - Connectors: map[string]*types.RoleConnector{ - "light": {Connector: "ds-light-conn", Override: "force"}, - }, - }, + Config: &types.SandboxConfig{}, + Roles: map[string]connector.Connector{ + "default": primaryConn, + "light": lightConn, }, } @@ -263,8 +235,6 @@ func TestInjectRoleProviders_NoConnectors(t *testing.T) { func TestInjectRoleProviders_AnthropicVision(t *testing.T) { visionConn := newFakeAnthropic("claude-vis", "https://api.anthropic.com", "claude-sonnet-4-5-20250929", "sk-ant") - cleanup := registerFakeConnectors(t, map[string]connector.Connector{"anthropic-vision": visionConn}) - defer cleanup() primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds") cfg := map[string]any{ @@ -275,12 +245,10 @@ func TestInjectRoleProviders_AnthropicVision(t *testing.T) { req := &types.PrepareRequest{ Connector: primaryConn, - Config: &types.SandboxConfig{ - Runner: types.RunnerConfig{ - Connectors: map[string]*types.RoleConnector{ - "vision": {Connector: "anthropic-vision", Override: "force"}, - }, - }, + Config: &types.SandboxConfig{}, + Roles: map[string]connector.Connector{ + "default": primaryConn, + "vision": visionConn, }, } @@ -304,16 +272,11 @@ func TestInjectRoleProviders_AnthropicVision(t *testing.T) { func TestInjectRoleEnvVars_Vision(t *testing.T) { visionConn := newFakeOpenAI("vis", "https://api.mymaas.com/v1", "gpt-4o-mini", "sk-vis-key") - cleanup := registerFakeConnectors(t, map[string]connector.Connector{"vision-conn": visionConn}) - defer cleanup() req := &types.StreamRequest{ - Config: &types.SandboxConfig{ - Runner: types.RunnerConfig{ - Connectors: map[string]*types.RoleConnector{ - "vision": {Connector: "vision-conn", Override: "force"}, - }, - }, + Config: &types.SandboxConfig{}, + Roles: map[string]connector.Connector{ + "vision": visionConn, }, } @@ -333,16 +296,11 @@ func TestInjectRoleEnvVars_Vision(t *testing.T) { func TestInjectRoleEnvVars_Light(t *testing.T) { lightConn := newFakeOpenAI("moon", "https://api.moonshot.cn/v1", "moonshot-v1-8k", "sk-moon") - cleanup := registerFakeConnectors(t, map[string]connector.Connector{"moon-conn": lightConn}) - defer cleanup() req := &types.StreamRequest{ - Config: &types.SandboxConfig{ - Runner: types.RunnerConfig{ - Connectors: map[string]*types.RoleConnector{ - "light": {Connector: "moon-conn", Override: "force"}, - }, - }, + Config: &types.SandboxConfig{}, + Roles: map[string]connector.Connector{ + "light": lightConn, }, } @@ -381,20 +339,11 @@ func TestInjectRoleEnvVars_MultipleRoles(t *testing.T) { visionConn := newFakeOpenAI("vis", "https://api.vision.com", "vis-model", "sk-vis") lightConn := newFakeOpenAI("light-c", "https://api.light.com", "light-model", "sk-light") - cleanup := registerFakeConnectors(t, map[string]connector.Connector{ - "vis-c": visionConn, - "light-c": lightConn, - }) - defer cleanup() - req := &types.StreamRequest{ - Config: &types.SandboxConfig{ - Runner: types.RunnerConfig{ - Connectors: map[string]*types.RoleConnector{ - "vision": {Connector: "vis-c", Override: "force"}, - "light": {Connector: "light-c", Override: "force"}, - }, - }, + Config: &types.SandboxConfig{}, + Roles: map[string]connector.Connector{ + "vision": visionConn, + "light": lightConn, }, } @@ -416,23 +365,16 @@ func TestInjectRoleEnvVars_MultipleRoles(t *testing.T) { func TestBuildOpenCodeConfig_WithVisionAndLight(t *testing.T) { visionConn := newFakeOpenAI("vis", "https://api.mymaas.com/v1", "gpt-4o-mini", "sk-vis") lightConn := newFakeOpenAI("moon", "https://api.moonshot.cn/v1", "moonshot-v1-8k", "sk-moon") - cleanup := registerFakeConnectors(t, map[string]connector.Connector{ - "vision-conn": visionConn, - "light-conn": lightConn, - }) - defer cleanup() primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds") req := &types.PrepareRequest{ AssistantID: "test-assistant", Connector: primaryConn, - Config: &types.SandboxConfig{ - Runner: types.RunnerConfig{ - Connectors: map[string]*types.RoleConnector{ - "vision": {Connector: "vision-conn", Override: "force"}, - "light": {Connector: "light-conn", Override: "force"}, - }, - }, + Config: &types.SandboxConfig{}, + Roles: map[string]connector.Connector{ + "default": primaryConn, + "vision": visionConn, + "light": lightConn, }, } @@ -480,18 +422,13 @@ func TestBuildOpenCodeConfig_WithVisionAndLight(t *testing.T) { func TestResolvePrimaryConnector_HeavyConfigured(t *testing.T) { defaultConn := newFakeOpenAI("default", "https://api.deepseek.com", "deepseek-chat", "sk-ds") heavyConn := newFakeOpenAI("heavy", "https://api.openai.com", "o3-pro", "sk-oai") - cleanup := registerFakeConnectors(t, map[string]connector.Connector{"heavy-conn": heavyConn}) - defer cleanup() - cfg := &types.SandboxConfig{ - Runner: types.RunnerConfig{ - Connectors: map[string]*types.RoleConnector{ - "heavy": {Connector: "heavy-conn", Override: "force"}, - }, - }, + roles := map[string]connector.Connector{ + "default": defaultConn, + "heavy": heavyConn, } - result := resolvePrimaryConnector(defaultConn, cfg) + result := resolvePrimaryConnector(defaultConn, roles) if result != heavyConn { t.Error("should return heavy connector when configured") } @@ -499,43 +436,24 @@ func TestResolvePrimaryConnector_HeavyConfigured(t *testing.T) { func TestResolvePrimaryConnector_NoHeavy(t *testing.T) { defaultConn := newFakeOpenAI("default", "https://api.deepseek.com", "deepseek-chat", "sk-ds") + lightConn := newFakeOpenAI("light", "https://api.moonshot.cn", "moon-v1", "sk-moon") - cfg := &types.SandboxConfig{ - Runner: types.RunnerConfig{ - Connectors: map[string]*types.RoleConnector{ - "light": {Connector: "light-conn", Override: "force"}, - }, - }, + roles := map[string]connector.Connector{ + "default": defaultConn, + "light": lightConn, } - result := resolvePrimaryConnector(defaultConn, cfg) + result := resolvePrimaryConnector(defaultConn, roles) if result != defaultConn { t.Error("should fallback to default when heavy not configured") } } -func TestResolvePrimaryConnector_HeavyNotRegistered(t *testing.T) { - defaultConn := newFakeOpenAI("default", "https://api.deepseek.com", "deepseek-chat", "sk-ds") - - cfg := &types.SandboxConfig{ - Runner: types.RunnerConfig{ - Connectors: map[string]*types.RoleConnector{ - "heavy": {Connector: "nonexistent-conn", Override: "force"}, - }, - }, - } - - result := resolvePrimaryConnector(defaultConn, cfg) - if result != defaultConn { - t.Error("should fallback to default when heavy connector not registered") - } -} - -func TestResolvePrimaryConnector_NilConfig(t *testing.T) { +func TestResolvePrimaryConnector_NilRoles(t *testing.T) { defaultConn := newFakeOpenAI("default", "https://api.deepseek.com", "deepseek-chat", "sk-ds") result := resolvePrimaryConnector(defaultConn, nil) if result != defaultConn { - t.Error("should return default when config is nil") + t.Error("should return default when roles is nil") } } diff --git a/agent/sandbox/v2/opencode/runner.go b/agent/sandbox/v2/opencode/runner.go index 334f5035..1800677c 100644 --- a/agent/sandbox/v2/opencode/runner.go +++ b/agent/sandbox/v2/opencode/runner.go @@ -12,6 +12,7 @@ import ( "github.com/yaoapp/yao/agent/sandbox/v2/shared" "github.com/yaoapp/yao/agent/sandbox/v2/types" infra "github.com/yaoapp/yao/sandbox/v2" + "github.com/yaoapp/yao/tools" ) // Runner implements the sandbox Runner interface for OpenCode CLI. @@ -47,6 +48,19 @@ func (r *Runner) Prepare(ctx context.Context, req *types.PrepareRequest) error { steps := append([]types.PrepareStep{}, req.Config.Prepare...) + // 0. Inject system tool SKILLs + prompts (before assistant-specific skills copy) + if ws := req.Computer.Workplace(); ws != nil { + if err := shared.InjectSystemSkills(ws, tools.SkillsFS, ".claude/skills"); err != nil { + log.Warn("[opencode-runner] inject system skills: %v", err) + } + if err := shared.AppendSystemPrompt(ws, "CLAUDE.md", tools.SystemPrompt); err != nil { + log.Warn("[opencode-runner] append CLAUDE.md: %v", err) + } + if err := shared.AppendSystemPrompt(ws, "AGENTS.md", tools.SystemPrompt); err != nil { + log.Warn("[opencode-runner] append AGENTS.md: %v", err) + } + } + // 1. Skills copy (aligned with Claude Runner) if req.SkillsDir != "" { ws := req.Computer.Workplace() @@ -79,16 +93,14 @@ func (r *Runner) Prepare(ctx context.Context, req *types.PrepareRequest) error { // config dir ($HOME/.config/opencode/tools/). Only needed when a // vision connector is configured — the custom read tool overrides the // built-in read to route image files through the vision API. - if req.Config != nil && req.Config.Runner.Connectors != nil { - if vc, ok := req.Config.Runner.Connectors["vision"]; ok && vc != nil && vc.Connector != "" { - p := resolvePlatform(req.Computer) - steps = append(steps, types.PrepareStep{ - Action: "exec", - Cmd: visionCopyCmd(p), - Once: true, - IgnoreError: true, - }) - } + if _, ok := req.Roles["vision"]; ok { + p := resolvePlatform(req.Computer) + steps = append(steps, types.PrepareStep{ + Action: "exec", + Cmd: visionCopyCmd(p), + Once: true, + IgnoreError: true, + }) } // 5. Generate opencode.json (project config at workspace root) diff --git a/agent/sandbox/v2/shared/inject.go b/agent/sandbox/v2/shared/inject.go new file mode 100644 index 00000000..5424adf6 --- /dev/null +++ b/agent/sandbox/v2/shared/inject.go @@ -0,0 +1,71 @@ +package shared + +import ( + "bytes" + "errors" + "io/fs" + "os" + "path" + "strings" +) + +const systemToolsMarker = "" + +// writerFS is the minimal filesystem interface needed by the injection helpers. +// workspace.FS satisfies this interface. +type writerFS interface { + ReadFile(name string) ([]byte, error) + WriteFile(name string, data []byte, perm os.FileMode) error + MkdirAll(name string, perm os.FileMode) error +} + +// InjectSystemSkills copies SKILL files from an embed.FS into the workspace. +// The skills parameter should be an embed.FS produced by `//go:embed skills`, +// where each file has a path like "skills/yao-web/SKILL.md". This function +// strips the "skills/" prefix and writes files into targetDir (e.g. ".claude/skills"). +func InjectSystemSkills(ws writerFS, skills fs.FS, targetDir string) error { + return fs.WalkDir(skills, "skills", func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + + rel := strings.TrimPrefix(p, "skills/") + dst := path.Join(targetDir, rel) + + data, err := fs.ReadFile(skills, p) + if err != nil { + return err + } + + dir := path.Dir(dst) + if err := ws.MkdirAll(dir, 0755); err != nil { + return err + } + return ws.WriteFile(dst, data, 0644) + }) +} + +// AppendSystemPrompt appends content to a file in the workspace with an +// idempotent marker. If the marker already exists the call is a no-op. +// If the file does not exist it is created with just the marker + content. +func AppendSystemPrompt(ws writerFS, filename string, content []byte) error { + existing, err := ws.ReadFile(filename) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return err + } + header := []byte(systemToolsMarker + "\n\n") + return ws.WriteFile(filename, append(header, content...), 0644) + } + + if bytes.Contains(existing, []byte(systemToolsMarker)) { + return nil + } + + separator := []byte("\n\n---\n\n" + systemToolsMarker + "\n\n") + merged := append(existing, append(separator, content...)...) + return ws.WriteFile(filename, merged, 0644) +} diff --git a/agent/sandbox/v2/shared/inject_test.go b/agent/sandbox/v2/shared/inject_test.go new file mode 100644 index 00000000..b0c92221 --- /dev/null +++ b/agent/sandbox/v2/shared/inject_test.go @@ -0,0 +1,148 @@ +package shared + +import ( + "io/fs" + "os" + "path/filepath" + "testing" + "testing/fstest" +) + +func TestInjectSystemSkills_CopiesAllFiles(t *testing.T) { + dir := t.TempDir() + ws := newDirFS(dir) + + skills := fstest.MapFS{ + "skills/yao-web/SKILL.md": {Data: []byte("web skill")}, + "skills/yao-process/SKILL.md": {Data: []byte("process skill")}, + "skills/yao-doc/SKILL.md": {Data: []byte("doc skill")}, + } + + if err := InjectSystemSkills(ws, skills, ".claude/skills"); err != nil { + t.Fatalf("InjectSystemSkills: %v", err) + } + + for _, tc := range []struct { + path string + want string + }{ + {".claude/skills/yao-web/SKILL.md", "web skill"}, + {".claude/skills/yao-process/SKILL.md", "process skill"}, + {".claude/skills/yao-doc/SKILL.md", "doc skill"}, + } { + data, err := os.ReadFile(filepath.Join(dir, tc.path)) + if err != nil { + t.Errorf("ReadFile(%s): %v", tc.path, err) + continue + } + if string(data) != tc.want { + t.Errorf("%s = %q, want %q", tc.path, data, tc.want) + } + } +} + +func TestAppendSystemPrompt_CreatesNewFile(t *testing.T) { + dir := t.TempDir() + ws := newDirFS(dir) + + content := []byte("## Yao System Tools\ntai tool ...") + if err := AppendSystemPrompt(ws, "CLAUDE.md", content); err != nil { + t.Fatalf("AppendSystemPrompt: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md")) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if got := string(data); got == "" { + t.Fatal("file should not be empty") + } + assertContains(t, string(data), systemToolsMarker) + assertContains(t, string(data), "Yao System Tools") +} + +func TestAppendSystemPrompt_AppendsToExisting(t *testing.T) { + dir := t.TempDir() + ws := newDirFS(dir) + + existing := []byte("# My Project\n\nExisting content.\n") + if err := os.WriteFile(filepath.Join(dir, "CLAUDE.md"), existing, 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + content := []byte("## System Tools\n") + if err := AppendSystemPrompt(ws, "CLAUDE.md", content); err != nil { + t.Fatalf("AppendSystemPrompt: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md")) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + got := string(data) + assertContains(t, got, "My Project") + assertContains(t, got, systemToolsMarker) + assertContains(t, got, "System Tools") +} + +func TestAppendSystemPrompt_Idempotent(t *testing.T) { + dir := t.TempDir() + ws := newDirFS(dir) + + content := []byte("## Yao System Tools\n") + + if err := AppendSystemPrompt(ws, "AGENTS.md", content); err != nil { + t.Fatalf("first call: %v", err) + } + first, _ := os.ReadFile(filepath.Join(dir, "AGENTS.md")) + + if err := AppendSystemPrompt(ws, "AGENTS.md", content); err != nil { + t.Fatalf("second call: %v", err) + } + second, _ := os.ReadFile(filepath.Join(dir, "AGENTS.md")) + + if string(first) != string(second) { + t.Errorf("second call modified the file (not idempotent):\n--- first ---\n%s\n--- second ---\n%s", first, second) + } +} + +func assertContains(t *testing.T, s, sub string) { + t.Helper() + if len(s) < len(sub) { + t.Errorf("string does not contain %q", sub) + return + } + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return + } + } + t.Errorf("string does not contain %q:\n%s", sub, s) +} + +// dirFS is a minimal workspace.FS backed by a real directory (for testing). +type dirFS struct { + root string +} + +func newDirFS(root string) *dirFS { return &dirFS{root: root} } + +func (d *dirFS) Open(name string) (fs.File, error) { + return os.Open(filepath.Join(d.root, name)) +} + +func (d *dirFS) ReadFile(name string) ([]byte, error) { + data, err := os.ReadFile(filepath.Join(d.root, name)) + if err != nil { + return nil, err + } + return data, nil +} + +func (d *dirFS) WriteFile(name string, data []byte, perm os.FileMode) error { + return os.WriteFile(filepath.Join(d.root, name), data, perm) +} + +func (d *dirFS) MkdirAll(name string, perm os.FileMode) error { + return os.MkdirAll(filepath.Join(d.root, name), perm) +} diff --git a/agent/sandbox/v2/types/runner.go b/agent/sandbox/v2/types/runner.go index 3929ab3c..10963075 100644 --- a/agent/sandbox/v2/types/runner.go +++ b/agent/sandbox/v2/types/runner.go @@ -38,7 +38,8 @@ type PrepareRequest struct { Computer infra.Computer Config *SandboxConfig Connector connector.Connector - AssistantID string // the assistant's own ID (e.g. "yao/postman") + Roles map[string]connector.Connector // pre-resolved role matrix from llmprovider + AssistantID string // the assistant's own ID (e.g. "yao/postman") SkillsDir string AssistantDir string // absolute host path to the assistant source directory MCPServers []MCPServer @@ -51,7 +52,8 @@ type StreamRequest struct { Computer infra.Computer Config *SandboxConfig Connector connector.Connector - AssistantID string // the assistant's own ID (e.g. "yao/postman") + Roles map[string]connector.Connector // pre-resolved role matrix from llmprovider + AssistantID string // the assistant's own ID (e.g. "yao/postman") Messages []agentContext.Message SystemPrompt string ChatID string diff --git a/llmprovider/presets.yml b/llmprovider/presets.yml index 11e3c790..014a2302 100644 --- a/llmprovider/presets.yml +++ b/llmprovider/presets.yml @@ -485,7 +485,8 @@ max_output_tokens: 384000 capabilities: [tool_calls, streaming, json] options: - enable_thinking: false + thinking: + type: disabled enabled: false - id: deepseek-v4-pro-thinking model: deepseek-v4-pro @@ -494,20 +495,34 @@ max_output_tokens: 384000 capabilities: [tool_calls, streaming, json, reasoning] options: - enable_thinking: true + thinking: + type: enabled enabled: true - id: deepseek-v4-flash name: DeepSeek V4 Flash max_input_tokens: 1048576 max_output_tokens: 384000 capabilities: [tool_calls, streaming, json] + options: + thinking: + type: disabled enabled: true + - id: deepseek-v4-flash-thinking + model: deepseek-v4-flash + name: DeepSeek V4 Flash Thinking + max_input_tokens: 1048576 + max_output_tokens: 384000 + capabilities: [tool_calls, streaming, json, reasoning] + options: + thinking: + type: enabled + enabled: false # ─── DeepSeek (Anthropic) ─────────────────────────────── - key: deepseek_anthropic name: DeepSeek (Anthropic) type: anthropic - api_url: https://api.deepseek.com + api_url: https://api.deepseek.com/anthropic require_key: true default_models: - id: deepseek-v4-pro @@ -515,6 +530,9 @@ max_input_tokens: 1048576 max_output_tokens: 384000 capabilities: [tool_calls, streaming, json] + options: + thinking: + type: disabled enabled: false - id: deepseek-v4-pro-thinking model: deepseek-v4-pro @@ -532,7 +550,21 @@ max_input_tokens: 1048576 max_output_tokens: 384000 capabilities: [tool_calls, streaming, json] + options: + thinking: + type: disabled enabled: true + - id: deepseek-v4-flash-thinking + model: deepseek-v4-flash + name: DeepSeek V4 Flash Thinking + max_input_tokens: 1048576 + max_output_tokens: 384000 + capabilities: [tool_calls, streaming, json, reasoning] + options: + thinking: + type: enabled + budget_tokens: 32000 + enabled: false # ─── Kimi / Moonshot (International) ──────────────────── - key: kimi_intl diff --git a/openapi/setting/llm.go b/openapi/setting/llm.go index 203e0dc2..d1b7a0fa 100644 --- a/openapi/setting/llm.go +++ b/openapi/setting/llm.go @@ -86,33 +86,132 @@ func llmModelsURL(apiURL string) string { return apiURL + "/v1/models" } -// llmValidateKey tests connectivity by calling GET {apiURL}/models. -// providerType controls the auth header format (anthropic uses x-api-key). +// llmCompletionURL builds the chat/messages endpoint URL. +func llmCompletionURL(providerType, apiURL string) string { + endpoint := "chat/completions" + if providerType == "anthropic" { + endpoint = "messages" + } + if strings.HasSuffix(apiURL, "/") { + return apiURL + endpoint + } + return apiURL + "/v1/" + endpoint +} + +// llmSetAuthHeader sets the appropriate auth header for the provider type. +func llmSetAuthHeader(req *http.Request, providerType, apiKey string) { + if apiKey == "" { + return + } + if providerType == "anthropic" { + req.Header.Set("x-api-key", apiKey) + req.Header.Set("anthropic-version", "2023-06-01") + } else { + req.Header.Set("Authorization", "Bearer "+apiKey) + } +} + +// llmValidateKey tests connectivity and API key validity using a three-step +// approach that works across all provider types (OpenAI, Anthropic, and +// third-party compatible APIs) without incurring any token costs: +// +// 1. POST to real completion endpoint with empty messages (zero cost). +// 401/403 → invalid key. Other response → connection works, proceed. +// 2. GET /models to confirm key validity. +// 200 → key valid. 401/403 → invalid key. 404 → endpoint unsupported, +// trust step-1 result. Other → report error. +// 3. If step-1 returned 404 (model-based routing, e.g. NVIDIA) AND step-2 +// returned 200, the /models endpoint may be public. Pick the first model +// from the response and POST again with that real model + empty messages. func llmValidateKey(providerType, apiURL, apiKey string) error { - url := llmModelsURL(apiURL) client := &http.Client{Timeout: 10 * time.Second} - req, err := http.NewRequest("GET", url, nil) + + // --- Step 1: POST real endpoint with fake model + empty messages --- + postURL := llmCompletionURL(providerType, apiURL) + req, err := http.NewRequest("POST", postURL, strings.NewReader(`{"model":"_","messages":[]}`)) if err != nil { return fmt.Errorf("failed to build request: %w", err) } - if apiKey != "" { - if providerType == "anthropic" { - req.Header.Set("x-api-key", apiKey) - req.Header.Set("anthropic-version", "2023-06-01") - } else { - req.Header.Set("Authorization", "Bearer "+apiKey) - } - } + req.Header.Set("Content-Type", "application/json") + llmSetAuthHeader(req, providerType, apiKey) + resp, err := client.Do(req) if err != nil { return fmt.Errorf("connection failed: %w", err) } - defer resp.Body.Close() + resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { return fmt.Errorf("invalid API key (HTTP %d)", resp.StatusCode) } - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("server returned HTTP %d", resp.StatusCode) + postStatus := resp.StatusCode + + // --- Step 2: GET /models to confirm key --- + req2, err := http.NewRequest("GET", llmModelsURL(apiURL), nil) + if err != nil { + return nil + } + llmSetAuthHeader(req2, providerType, apiKey) + + resp2, err := client.Do(req2) + if err != nil { + return nil // POST connected, GET network failure is non-fatal + } + + modelsStatus := resp2.StatusCode + var modelsBody []byte + if modelsStatus == http.StatusOK { + modelsBody, _ = io.ReadAll(resp2.Body) + } + resp2.Body.Close() + + if modelsStatus == http.StatusUnauthorized || modelsStatus == http.StatusForbidden { + return fmt.Errorf("invalid API key (HTTP %d)", modelsStatus) + } + if modelsStatus == http.StatusNotFound { + return nil + } + if modelsStatus == http.StatusOK { + if postStatus == http.StatusNotFound && len(modelsBody) > 0 { + return llmValidateWithModel(client, providerType, apiURL, apiKey, modelsBody) + } + return nil + } + + return fmt.Errorf("server returned HTTP %d", modelsStatus) +} + +// llmValidateWithModel is the step-3 fallback for providers whose /models +// endpoint is public (always 200). It picks the first model from the /models +// response and POSTs to the completion endpoint with that model + empty +// messages to trigger a real auth check. +func llmValidateWithModel(client *http.Client, providerType, apiURL, apiKey string, modelsBody []byte) error { + var parsed struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(modelsBody, &parsed); err != nil || len(parsed.Data) == 0 { + return nil + } + + postURL := llmCompletionURL(providerType, apiURL) + body := fmt.Sprintf(`{"model":%q,"messages":[]}`, parsed.Data[0].ID) + req, err := http.NewRequest("POST", postURL, strings.NewReader(body)) + if err != nil { + return nil + } + req.Header.Set("Content-Type", "application/json") + llmSetAuthHeader(req, providerType, apiKey) + + resp, err := client.Do(req) + if err != nil { + return nil + } + resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return fmt.Errorf("invalid API key (HTTP %d)", resp.StatusCode) } return nil } @@ -366,39 +465,14 @@ func handleLLMTest(c *gin.Context) { return } - url := llmModelsURL(input.APIURL) start := time.Now() - client := &http.Client{Timeout: 10 * time.Second} - req, err := http.NewRequest("GET", url, nil) - if err != nil { - respondError(c, http.StatusInternalServerError, err.Error()) - return - } - if input.APIKey != "" { - if input.Type == "anthropic" { - req.Header.Set("x-api-key", input.APIKey) - req.Header.Set("anthropic-version", "2023-06-01") - } else { - req.Header.Set("Authorization", "Bearer "+input.APIKey) - } - } - - resp, err := client.Do(req) + err := llmValidateKey(input.Type, input.APIURL, input.APIKey) latency := time.Since(start).Milliseconds() if err != nil { response.RespondWithSuccess(c, http.StatusOK, llmprovider.ProviderTestResult{ Success: false, - Message: fmt.Sprintf("Connection failed: %s", err.Error()), - }) - return - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - response.RespondWithSuccess(c, http.StatusOK, llmprovider.ProviderTestResult{ - Success: false, - Message: fmt.Sprintf("Server returned HTTP %d", resp.StatusCode), + Message: err.Error(), }) return } diff --git a/tools/prompts/system-tools.md b/tools/prompts/system-tools.md new file mode 100644 index 00000000..e3b162b6 --- /dev/null +++ b/tools/prompts/system-tools.md @@ -0,0 +1,45 @@ +## Yao Sandbox Environment + +### Environment Variables + +These environment variables are set by the Yao sandbox. **Always use these variables — never hardcode paths.** + +| Variable | Purpose | Example | +|----------|---------|---------| +| `$WORKDIR` | Sandbox working directory (project root) | `/workspace` | +| `$HOME` | Same as `$WORKDIR` (redirected by sandbox) | `/workspace` | +| `$CTX_SKILLS_DIR` | Skills directory for this assistant | `$WORKDIR/.yao/assistants//skills` | +| `$CTX_ASSISTANT_ID` | Current assistant ID | `yao.agent-smith` | +| `$CTX_WORKSPACE_ID` | Current workspace ID | `ws-abc123` | + +### Path Rules + +- **Use `$WORKDIR`** for all file paths — never hardcode `/workspace` +- **Use `$CTX_SKILLS_DIR`** for assistant-specific skills (custom skills provided by the assistant) +- System tool skills are in `$HOME/.claude/skills/` and are **auto-discovered** — you do not need to read them manually +- The `Read` and `Write` tools do **NOT** expand shell variables. + Resolve first: `echo "$WORKDIR"`, then use the printed value. +- On Windows, use `$env:WORKDIR` / `$env:CTX_SKILLS_DIR` syntax instead. + +### Attachments + +User-uploaded files are placed in `$WORKDIR/.attachments/{chatID}/`. +When the user references an attached file, read it from this directory. + +## Yao System Tools + +You have access to Yao system tools via the `tai` command in bash. + +**Calling convention**: `tai tool ''` + +| Tool | Skill (auto-loaded) | Description | +|------|---------------------|-------------| +| `web_search` | yao-web | Search the web for real-time information | +| `web_fetch` | yao-web | Fetch and read a web page by URL | +| `process_call` | yao-process | Execute a Yao Process (server-side function) | +| `process_allowed` | yao-process | Check which processes are allowed | +| `doc_list` | yao-doc | Search/list available process documentation | +| `doc_inspect` | yao-doc | Get detailed docs for a specific process | +| `doc_validate` | yao-doc | Validate a process name and get suggestions | + +The three system skills (`yao-web`, `yao-process`, `yao-doc`) in `$HOME/.claude/skills/` are **auto-discovered** — they contain detailed parameter docs and workflow guidance. You do not need to manually read them; they are loaded automatically when your task matches their description. diff --git a/tools/skills.go b/tools/skills.go new file mode 100644 index 00000000..30a02334 --- /dev/null +++ b/tools/skills.go @@ -0,0 +1,18 @@ +package tools + +import "embed" + +// SkillsFS contains the capability-grouped SKILL.md files for injection +// into sandbox workspaces. Each SKILL teaches the LLM how to use a group +// of system tools via `tai tool `. +// +//go:embed skills +var SkillsFS embed.FS + +// SystemPrompt is the shared content appended to both CLAUDE.md and AGENTS.md +// in sandbox workspaces. It provides environment variable documentation and +// the `tai tool` calling convention. Stored as a single source file to prevent +// content drift between the two runner instruction files. +// +//go:embed prompts/system-tools.md +var SystemPrompt []byte diff --git a/tools/skills/yao-doc/SKILL.md b/tools/skills/yao-doc/SKILL.md new file mode 100644 index 00000000..9e6dba89 --- /dev/null +++ b/tools/skills/yao-doc/SKILL.md @@ -0,0 +1,56 @@ +--- +name: yao-doc +description: Yao process documentation expert. ALWAYS invoke this skill when the user needs to discover available processes, read process signatures, or validate process names. Do not guess process APIs — use this skill first. +--- + +# Documentation Tools + +Three tools for browsing Yao process documentation, called via bash. + +## doc_list + +Search and list available process documentation entries. + +```bash +tai tool doc_list '{"keyword": "user", "limit": 10}' +``` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `keyword` | string | no | Search keyword (empty to list all) | +| `limit` | integer | no | Max results (default 20) | + +## doc_inspect + +Get detailed documentation for a specific process: arguments, return type, methods. + +```bash +tai tool doc_inspect '{"name": "models.user.Find"}' +``` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | yes | Process name (e.g. `models.user.Find`) | + +## doc_validate + +Check if a process name is valid. Returns suggestions for similar processes if not found. + +```bash +tai tool doc_validate '{"name": "models.user.Findd"}' +``` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | yes | Process name to validate | + +## Workflow + +1. **doc_list** — discover available processes by keyword +2. **doc_inspect** — read the full signature before calling +3. **doc_validate** — fix typos when a process name doesn't work + +## Guidelines + +- Always use doc_inspect before calling an unfamiliar process via process_call +- Use doc_validate when you get unexpected errors — the name might be misspelled diff --git a/tools/skills/yao-process/SKILL.md b/tools/skills/yao-process/SKILL.md new file mode 100644 index 00000000..086fc8cc --- /dev/null +++ b/tools/skills/yao-process/SKILL.md @@ -0,0 +1,50 @@ +--- +name: yao-process +description: Yao process execution expert. ALWAYS invoke this skill when the user needs to call a Yao process, query data models, run scripts, or check process permissions. Do not call processes without checking this skill first. +--- + +# Process Tools + +Two tools for Yao process execution, called via bash. + +## process_call + +Execute a Yao Process by its fully qualified name. + +```bash +tai tool process_call '{"name": "models.user.Find", "args": [1, {}]}' +``` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | yes | Process name (e.g. `models.user.Find`) | +| `args` | array | no | Positional arguments | + +## process_allowed + +Check which processes are permitted, or verify a specific process. + +```bash +# List all allowed rules +tai tool process_allowed '{}' + +# Check a specific process +tai tool process_allowed '{"name": "models.user.Find"}' +``` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | no | Process name to check. Omit to list all rules. | + +## Workflow + +1. Use **process_allowed** to check what is permitted +2. Use **doc_inspect** (from yao-doc skill) to understand the process signature +3. Use **process_call** to execute + +## Guidelines + +- Always check documentation before calling an unfamiliar process +- A 403 error means the process is not in the allowed list +- Process names follow the pattern `group.id.Method` (e.g. `models.user.Find`, `scripts.auth.Check`) +- Rules use prefix matching: `models.*` matches all model processes diff --git a/tools/skills/yao-web/SKILL.md b/tools/skills/yao-web/SKILL.md new file mode 100644 index 00000000..245ba9b0 --- /dev/null +++ b/tools/skills/yao-web/SKILL.md @@ -0,0 +1,40 @@ +--- +name: yao-web +description: Web information retrieval expert. ALWAYS invoke this skill when the user needs to search the web, fetch a URL, or access real-time information beyond training data. Do not guess or use stale knowledge — use this skill first. +--- + +# Web Tools + +Two tools for web information retrieval, called via bash. + +## web_search + +Search the web for real-time information. Returns structured results with title, URL, and content snippet. + +```bash +tai tool web_search '{"query": "search terms", "limit": 5}' +``` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `query` | string | yes | Search query | +| `limit` | integer | no | Max results (default 10) | + +## web_fetch + +Fetch a web page and return its content in readable format. + +```bash +tai tool web_fetch '{"url": "https://example.com", "format": "markdown"}' +``` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `url` | string | yes | Fully-formed URL to fetch | +| `format` | string | no | `markdown` (default) or `html` | + +## Guidelines + +- Use web_search to discover URLs, then web_fetch to read the content +- Include the current year when searching for recent information +- All output is JSON diff --git a/tools/skills_test.go b/tools/skills_test.go new file mode 100644 index 00000000..00027909 --- /dev/null +++ b/tools/skills_test.go @@ -0,0 +1,84 @@ +package tools + +import ( + "io/fs" + "strings" + "testing" +) + +func TestSkillsFS_ContainsThreeSkills(t *testing.T) { + expected := map[string]bool{ + "skills/yao-web/SKILL.md": false, + "skills/yao-process/SKILL.md": false, + "skills/yao-doc/SKILL.md": false, + } + + err := fs.WalkDir(SkillsFS, "skills", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if _, ok := expected[path]; ok { + expected[path] = true + } + return nil + }) + if err != nil { + t.Fatalf("WalkDir failed: %v", err) + } + + for path, found := range expected { + if !found { + t.Errorf("expected file not found in SkillsFS: %s", path) + } + } +} + +func TestSkillsFS_FrontmatterFields(t *testing.T) { + skills := []struct { + path string + name string + }{ + {"skills/yao-web/SKILL.md", "yao-web"}, + {"skills/yao-process/SKILL.md", "yao-process"}, + {"skills/yao-doc/SKILL.md", "yao-doc"}, + } + + for _, s := range skills { + data, err := fs.ReadFile(SkillsFS, s.path) + if err != nil { + t.Fatalf("ReadFile(%s): %v", s.path, err) + } + content := string(data) + + if !strings.Contains(content, "name: "+s.name) { + t.Errorf("%s: missing 'name: %s' in frontmatter", s.path, s.name) + } + if !strings.Contains(content, "description:") { + t.Errorf("%s: missing 'description:' in frontmatter", s.path) + } + if !strings.Contains(content, "ALWAYS invoke this skill") { + t.Errorf("%s: description missing directive 'ALWAYS invoke this skill'", s.path) + } + } +} + +func TestSystemPrompt_NonEmpty(t *testing.T) { + if len(SystemPrompt) == 0 { + t.Fatal("SystemPrompt is empty") + } + + content := string(SystemPrompt) + + markers := []string{ + "Yao Sandbox Environment", + "Yao System Tools", + "tai tool", + "$WORKDIR", + "$CTX_SKILLS_DIR", + } + for _, m := range markers { + if !strings.Contains(content, m) { + t.Errorf("SystemPrompt missing expected marker: %q", m) + } + } +}