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
|
openapi/setting/*.md
|
||||||
agent/docs/design/*.md
|
agent/docs/design/*.md
|
||||||
tools/README.md
|
tools/README.md
|
||||||
|
tools/TOOL-REGISTRATION.md
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
return c
|
||||||
if cfg == nil || cfg.Runner.Connectors == nil {
|
|
||||||
return primary
|
|
||||||
}
|
}
|
||||||
rc, ok := cfg.Runner.Connectors["heavy"]
|
return primary
|
||||||
if !ok || rc == nil || rc.Connector == "" {
|
|
||||||
return primary
|
|
||||||
}
|
|
||||||
c, exists := connector.Connectors[rc.Connector]
|
|
||||||
if !exists || c == nil {
|
|
||||||
return primary
|
|
||||||
}
|
|
||||||
return c
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,16 +93,14 @@ 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",
|
Cmd: visionCopyCmd(p),
|
||||||
Cmd: visionCopyCmd(p),
|
Once: true,
|
||||||
Once: true,
|
IgnoreError: true,
|
||||||
IgnoreError: true,
|
})
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Generate opencode.json (project config at workspace root)
|
// 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
|
Computer infra.Computer
|
||||||
Config *SandboxConfig
|
Config *SandboxConfig
|
||||||
Connector connector.Connector
|
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
|
SkillsDir string
|
||||||
AssistantDir string // absolute host path to the assistant source directory
|
AssistantDir string // absolute host path to the assistant source directory
|
||||||
MCPServers []MCPServer
|
MCPServers []MCPServer
|
||||||
|
|
@ -51,7 +52,8 @@ type StreamRequest struct {
|
||||||
Computer infra.Computer
|
Computer infra.Computer
|
||||||
Config *SandboxConfig
|
Config *SandboxConfig
|
||||||
Connector connector.Connector
|
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
|
Messages []agentContext.Message
|
||||||
SystemPrompt string
|
SystemPrompt string
|
||||||
ChatID string
|
ChatID string
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
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 {
|
func llmValidateKey(providerType, apiURL, apiKey string) error {
|
||||||
url := llmModelsURL(apiURL)
|
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to build request: %w", err)
|
return fmt.Errorf("failed to build request: %w", err)
|
||||||
}
|
}
|
||||||
if apiKey != "" {
|
req.Header.Set("Content-Type", "application/json")
|
||||||
if providerType == "anthropic" {
|
llmSetAuthHeader(req, providerType, apiKey)
|
||||||
req.Header.Set("x-api-key", apiKey)
|
|
||||||
req.Header.Set("anthropic-version", "2023-06-01")
|
|
||||||
} else {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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