feat(agent): enhance sandbox V2 initialization and role management

- Refactored the initSandboxV2 function to return a structured result, consolidating the runner, computer, configuration, cleanup function, loading message ID, and roles into a single return type.
- Updated the Stream method to utilize the new sandboxV2InitResult structure, improving clarity and reducing complexity in handling sandbox initialization.
- Introduced role management enhancements, allowing for pre-resolved role connectors to be passed through the request, streamlining connector resolution during execution.
- Adjusted various components to support the new roles structure, ensuring consistent handling across the agent's sandbox operations.
- Added logging for connector resolution and role management, improving diagnostics and traceability during sandbox execution.
- Updated .gitignore to include tools/TOOL-REGISTRATION.md for better project organization.
This commit is contained in:
Max 2026-05-03 19:11:29 +08:00
parent 7877797549
commit 194faac9b7
22 changed files with 1052 additions and 528 deletions

1
.gitignore vendored
View file

@ -86,3 +86,4 @@ POSTGRESQL_COMPAT.md
openapi/setting/*.md openapi/setting/*.md
agent/docs/design/*.md agent/docs/design/*.md
tools/README.md tools/README.md
tools/TOOL-REGISTRATION.md

View file

@ -14,9 +14,7 @@ import (
"github.com/yaoapp/yao/agent/llm" "github.com/yaoapp/yao/agent/llm"
"github.com/yaoapp/yao/agent/output/message" "github.com/yaoapp/yao/agent/output/message"
agentsandbox "github.com/yaoapp/yao/agent/sandbox" agentsandbox "github.com/yaoapp/yao/agent/sandbox"
sandboxTypes "github.com/yaoapp/yao/agent/sandbox/v2/types"
"github.com/yaoapp/yao/llmprovider" "github.com/yaoapp/yao/llmprovider"
infraV2 "github.com/yaoapp/yao/sandbox/v2"
) )
// Stream stream the agent // Stream stream the agent
@ -168,30 +166,25 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
var sandboxLoadingMsgID string var sandboxLoadingMsgID string
// V2 sandbox state // V2 sandbox state
var v2Runner sandboxTypes.Runner var v2Init *sandboxV2InitResult
var v2Computer infraV2.Computer
var v2LoadingMsgID string
var v2Cfg *sandboxTypes.SandboxConfig
if ast.HasSandboxV2() { if ast.HasSandboxV2() {
ctx.Logger.Phase("Sandbox V2") ctx.Logger.Phase("Sandbox V2")
var err error var err error
var v2Cleanup func() v2Init, err = ast.initSandboxV2(ctx, opts)
v2Runner, v2Computer, v2Cfg, v2Cleanup, v2LoadingMsgID, err = ast.initSandboxV2(ctx, opts)
if err != nil { if err != nil {
ast.traceAgentFail(agentNode, err) ast.traceAgentFail(agentNode, err)
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err) ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
return nil, err return nil, err
} }
sandboxCleanup = v2Cleanup sandboxCleanup = v2Init.Cleanup
ctx.Logger.PhaseComplete("Sandbox V2") ctx.Logger.PhaseComplete("Sandbox V2")
if v2Computer != nil { if v2Init.Computer != nil {
ci := v2Computer.ComputerInfo() ci := v2Init.Computer.ComputerInfo()
ctx.Logger.Trace("Node: %s (%s)", ci.NodeID, ci.Kind) ctx.Logger.Trace("Node: %s (%s)", ci.NodeID, ci.Kind)
if ci.BoxID != "" { if ci.BoxID != "" {
ctx.Logger.Trace("Computer: %s", 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 { if conn, _, err := ast.GetConnector(ctx, opts); err == nil && conn != nil {
ctx.Logger.Trace("Connector: %s", conn.ID()) 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 // Execute the LLM streaming call
// Choose between sandbox execution or direct LLM execution // 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) // V2 Sandbox execution path (non-yao runners replace LLM.Stream)
completionResponse, err = ast.executeSandboxV2Stream(ctx, &sandboxV2StreamParams{ completionResponse, err = ast.executeSandboxV2Stream(ctx, &sandboxV2StreamParams{
Messages: completionMessages, Messages: completionMessages,
AgentNode: agentNode, AgentNode: agentNode,
Handler: streamHandler, Handler: streamHandler,
Runner: v2Runner, Runner: v2Init.Runner,
Computer: v2Computer, Computer: v2Init.Computer,
Config: v2Cfg, Config: v2Init.Config,
LoadingMsgID: v2LoadingMsgID, LoadingMsgID: v2Init.LoadingMsgID,
Options: opts, 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 // V2 yao runner: Prepare is done, close loading, fall through to LLM
if v2LoadingMsgID != "" { if v2Init.LoadingMsgID != "" {
closeLoadingV2(ctx, v2LoadingMsgID, "") closeLoadingV2(ctx, v2Init.LoadingMsgID, "")
} }
completionResponse, err = ast.executeLLMStream(ctx, completionMessages, completionOptions, agentNode, streamHandler, opts) completionResponse, err = ast.executeLLMStream(ctx, completionMessages, completionOptions, agentNode, streamHandler, opts)
} else if ast.HasSandbox() { } else if ast.HasSandbox() {

View file

@ -8,6 +8,7 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"github.com/yaoapp/gou/connector"
"github.com/yaoapp/yao/agent/context" "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/i18n" "github.com/yaoapp/yao/agent/i18n"
"github.com/yaoapp/yao/agent/output/message" "github.com/yaoapp/yao/agent/output/message"
@ -15,6 +16,7 @@ import (
sandboxTypes "github.com/yaoapp/yao/agent/sandbox/v2/types" sandboxTypes "github.com/yaoapp/yao/agent/sandbox/v2/types"
store "github.com/yaoapp/yao/agent/store/types" store "github.com/yaoapp/yao/agent/store/types"
"github.com/yaoapp/yao/config" "github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/llmprovider"
infraV2 "github.com/yaoapp/yao/sandbox/v2" infraV2 "github.com/yaoapp/yao/sandbox/v2"
traceTypes "github.com/yaoapp/yao/trace/types" traceTypes "github.com/yaoapp/yao/trace/types"
"github.com/yaoapp/yao/workspace" "github.com/yaoapp/yao/workspace"
@ -25,15 +27,22 @@ func (ast *Assistant) HasSandboxV2() bool {
return ast.SandboxV2 != nil 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, // initSandboxV2 initializes the V2 sandbox: obtains a Computer, gets a Runner,
// runs Prepare, and returns the runner, computer, a per-request copy of the // resolves the role matrix, runs Prepare, and returns the result.
// SandboxConfig, cleanup closure, loading message ID, and any error.
// //
// A shallow copy of ast.SandboxV2 is made so that concurrent requests to the // 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.). // same assistant each get their own mutable config (Owner, ID, NodeID, etc.).
func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options) ( func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options) (*sandboxV2InitResult, error) {
sandboxTypes.Runner, infraV2.Computer, *sandboxTypes.SandboxConfig, func(), string, error,
) {
cfgCopy := *ast.SandboxV2 cfgCopy := *ast.SandboxV2
cfg := &cfgCopy cfg := &cfgCopy
manager := infraV2.M() manager := infraV2.M()
@ -52,9 +61,12 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
conn, _, err := ast.GetConnector(ctx, opts) conn, _, err := ast.GetConnector(ctx, opts)
if err != nil && cfg.Runner.Name != "yao" { if err != nil && cfg.Runner.Name != "yao" {
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed") 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. // 2. Build human-readable DisplayName from real Agent name + Workspace name.
cfg.DisplayName = buildBoxDisplayName(ctx, ast.ID, ast.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) computer, identifier, err := sandboxv2.GetComputer(ctx, cfg, manager)
if err != nil { if err != nil {
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed") 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 _ = identifier
@ -98,7 +110,7 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
if err != nil { if err != nil {
sandboxv2.LifecycleAction(stdCtx, cfg, computer, manager) sandboxv2.LifecycleAction(stdCtx, cfg, computer, manager)
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed") 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. // 5. Resolve assistant directory and skills subdirectory.
@ -129,6 +141,7 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
Computer: computer, Computer: computer,
Config: cfg, Config: cfg,
Connector: conn, Connector: conn,
Roles: roles,
AssistantID: ast.ID, AssistantID: ast.ID,
SkillsDir: skillsDir, SkillsDir: skillsDir,
AssistantDir: assistantDir, AssistantDir: assistantDir,
@ -140,11 +153,9 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
runner.Cleanup(stdCtx, computer) runner.Cleanup(stdCtx, computer)
sandboxv2.LifecycleAction(stdCtx, cfg, computer, manager) sandboxv2.LifecycleAction(stdCtx, cfg, computer, manager)
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed") 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) ctx.SetComputer(computer)
cleanup := func() { cleanup := func() {
@ -154,7 +165,14 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
sandboxv2.LifecycleAction(cleanCtx, cfg, computer, manager) 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. // sandboxV2StreamParams groups arguments for executeSandboxV2Stream.
@ -167,6 +185,7 @@ type sandboxV2StreamParams struct {
Config *sandboxTypes.SandboxConfig Config *sandboxTypes.SandboxConfig
LoadingMsgID string LoadingMsgID string
Options *context.Options Options *context.Options
Roles map[string]connector.Connector
} }
// executeSandboxV2Stream calls the V2 Runner.Stream and wraps it in the // executeSandboxV2Stream calls the V2 Runner.Stream and wraps it in the
@ -208,6 +227,7 @@ func (ast *Assistant) executeSandboxV2Stream(
Computer: p.Computer, Computer: p.Computer,
Config: cfg, Config: cfg,
Connector: conn, Connector: conn,
Roles: p.Roles,
AssistantID: ast.ID, AssistantID: ast.ID,
Messages: p.Messages, Messages: p.Messages,
SystemPrompt: systemPrompt, SystemPrompt: systemPrompt,
@ -229,6 +249,25 @@ func (ast *Assistant) executeSandboxV2Stream(
return sandboxv2.ExecuteSandboxStream(ctx, execReq, p.Handler) 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 // initStandaloneWorkspace loads the workspace FS into context when no sandbox
// is configured but the user selected a workspace (metadata["workspace_id"]). // is configured but the user selected a workspace (metadata["workspace_id"]).
func (ast *Assistant) initStandaloneWorkspace(ctx *context.Context) { func (ast *Assistant) initStandaloneWorkspace(ctx *context.Context) {

View file

@ -12,6 +12,7 @@ import (
"github.com/yaoapp/gou/connector" "github.com/yaoapp/gou/connector"
goullm "github.com/yaoapp/gou/llm" goullm "github.com/yaoapp/gou/llm"
"github.com/yaoapp/gou/store" "github.com/yaoapp/gou/store"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/kun/str" "github.com/yaoapp/kun/str"
agentContext "github.com/yaoapp/yao/agent/context" agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/sandbox/v2/types" "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) model, _ = setting["model"].(string)
} }
roleConnectors := getRoleConnectors(req) isAnthropic := req.Connector.Is(connector.ANTHROPIC)
getConn := func(id string) connector.Connector {
c, _ := connector.Connectors[id]
return c
}
if req.Connector.Is(connector.ANTHROPIC) { if isAnthropic {
env["ANTHROPIC_BASE_URL"] = host setAnthropicModelEnv(env, host, key, model, req.Connector)
env["ANTHROPIC_API_KEY"] = key applyAnthropicRoleOverrides(env, host, req.Roles)
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
}
}
}
}
} else { } else {
connectorID := req.Connector.ID() setA2OModelEnv(env, req.Connector.ID(), model, req.Connector)
env["ANTHROPIC_BASE_URL"] = fmt.Sprintf("http://127.0.0.1:%d/c/%s", defaultA2OPort, connectorID) applyA2ORoleOverrides(env, req.Roles)
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
}
}
} }
if lc, ok := req.Connector.(goullm.LLMConnector); ok { 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 return env
} }
@ -331,26 +301,13 @@ func buildSandboxEnvPrompt(p platform, workDir string) string {
shellNote := p.EnvPromptNote() shellNote := p.EnvPromptNote()
envVarSyntax := "$VAR_NAME"
if osName == "windows" {
envVarSyntax = "$env:VAR_NAME"
}
return fmt.Sprintf(`## Sandbox Environment return fmt.Sprintf(`## Sandbox Environment
- **Operating System**: %[2]s - **Operating System**: %[2]s
- **Shell**: %[3]s - **Shell**: %[3]s
- **Working Directory**: %[1]s - **Working Directory**: %[1]s
- **File Access**: You have full read/write access to %[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 %[4]s`, workDir, osName, shell, shellNote)
## 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)
} }
func hasExistingSession(ctx context.Context, computer infra.Computer, p platform, assistantID string) bool { 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 // claudeRoleEnvMap maps abstract Yao model roles to Claude CLI environment
// variables and virtual model name identifiers used as A2O route keys. // variables. Only roles with matching Claude CLI env vars are listed here.
// Only roles with matching Claude CLI env vars are listed here. // ANTHROPIC_DEFAULT_SONNET_MODEL is set to the primary model in buildEnv.
// ANTHROPIC_DEFAULT_SONNET_MODEL and CLAUDE_CODE_SUBAGENT_MODEL are set to var claudeRoleEnvMap = map[string]struct{ EnvVar string }{
// the primary model in buildEnv (Claude CLI doesn't have vision/subagent as "default": {EnvVar: "ANTHROPIC_MODEL"},
// independent role concepts). "heavy": {EnvVar: "ANTHROPIC_DEFAULT_OPUS_MODEL"},
var claudeRoleEnvMap = map[string]struct { "light": {EnvVar: "ANTHROPIC_DEFAULT_HAIKU_MODEL"},
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"},
} }
func connectorHost(c connector.Connector) string { func connectorHost(c connector.Connector) string {
@ -477,33 +428,149 @@ func supportsProtocol(c connector.Connector, proto string) bool {
return false 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{ var claudeArgWhitelist = map[string]string{
"max_turns": "--max-turns", "max_turns": "--max-turns",
"disallowed_tools": "--disallowed-tools", "disallowed_tools": "--disallowed-tools",
"allowed_tools": "--allowedTools", "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
}
}
}
}

View file

@ -308,7 +308,6 @@ func TestBuildSandboxEnvPrompt(t *testing.T) {
assert.Contains(t, prompt, "darwin") assert.Contains(t, prompt, "darwin")
assert.Contains(t, prompt, "bash") assert.Contains(t, prompt, "bash")
assert.Contains(t, prompt, "Sandbox Environment") assert.Contains(t, prompt, "Sandbox Environment")
assert.Contains(t, prompt, ".attachments")
} }
func TestBuildSandboxEnvPrompt_WindowsPlatform(t *testing.T) { func TestBuildSandboxEnvPrompt_WindowsPlatform(t *testing.T) {
@ -525,64 +524,6 @@ func TestSupportsProtocol(t *testing.T) {
assert.True(t, supportsProtocol(oai, "openai")) 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) { func TestBuildEnv_OpenAI_SingleConnector(t *testing.T) {
oai := newOpenAIConnector("kimi", "https://api.moonshot.cn", "kimi-k2.5", "sk-test") oai := newOpenAIConnector("kimi", "https://api.moonshot.cn", "kimi-k2.5", "sk-test")
req := &types.StreamRequest{ req := &types.StreamRequest{
@ -595,37 +536,32 @@ func TestBuildEnv_OpenAI_SingleConnector(t *testing.T) {
env := buildEnv(req, p) env := buildEnv(req, p)
assert.Contains(t, env["ANTHROPIC_BASE_URL"], "127.0.0.1") assert.Contains(t, env["ANTHROPIC_BASE_URL"], "127.0.0.1")
assert.Contains(t, env["ANTHROPIC_BASE_URL"], "kimi") assert.Contains(t, env["ANTHROPIC_BASE_URL"], "kimi")
assert.Equal(t, "claude-sonnet-4-6", env["ANTHROPIC_MODEL"]) assert.Equal(t, "kimi-k2.5", env["ANTHROPIC_MODEL"])
assert.Equal(t, "claude-sonnet-4-6", env["ANTHROPIC_DEFAULT_OPUS_MODEL"]) assert.Equal(t, "kimi-k2.5", env["ANTHROPIC_DEFAULT_OPUS_MODEL"])
} }
func TestBuildEnv_OpenAI_MultiConnector(t *testing.T) { func TestBuildEnv_OpenAI_MultiConnector(t *testing.T) {
primary := newOpenAIConnector("kimi", "https://api.moonshot.cn", "kimi-k2.5", "sk-test") primary := newOpenAIConnector("kimi", "https://api.moonshot.cn", "kimi-k2.5", "sk-test")
heavyConn := newOpenAIConnector("heavy-conn", "https://api.heavy.com", "heavy-model", "sk-h") 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{ req := &types.StreamRequest{
Config: &types.SandboxConfig{ Config: &types.SandboxConfig{},
Runner: types.RunnerConfig{
Connectors: map[string]*types.RoleConnector{
"heavy": {Connector: "heavy-conn", Override: "force"},
},
},
},
Connector: primary, Connector: primary,
Roles: map[string]connector.Connector{
"default": primary,
"heavy": heavyConn,
},
} }
req.Computer = newFakeComputer("/workspace") req.Computer = newFakeComputer("/workspace")
p := testPlatform() p := testPlatform()
env := buildEnv(req, p) env := buildEnv(req, p)
assert.Equal(t, "claude-opus-4-6", env["ANTHROPIC_DEFAULT_OPUS_MODEL"], assert.Equal(t, "heavy-model", env["ANTHROPIC_DEFAULT_OPUS_MODEL"],
"heavy role should get its virtual model name for A2O routing") "heavy role should use actual model name from connector")
assert.Equal(t, "claude-sonnet-4-6", env["ANTHROPIC_MODEL"], assert.Equal(t, "kimi-k2.5", env["ANTHROPIC_MODEL"],
"primary should keep default virtual 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) { 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") 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") 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{ req := &types.StreamRequest{
Config: &types.SandboxConfig{ Config: &types.SandboxConfig{},
Runner: types.RunnerConfig{
Connectors: map[string]*types.RoleConnector{
"light": {Connector: "light-conn", Override: "force"},
},
},
},
Connector: primary, Connector: primary,
Roles: map[string]connector.Connector{
"default": primary,
"light": lightConn,
},
} }
req.Computer = newFakeComputer("/workspace") req.Computer = newFakeComputer("/workspace")
p := testPlatform() p := testPlatform()
@ -695,7 +624,7 @@ func TestInjectA2OConfigWithRoutes_BuildsCorrectJSON(t *testing.T) {
heavyConn := newOpenAIConnector("heavy", "https://api.heavy.com", "heavy-model", "sk-h") heavyConn := newOpenAIConnector("heavy", "https://api.heavy.com", "heavy-model", "sk-h")
roleConnectors := map[string]connector.Connector{ roleConnectors := map[string]connector.Connector{
"claude-opus-4-6": heavyConn, "heavy-model": heavyConn,
} }
primaryCfg := buildSingleA2OConfig(primary) primaryCfg := buildSingleA2OConfig(primary)
@ -720,7 +649,7 @@ func TestInjectA2OConfigWithRoutes_BuildsCorrectJSON(t *testing.T) {
require.True(t, ok, "routes should be present in JSON") require.True(t, ok, "routes should be present in JSON")
assert.Len(t, routesMap, 1) 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) require.True(t, ok)
assert.Equal(t, "heavy-model", heavyRoute["model"]) assert.Equal(t, "heavy-model", heavyRoute["model"])
assert.Contains(t, heavyRoute["backend"], "api.heavy.com") assert.Contains(t, heavyRoute["backend"], "api.heavy.com")
@ -736,43 +665,34 @@ func TestResolveAllRoleConnectors_Empty(t *testing.T) {
} }
func TestResolveAllRoleConnectors_WithRoles(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") heavyConn := newOpenAIConnector("hvy", "https://heavy.com", "heavy-m", "sk")
cleanup := registerTestConnectors(t, map[string]connector.Connector{"hvy": heavyConn})
defer cleanup()
req := &types.StreamRequest{ req := &types.StreamRequest{
Config: &types.SandboxConfig{ Config: &types.SandboxConfig{},
Runner: types.RunnerConfig{ Connector: primaryConn,
Connectors: map[string]*types.RoleConnector{ Roles: map[string]connector.Connector{
"heavy": {Connector: "hvy", Override: "force"}, "default": primaryConn,
"heavy": heavyConn,
}, },
},
},
Connector: newOpenAIConnector("primary", "h", "m", "k"),
} }
result := resolveAllRoleConnectors(req) result := resolveAllRoleConnectors(req)
assert.Len(t, result, 1) assert.Len(t, result, 2)
assert.Equal(t, heavyConn, result["claude-opus-4-6"]) assert.Equal(t, primaryConn, result["primary-m"])
assert.Equal(t, heavyConn, result["heavy-m"])
} }
func TestBuildEnv_Anthropic_MultiConnector_Incompatible(t *testing.T) { func TestBuildEnv_Anthropic_MultiConnector_Incompatible(t *testing.T) {
primary := newAnthropicConnector("claude", "https://api.anthropic.com", "claude-sonnet-4-20250514", "sk-ant") 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") 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{ req := &types.StreamRequest{
Config: &types.SandboxConfig{ Config: &types.SandboxConfig{},
Runner: types.RunnerConfig{
Connectors: map[string]*types.RoleConnector{
"heavy": {Connector: "heavy-oai", Override: "force"},
},
},
},
Connector: primary, Connector: primary,
Roles: map[string]connector.Connector{
"default": primary,
"heavy": heavyConn,
},
} }
req.Computer = newFakeComputer("/workspace") req.Computer = newFakeComputer("/workspace")
p := testPlatform() p := testPlatform()

View file

@ -12,8 +12,10 @@ import (
"github.com/yaoapp/kun/log" "github.com/yaoapp/kun/log"
agentContext "github.com/yaoapp/yao/agent/context" agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message" "github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/agent/sandbox/v2/shared"
"github.com/yaoapp/yao/agent/sandbox/v2/types" "github.com/yaoapp/yao/agent/sandbox/v2/types"
infra "github.com/yaoapp/yao/sandbox/v2" infra "github.com/yaoapp/yao/sandbox/v2"
"github.com/yaoapp/yao/tools"
) )
// Runner implements the sandbox Runner interface for Claude CLI (mode=cli). // 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...) 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 != "" { if req.SkillsDir != "" {
ws := req.Computer.Workplace() ws := req.Computer.Workplace()
if ws != nil { if ws != nil {
@ -243,27 +257,25 @@ func buildSingleA2OConfig(conn connector.Connector) *a2oConnectorConfig {
return cfg return cfg
} }
// resolveAllRoleConnectors resolves all declared role connectors and returns // resolveAllRoleConnectors maps pre-resolved role connectors from req.Roles
// a map of virtual model name -> connector for roles that have independent connectors. // to actual model names used as A2O proxy route keys.
func resolveAllRoleConnectors(req *types.StreamRequest) map[string]connector.Connector { func resolveAllRoleConnectors(req *types.StreamRequest) map[string]connector.Connector {
roleConns := getRoleConnectors(req) if len(req.Roles) == 0 {
if len(roleConns) == 0 {
return nil return nil
} }
result := make(map[string]connector.Connector) result := make(map[string]connector.Connector)
for role, rm := range claudeRoleEnvMap { for _, rc := range req.Roles {
if role == "default" { var model string
continue if lc, ok := rc.(goullm.LLMConnector); ok {
model = lc.GetModel()
} }
rc := resolveRoleConnector(role, roleConns, req.UserExplicit, func(id string) connector.Connector { if model == "" {
c, _ := connector.Connectors[id] model, _ = rc.Setting()["model"].(string)
return c }
}) if model != "" {
if rc == nil { result[model] = rc
continue
} }
result[rm.ModelName] = rc
} }
return result return result
} }

View file

@ -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. // like browsers should be nohup'd; this prevents accidental 2-min hangs.
env["OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"] = "30000" env["OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"] = "30000"
primaryConn := resolvePrimaryConnector(req.Connector, req.Config) primaryConn := resolvePrimaryConnector(req.Connector, req.Roles)
if primaryConn != nil { if primaryConn != nil {
setting := primaryConn.Setting() setting := primaryConn.Setting()
key, _ := setting["key"].(string) key, _ := setting["key"].(string)
@ -176,7 +176,7 @@ func buildArgs(req *types.StreamRequest, r *Runner, isContinuation bool, chatID
args = append(args, "--continue", "--session", sessionID) args = append(args, "--continue", "--session", sessionID)
} }
primaryConn := resolvePrimaryConnector(req.Connector, req.Config) primaryConn := resolvePrimaryConnector(req.Connector, req.Roles)
if primaryConn != nil { if primaryConn != nil {
if mid := connectorModelID(primaryConn); mid != "" { if mid := connectorModelID(primaryConn); mid != "" {
args = append(args, "--model", mid) args = append(args, "--model", mid)
@ -258,26 +258,13 @@ func buildSandboxEnvPrompt(p platform, workDir string) string {
shell = "bash" shell = "bash"
} }
envVarSyntax := "$VAR_NAME"
if osName == "windows" {
envVarSyntax = "$env:VAR_NAME"
}
return fmt.Sprintf(`## Sandbox Environment return fmt.Sprintf(`## Sandbox Environment
- **Operating System**: %[2]s - **Operating System**: %[2]s
- **Shell**: %[3]s - **Shell**: %[3]s
- **Working Directory**: %[1]s - **Working Directory**: %[1]s
- **File Access**: You have full read/write access to %[1]s - **File Access**: You have full read/write access to %[1]s
- **Environment variable syntax**: `+"`%[4]s`"+` `, workDir, osName, shell)
## 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)
} }
func getProviderPrefix(conn connector.Connector) string { func getProviderPrefix(conn connector.Connector) string {
@ -287,30 +274,6 @@ func getProviderPrefix(conn connector.Connector) string {
return "openai" 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 // shellQuoteForPlatform builds a shell-safe command string. On Windows
// (PowerShell) it uses single quotes with ” escaping; on POSIX it uses // (PowerShell) it uses single quotes with ” escaping; on POSIX it uses
// single quotes with '\” escaping. // single quotes with '\” escaping.
@ -377,19 +340,15 @@ func connectorModelID(c connector.Connector) string {
// consumed by opencode.json provider blocks (via {env:...} references) and // consumed by opencode.json provider blocks (via {env:...} references) and
// by the custom read.ts tool (for vision API calls). // by the custom read.ts tool (for vision API calls).
func injectRoleEnvVars(env map[string]string, req *types.StreamRequest) { func injectRoleEnvVars(env map[string]string, req *types.StreamRequest) {
if req.Config == nil || req.Config.Runner.Connectors == nil { if len(req.Roles) == 0 {
return return
} }
for role, spec := range openCodeRoleMap { for role, spec := range openCodeRoleMap {
if spec.EnvKeyPrefix == "" { if spec.EnvKeyPrefix == "" {
continue continue
} }
rc, ok := req.Config.Runner.Connectors[role] c, ok := req.Roles[role]
if !ok || rc == nil || rc.Connector == "" { if !ok || c == nil {
continue
}
c, exists := connector.Connectors[rc.Connector]
if !exists || c == nil {
continue continue
} }
setting := c.Setting() setting := c.Setting()

View file

@ -27,23 +27,13 @@ var openCodeRoleMap = map[string]roleSpec{
}, },
} }
// resolvePrimaryConnector returns the heavy connector if configured, // resolvePrimaryConnector returns the heavy role connector if present in the
// otherwise falls back to the caller-supplied primary (typically the // pre-resolved roles map, otherwise falls back to the caller-supplied primary.
// assistant's default connector). This aligns with OpenCode's semantics func resolvePrimaryConnector(primary connector.Connector, roles map[string]connector.Connector) connector.Connector {
// where the top-level "model" handles complex coding tasks. if c, ok := roles["heavy"]; ok && c != nil {
func resolvePrimaryConnector(primary connector.Connector, cfg *types.SandboxConfig) connector.Connector {
if cfg == nil || cfg.Runner.Connectors == nil {
return primary
}
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 c
}
return primary
} }
// buildOpenCodeConfig generates the opencode.json project configuration. // buildOpenCodeConfig generates the opencode.json project configuration.
@ -58,7 +48,7 @@ func buildOpenCodeConfig(req *types.PrepareRequest, mcpServers []types.MCPServer
"permission": map[string]any{"*": "allow"}, "permission": map[string]any{"*": "allow"},
} }
primaryConn := resolvePrimaryConnector(req.Connector, req.Config) primaryConn := resolvePrimaryConnector(req.Connector, req.Roles)
if primaryConn != nil { if primaryConn != nil {
providerID, providerCfg, modelStr := buildProviderConfig(primaryConn) providerID, providerCfg, modelStr := buildProviderConfig(primaryConn)
cfg["provider"] = map[string]any{providerID: providerCfg} 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 // also sets the top-level "small_model" field. primaryConn is the resolved
// primary connector (may be heavy or default) used for sameProvider checks. // primary connector (may be heavy or default) used for sameProvider checks.
func injectRoleProviders(cfg map[string]any, req *types.PrepareRequest, primaryConn connector.Connector) { 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 return
} }
@ -223,13 +213,8 @@ func injectRoleProviders(cfg map[string]any, req *types.PrepareRequest, primaryC
} }
for role, spec := range openCodeRoleMap { for role, spec := range openCodeRoleMap {
rc, ok := req.Config.Runner.Connectors[role] c, ok := req.Roles[role]
if !ok || rc == nil || rc.Connector == "" { if !ok || c == nil {
continue
}
c, exists := connector.Connectors[rc.Connector]
if !exists || c == nil {
continue continue
} }

View file

@ -185,8 +185,6 @@ func TestBuildSandboxEnvPrompt_Linux(t *testing.T) {
assert.Contains(t, prompt, "linux") assert.Contains(t, prompt, "linux")
assert.Contains(t, prompt, "bash") assert.Contains(t, prompt, "bash")
assert.Contains(t, prompt, "/workspace") assert.Contains(t, prompt, "/workspace")
assert.Contains(t, prompt, "$VAR_NAME")
assert.NotContains(t, prompt, "$env:")
} }
func TestBuildSandboxEnvPrompt_Windows(t *testing.T) { 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, "windows")
assert.Contains(t, prompt, "pwsh") assert.Contains(t, prompt, "pwsh")
assert.Contains(t, prompt, `C:\workspace`) assert.Contains(t, prompt, `C:\workspace`)
assert.Contains(t, prompt, "$env:VAR_NAME")
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -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 // injectRoleProviders tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func TestInjectRoleProviders_VisionCustomProvider(t *testing.T) { func TestInjectRoleProviders_VisionCustomProvider(t *testing.T) {
visionConn := newFakeOpenAI("vis", "https://api.mymaas.com/v1", "gpt-4o-mini", "sk-vis") 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") primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds")
cfg := map[string]any{ cfg := map[string]any{
@ -80,12 +66,10 @@ func TestInjectRoleProviders_VisionCustomProvider(t *testing.T) {
req := &types.PrepareRequest{ req := &types.PrepareRequest{
Connector: primaryConn, Connector: primaryConn,
Config: &types.SandboxConfig{ Config: &types.SandboxConfig{},
Runner: types.RunnerConfig{ Roles: map[string]connector.Connector{
Connectors: map[string]*types.RoleConnector{ "default": primaryConn,
"vision": {Connector: "vision-conn", Override: "force"}, "vision": visionConn,
},
},
}, },
} }
@ -117,8 +101,6 @@ func TestInjectRoleProviders_VisionCustomProvider(t *testing.T) {
func TestInjectRoleProviders_VisionNativeOpenAI(t *testing.T) { func TestInjectRoleProviders_VisionNativeOpenAI(t *testing.T) {
visionConn := newFakeOpenAI("vis", "", "gpt-4o-mini", "sk-oai") 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") primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds")
cfg := map[string]any{ cfg := map[string]any{
@ -129,12 +111,10 @@ func TestInjectRoleProviders_VisionNativeOpenAI(t *testing.T) {
req := &types.PrepareRequest{ req := &types.PrepareRequest{
Connector: primaryConn, Connector: primaryConn,
Config: &types.SandboxConfig{ Config: &types.SandboxConfig{},
Runner: types.RunnerConfig{ Roles: map[string]connector.Connector{
Connectors: map[string]*types.RoleConnector{ "default": primaryConn,
"vision": {Connector: "oai-vision", Override: "force"}, "vision": visionConn,
},
},
}, },
} }
@ -148,8 +128,6 @@ func TestInjectRoleProviders_VisionNativeOpenAI(t *testing.T) {
func TestInjectRoleProviders_LightWithDifferentHost(t *testing.T) { func TestInjectRoleProviders_LightWithDifferentHost(t *testing.T) {
lightConn := newFakeOpenAI("moonshot", "https://api.moonshot.cn/v1", "moonshot-v1-8k", "sk-moon") 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") primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds")
cfg := map[string]any{ cfg := map[string]any{
@ -160,12 +138,10 @@ func TestInjectRoleProviders_LightWithDifferentHost(t *testing.T) {
req := &types.PrepareRequest{ req := &types.PrepareRequest{
Connector: primaryConn, Connector: primaryConn,
Config: &types.SandboxConfig{ Config: &types.SandboxConfig{},
Runner: types.RunnerConfig{ Roles: map[string]connector.Connector{
Connectors: map[string]*types.RoleConnector{ "default": primaryConn,
"light": {Connector: "moonshot-conn", Override: "force"}, "light": lightConn,
},
},
}, },
} }
@ -198,8 +174,6 @@ func TestInjectRoleProviders_LightWithDifferentHost(t *testing.T) {
func TestInjectRoleProviders_LightSameHostAsPrimary(t *testing.T) { func TestInjectRoleProviders_LightSameHostAsPrimary(t *testing.T) {
lightConn := newFakeOpenAI("ds-light", "https://api.deepseek.com", "deepseek-chat", "sk-ds") 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") primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds")
primaryProviderID, primaryCfg, modelStr := buildProviderConfig(primaryConn) primaryProviderID, primaryCfg, modelStr := buildProviderConfig(primaryConn)
@ -212,12 +186,10 @@ func TestInjectRoleProviders_LightSameHostAsPrimary(t *testing.T) {
req := &types.PrepareRequest{ req := &types.PrepareRequest{
Connector: primaryConn, Connector: primaryConn,
Config: &types.SandboxConfig{ Config: &types.SandboxConfig{},
Runner: types.RunnerConfig{ Roles: map[string]connector.Connector{
Connectors: map[string]*types.RoleConnector{ "default": primaryConn,
"light": {Connector: "ds-light-conn", Override: "force"}, "light": lightConn,
},
},
}, },
} }
@ -263,8 +235,6 @@ func TestInjectRoleProviders_NoConnectors(t *testing.T) {
func TestInjectRoleProviders_AnthropicVision(t *testing.T) { func TestInjectRoleProviders_AnthropicVision(t *testing.T) {
visionConn := newFakeAnthropic("claude-vis", "https://api.anthropic.com", "claude-sonnet-4-5-20250929", "sk-ant") 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") primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds")
cfg := map[string]any{ cfg := map[string]any{
@ -275,12 +245,10 @@ func TestInjectRoleProviders_AnthropicVision(t *testing.T) {
req := &types.PrepareRequest{ req := &types.PrepareRequest{
Connector: primaryConn, Connector: primaryConn,
Config: &types.SandboxConfig{ Config: &types.SandboxConfig{},
Runner: types.RunnerConfig{ Roles: map[string]connector.Connector{
Connectors: map[string]*types.RoleConnector{ "default": primaryConn,
"vision": {Connector: "anthropic-vision", Override: "force"}, "vision": visionConn,
},
},
}, },
} }
@ -304,16 +272,11 @@ func TestInjectRoleProviders_AnthropicVision(t *testing.T) {
func TestInjectRoleEnvVars_Vision(t *testing.T) { func TestInjectRoleEnvVars_Vision(t *testing.T) {
visionConn := newFakeOpenAI("vis", "https://api.mymaas.com/v1", "gpt-4o-mini", "sk-vis-key") 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{ req := &types.StreamRequest{
Config: &types.SandboxConfig{ Config: &types.SandboxConfig{},
Runner: types.RunnerConfig{ Roles: map[string]connector.Connector{
Connectors: map[string]*types.RoleConnector{ "vision": visionConn,
"vision": {Connector: "vision-conn", Override: "force"},
},
},
}, },
} }
@ -333,16 +296,11 @@ func TestInjectRoleEnvVars_Vision(t *testing.T) {
func TestInjectRoleEnvVars_Light(t *testing.T) { func TestInjectRoleEnvVars_Light(t *testing.T) {
lightConn := newFakeOpenAI("moon", "https://api.moonshot.cn/v1", "moonshot-v1-8k", "sk-moon") 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{ req := &types.StreamRequest{
Config: &types.SandboxConfig{ Config: &types.SandboxConfig{},
Runner: types.RunnerConfig{ Roles: map[string]connector.Connector{
Connectors: map[string]*types.RoleConnector{ "light": lightConn,
"light": {Connector: "moon-conn", Override: "force"},
},
},
}, },
} }
@ -381,20 +339,11 @@ func TestInjectRoleEnvVars_MultipleRoles(t *testing.T) {
visionConn := newFakeOpenAI("vis", "https://api.vision.com", "vis-model", "sk-vis") visionConn := newFakeOpenAI("vis", "https://api.vision.com", "vis-model", "sk-vis")
lightConn := newFakeOpenAI("light-c", "https://api.light.com", "light-model", "sk-light") 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{ req := &types.StreamRequest{
Config: &types.SandboxConfig{ Config: &types.SandboxConfig{},
Runner: types.RunnerConfig{ Roles: map[string]connector.Connector{
Connectors: map[string]*types.RoleConnector{ "vision": visionConn,
"vision": {Connector: "vis-c", Override: "force"}, "light": lightConn,
"light": {Connector: "light-c", Override: "force"},
},
},
}, },
} }
@ -416,23 +365,16 @@ func TestInjectRoleEnvVars_MultipleRoles(t *testing.T) {
func TestBuildOpenCodeConfig_WithVisionAndLight(t *testing.T) { func TestBuildOpenCodeConfig_WithVisionAndLight(t *testing.T) {
visionConn := newFakeOpenAI("vis", "https://api.mymaas.com/v1", "gpt-4o-mini", "sk-vis") 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") 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") primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds")
req := &types.PrepareRequest{ req := &types.PrepareRequest{
AssistantID: "test-assistant", AssistantID: "test-assistant",
Connector: primaryConn, Connector: primaryConn,
Config: &types.SandboxConfig{ Config: &types.SandboxConfig{},
Runner: types.RunnerConfig{ Roles: map[string]connector.Connector{
Connectors: map[string]*types.RoleConnector{ "default": primaryConn,
"vision": {Connector: "vision-conn", Override: "force"}, "vision": visionConn,
"light": {Connector: "light-conn", Override: "force"}, "light": lightConn,
},
},
}, },
} }
@ -480,18 +422,13 @@ func TestBuildOpenCodeConfig_WithVisionAndLight(t *testing.T) {
func TestResolvePrimaryConnector_HeavyConfigured(t *testing.T) { func TestResolvePrimaryConnector_HeavyConfigured(t *testing.T) {
defaultConn := newFakeOpenAI("default", "https://api.deepseek.com", "deepseek-chat", "sk-ds") defaultConn := newFakeOpenAI("default", "https://api.deepseek.com", "deepseek-chat", "sk-ds")
heavyConn := newFakeOpenAI("heavy", "https://api.openai.com", "o3-pro", "sk-oai") 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{ roles := map[string]connector.Connector{
Runner: types.RunnerConfig{ "default": defaultConn,
Connectors: map[string]*types.RoleConnector{ "heavy": heavyConn,
"heavy": {Connector: "heavy-conn", Override: "force"},
},
},
} }
result := resolvePrimaryConnector(defaultConn, cfg) result := resolvePrimaryConnector(defaultConn, roles)
if result != heavyConn { if result != heavyConn {
t.Error("should return heavy connector when configured") t.Error("should return heavy connector when configured")
} }
@ -499,43 +436,24 @@ func TestResolvePrimaryConnector_HeavyConfigured(t *testing.T) {
func TestResolvePrimaryConnector_NoHeavy(t *testing.T) { func TestResolvePrimaryConnector_NoHeavy(t *testing.T) {
defaultConn := newFakeOpenAI("default", "https://api.deepseek.com", "deepseek-chat", "sk-ds") 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{ roles := map[string]connector.Connector{
Runner: types.RunnerConfig{ "default": defaultConn,
Connectors: map[string]*types.RoleConnector{ "light": lightConn,
"light": {Connector: "light-conn", Override: "force"},
},
},
} }
result := resolvePrimaryConnector(defaultConn, cfg) result := resolvePrimaryConnector(defaultConn, roles)
if result != defaultConn { if result != defaultConn {
t.Error("should fallback to default when heavy not configured") t.Error("should fallback to default when heavy not configured")
} }
} }
func TestResolvePrimaryConnector_HeavyNotRegistered(t *testing.T) { func TestResolvePrimaryConnector_NilRoles(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) {
defaultConn := newFakeOpenAI("default", "https://api.deepseek.com", "deepseek-chat", "sk-ds") defaultConn := newFakeOpenAI("default", "https://api.deepseek.com", "deepseek-chat", "sk-ds")
result := resolvePrimaryConnector(defaultConn, nil) result := resolvePrimaryConnector(defaultConn, nil)
if result != defaultConn { if result != defaultConn {
t.Error("should return default when config is nil") t.Error("should return default when roles is nil")
} }
} }

View file

@ -12,6 +12,7 @@ import (
"github.com/yaoapp/yao/agent/sandbox/v2/shared" "github.com/yaoapp/yao/agent/sandbox/v2/shared"
"github.com/yaoapp/yao/agent/sandbox/v2/types" "github.com/yaoapp/yao/agent/sandbox/v2/types"
infra "github.com/yaoapp/yao/sandbox/v2" infra "github.com/yaoapp/yao/sandbox/v2"
"github.com/yaoapp/yao/tools"
) )
// Runner implements the sandbox Runner interface for OpenCode CLI. // 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...) 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) // 1. Skills copy (aligned with Claude Runner)
if req.SkillsDir != "" { if req.SkillsDir != "" {
ws := req.Computer.Workplace() ws := req.Computer.Workplace()
@ -79,8 +93,7 @@ func (r *Runner) Prepare(ctx context.Context, req *types.PrepareRequest) error {
// config dir ($HOME/.config/opencode/tools/). Only needed when a // config dir ($HOME/.config/opencode/tools/). Only needed when a
// vision connector is configured — the custom read tool overrides the // vision connector is configured — the custom read tool overrides the
// built-in read to route image files through the vision API. // built-in read to route image files through the vision API.
if req.Config != nil && req.Config.Runner.Connectors != nil { if _, ok := req.Roles["vision"]; ok {
if vc, ok := req.Config.Runner.Connectors["vision"]; ok && vc != nil && vc.Connector != "" {
p := resolvePlatform(req.Computer) p := resolvePlatform(req.Computer)
steps = append(steps, types.PrepareStep{ steps = append(steps, types.PrepareStep{
Action: "exec", Action: "exec",
@ -89,7 +102,6 @@ func (r *Runner) Prepare(ctx context.Context, req *types.PrepareRequest) error {
IgnoreError: true, IgnoreError: true,
}) })
} }
}
// 5. Generate opencode.json (project config at workspace root) // 5. Generate opencode.json (project config at workspace root)
configJSON := buildOpenCodeConfig(req, r.mcpServers) configJSON := buildOpenCodeConfig(req, r.mcpServers)

View file

@ -0,0 +1,71 @@
package shared
import (
"bytes"
"errors"
"io/fs"
"os"
"path"
"strings"
)
const systemToolsMarker = "<!-- Yao System Tools (auto-injected) -->"
// 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)
}

View file

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

View file

@ -38,6 +38,7 @@ type PrepareRequest struct {
Computer infra.Computer Computer infra.Computer
Config *SandboxConfig Config *SandboxConfig
Connector connector.Connector Connector connector.Connector
Roles map[string]connector.Connector // pre-resolved role matrix from llmprovider
AssistantID string // the assistant's own ID (e.g. "yao/postman") AssistantID string // the assistant's own ID (e.g. "yao/postman")
SkillsDir string SkillsDir string
AssistantDir string // absolute host path to the assistant source directory AssistantDir string // absolute host path to the assistant source directory
@ -51,6 +52,7 @@ type StreamRequest struct {
Computer infra.Computer Computer infra.Computer
Config *SandboxConfig Config *SandboxConfig
Connector connector.Connector Connector connector.Connector
Roles map[string]connector.Connector // pre-resolved role matrix from llmprovider
AssistantID string // the assistant's own ID (e.g. "yao/postman") AssistantID string // the assistant's own ID (e.g. "yao/postman")
Messages []agentContext.Message Messages []agentContext.Message
SystemPrompt string SystemPrompt string

View file

@ -485,7 +485,8 @@
max_output_tokens: 384000 max_output_tokens: 384000
capabilities: [tool_calls, streaming, json] capabilities: [tool_calls, streaming, json]
options: options:
enable_thinking: false thinking:
type: disabled
enabled: false enabled: false
- id: deepseek-v4-pro-thinking - id: deepseek-v4-pro-thinking
model: deepseek-v4-pro model: deepseek-v4-pro
@ -494,20 +495,34 @@
max_output_tokens: 384000 max_output_tokens: 384000
capabilities: [tool_calls, streaming, json, reasoning] capabilities: [tool_calls, streaming, json, reasoning]
options: options:
enable_thinking: true thinking:
type: enabled
enabled: true enabled: true
- id: deepseek-v4-flash - id: deepseek-v4-flash
name: DeepSeek V4 Flash name: DeepSeek V4 Flash
max_input_tokens: 1048576 max_input_tokens: 1048576
max_output_tokens: 384000 max_output_tokens: 384000
capabilities: [tool_calls, streaming, json] capabilities: [tool_calls, streaming, json]
options:
thinking:
type: disabled
enabled: true 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) ─────────────────────────────── # ─── DeepSeek (Anthropic) ───────────────────────────────
- key: deepseek_anthropic - key: deepseek_anthropic
name: DeepSeek (Anthropic) name: DeepSeek (Anthropic)
type: anthropic type: anthropic
api_url: https://api.deepseek.com api_url: https://api.deepseek.com/anthropic
require_key: true require_key: true
default_models: default_models:
- id: deepseek-v4-pro - id: deepseek-v4-pro
@ -515,6 +530,9 @@
max_input_tokens: 1048576 max_input_tokens: 1048576
max_output_tokens: 384000 max_output_tokens: 384000
capabilities: [tool_calls, streaming, json] capabilities: [tool_calls, streaming, json]
options:
thinking:
type: disabled
enabled: false enabled: false
- id: deepseek-v4-pro-thinking - id: deepseek-v4-pro-thinking
model: deepseek-v4-pro model: deepseek-v4-pro
@ -532,7 +550,21 @@
max_input_tokens: 1048576 max_input_tokens: 1048576
max_output_tokens: 384000 max_output_tokens: 384000
capabilities: [tool_calls, streaming, json] capabilities: [tool_calls, streaming, json]
options:
thinking:
type: disabled
enabled: true 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) ──────────────────── # ─── Kimi / Moonshot (International) ────────────────────
- key: kimi_intl - key: kimi_intl

View file

@ -86,33 +86,132 @@ func llmModelsURL(apiURL string) string {
return apiURL + "/v1/models" return apiURL + "/v1/models"
} }
// llmValidateKey tests connectivity by calling GET {apiURL}/models. // llmCompletionURL builds the chat/messages endpoint URL.
// providerType controls the auth header format (anthropic uses x-api-key). func llmCompletionURL(providerType, apiURL string) string {
func llmValidateKey(providerType, apiURL, apiKey string) error { endpoint := "chat/completions"
url := llmModelsURL(apiURL) if providerType == "anthropic" {
client := &http.Client{Timeout: 10 * time.Second} endpoint = "messages"
req, err := http.NewRequest("GET", url, nil) }
if err != nil { if strings.HasSuffix(apiURL, "/") {
return fmt.Errorf("failed to build request: %w", err) 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 apiKey != "" {
if providerType == "anthropic" { if providerType == "anthropic" {
req.Header.Set("x-api-key", apiKey) req.Header.Set("x-api-key", apiKey)
req.Header.Set("anthropic-version", "2023-06-01") req.Header.Set("anthropic-version", "2023-06-01")
} else { } else {
req.Header.Set("Authorization", "Bearer "+apiKey) 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 {
client := &http.Client{Timeout: 10 * time.Second}
// --- 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)
} }
req.Header.Set("Content-Type", "application/json")
llmSetAuthHeader(req, providerType, apiKey)
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return fmt.Errorf("connection failed: %w", err) return fmt.Errorf("connection failed: %w", err)
} }
defer resp.Body.Close() resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("invalid API key (HTTP %d)", resp.StatusCode) return fmt.Errorf("invalid API key (HTTP %d)", resp.StatusCode)
} }
if resp.StatusCode != http.StatusOK { postStatus := resp.StatusCode
return fmt.Errorf("server returned HTTP %d", 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 return nil
} }
@ -366,39 +465,14 @@ func handleLLMTest(c *gin.Context) {
return return
} }
url := llmModelsURL(input.APIURL)
start := time.Now() start := time.Now()
client := &http.Client{Timeout: 10 * time.Second} err := llmValidateKey(input.Type, input.APIURL, input.APIKey)
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)
latency := time.Since(start).Milliseconds() latency := time.Since(start).Milliseconds()
if err != nil { if err != nil {
response.RespondWithSuccess(c, http.StatusOK, llmprovider.ProviderTestResult{ response.RespondWithSuccess(c, http.StatusOK, llmprovider.ProviderTestResult{
Success: false, Success: false,
Message: fmt.Sprintf("Connection failed: %s", err.Error()), Message: 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),
}) })
return return
} }

View file

@ -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/<id>/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 <name> '<json_args>'`
| 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.

18
tools/skills.go Normal file
View file

@ -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 <name>`.
//
//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

View file

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

View file

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

View file

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

84
tools/skills_test.go Normal file
View file

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