feat(sandbox): enhance connector configuration and streamline sandbox execution

- Introduced a new sandboxV2StreamParams struct to group parameters for the executeSandboxV2Stream function, improving code clarity and maintainability.
- Updated the GetComputer function to handle connector configuration injection via the a2o proxy, removing the need for direct connector parameters.
- Enhanced the executeSandboxV2Stream function to utilize the new parameters struct, simplifying the function signature and improving readability.
- Implemented RegisterProxyConfigs to inject OpenAI-compatible connector configurations into the a2o proxy, ensuring proper environment setup for sandbox execution.
- Refactored BuildCreateOptions to remove direct connector handling, aligning with the new configuration injection approach.
This commit is contained in:
Max 2026-03-21 23:10:15 +08:00
parent 10e004bfad
commit 5465fb2718
5 changed files with 190 additions and 93 deletions

View file

@ -190,6 +190,9 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
ctx.Logger.Trace("Computer: %s", ci.BoxID)
}
ctx.Logger.Trace("Workspace: %s", ast.SandboxV2.WorkspaceID)
if conn, _, err := ast.GetConnector(ctx, opts); err == nil && conn != nil {
ctx.Logger.Trace("Connector: %s", conn.ID())
}
}
} else if ast.HasSandbox() {
ctx.Logger.Phase("Sandbox")
@ -328,7 +331,15 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
// Choose between sandbox execution or direct LLM execution
if ast.HasSandboxV2() && v2Runner != nil && v2Computer != nil && v2Runner.Name() != "yao" {
// V2 Sandbox execution path (non-yao runners replace LLM.Stream)
completionResponse, err = ast.executeSandboxV2Stream(ctx, completionMessages, agentNode, streamHandler, v2Runner, v2Computer, v2LoadingMsgID)
completionResponse, err = ast.executeSandboxV2Stream(ctx, &sandboxV2StreamParams{
Messages: completionMessages,
AgentNode: agentNode,
Handler: streamHandler,
Runner: v2Runner,
Computer: v2Computer,
LoadingMsgID: v2LoadingMsgID,
Options: opts,
})
} else if ast.HasSandboxV2() && v2Runner != nil && v2Runner.Name() == "yao" {
// V2 yao runner: Prepare is done, close loading, fall through to LLM
if v2LoadingMsgID != "" {

View file

@ -79,15 +79,20 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
}
}
// 3. Obtain Computer (passes connector for OPENAI_PROXY_* env injection).
// 3. Obtain Computer.
updateLoadingV2(ctx, loadingMsgID, "sandbox.starting")
computer, identifier, err := sandboxv2.GetComputer(ctx, cfg, manager, conn)
computer, identifier, err := sandboxv2.GetComputer(ctx, cfg, manager)
if err != nil {
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed")
return nil, nil, nil, "", fmt.Errorf("getComputer failed: %w", err)
}
_ = identifier
// 3.5. Inject connector configs into a2o proxy via HTTP API.
if regErr := sandboxv2.RegisterProxyConfigs(stdCtx, computer); regErr != nil {
log.Printf("[sandbox/v2] RegisterProxyConfigs: %v (non-fatal)", regErr)
}
// 4. Get Runner.
runner, err := sandboxv2.Get(cfg.Runner.Name)
if err != nil {
@ -151,18 +156,23 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
return runner, computer, cleanup, loadingMsgID, nil
}
// sandboxV2StreamParams groups arguments for executeSandboxV2Stream.
type sandboxV2StreamParams struct {
Messages []context.Message
AgentNode traceTypes.Node
Handler message.StreamFunc
Runner sandboxTypes.Runner
Computer infraV2.Computer
LoadingMsgID string
Options *context.Options
}
// executeSandboxV2Stream calls the V2 Runner.Stream and wraps it in the
// standard completion response.
func (ast *Assistant) executeSandboxV2Stream(
ctx *context.Context,
completionMessages []context.Message,
agentNode traceTypes.Node,
streamHandler message.StreamFunc,
runner sandboxTypes.Runner,
computer infraV2.Computer,
loadingMsgID string,
ctx *context.Context, p *sandboxV2StreamParams,
) (*context.CompletionResponse, error) {
_ = agentNode
_ = p.AgentNode
cfg := ast.SandboxV2
manager := infraV2.M()
@ -170,16 +180,16 @@ func (ast *Assistant) executeSandboxV2Stream(
// Build system prompt.
var systemPrompt string
if len(ast.Prompts) > 0 {
for _, p := range ast.Prompts {
if p.Role == "system" && p.Content != "" {
systemPrompt = p.Content
for _, pr := range ast.Prompts {
if pr.Role == "system" && pr.Content != "" {
systemPrompt = pr.Content
break
}
}
}
// Resolve connector for Stream.
conn, _, _ := ast.GetConnector(ctx)
// Resolve connector for Stream (respects user-selected connector via opts).
conn, _, _ := ast.GetConnector(ctx, p.Options)
var tok *sandboxTypes.SandboxToken
if ctx.Authorized != nil {
@ -191,10 +201,10 @@ func (ast *Assistant) executeSandboxV2Stream(
}
streamReq := &sandboxTypes.StreamRequest{
Computer: computer,
Computer: p.Computer,
Config: cfg,
Connector: conn,
Messages: completionMessages,
Messages: p.Messages,
SystemPrompt: systemPrompt,
ChatID: ctx.ChatID,
Token: tok,
@ -202,15 +212,15 @@ func (ast *Assistant) executeSandboxV2Stream(
}
execReq := &sandboxv2.ExecuteRequest{
Computer: computer,
Runner: runner,
Computer: p.Computer,
Runner: p.Runner,
Config: cfg,
StreamReq: streamReq,
Manager: manager,
LoadingMsgID: loadingMsgID,
LoadingMsgID: p.LoadingMsgID,
}
return sandboxv2.ExecuteSandboxStream(ctx, execReq, streamHandler)
return sandboxv2.ExecuteSandboxStream(ctx, execReq, p.Handler)
}
// initStandaloneWorkspace loads the workspace FS into context when no sandbox

View file

@ -12,7 +12,7 @@ import (
infra "github.com/yaoapp/yao/sandbox/v2"
)
const defaultProxyPort = 3456
const defaultA2OPort = 3099
type command struct {
shell []string
@ -89,19 +89,19 @@ func buildEnv(req *types.StreamRequest, p platform) map[string]string {
if req.Connector.Is(connector.ANTHROPIC) {
env["ANTHROPIC_BASE_URL"] = host
env["ANTHROPIC_API_KEY"] = key
if model != "" {
env["ANTHROPIC_MODEL"] = model
env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = model
env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = model
env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = model
env["CLAUDE_CODE_SUBAGENT_MODEL"] = model
}
} else {
env["ANTHROPIC_BASE_URL"] = fmt.Sprintf("http://127.0.0.1:%d", defaultProxyPort)
connectorID := req.Connector.ID()
env["ANTHROPIC_BASE_URL"] = fmt.Sprintf("http://127.0.0.1:%d/c/%s", defaultA2OPort, connectorID)
env["ANTHROPIC_API_KEY"] = "dummy"
}
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 thinking, ok := setting["thinking"].(map[string]interface{}); ok {
thinkType, _ := thinking["type"].(string)
switch thinkType {

View file

@ -1,12 +1,16 @@
package sandboxv2
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
mathrand "math/rand"
"net/http"
"strings"
"time"
"github.com/yaoapp/gou/connector"
"github.com/yaoapp/kun/log"
@ -96,9 +100,10 @@ func ResolveNodeID(ctx *agentContext.Context, cfg *types.SandboxConfig, manager
}
// GetComputer obtains or creates a Computer for the current request.
// An optional connector may be passed to inject OPENAI_PROXY_* env vars.
// Connector config injection is handled via RegisterProxyConfigs after
// the Computer is obtained.
// Returns the Computer, the resolved identifier, and any error.
func GetComputer(ctx *agentContext.Context, cfg *types.SandboxConfig, manager *infra.Manager, conn ...connector.Connector) (infra.Computer, string, error) {
func GetComputer(ctx *agentContext.Context, cfg *types.SandboxConfig, manager *infra.Manager) (infra.Computer, string, error) {
ownerID := resolveOwnerID(ctx)
workspaceID := ""
@ -139,11 +144,11 @@ func GetComputer(ctx *agentContext.Context, cfg *types.SandboxConfig, manager *i
if computerID != "" {
log.Trace("[sandbox/v2] GetComputer: -> resolveComputerByID(%s)", computerID)
return resolveComputerByID(cfg, manager, computerID, ownerID, identifier, workspaceID, conn...)
return resolveComputerByID(cfg, manager, computerID, ownerID, identifier, workspaceID)
}
log.Trace("[sandbox/v2] GetComputer: -> resolveComputerByDSL (no computerID)")
return resolveComputerByDSL(cfg, manager, ownerID, identifier, workspaceID, conn...)
return resolveComputerByDSL(cfg, manager, ownerID, identifier, workspaceID)
}
// resolveComputerByID dispatches based on the runtime computer_id from metadata.
@ -151,7 +156,6 @@ func GetComputer(ctx *agentContext.Context, cfg *types.SandboxConfig, manager *i
func resolveComputerByID(
cfg *types.SandboxConfig, manager *infra.Manager,
computerID, ownerID, identifier, workspaceID string,
conn ...connector.Connector,
) (infra.Computer, string, error) {
// 1) Check if computer_id is a known Tai node (host or node kind).
@ -188,7 +192,7 @@ func resolveComputerByID(
// Node with container runtime and DSL has image: create/reuse a box.
cfg.Kind = "box"
return resolveBox(cfg, manager, ownerID, identifier, workspaceID, conn...)
return resolveBox(cfg, manager, ownerID, identifier, workspaceID)
}
// 2) Check if computer_id is an existing box ID.
@ -208,7 +212,6 @@ func resolveComputerByID(
func resolveComputerByDSL(
cfg *types.SandboxConfig, manager *infra.Manager,
ownerID, identifier, workspaceID string,
conn ...connector.Connector,
) (infra.Computer, string, error) {
log.Trace("[sandbox/v2] resolveComputerByDSL: cfgNodeID=%q image=%q", cfg.NodeID, cfg.Computer.Image)
@ -223,14 +226,13 @@ func resolveComputerByDSL(
}
log.Trace("[sandbox/v2] resolveComputerByDSL: -> resolveComputerByID(%s)", cfg.NodeID)
return resolveComputerByID(cfg, manager, cfg.NodeID, ownerID, identifier, workspaceID, conn...)
return resolveComputerByID(cfg, manager, cfg.NodeID, ownerID, identifier, workspaceID)
}
// resolveBox reuses or creates a box container.
func resolveBox(
cfg *types.SandboxConfig, manager *infra.Manager,
ownerID, identifier, workspaceID string,
conn ...connector.Connector,
) (infra.Computer, string, error) {
if workspaceID == "" && cfg.NodeID != "" {
@ -261,11 +263,7 @@ func resolveBox(
}
// Create new box.
var c connector.Connector
if len(conn) > 0 {
c = conn[0]
}
createOpts, err := BuildCreateOptions(cfg, identifier, ownerID, workspaceID, c)
createOpts, err := BuildCreateOptions(cfg, identifier, ownerID, workspaceID)
if err != nil {
return nil, identifier, fmt.Errorf("build create options: %w", err)
}
@ -390,6 +388,128 @@ func pickNodeByFilter(filter *types.ComputerFilter, image string) (string, error
return candidates[mathrand.Intn(len(candidates))], nil
}
const defaultA2OPort = 3099
// a2oConnectorConfig mirrors the tai/a2o ConnectorConfig struct for JSON serialization.
type a2oConnectorConfig struct {
Backend string `json:"backend"`
Model string `json:"model"`
APIKey string `json:"api_key"`
Options map[string]interface{} `json:"options,omitempty"`
}
// RegisterProxyConfigs injects all OpenAI-compatible connector configs into
// the a2o proxy running inside the container via POST /config.
// For host mode it uses Go's net/http directly; for box mode it uses computer.Exec.
func RegisterProxyConfigs(ctx context.Context, computer infra.Computer) error {
configs := buildA2OConfigs()
if len(configs) == 0 {
log.Trace("[sandbox/v2] RegisterProxyConfigs: no OpenAI connectors to register")
return nil
}
data, err := json.Marshal(configs)
if err != nil {
return fmt.Errorf("marshal connector configs: %w", err)
}
info := computer.ComputerInfo()
a2oURL := fmt.Sprintf("http://127.0.0.1:%d", defaultA2OPort)
if info.Kind == "host" {
return registerProxyConfigsHTTP(a2oURL, data)
}
return registerProxyConfigsExec(ctx, computer, data)
}
func buildA2OConfigs() map[string]*a2oConnectorConfig {
configs := make(map[string]*a2oConnectorConfig)
for id, conn := range connector.Connectors {
if !conn.Is(connector.OPENAI) {
continue
}
settings := conn.Setting()
if settings == nil {
continue
}
cfg := &a2oConnectorConfig{}
if host, ok := settings["host"].(string); ok && host != "" {
cfg.Backend = connector.BuildAPIURL(host, "/chat/completions")
}
if model, ok := settings["model"].(string); ok && model != "" {
cfg.Model = model
}
if key, ok := settings["key"].(string); ok && key != "" {
cfg.APIKey = key
}
extra := make(map[string]interface{})
for k, v := range settings {
switch k {
case "host", "model", "key", "proxy", "type":
continue
default:
extra[k] = v
}
}
if len(extra) > 0 {
cfg.Options = extra
}
if cfg.Backend != "" {
configs[id] = cfg
}
}
return configs
}
func registerProxyConfigsHTTP(a2oURL string, data []byte) error {
client := &http.Client{Timeout: 10 * time.Second}
configURL := a2oURL + "/config"
for attempt := 0; attempt < 10; attempt++ {
resp, err := client.Get(a2oURL + "/health")
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
break
}
}
time.Sleep(500 * time.Millisecond)
}
resp, err := client.Post(configURL, "application/json", bytes.NewReader(data))
if err != nil {
return fmt.Errorf("POST %s: %w", configURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("POST %s: status %d", configURL, resp.StatusCode)
}
log.Trace("[sandbox/v2] RegisterProxyConfigs(host): injected %d bytes", len(data))
return nil
}
func registerProxyConfigsExec(ctx context.Context, computer infra.Computer, data []byte) error {
escaped := strings.ReplaceAll(string(data), "'", "'\\''")
healthCmd := fmt.Sprintf("for i in $(seq 1 20); do wget -qO- http://127.0.0.1:%d/health >/dev/null 2>&1 && break; sleep 0.5; done", defaultA2OPort)
postCmd := fmt.Sprintf("echo '%s' | wget -qO- --post-data=@- --header='Content-Type: application/json' http://127.0.0.1:%d/config", escaped, defaultA2OPort)
script := healthCmd + " && " + postCmd
result, err := computer.Exec(ctx, []string{"sh", "-c", script})
if err != nil {
return fmt.Errorf("exec register proxy configs: %w", err)
}
if result.ExitCode != 0 {
return fmt.Errorf("register proxy configs: exit %d, stderr=%s", result.ExitCode, result.Stderr)
}
log.Trace("[sandbox/v2] RegisterProxyConfigs(box): injected %d bytes, stdout=%s", len(data), result.Stdout)
return nil
}
func randomID() string {
b := make([]byte, 8)
_, _ = rand.Read(b)

View file

@ -1,13 +1,11 @@
package sandboxv2
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/yaoapp/gou/connector"
"github.com/yaoapp/yao/agent/sandbox/v2/types"
infra "github.com/yaoapp/yao/sandbox/v2"
)
@ -21,9 +19,9 @@ func resolveEnvRef(value string) string {
}
// BuildCreateOptions converts a SandboxConfig into the V2 infrastructure
// CreateOptions. An optional connector is used to inject OPENAI_PROXY_*
// environment variables when the connector is OpenAI-compatible (non-Anthropic).
func BuildCreateOptions(cfg *types.SandboxConfig, identifier, ownerID, workspaceID string, conn ...connector.Connector) (infra.CreateOptions, error) {
// CreateOptions. Connector config injection is handled separately via the
// a2o HTTP API (POST /config) after the container starts.
func BuildCreateOptions(cfg *types.SandboxConfig, identifier, ownerID, workspaceID string) (infra.CreateOptions, error) {
opts := infra.CreateOptions{
ID: identifier,
Owner: ownerID,
@ -133,12 +131,6 @@ func BuildCreateOptions(cfg *types.SandboxConfig, identifier, ownerID, workspace
opts.Env = make(map[string]string)
}
// Inject OPENAI_PROXY_* when connector is OpenAI-compatible (non-Anthropic).
// The a2o proxy inside the container translates Anthropic API → OpenAI API.
if len(conn) > 0 && conn[0] != nil && !conn[0].Is(connector.ANTHROPIC) {
injectProxyEnv(opts.Env, conn[0])
}
// Inject VNC_* environment variables from config.
if cfg.Computer.VNC.Enabled {
opts.Env["VNC_ENABLED"] = "true"
@ -156,42 +148,6 @@ func BuildCreateOptions(cfg *types.SandboxConfig, identifier, ownerID, workspace
return opts, nil
}
// injectProxyEnv extracts backend URL, model, and API key from an
// OpenAI-compatible connector's settings and writes them as OPENAI_PROXY_*
// environment variables into env.
func injectProxyEnv(env map[string]string, conn connector.Connector) {
settings := conn.Setting()
if settings == nil {
return
}
if host, ok := settings["host"].(string); ok && host != "" {
env["OPENAI_PROXY_BACKEND"] = host
}
if model, ok := settings["model"].(string); ok && model != "" {
env["OPENAI_PROXY_MODEL"] = model
}
if key, ok := settings["key"].(string); ok && key != "" {
env["OPENAI_PROXY_API_KEY"] = key
}
// Forward extra options as JSON.
extra := make(map[string]interface{})
for k, v := range settings {
switch k {
case "host", "model", "key", "proxy", "type":
continue
default:
extra[k] = v
}
}
if len(extra) > 0 {
if data, err := json.Marshal(extra); err == nil {
env["OPENAI_PROXY_OPTIONS"] = string(data)
}
}
}
// parseMemory converts a human-readable memory string to bytes.
// Supported formats: "4GB", "4G", "4g", "512MB", "512M", "512m", "1024KB", "1024K", "1024".
func parseMemory(s string) (int64, error) {