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:
parent
7877797549
commit
194faac9b7
22 changed files with 1052 additions and 528 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -86,3 +86,4 @@ POSTGRESQL_COMPAT.md
|
|||
openapi/setting/*.md
|
||||
agent/docs/design/*.md
|
||||
tools/README.md
|
||||
tools/TOOL-REGISTRATION.md
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
71
agent/sandbox/v2/shared/inject.go
Normal file
71
agent/sandbox/v2/shared/inject.go
Normal 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)
|
||||
}
|
||||
148
agent/sandbox/v2/shared/inject_test.go
Normal file
148
agent/sandbox/v2/shared/inject_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
45
tools/prompts/system-tools.md
Normal file
45
tools/prompts/system-tools.md
Normal 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
18
tools/skills.go
Normal 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
|
||||
56
tools/skills/yao-doc/SKILL.md
Normal file
56
tools/skills/yao-doc/SKILL.md
Normal 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
|
||||
50
tools/skills/yao-process/SKILL.md
Normal file
50
tools/skills/yao-process/SKILL.md
Normal 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
|
||||
40
tools/skills/yao-web/SKILL.md
Normal file
40
tools/skills/yao-web/SKILL.md
Normal 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
84
tools/skills_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue