feat(claude): refactor runner and enhance request handling

- Renamed ClaudeRunner to Runner for consistency across the codebase, aligning with the sandbox Runner interface.
- Updated buildCommand and Stream methods to utilize AssistantID directly from StreamRequest, improving clarity and reducing dependency on Config.
- Modified tests to reflect the changes in runner instantiation and argument handling, ensuring compatibility with the new Runner structure.
- Enhanced environment variable setup in buildEnv to include WORKDIR, streamlining the execution context for the runner.
This commit is contained in:
Max 2026-03-27 23:07:11 +08:00
parent 6e003875b4
commit e90808e861
7 changed files with 107 additions and 69 deletions

View file

@ -13,6 +13,7 @@ import (
"github.com/yaoapp/yao/agent/output/message"
sandboxv2 "github.com/yaoapp/yao/agent/sandbox/v2"
sandboxTypes "github.com/yaoapp/yao/agent/sandbox/v2/types"
store "github.com/yaoapp/yao/agent/store/types"
"github.com/yaoapp/yao/config"
infraV2 "github.com/yaoapp/yao/sandbox/v2"
traceTypes "github.com/yaoapp/yao/trace/types"
@ -124,6 +125,7 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
Computer: computer,
Config: cfg,
Connector: conn,
AssistantID: ast.ID,
SkillsDir: skillsDir,
AssistantDir: assistantDir,
MCPServers: mcpServers,
@ -172,10 +174,12 @@ func (ast *Assistant) executeSandboxV2Stream(
cfg := ast.SandboxV2
manager := infraV2.M()
// Build system prompt.
// Build system prompt (parse $CTX variables the same way as buildSystemPrompts).
var systemPrompt string
if len(ast.Prompts) > 0 {
for _, pr := range ast.Prompts {
ctxVars := ast.buildContextVariables(ctx)
parsed := store.Prompts(ast.Prompts).Parse(ctxVars)
for _, pr := range parsed {
if pr.Role == "system" && pr.Content != "" {
systemPrompt = pr.Content
break
@ -199,6 +203,7 @@ func (ast *Assistant) executeSandboxV2Stream(
Computer: p.Computer,
Config: cfg,
Connector: conn,
AssistantID: ast.ID,
Messages: p.Messages,
SystemPrompt: systemPrompt,
ChatID: ctx.ChatID,

View file

@ -53,13 +53,10 @@ type command struct {
workDir string
}
func (r *ClaudeRunner) buildCommand(ctx context.Context, req *types.StreamRequest, p platform) command {
func (r *Runner) buildCommand(ctx context.Context, req *types.StreamRequest, p platform) command {
computer := req.Computer
workDir := computer.GetWorkDir()
assistantID := ""
if req.Config != nil {
assistantID = req.Config.ID
}
assistantID := req.AssistantID
chatID := req.ChatID
var isContinuation bool
@ -110,14 +107,17 @@ func buildEnv(req *types.StreamRequest, p platform) map[string]string {
for k, v := range p.HomeEnv(workDir) {
env[k] = v
}
env["WORKDIR"] = workDir
assistantID := ""
if req.Config != nil {
assistantID = req.Config.ID
}
assistantID := req.AssistantID
if assistantID != "" {
configDir := p.PathJoin(workDir, ".yao", "assistants", assistantID)
env["CLAUDE_CONFIG_DIR"] = configDir
env["CTX_ASSISTANT_ID"] = assistantID
// CTX_SKILLS_DIR: absolute path to the skills directory inside the sandbox.
// Use this in skill scripts instead of constructing the path manually,
// so it works correctly on all platforms (Linux, macOS, Windows).
env["CTX_SKILLS_DIR"] = p.PathJoin(workDir, ".yao", "assistants", assistantID, "skills")
}
if req.Connector != nil {
@ -130,11 +130,11 @@ func buildEnv(req *types.StreamRequest, p platform) map[string]string {
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
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"
}
} else {
connectorID := req.Connector.ID()
@ -181,7 +181,7 @@ func buildEnv(req *types.StreamRequest, p platform) map[string]string {
return env
}
func buildArgs(req *types.StreamRequest, r *ClaudeRunner, p platform, isContinuation bool, assistantID, chatID string) []string {
func buildArgs(req *types.StreamRequest, r *Runner, p platform, isContinuation bool, assistantID, chatID string) []string {
var args []string
permMode := ""
@ -248,12 +248,18 @@ func buildSandboxEnvPrompt(p platform, workDir string) string {
shellNote := p.EnvPromptNote()
envVarSyntax := "$VAR_NAME"
if osName == "windows" {
envVarSyntax = "$env:VAR_NAME"
}
return fmt.Sprintf(`## Sandbox Environment
- **Operating System**: %[2]s
- **Shell**: %[3]s
- **Working Directory**: %[1]s
- **File Access**: You have full read/write access to %[1]s%[4]s
- **File Access**: You have full read/write access to %[1]s
- **Environment variable syntax**: `+"`%[5]s`"+` (e.g. `+"`$CTX_SKILLS_DIR`"+` on POSIX, `+"`$env:CTX_SKILLS_DIR`"+` on Windows)%[4]s
## User Attachments
@ -261,7 +267,7 @@ User-uploaded files (images, documents, code files, etc.) are placed in %[1]s/.a
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)
`, workDir, osName, shell, shellNote, envVarSyntax)
}
func hasExistingSession(ctx context.Context, computer infra.Computer, p platform, assistantID string) bool {

View file

@ -34,6 +34,7 @@ func TestBuildEnv_HomeEnv(t *testing.T) {
func TestBuildEnv_ConfigDirIsolation(t *testing.T) {
req := &types.StreamRequest{
Config: &types.SandboxConfig{ID: "my-assistant"},
AssistantID: "my-assistant",
}
req.Computer = newFakeComputer("/workspace")
p := testPlatform()
@ -89,7 +90,7 @@ func TestBuildEnv_Secrets(t *testing.T) {
func TestBuildArgs_Default(t *testing.T) {
req := &types.StreamRequest{Config: &types.SandboxConfig{}}
req.Computer = newFakeComputer("/workspace")
r := &ClaudeRunner{}
r := &Runner{}
p := testPlatform()
args := buildArgs(req, r, p, false, "", "")
@ -103,7 +104,7 @@ func TestBuildArgs_Default(t *testing.T) {
func TestBuildArgs_Continuation(t *testing.T) {
req := &types.StreamRequest{Config: &types.SandboxConfig{}}
req.Computer = newFakeComputer("/workspace")
r := &ClaudeRunner{}
r := &Runner{}
p := testPlatform()
args := buildArgs(req, r, p, true, "", "")
@ -121,7 +122,7 @@ func TestBuildArgs_PermissionMode(t *testing.T) {
},
}
req.Computer = newFakeComputer("/workspace")
r := &ClaudeRunner{}
r := &Runner{}
p := testPlatform()
args := buildArgs(req, r, p, false, "", "")
@ -132,7 +133,7 @@ func TestBuildArgs_PermissionMode(t *testing.T) {
func TestBuildArgs_MCP(t *testing.T) {
req := &types.StreamRequest{Config: &types.SandboxConfig{}}
req.Computer = newFakeComputer("/workspace")
r := &ClaudeRunner{hasMCP: true, mcpToolPattern: "mcp__yao__*"}
r := &Runner{hasMCP: true, mcpToolPattern: "mcp__yao__*"}
p := testPlatform()
args := buildArgs(req, r, p, false, "test-assistant", "")
@ -163,7 +164,7 @@ func TestBuildArgs_WhitelistOptions(t *testing.T) {
},
}
req.Computer = newFakeComputer("/workspace")
r := &ClaudeRunner{}
r := &Runner{}
p := testPlatform()
args := buildArgs(req, r, p, false, "", "")
@ -378,7 +379,7 @@ func TestSanitizeSessionName_Empty(t *testing.T) {
func TestBuildArgs_SessionID_NewSession(t *testing.T) {
req := &types.StreamRequest{Config: &types.SandboxConfig{}}
req.Computer = newFakeComputer("/workspace")
r := &ClaudeRunner{}
r := &Runner{}
p := testPlatform()
args := buildArgs(req, r, p, false, "asst-1", "robot_m1_e1")
@ -402,7 +403,7 @@ func TestBuildArgs_SessionID_NewSession(t *testing.T) {
func TestBuildArgs_SessionID_Continuation(t *testing.T) {
req := &types.StreamRequest{Config: &types.SandboxConfig{}}
req.Computer = newFakeComputer("/workspace")
r := &ClaudeRunner{}
r := &Runner{}
p := testPlatform()
args := buildArgs(req, r, p, true, "asst-1", "robot_m1_e1")
@ -425,7 +426,7 @@ func TestBuildArgs_SessionID_Continuation(t *testing.T) {
func TestBuildArgs_EmptyChatID_Continuation(t *testing.T) {
req := &types.StreamRequest{Config: &types.SandboxConfig{}}
req.Computer = newFakeComputer("/workspace")
r := &ClaudeRunner{}
r := &Runner{}
p := testPlatform()
args := buildArgs(req, r, p, true, "", "")

View file

@ -172,6 +172,30 @@ func (p *streamParser) closeTextMessage() {
}
}
// closeCurrentTool closes the in-flight streaming tool message (if any),
// flushing its accumulated input and emitting message_end. This must be
// called before opening a new message group so that the downstream handler
// never sees interleaved message_start/message_end pairs.
func (p *streamParser) closeCurrentTool() {
if p.curTool == nil {
return
}
toolID := p.curTool.id
inputStr := p.curTool.inputJSON.String()
if inputStr != "" {
p.toolInputs[toolID] = inputStr
summary := extractSummary(p.curTool.name, inputStr)
if summary != "" {
p.toolSummaries[toolID] = summary
p.emitExecute(map[string]any{
"summary": summary,
})
}
}
p.endMessage()
p.curTool = nil
}
func (p *streamParser) ensureTextMessage() (stopped bool) {
if !p.textActive {
_, stopped = p.beginMessage("text")
@ -318,22 +342,7 @@ func (p *streamParser) onContentBlockStart(event map[string]any) (stopped bool)
}
func (p *streamParser) onContentBlockStop() (stopped bool) {
if p.curTool != nil {
toolID := p.curTool.id
inputStr := p.curTool.inputJSON.String()
if inputStr != "" {
p.toolInputs[toolID] = inputStr
summary := extractSummary(p.curTool.name, inputStr)
if summary != "" {
p.toolSummaries[toolID] = summary
p.emitExecute(map[string]any{
"summary": summary,
})
}
}
p.endMessage()
p.curTool = nil
}
p.closeCurrentTool()
return false
}
@ -417,6 +426,7 @@ func (p *streamParser) handleAssistant(msg map[string]any) (stopped bool) {
}
p.closeTextMessage()
p.closeCurrentTool()
toolName, _ := ci["name"].(string)
if toolID == "" {
@ -488,12 +498,17 @@ func (p *streamParser) handleUser(msg map[string]any) (stopped bool) {
continue
}
// Close any open text message before opening an execute message,
// otherwise textActive stays true while currentGroupID gets
// overwritten by the execute message lifecycle, causing subsequent
// text chunks to be emitted without a message_id.
// Close any open text message before opening an execute message.
p.closeTextMessage()
// When Claude CLI executes tools in parallel, tool_result messages
// can arrive while a new tool_use is still streaming. The downstream
// handler (stream.go) tracks only a single currentGroupID, so we
// must close the in-flight streaming tool message before opening
// the result message — otherwise the message_start/message_end
// pairs become interleaved and chunks lose their message_id.
p.closeCurrentTool()
toolUseID, _ := ci["tool_use_id"].(string)
content := ci["content"]
isError, _ := ci["is_error"].(bool)

View file

@ -15,8 +15,8 @@ import (
infra "github.com/yaoapp/yao/sandbox/v2"
)
// ClaudeRunner implements the Runner interface for Claude CLI (mode=cli).
type ClaudeRunner struct {
// Runner implements the sandbox Runner interface for Claude CLI (mode=cli).
type Runner struct {
mode string
hasMCP bool
mcpToolPattern string
@ -25,21 +25,22 @@ type ClaudeRunner struct {
logger *agentContext.RequestLogger
}
// New creates a new ClaudeRunner.
func New() *ClaudeRunner {
return &ClaudeRunner{mode: "cli"}
// New creates a new Runner.
func New() *Runner {
return &Runner{mode: "cli"}
}
func (r *ClaudeRunner) Name() string { return "claude" }
// Name returns the runner identifier.
func (r *Runner) Name() string { return "claude" }
// Prepare executes user-defined and runner-specific prepare steps.
func (r *ClaudeRunner) Prepare(ctx context.Context, req *types.PrepareRequest) error {
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.Config.ID
assistantID := req.AssistantID
prefix := ".yao/assistants/" + assistantID
if assistantID == "" {
prefix = ".claude"
@ -70,7 +71,7 @@ func (r *ClaudeRunner) Prepare(ctx context.Context, req *types.PrepareRequest) e
}
if req.RunSteps != nil && len(steps) > 0 {
if err := req.RunSteps(ctx, steps, req.Computer, req.Config.ID, req.ConfigHash, req.AssistantDir); err != nil {
if err := req.RunSteps(ctx, steps, req.Computer, req.AssistantID, req.ConfigHash, req.AssistantDir); err != nil {
return fmt.Errorf("claude prepare steps: %w", err)
}
}
@ -79,7 +80,7 @@ func (r *ClaudeRunner) Prepare(ctx context.Context, req *types.PrepareRequest) e
}
// Stream executes the Claude CLI and streams output to handler.
func (r *ClaudeRunner) Stream(ctx context.Context, req *types.StreamRequest, handler message.StreamFunc) error {
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")
@ -111,10 +112,7 @@ func (r *ClaudeRunner) Stream(ctx context.Context, req *types.StreamRequest, han
chatID := req.ChatID
r.lastChatID = chatID
assistantID := ""
if req.Config != nil {
assistantID = req.Config.ID
}
assistantID := req.AssistantID
log.Trace("[claude-runner] Stream started: assistantID=%s chatID=%s promptLen=%d", assistantID, chatID, len(cmd.shell))
@ -132,10 +130,6 @@ func (r *ClaudeRunner) Stream(ctx context.Context, req *types.StreamRequest, han
if completed {
sess.shutdown()
if chatID != "" {
assistantID := ""
if req.Config != nil {
assistantID = req.Config.ID
}
storeKey := "claude-session:" + assistantID + ":" + chatID
sessionUUID := chatIDToSessionUUID(assistantID, chatID)
markChatSession(storeKey, sessionUUID, 90*24*time.Hour)
@ -146,7 +140,7 @@ func (r *ClaudeRunner) Stream(ctx context.Context, req *types.StreamRequest, han
// Cleanup kills any remaining claude processes. If the stream completed
// normally (received "result"), child processes are preserved.
func (r *ClaudeRunner) Cleanup(ctx context.Context, computer infra.Computer) error {
func (r *Runner) Cleanup(ctx context.Context, computer infra.Computer) error {
if computer == nil {
return nil
}

View file

@ -93,6 +93,7 @@ func runFileStep(ws workspace.FS, step types.PrepareStep) error {
return fmt.Errorf("file step requires workspace")
}
step.Path = expandTilde(step.Path)
dir := path.Dir(step.Path)
if dir != "." && dir != "/" {
if err := ws.MkdirAll(dir, 0755); err != nil {
@ -126,8 +127,9 @@ func runCopyStep(ws workspace.FS, step types.PrepareStep, assistantDir string) e
src = "local:///" + pathpkg.Join(assistantDir, src)
}
if _, err := ws.Copy(src, step.Dst); err != nil {
return fmt.Errorf("copy %s -> %s: %w", src, step.Dst, err)
dst := expandTilde(step.Dst)
if _, err := ws.Copy(src, dst); err != nil {
return fmt.Errorf("copy %s -> %s: %w", src, dst, err)
}
return nil
}
@ -136,6 +138,19 @@ func isHostURI(s string) bool {
return strings.HasPrefix(s, "local:///") || strings.HasPrefix(s, "tmp:///")
}
// expandTilde replaces a leading "~/" with the empty string so the path
// becomes relative to the workspace root (which is HOME inside the sandbox).
// Paths without "~/" are returned unchanged.
func expandTilde(p string) string {
if strings.HasPrefix(p, "~/") {
return p[2:]
}
if p == "~" {
return "."
}
return p
}
func runExecStep(ctx context.Context, computer infra.Computer, step types.PrepareStep) error {
if step.Cmd == "" {
return fmt.Errorf("exec step requires cmd")

View file

@ -38,6 +38,7 @@ type PrepareRequest struct {
Computer infra.Computer
Config *SandboxConfig
Connector connector.Connector
AssistantID string // the assistant's own ID (e.g. "yao/postman")
SkillsDir string
AssistantDir string // absolute host path to the assistant source directory
MCPServers []MCPServer
@ -50,6 +51,7 @@ type StreamRequest struct {
Computer infra.Computer
Config *SandboxConfig
Connector connector.Connector
AssistantID string // the assistant's own ID (e.g. "yao/postman")
Messages []agentContext.Message
SystemPrompt string
ChatID string