- Updated the system configuration to include new role-level defaults for Light, Vision, and Audio connectors. - Refactored the resolveSystemConnector function to prioritize per-agent overrides, improving connector resolution logic. - Enhanced LLMConnector integration across various components to streamline settings retrieval and capabilities management. - Improved error handling and logging for connector-related operations, ensuring better diagnostics and user feedback.
355 lines
11 KiB
Go
355 lines
11 KiB
Go
package claude
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/yaoapp/gou/connector"
|
|
goullm "github.com/yaoapp/gou/llm"
|
|
"github.com/yaoapp/kun/log"
|
|
agentContext "github.com/yaoapp/yao/agent/context"
|
|
"github.com/yaoapp/yao/agent/output/message"
|
|
"github.com/yaoapp/yao/agent/sandbox/v2/types"
|
|
infra "github.com/yaoapp/yao/sandbox/v2"
|
|
)
|
|
|
|
// Runner implements the sandbox Runner interface for Claude CLI (mode=cli).
|
|
type Runner struct {
|
|
mode string
|
|
hasMCP bool
|
|
mcpToolPattern string
|
|
lastCompleted bool
|
|
lastChatID string
|
|
logger *agentContext.RequestLogger
|
|
}
|
|
|
|
// New creates a new Runner.
|
|
func New() *Runner {
|
|
return &Runner{mode: "cli"}
|
|
}
|
|
|
|
// Name returns the runner identifier.
|
|
func (r *Runner) Name() string { return "claude" }
|
|
|
|
// Prepare executes user-defined and runner-specific prepare steps.
|
|
func (r *Runner) Prepare(ctx context.Context, req *types.PrepareRequest) error {
|
|
r.mode = req.Config.Runner.Mode
|
|
if r.mode == "" {
|
|
r.mode = "cli"
|
|
}
|
|
|
|
assistantID := req.AssistantID
|
|
prefix := ".yao/assistants/" + assistantID
|
|
if assistantID == "" {
|
|
prefix = ".claude"
|
|
}
|
|
|
|
steps := append([]types.PrepareStep{}, req.Config.Prepare...)
|
|
|
|
if req.SkillsDir != "" {
|
|
ws := req.Computer.Workplace()
|
|
if ws != nil {
|
|
src := "local:///" + req.SkillsDir
|
|
dst := prefix + "/skills"
|
|
if _, err := ws.Copy(src, dst); err != nil {
|
|
r.logger.Warn("copy skills %s -> %s: %v", src, dst, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(req.MCPServers) > 0 {
|
|
r.hasMCP = true
|
|
r.mcpToolPattern = buildMCPAllowedTools(req.MCPServers)
|
|
mcpJSON := buildMCPConfig(req.MCPServers)
|
|
steps = append(steps, types.PrepareStep{
|
|
Action: "file",
|
|
Path: prefix + "/mcp.json",
|
|
Content: mcpJSON,
|
|
})
|
|
}
|
|
|
|
if req.RunSteps != nil && len(steps) > 0 {
|
|
if err := req.RunSteps(ctx, steps, req.Computer, req.AssistantID, req.ConfigHash, req.AssistantDir); err != nil {
|
|
return fmt.Errorf("claude prepare steps: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Stream executes the Claude CLI and streams output to handler.
|
|
func (r *Runner) Stream(ctx context.Context, req *types.StreamRequest, handler message.StreamFunc) error {
|
|
computer := req.Computer
|
|
if computer == nil {
|
|
return fmt.Errorf("computer is nil")
|
|
}
|
|
|
|
p := resolvePlatform(computer)
|
|
|
|
// Inject connector config into a2o proxy (best-effort, errors ignored).
|
|
if req.Connector != nil && req.Connector.Is(connector.OPENAI) {
|
|
roleConnectors := resolveAllRoleConnectors(req)
|
|
if len(roleConnectors) > 0 {
|
|
injectA2OConfigWithRoutes(ctx, computer, req.Connector, roleConnectors)
|
|
} else {
|
|
injectA2OConfig(ctx, computer, req.Connector)
|
|
}
|
|
}
|
|
|
|
if req.ChatID != "" {
|
|
if ws := computer.Workplace(); ws != nil {
|
|
processed, err := prepareAttachments(ctx, req.Messages, req.ChatID, ws)
|
|
if err != nil {
|
|
return fmt.Errorf("prepareAttachments: %w", err)
|
|
}
|
|
req.Messages = processed
|
|
}
|
|
}
|
|
|
|
cmd := r.buildCommand(ctx, req, p)
|
|
|
|
r.logger = req.Logger
|
|
if r.logger == nil {
|
|
r.logger = agentContext.NoopLogger()
|
|
}
|
|
|
|
chatID := req.ChatID
|
|
r.lastChatID = chatID
|
|
assistantID := req.AssistantID
|
|
|
|
log.Trace("[claude-runner] Stream started: assistantID=%s chatID=%s promptLen=%d", assistantID, chatID, len(cmd.shell))
|
|
r.logger.Debug("env vars passed to session (%d total):", len(cmd.env))
|
|
for k, v := range cmd.env {
|
|
if strings.HasPrefix(k, "CTX_") || k == "CLAUDE_CONFIG_DIR" || k == "HOME" || k == "WORKDIR" {
|
|
r.logger.Debug(" %s=%s", k, v)
|
|
} else {
|
|
r.logger.Debug(" %s=(set, len=%d)", k, len(v))
|
|
}
|
|
}
|
|
|
|
sess, err := startSession(ctx, computer, p, cmd, chatID, r.logger)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Mark session in store immediately after CLI process starts.
|
|
// Claude CLI creates session files on disk at startup, so subsequent
|
|
// requests must use --resume (not --session-id) even if this stream fails.
|
|
if chatID != "" {
|
|
storeKey := "claude-session:" + assistantID + ":" + chatID
|
|
sessionUUID := chatIDToSessionUUID(assistantID, chatID)
|
|
markChatSession(storeKey, sessionUUID, 90*24*time.Hour)
|
|
}
|
|
|
|
streamStart := time.Now()
|
|
completed, err := sess.runStream(handler)
|
|
r.lastCompleted = completed
|
|
elapsed := time.Since(streamStart).Round(time.Second)
|
|
log.Trace("[claude-runner] Stream finished: assistantID=%s chatID=%s completed=%v elapsed=%v err=%v", assistantID, chatID, completed, elapsed, err)
|
|
r.logger.Debug("Stream: runStream returned completed=%v err=%v elapsed=%v", completed, err, elapsed)
|
|
if completed {
|
|
sess.shutdown()
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Cleanup kills any remaining claude processes. If the stream completed
|
|
// normally (received "result"), child processes are preserved.
|
|
func (r *Runner) Cleanup(ctx context.Context, computer infra.Computer) error {
|
|
if computer == nil {
|
|
return nil
|
|
}
|
|
|
|
log.Trace("[claude-runner] Cleanup: chatID=%s lastCompleted=%v", r.lastChatID, r.lastCompleted)
|
|
|
|
if r.lastCompleted {
|
|
if r.logger != nil {
|
|
r.logger.Info("cleanup: stream completed normally, preserving child processes")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if r.mode != "service" {
|
|
p := resolvePlatform(computer)
|
|
if r.lastChatID != "" {
|
|
computer.Exec(ctx, p.KillSessionCmd(sanitizeSessionName(r.lastChatID)))
|
|
} else {
|
|
computer.Exec(ctx, p.KillCmd("claude"))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type a2oConnectorConfig struct {
|
|
Backend string `json:"backend"`
|
|
Model string `json:"model"`
|
|
APIKey string `json:"api_key"`
|
|
AuthMode string `json:"auth_mode,omitempty"`
|
|
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
|
|
Options map[string]interface{} `json:"options,omitempty"`
|
|
Routes map[string]*a2oConnectorConfig `json:"routes,omitempty"`
|
|
}
|
|
|
|
func buildSingleA2OConfig(conn connector.Connector) *a2oConnectorConfig {
|
|
settings := conn.Setting()
|
|
if settings == nil {
|
|
return nil
|
|
}
|
|
|
|
cfg := &a2oConnectorConfig{}
|
|
|
|
// Extract standard fields via LLMConnector methods when available
|
|
if lc, ok := conn.(goullm.LLMConnector); ok {
|
|
if url := lc.GetURL(); url != "" {
|
|
cfg.Backend = connector.BuildAPIURL(url, "/chat/completions")
|
|
}
|
|
cfg.Model = lc.GetModel()
|
|
cfg.APIKey = lc.GetKey()
|
|
cfg.AuthMode = string(lc.GetAuthMode())
|
|
if caps := lc.GetCapabilities(); caps != nil && caps.MaxOutputTokens > 0 {
|
|
cfg.MaxOutputTokens = caps.MaxOutputTokens
|
|
}
|
|
} else {
|
|
if host, ok := settings["host"].(string); ok && host != "" {
|
|
cfg.Backend = connector.BuildAPIURL(host, "/chat/completions")
|
|
} else if proxy, ok := settings["proxy"].(string); ok && proxy != "" {
|
|
cfg.Backend = connector.BuildAPIURL(proxy, "/chat/completions")
|
|
}
|
|
if model, ok := settings["model"].(string); ok && model != "" {
|
|
cfg.Model = model
|
|
}
|
|
if key, ok := settings["key"].(string); ok && key != "" {
|
|
cfg.APIKey = key
|
|
}
|
|
}
|
|
|
|
// Whitelist-filter remaining settings for the options field
|
|
extra := connector.FilterRequestBodyParams(settings, conn)
|
|
if len(extra) > 0 {
|
|
cfg.Options = extra
|
|
}
|
|
|
|
if cfg.Backend == "" {
|
|
return nil
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
// resolveAllRoleConnectors resolves all declared role connectors and returns
|
|
// a map of virtual model name -> connector for roles that have independent connectors.
|
|
func resolveAllRoleConnectors(req *types.StreamRequest) map[string]connector.Connector {
|
|
roleConns := getRoleConnectors(req)
|
|
if len(roleConns) == 0 {
|
|
return nil
|
|
}
|
|
|
|
result := make(map[string]connector.Connector)
|
|
for role, rm := range claudeRoleEnvMap {
|
|
if role == "primary" {
|
|
continue
|
|
}
|
|
rc := resolveRoleConnector(role, roleConns, req.UserExplicit, func(id string) connector.Connector {
|
|
c, _ := connector.Connectors[id]
|
|
return c
|
|
})
|
|
if rc == nil {
|
|
continue
|
|
}
|
|
result[rm.ModelName] = rc
|
|
}
|
|
return result
|
|
}
|
|
|
|
// injectA2OConfigWithRoutes pushes the primary connector config along with
|
|
// role-based routes to the a2o proxy. Each route maps a virtual model name
|
|
// to a different backend connector.
|
|
func injectA2OConfigWithRoutes(ctx context.Context, computer infra.Computer, primaryConn connector.Connector, roleConnectors map[string]connector.Connector) {
|
|
primaryCfg := buildSingleA2OConfig(primaryConn)
|
|
if primaryCfg == nil {
|
|
log.Trace("[claude] injectA2OConfigWithRoutes: no valid primary config for connector %s", primaryConn.ID())
|
|
return
|
|
}
|
|
|
|
routes := make(map[string]*a2oConnectorConfig, len(roleConnectors))
|
|
for modelName, rc := range roleConnectors {
|
|
routeCfg := buildSingleA2OConfig(rc)
|
|
if routeCfg != nil {
|
|
routes[modelName] = routeCfg
|
|
}
|
|
}
|
|
primaryCfg.Routes = routes
|
|
|
|
data, err := json.Marshal(primaryCfg)
|
|
if err != nil {
|
|
log.Trace("[claude] injectA2OConfigWithRoutes: marshal error: %v", err)
|
|
return
|
|
}
|
|
|
|
connID := primaryConn.ID()
|
|
var result *infra.ExecResult
|
|
|
|
info := computer.ComputerInfo()
|
|
if info.Kind == "host" {
|
|
result, err = computer.Exec(ctx, []string{"tai", "a2o", "config", "put", connID}, infra.WithStdin(data))
|
|
} else {
|
|
escaped := strings.ReplaceAll(string(data), "'", "'\\''")
|
|
script := fmt.Sprintf("echo '%s' | tai a2o config put %s", escaped, connID)
|
|
result, err = computer.Exec(ctx, []string{"sh", "-c", script})
|
|
}
|
|
|
|
if err != nil {
|
|
log.Trace("[claude] injectA2OConfigWithRoutes: exec error (ignored): %v", err)
|
|
return
|
|
}
|
|
if result.ExitCode != 0 {
|
|
log.Trace("[claude] injectA2OConfigWithRoutes: exit %d stderr=%s (ignored)", result.ExitCode, result.Stderr)
|
|
return
|
|
}
|
|
|
|
log.Trace("[claude] injectA2OConfigWithRoutes: connector=%s injected with %d routes", connID, len(routes))
|
|
}
|
|
|
|
// injectA2OConfig pushes the connector config to the a2o proxy.
|
|
// For box (Linux container): uses sh pipe since Docker exec stdin may not work.
|
|
// For host: uses WithStdin which works reliably on all platforms.
|
|
// Best-effort: errors are logged and ignored.
|
|
func injectA2OConfig(ctx context.Context, computer infra.Computer, conn connector.Connector) {
|
|
cfg := buildSingleA2OConfig(conn)
|
|
if cfg == nil {
|
|
log.Trace("[claude] injectA2OConfig: no valid config for connector %s", conn.ID())
|
|
return
|
|
}
|
|
|
|
data, err := json.Marshal(cfg)
|
|
if err != nil {
|
|
log.Trace("[claude] injectA2OConfig: marshal error: %v", err)
|
|
return
|
|
}
|
|
|
|
connID := conn.ID()
|
|
var result *infra.ExecResult
|
|
|
|
info := computer.ComputerInfo()
|
|
if info.Kind == "host" {
|
|
result, err = computer.Exec(ctx, []string{"tai", "a2o", "config", "put", connID}, infra.WithStdin(data))
|
|
} else {
|
|
escaped := strings.ReplaceAll(string(data), "'", "'\\''")
|
|
script := fmt.Sprintf("echo '%s' | tai a2o config put %s", escaped, connID)
|
|
result, err = computer.Exec(ctx, []string{"sh", "-c", script})
|
|
}
|
|
|
|
if err != nil {
|
|
log.Trace("[claude] injectA2OConfig: exec error (ignored): %v", err)
|
|
return
|
|
}
|
|
if result.ExitCode != 0 {
|
|
log.Trace("[claude] injectA2OConfig: exit %d stderr=%s (ignored)", result.ExitCode, result.Stderr)
|
|
return
|
|
}
|
|
|
|
log.Trace("[claude] injectA2OConfig: connector=%s injected ok", connID)
|
|
}
|