Unified Agent + reusable Go agent core.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add ACP external agent support

Lyric bcd5bf0c 8b75a9b5

+3909 -26
+231
agent/acp_spawn_tool.go
··· 1 + package agent 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "strings" 8 + 9 + "github.com/quailyquaily/mistermorph/internal/acpclient" 10 + ) 11 + 12 + const acpSpawnToolName = "acp_spawn" 13 + 14 + type acpSpawnTool struct { 15 + deps acpSpawnToolDeps 16 + } 17 + 18 + func newACPSpawnTool(deps acpSpawnToolDeps) *acpSpawnTool { 19 + return &acpSpawnTool{deps: deps} 20 + } 21 + 22 + func (t *acpSpawnTool) Name() string { return acpSpawnToolName } 23 + 24 + func (t *acpSpawnTool) Description() string { 25 + return "Spawn an external ACP agent to handle a self-contained sub-task. " + 26 + "The ACP agent runs over stdio, receives a single prompt turn, and returns a structured subtask envelope." 27 + } 28 + 29 + func (t *acpSpawnTool) ParameterSchema() string { 30 + s := map[string]any{ 31 + "type": "object", 32 + "properties": map[string]any{ 33 + "agent": map[string]any{ 34 + "type": "string", 35 + "description": "ACP agent profile name from acp.agents.", 36 + }, 37 + "task": map[string]any{ 38 + "type": "string", 39 + "description": "Task prompt for the external ACP agent.", 40 + }, 41 + "cwd": map[string]any{ 42 + "type": "string", 43 + "description": "Optional working directory override for the ACP session.", 44 + }, 45 + "output_schema": map[string]any{ 46 + "type": "string", 47 + "description": "Optional schema identifier for the child task's structured output.", 48 + }, 49 + "observe_profile": map[string]any{ 50 + "type": "string", 51 + "description": "Optional local observer profile for this child task. Supported values: default, long_shell, web_extract.", 52 + }, 53 + }, 54 + "required": []string{"agent", "task"}, 55 + } 56 + b, _ := json.MarshalIndent(s, "", " ") 57 + return string(b) 58 + } 59 + 60 + func (t *acpSpawnTool) Execute(ctx context.Context, params map[string]any) (string, error) { 61 + agentName, _ := params["agent"].(string) 62 + agentName = strings.TrimSpace(agentName) 63 + if agentName == "" { 64 + return "", fmt.Errorf("missing required param: agent") 65 + } 66 + task, _ := params["task"].(string) 67 + task = strings.TrimSpace(task) 68 + if task == "" { 69 + return "", fmt.Errorf("missing required param: task") 70 + } 71 + if t.deps.LookupAgent == nil { 72 + return "", fmt.Errorf("acp agent lookup is unavailable") 73 + } 74 + cfg, ok := t.deps.LookupAgent(agentName) 75 + if !ok { 76 + return "", fmt.Errorf("unknown acp agent profile: %s", agentName) 77 + } 78 + cwd, _ := params["cwd"].(string) 79 + prepared, err := acpclient.PrepareAgentConfig(cfg, cwd) 80 + if err != nil { 81 + return "", err 82 + } 83 + 84 + outputSchema, _ := params["output_schema"].(string) 85 + outputSchema = strings.TrimSpace(outputSchema) 86 + observeProfile, _ := params["observe_profile"].(string) 87 + 88 + runner := t.deps.Runner 89 + if runner == nil { 90 + return "", fmt.Errorf("subtask runner unavailable") 91 + } 92 + runPrompt := t.deps.RunPrompt 93 + if runPrompt == nil { 94 + runPrompt = acpclient.RunPrompt 95 + } 96 + 97 + result, err := runner.RunSubtask(ctx, SubtaskRequest{ 98 + OutputSchema: outputSchema, 99 + ObserveProfile: NormalizeObserveProfile(observeProfile), 100 + RunFunc: func(runCtx context.Context) (*SubtaskResult, error) { 101 + runResult, runErr := runPrompt(runCtx, prepared, acpclient.RunRequest{ 102 + Prompt: BuildSubtaskTask(task, outputSchema), 103 + Observer: newACPObserver(runCtx, NormalizeObserveProfile(observeProfile)), 104 + }) 105 + if runErr != nil { 106 + return nil, runErr 107 + } 108 + return subtaskResultFromACPResult("", outputSchema, runResult), nil 109 + }, 110 + }) 111 + if err != nil { 112 + if result == nil { 113 + result = FailedSubtaskResult("", err) 114 + } 115 + } 116 + if result == nil { 117 + result = FailedSubtaskResult("", fmt.Errorf("acp subtask returned nil result")) 118 + } 119 + b, err := json.Marshal(result) 120 + if err != nil { 121 + return "", err 122 + } 123 + return string(b), nil 124 + } 125 + 126 + func newACPObserver(ctx context.Context, profile ObserveProfile) acpclient.Observer { 127 + return acpclient.ObserverFunc(func(eventCtx context.Context, event acpclient.Event) { 128 + if eventCtx == nil { 129 + eventCtx = ctx 130 + } 131 + switch event.Kind { 132 + case acpclient.EventKindAgentMessageChunk: 133 + if strings.TrimSpace(event.Text) == "" { 134 + return 135 + } 136 + EmitEvent(eventCtx, nil, Event{ 137 + Kind: EventKindToolOutput, 138 + ToolName: acpSpawnToolName, 139 + Profile: string(profile), 140 + Stream: "agent", 141 + Text: event.Text, 142 + Status: "running", 143 + }) 144 + case acpclient.EventKindToolCallStart: 145 + EmitEvent(eventCtx, nil, Event{ 146 + Kind: EventKindToolStart, 147 + ToolName: acpToolDisplayName(event), 148 + Profile: string(profile), 149 + Status: normalizeACPEventStatus(event.Status, "running"), 150 + }) 151 + if strings.TrimSpace(event.Text) != "" { 152 + EmitEvent(eventCtx, nil, Event{ 153 + Kind: EventKindToolOutput, 154 + ToolName: acpToolDisplayName(event), 155 + Profile: string(profile), 156 + Stream: "acp", 157 + Text: event.Text, 158 + Status: normalizeACPEventStatus(event.Status, "running"), 159 + }) 160 + } 161 + case acpclient.EventKindToolCallUpdate: 162 + if strings.TrimSpace(event.Text) == "" { 163 + return 164 + } 165 + EmitEvent(eventCtx, nil, Event{ 166 + Kind: EventKindToolOutput, 167 + ToolName: acpToolDisplayName(event), 168 + Profile: string(profile), 169 + Stream: "acp", 170 + Text: event.Text, 171 + Status: normalizeACPEventStatus(event.Status, "running"), 172 + }) 173 + case acpclient.EventKindToolCallDone: 174 + EmitEvent(eventCtx, nil, Event{ 175 + Kind: EventKindToolDone, 176 + ToolName: acpToolDisplayName(event), 177 + Profile: string(profile), 178 + Status: normalizeACPEventStatus(event.Status, "done"), 179 + Error: acpToolErrorText(event), 180 + }) 181 + } 182 + }) 183 + } 184 + 185 + func normalizeACPEventStatus(status string, fallback string) string { 186 + status = strings.TrimSpace(status) 187 + if status != "" { 188 + return status 189 + } 190 + return strings.TrimSpace(fallback) 191 + } 192 + 193 + func acpToolDisplayName(event acpclient.Event) string { 194 + if title := strings.TrimSpace(event.Title); title != "" { 195 + return title 196 + } 197 + if kind := strings.TrimSpace(event.ToolKind); kind != "" { 198 + return kind 199 + } 200 + if id := strings.TrimSpace(event.ToolCallID); id != "" { 201 + return id 202 + } 203 + return "acp_tool" 204 + } 205 + 206 + func acpToolErrorText(event acpclient.Event) string { 207 + if strings.TrimSpace(strings.ToLower(event.Status)) != "failed" { 208 + return "" 209 + } 210 + return strings.TrimSpace(event.Text) 211 + } 212 + 213 + func subtaskResultFromACPResult(taskID string, outputSchema string, result acpclient.RunResult) *SubtaskResult { 214 + stopReason := strings.TrimSpace(result.StopReason) 215 + if stopReason == "end_turn" { 216 + return SubtaskResultFromFinal(taskID, outputSchema, &Final{Output: strings.TrimSpace(result.Output)}) 217 + } 218 + msg := "acp stop reason: " + stopReason 219 + if stopReason == "" { 220 + msg = "acp task failed" 221 + } 222 + out := FailedSubtaskResult(taskID, fmt.Errorf("%s", msg)) 223 + out.Output = strings.TrimSpace(result.Output) 224 + if strings.TrimSpace(out.Output.(string)) != "" { 225 + out.OutputKind = SubtaskOutputKindText 226 + } 227 + if strings.TrimSpace(outputSchema) != "" { 228 + out.OutputSchema = strings.TrimSpace(outputSchema) 229 + } 230 + return out 231 + }
+117
agent/acp_spawn_tool_test.go
··· 1 + package agent 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "testing" 7 + 8 + "github.com/quailyquaily/mistermorph/internal/acpclient" 9 + "github.com/quailyquaily/mistermorph/tools" 10 + ) 11 + 12 + type execDirectSubtaskRunner struct { 13 + req SubtaskRequest 14 + } 15 + 16 + func (r *execDirectSubtaskRunner) RunSubtask(ctx context.Context, req SubtaskRequest) (*SubtaskResult, error) { 17 + r.req = req 18 + if req.RunFunc == nil { 19 + return nil, nil 20 + } 21 + return req.RunFunc(ctx) 22 + } 23 + 24 + func TestACPSpawnTool_Execute(t *testing.T) { 25 + t.Parallel() 26 + 27 + dir := t.TempDir() 28 + runner := &execDirectSubtaskRunner{} 29 + var gotPrompt string 30 + 31 + tool := newACPSpawnTool(acpSpawnToolDeps{ 32 + LookupAgent: func(name string) (acpclient.AgentConfig, bool) { 33 + if name != "codex" { 34 + return acpclient.AgentConfig{}, false 35 + } 36 + return acpclient.AgentConfig{ 37 + Name: "codex", 38 + Enable: true, 39 + Type: "stdio", 40 + Command: "helper", 41 + CWD: dir, 42 + ReadRoots: []string{"."}, 43 + WriteRoots: []string{"."}, 44 + }, true 45 + }, 46 + Runner: runner, 47 + RunPrompt: func(_ context.Context, cfg acpclient.PreparedAgentConfig, req acpclient.RunRequest) (acpclient.RunResult, error) { 48 + if cfg.CWD != dir { 49 + t.Fatalf("prepared cwd = %q, want %q", cfg.CWD, dir) 50 + } 51 + gotPrompt = req.Prompt 52 + return acpclient.RunResult{ 53 + SessionID: "sess_1", 54 + StopReason: "end_turn", 55 + Output: `{"ok":true}`, 56 + }, nil 57 + }, 58 + }) 59 + 60 + raw, err := tool.Execute(context.Background(), map[string]any{ 61 + "agent": "codex", 62 + "task": "inspect the repo", 63 + "output_schema": "subtask.test.v1", 64 + "observe_profile": "web_extract", 65 + }) 66 + if err != nil { 67 + t.Fatalf("Execute() error = %v", err) 68 + } 69 + if runner.req.OutputSchema != "subtask.test.v1" { 70 + t.Fatalf("runner.req.OutputSchema = %q, want %q", runner.req.OutputSchema, "subtask.test.v1") 71 + } 72 + if runner.req.ObserveProfile != ObserveProfileWebExtract { 73 + t.Fatalf("runner.req.ObserveProfile = %q, want %q", runner.req.ObserveProfile, ObserveProfileWebExtract) 74 + } 75 + if gotPrompt == "" || gotPrompt == "inspect the repo" { 76 + t.Fatalf("gotPrompt = %q, want output schema requirement appended", gotPrompt) 77 + } 78 + 79 + var result SubtaskResult 80 + if err := json.Unmarshal([]byte(raw), &result); err != nil { 81 + t.Fatalf("json.Unmarshal(result) error = %v", err) 82 + } 83 + if result.Status != SubtaskStatusDone { 84 + t.Fatalf("result.Status = %q, want %q", result.Status, SubtaskStatusDone) 85 + } 86 + if result.OutputSchema != "subtask.test.v1" { 87 + t.Fatalf("result.OutputSchema = %q, want %q", result.OutputSchema, "subtask.test.v1") 88 + } 89 + if result.OutputKind != SubtaskOutputKindJSON { 90 + t.Fatalf("result.OutputKind = %q, want %q", result.OutputKind, SubtaskOutputKindJSON) 91 + } 92 + } 93 + 94 + func TestACPSpawnTool_CanBeDisabled(t *testing.T) { 95 + t.Parallel() 96 + 97 + e := New(newMockClient(finalResponse("ok")), tools.NewRegistry(), baseCfg(), DefaultPromptSpec()) 98 + if _, ok := e.registry.Get(acpSpawnToolName); ok { 99 + t.Fatal("acp_spawn should not be registered by default") 100 + } 101 + } 102 + 103 + func TestACPSpawnTool_CanBeEnabled(t *testing.T) { 104 + t.Parallel() 105 + 106 + e := New( 107 + newMockClient(finalResponse("ok")), 108 + tools.NewRegistry(), 109 + baseCfg(), 110 + DefaultPromptSpec(), 111 + WithACPSpawnToolEnabled(true), 112 + WithACPAgents([]acpclient.AgentConfig{{Name: "codex", Enable: true, Type: "stdio", Command: "helper"}}), 113 + ) 114 + if _, ok := e.registry.Get(acpSpawnToolName); !ok { 115 + t.Fatal("acp_spawn should be registered when enabled") 116 + } 117 + }
+57 -5
agent/engine.go
··· 9 9 "time" 10 10 11 11 "github.com/quailyquaily/mistermorph/guard" 12 + "github.com/quailyquaily/mistermorph/internal/acpclient" 12 13 "github.com/quailyquaily/mistermorph/internal/llmstats" 13 14 "github.com/quailyquaily/mistermorph/internal/runtimeclock" 14 15 "github.com/quailyquaily/mistermorph/llm" ··· 118 119 } 119 120 } 120 121 122 + func WithACPSpawnToolEnabled(enabled bool) Option { 123 + return func(e *Engine) { 124 + e.engineToolsConfig.ACPSpawnEnabled = enabled 125 + } 126 + } 127 + 128 + func WithACPAgents(configs []acpclient.AgentConfig) Option { 129 + return func(e *Engine) { 130 + e.acpAgents = cloneACPAgents(configs) 131 + } 132 + } 133 + 121 134 type Config struct { 122 135 MaxSteps int 123 136 MaxTokenBudget int ··· 147 160 148 161 subClientFactory SubClientFactory 149 162 subtaskRunner SubtaskRunner 163 + acpAgents []acpclient.AgentConfig 150 164 151 165 guard *guard.Guard 152 166 } ··· 181 195 if e.subtaskRunner == nil { 182 196 e.subtaskRunner = &localSubtaskRunner{engine: e} 183 197 } 184 - registerEngineTools(e.registry, e.engineToolsConfig, spawnToolDeps{ 185 - LookupTool: e.registry.Get, 186 - DefaultModel: e.config.DefaultModel, 187 - Runner: e.subtaskRunner, 188 - }) 198 + registerEngineTools( 199 + e.registry, 200 + e.engineToolsConfig, 201 + spawnToolDeps{ 202 + LookupTool: e.registry.Get, 203 + DefaultModel: e.config.DefaultModel, 204 + Runner: e.subtaskRunner, 205 + }, 206 + acpSpawnToolDeps{ 207 + LookupAgent: func(name string) (acpclient.AgentConfig, bool) { 208 + return lookupACPAgent(e.acpAgents, name) 209 + }, 210 + Runner: e.subtaskRunner, 211 + RunPrompt: acpclient.RunPrompt, 212 + }, 213 + ) 189 214 return e 190 215 } 191 216 ··· 275 300 onStream: opts.OnStream, 276 301 nextStep: 0, 277 302 }) 303 + } 304 + 305 + func cloneACPAgents(in []acpclient.AgentConfig) []acpclient.AgentConfig { 306 + if len(in) == 0 { 307 + return nil 308 + } 309 + out := make([]acpclient.AgentConfig, 0, len(in)) 310 + for _, cfg := range in { 311 + item := cfg 312 + item.Args = append([]string(nil), cfg.Args...) 313 + if len(cfg.Env) > 0 { 314 + item.Env = make(map[string]string, len(cfg.Env)) 315 + for k, v := range cfg.Env { 316 + item.Env[k] = v 317 + } 318 + } 319 + item.ReadRoots = append([]string(nil), cfg.ReadRoots...) 320 + item.WriteRoots = append([]string(nil), cfg.WriteRoots...) 321 + if len(cfg.SessionOptions) > 0 { 322 + item.SessionOptions = make(map[string]any, len(cfg.SessionOptions)) 323 + for k, v := range cfg.SessionOptions { 324 + item.SessionOptions[k] = v 325 + } 326 + } 327 + out = append(out, item) 328 + } 329 + return out 278 330 } 279 331 280 332 func missingFiles(paths []string) []string {
+36 -5
agent/engine_tools.go
··· 1 1 package agent 2 2 3 - import "github.com/quailyquaily/mistermorph/tools" 3 + import ( 4 + "context" 5 + "strings" 6 + 7 + "github.com/quailyquaily/mistermorph/internal/acpclient" 8 + "github.com/quailyquaily/mistermorph/tools" 9 + ) 4 10 5 11 type EngineToolsConfig struct { 6 - SpawnEnabled bool 12 + SpawnEnabled bool 13 + ACPSpawnEnabled bool 7 14 } 8 15 9 16 func DefaultEngineToolsConfig() EngineToolsConfig { 10 17 return EngineToolsConfig{ 11 - SpawnEnabled: true, 18 + SpawnEnabled: true, 19 + ACPSpawnEnabled: false, 12 20 } 13 21 } 14 22 ··· 18 26 Runner SubtaskRunner 19 27 } 20 28 21 - func registerEngineTools(reg *tools.Registry, cfg EngineToolsConfig, deps spawnToolDeps) { 29 + type acpSpawnToolDeps struct { 30 + LookupAgent func(name string) (acpclient.AgentConfig, bool) 31 + Runner SubtaskRunner 32 + RunPrompt func(ctx context.Context, cfg acpclient.PreparedAgentConfig, req acpclient.RunRequest) (acpclient.RunResult, error) 33 + } 34 + 35 + func registerEngineTools(reg *tools.Registry, cfg EngineToolsConfig, spawnDeps spawnToolDeps, acpDeps acpSpawnToolDeps) { 22 36 if reg == nil { 23 37 return 24 38 } 25 39 if cfg.SpawnEnabled { 26 - reg.Register(newSpawnTool(deps)) 40 + reg.Register(newSpawnTool(spawnDeps)) 27 41 } 42 + if cfg.ACPSpawnEnabled { 43 + reg.Register(newACPSpawnTool(acpDeps)) 44 + } 45 + } 46 + 47 + func lookupACPAgent(configs []acpclient.AgentConfig, name string) (acpclient.AgentConfig, bool) { 48 + name = strings.ToLower(strings.TrimSpace(name)) 49 + if name == "" { 50 + return acpclient.AgentConfig{}, false 51 + } 52 + for _, cfg := range configs { 53 + if strings.ToLower(strings.TrimSpace(cfg.Name)) != name { 54 + continue 55 + } 56 + return cfg, true 57 + } 58 + return acpclient.AgentConfig{}, false 28 59 }
+4 -1
agent/local_subtask_runner.go
··· 83 83 ToolRepeatLimit: r.engine.config.ToolRepeatLimit, 84 84 DefaultModel: req.resolvedModel(r.engine.config.DefaultModel), 85 85 ToolCallTimeout: r.engine.config.ToolCallTimeout, 86 - }, r.engine.spec, append(subOpts, WithSpawnToolEnabled(false))...) 86 + }, r.engine.spec, append(subOpts, WithEngineToolsConfig(EngineToolsConfig{ 87 + SpawnEnabled: false, 88 + ACPSpawnEnabled: false, 89 + }), WithACPAgents(r.engine.acpAgents))...) 87 90 88 91 final, _, err := subEngine.Run(ctx, BuildSubtaskTask(req.Task, req.OutputSchema), RunOptions{ 89 92 Model: req.resolvedModel(r.engine.config.DefaultModel),
+26
assets/config/config.example.yaml
··· 196 196 # Enable the spawn tool. 197 197 # This controls the explicit spawn tool only; subtask remains a runtime mechanism. 198 198 enabled: true 199 + acp_spawn: 200 + # Enable the acp_spawn tool. 201 + # This controls the explicit ACP subtask entry only. 202 + enabled: false 199 203 contacts_send: 200 204 # Enable contacts_send tool. 201 205 enabled: true ··· 261 265 # headers: # custom HTTP headers (e.g. auth); values support ${ENV_VAR} 262 266 # Authorization: "Bearer ${MCP_REMOTE_TOKEN}" 263 267 # allowed_tools: ["search"] 268 + 269 + # ACP external agents. 270 + # ACP tools are engine-scoped. Enable `tools.acp_spawn.enabled` to expose the explicit entry. 271 + acp: 272 + agents: [] 273 + # - name: "codex" 274 + # enable: true 275 + # type: "stdio" 276 + # command: "codex-acp" 277 + # args: [] 278 + # # Or install nothing and run through npx: 279 + # # command: "npx" 280 + # # args: ["-y", "@zed-industries/codex-acp"] 281 + # env: {} 282 + # cwd: "." 283 + # read_roots: ["."] 284 + # write_roots: ["."] 285 + # session_options: {} # forwarded to session/new._meta; advertised config ids also receive session/set_config_option 286 + # # Example for adapters that advertise these config ids: 287 + # # session_options: 288 + # # mode: "auto" 289 + # # reasoning_effort: "low" 264 290 265 291 # Markdown-based memory 266 292 memory:
+12 -4
cmd/mistermorph/consolecmd/local_runtime.go
··· 17 17 "github.com/quailyquaily/mistermorph/agent" 18 18 "github.com/quailyquaily/mistermorph/contacts" 19 19 "github.com/quailyquaily/mistermorph/guard" 20 + "github.com/quailyquaily/mistermorph/internal/acpclient" 20 21 busruntime "github.com/quailyquaily/mistermorph/internal/bus" 21 22 runtimecore "github.com/quailyquaily/mistermorph/internal/channelruntime/core" 22 23 "github.com/quailyquaily/mistermorph/internal/channelruntime/depsutil" ··· 155 156 Registry: func() *tools.Registry { 156 157 return baseRegistry 157 158 }, 159 + ACPAgents: acpclient.AgentsFromViper, 158 160 Guard: func(_ *slog.Logger) *guard.Guard { 159 161 return sharedGuard 160 162 }, ··· 170 172 baseRegistry, mcpHost = buildConsoleBaseRegistry(context.Background(), logger) 171 173 sharedGuard = buildConsoleGuardFromViper(logger) 172 174 taskRuntimeOpts := taskruntime.BootstrapOptions{ 173 - AgentConfig: consoleAgentConfigFromViper(), 174 - EngineToolsConfig: &agent.EngineToolsConfig{SpawnEnabled: consoleEngineToolsConfigFromViper().SpawnEnabled}, 175 + AgentConfig: consoleAgentConfigFromViper(), 176 + EngineToolsConfig: &agent.EngineToolsConfig{ 177 + SpawnEnabled: consoleEngineToolsConfigFromViper().SpawnEnabled, 178 + ACPSpawnEnabled: consoleEngineToolsConfigFromViper().ACPSpawnEnabled, 179 + }, 175 180 } 176 181 execRuntime, err := taskruntime.Bootstrap(commonDeps, taskRuntimeOpts) 177 182 if err != nil { ··· 323 328 deps.Guard = func(_ *slog.Logger) *guard.Guard { return sharedGuard } 324 329 deps.RuntimeToolsConfig = toolsutil.LoadRuntimeToolsRegisterConfigFromViper() 325 330 rt, err := taskruntime.Bootstrap(deps, taskruntime.BootstrapOptions{ 326 - AgentConfig: consoleAgentConfigFromViper(), 327 - EngineToolsConfig: &agent.EngineToolsConfig{SpawnEnabled: consoleEngineToolsConfigFromViper().SpawnEnabled}, 331 + AgentConfig: consoleAgentConfigFromViper(), 332 + EngineToolsConfig: &agent.EngineToolsConfig{ 333 + SpawnEnabled: consoleEngineToolsConfigFromViper().SpawnEnabled, 334 + ACPSpawnEnabled: consoleEngineToolsConfigFromViper().ACPSpawnEnabled, 335 + }, 328 336 }) 329 337 if err != nil { 330 338 if mcpHost != nil {
+2 -1
cmd/mistermorph/consolecmd/runtime_support.go
··· 307 307 308 308 func consoleEngineToolsConfigFromViper() agent.EngineToolsConfig { 309 309 return agent.EngineToolsConfig{ 310 - SpawnEnabled: viper.GetBool("tools.spawn.enabled"), 310 + SpawnEnabled: viper.GetBool("tools.spawn.enabled"), 311 + ACPSpawnEnabled: viper.GetBool("tools.acp_spawn.enabled"), 311 312 } 312 313 } 313 314
+8 -1
cmd/mistermorph/runcmd/run.go
··· 14 14 15 15 "github.com/quailyquaily/mistermorph/agent" 16 16 "github.com/quailyquaily/mistermorph/guard" 17 + "github.com/quailyquaily/mistermorph/internal/acpclient" 17 18 "github.com/quailyquaily/mistermorph/internal/configutil" 18 19 "github.com/quailyquaily/mistermorph/internal/heartbeatutil" 19 20 "github.com/quailyquaily/mistermorph/internal/llmconfig" ··· 277 278 DefaultModel: strings.TrimSpace(mainCfg.Model), 278 279 }, 279 280 promptSpec, 280 - append(opts, agent.WithSpawnToolEnabled(viper.GetBool("tools.spawn.enabled")))..., 281 + append(opts, 282 + agent.WithEngineToolsConfig(agent.EngineToolsConfig{ 283 + SpawnEnabled: viper.GetBool("tools.spawn.enabled"), 284 + ACPSpawnEnabled: viper.GetBool("tools.acp_spawn.enabled"), 285 + }), 286 + agent.WithACPAgents(acpclient.AgentsFromViper()), 287 + )..., 281 288 ) 282 289 283 290 runID := llmstats.NewSyntheticRunID("cli")
+6
cmd/mistermorph/tools.go
··· 52 52 Description: "Starts a subtask with its own context and a restricted tool whitelist, then returns a structured result envelope.", 53 53 }) 54 54 } 55 + if viper.GetBool("tools.acp_spawn.enabled") { 56 + addToolPreview(extraPreviews, toolPreview{ 57 + Name: "acp_spawn", 58 + Description: "Starts a one-shot external ACP agent subtask over stdio and returns a structured result envelope.", 59 + }) 60 + } 55 61 // Runtime tools are injected in run/serve/telegram/slack. 56 62 toolsutil.RegisterRuntimeTools(r, toolsutil.LoadRuntimeToolsRegisterConfigFromViper(), toolsutil.RuntimeToolLLMOptions{}) 57 63 for _, name := range []string{toolsutil.BuiltinPlanCreate, toolsutil.BuiltinTodoUpdate} {
+501
docs/feat/feat_20260410_acp_agent_support.md
··· 1 + --- 2 + date: 2026-04-10 3 + title: ACP 外部 Agent 支持设计 4 + status: draft 5 + --- 6 + 7 + # ACP 外部 Agent 支持设计 8 + 9 + ## 1) 目标 10 + 11 + 这期要解决的问题很直接: 12 + 13 + - 让 MisterMorph 能把一个子任务委托给外部 ACP Agent。 14 + - 目标对象包括 Claude Code、Codex,以及别的 ACP Agent。 15 + - 复用现有子任务 envelope、事件和 Console 预览链路。 16 + - 让外部 Agent 在本地工作区里执行时,继续受本地路径、写入和命令执行约束。 17 + 18 + 一期范围收敛为: 19 + 20 + - MisterMorph 只实现 ACP client,不实现 ACP agent/server。 21 + - 只做 `stdio` 传输。 22 + - 只做同步、一次性子任务。 23 + - 每次调用新建一个 ACP session,任务结束后关闭。 24 + - 只发送文本 prompt,不做图片和多模态桥接。 25 + - 支持 `authenticate`。 26 + - 对 `session/new` 返回的已声明 config option,支持 `session/set_config_option`。 27 + - 支持外部 Agent 常见必需能力: 28 + - `session/request_permission` 29 + - `fs/read_text_file` 30 + - `fs/write_text_file` 31 + - 支持最小 `terminal/*`: 32 + - `terminal/create` 33 + - `terminal/output` 34 + - `terminal/wait_for_exit` 35 + - `terminal/kill` 36 + - `terminal/release` 37 + - 不实现 MCP 透传。 38 + 39 + ## 2) 非目标 40 + 41 + 这期先不做下面这些事: 42 + 43 + - 不把 ACP 塞进 `llm.Client` / provider 层。 44 + - 不把 MisterMorph 暴露成一个 ACP agent。 45 + - 不做 `session/load`、会话恢复、会话列表。 46 + - 不做交互式权限弹窗。 47 + - 不做 ACP session mode / config option 的完整抽象。 48 + - 不把现有本地 tools 全部再包成 ACP tool。 49 + - 不做自动路由,让主 agent 自己把普通任务默认委托给 ACP。 50 + - 不做 MCP 透传。 51 + - 不做 HTTP / SSE ACP transport。 52 + 53 + ## 3) 协议事实 54 + 55 + ACP 本身的关键点很明确: 56 + 57 + - ACP 基于 JSON-RPC 2.0。 58 + - 基本顺序是: 59 + - `initialize` 60 + - 某些 agent 会先要求 `authenticate` 61 + - `session/new` 62 + - 某些 agent 会通过 `session/new` 返回 `configOptions` 63 + - `session/prompt` 64 + - 过程中持续接收 `session/update` 65 + - 最后由 `session/prompt` 返回 `stopReason` 66 + - ACP agent 在运行中可以反向调用 client 能力: 67 + - 权限请求 68 + - 文件读写 69 + - 终端执行 70 + - `session/new` 要求 client 提供: 71 + - `cwd` 72 + - `mcpServers` 73 + - 没有 MCP 时也应传空列表 74 + - `session/update` 里会出现: 75 + - `agent_message_chunk` 76 + - `tool_call` 77 + - `tool_call_update` 78 + - 以及终端、diff 等内容块 79 + 80 + 截至 2026-04-11,ACP Registry 已列出 Claude Agent 和 Codex CLI 适配项。这说明“用 ACP 去操控外部 coding agent”不是私有扩展,而是协议的标准用法之一。 81 + 82 + ## 4) 当前仓库的正确落点 83 + 84 + 当前代码已经有几块现成的基础设施: 85 + 86 + - `spawn` 已经是 engine-scoped tool。 87 + - `SubtaskRunner` 已经统一了子任务执行入口。 88 + - `SubtaskResult` 已经统一了子任务返回 envelope。 89 + - `taskruntime.Runtime.RunSubtask(...)` 已经能接住同步子任务。 90 + - `agent.Event` 和 Console 本地观察链已经能显示子任务和工具过程。 91 + - `mcp.servers` 和 `mcphost` 已经能把 MCP server 接到本地 runtime。 92 + 93 + 所以 ACP 最合适的定位不是“另一种模型 provider”,而是“另一种外部子 agent 执行路径”。 94 + 95 + 原因很简单: 96 + 97 + - provider 只负责一次 LLM 请求。 98 + - ACP 是一个会话型 agent 协议。 99 + - ACP agent 会主动回调 client 的文件系统和终端能力。 100 + - ACP 还会持续发 `session/update`,这和当前子任务观察模型更接近。 101 + 102 + ## 5) 核心判断 103 + 104 + ### 5.1 不改造现有 `spawn` 语义 105 + 106 + 当前 `spawn` 的核心语义是: 107 + 108 + - 给子 agent 一段任务描述 109 + - 给它一个本地 tool 白名单 110 + - 让它跑本地 `SubtaskRunner` 111 + 112 + 这套语义和 ACP 并不相同。 113 + 114 + ACP 子任务不是“父 agent 白名单里的本地 tools 子集”,而是: 115 + 116 + - 一个外部 agent 自己的执行栈 117 + - 加上 client 侧暴露的 ACP 能力 118 + - 再加上可选 MCP servers 119 + 120 + 如果硬把两者合成一个工具,会出现两个问题: 121 + 122 + - `tools` 参数语义会变得含混。 123 + - 旧 prompt 对 `spawn` 的理解会被破坏。 124 + 125 + 所以一期不建议把 ACP 塞进 `spawn`。 126 + 127 + 更稳的方案是新增一个单独的 engine-scoped tool,例如 `acp_spawn`。 128 + 129 + ### 5.2 ACP 的安全边界在 client 能力,不在 permission 请求 130 + 131 + `session/request_permission` 更像 UX 协议,不是强安全边界。 132 + 133 + 真正决定外部 agent 能不能做某件事的,是我们在 client 侧提供的这些方法: 134 + 135 + - `fs/read_text_file` 136 + - `fs/write_text_file` 137 + - `terminal/*` 138 + 139 + 所以一期的原则是: 140 + 141 + - permission 请求可以按本地策略自动应答 142 + - 真正的拒绝发生在文件和终端方法实现里 143 + 144 + ### 5.3 一期先做“每次任务一个 session” 145 + 146 + 当前本仓库的子任务就是同步的一次性调用。 147 + 148 + ACP 一期直接对齐这个模型: 149 + 150 + - 每次 `acp_spawn` 都新建 session 151 + - 发一轮 `session/prompt` 152 + - 收到终态后立刻关闭连接 153 + 154 + 这样最简单,也最符合当前 `SubtaskResult` 的边界。 155 + 156 + ## 6) 一期方案 157 + 158 + ### 6.1 新增配置:`acp.agents` 159 + 160 + 建议新增独立配置块: 161 + 162 + ```yaml 163 + tools: 164 + acp_spawn: 165 + enabled: false 166 + 167 + acp: 168 + agents: 169 + - name: codex 170 + enable: true 171 + type: stdio 172 + command: "<acp-wrapper-command>" 173 + args: [] 174 + env: {} 175 + cwd: "." 176 + read_roots: ["."] 177 + write_roots: ["."] 178 + session_options: {} 179 + 180 + - name: claude 181 + enable: true 182 + type: stdio 183 + command: "<acp-wrapper-command>" 184 + args: [] 185 + env: {} 186 + cwd: "." 187 + read_roots: ["."] 188 + write_roots: ["."] 189 + session_options: {} 190 + ``` 191 + 192 + 字段说明: 193 + 194 + - `tools.acp_spawn.enabled` 195 + - 显式工具入口开关。 196 + - 默认 `false`。 197 + - `name` 198 + - 给主 agent 看的 profile 名。 199 + - `type` 200 + - 一期固定只支持 `stdio`。 201 + - `command` / `args` / `env` 202 + - 启动 ACP agent wrapper 的命令。 203 + - 这里不在文档里冻结具体 wrapper 命令,避免把某个外部实现写死。 204 + - `cwd` 205 + - session 默认工作目录。 206 + - 配置允许相对路径,运行时再解成绝对路径。 207 + - `read_roots` 208 + - ACP agent 允许读取的路径根。 209 + - `write_roots` 210 + - ACP agent 允许写入的路径根。 211 + - `session_options` 212 + - 透传给外部 ACP wrapper / session 的附加字段。 213 + - 一期会先原样放进 `session/new._meta`。 214 + - 如果 `session/new` 明确声明了某个 config option id,也会再补一轮 `session/set_config_option`。 215 + - MisterMorph 不对这些字段做通用语义解释。 216 + 217 + ### 6.2 新增工具:`acp_spawn` 218 + 219 + 建议新增 engine-scoped tool: 220 + 221 + - `agent` 222 + - 必填,ACP profile 名。 223 + - `task` 224 + - 必填,交给外部 ACP agent 的任务文本。 225 + - `cwd` 226 + - 可选,覆盖 profile 默认工作目录。 227 + - `output_schema` 228 + - 可选,沿用现有子任务 JSON 输出契约。 229 + - `observe_profile` 230 + - 可选,沿用现有观察策略。 231 + 232 + 返回值不另起格式,继续复用现有 `SubtaskResult`: 233 + 234 + ```json 235 + { 236 + "task_id": "sub_xxx", 237 + "status": "done", 238 + "summary": "subtask completed", 239 + "output_kind": "text", 240 + "output_schema": "", 241 + "output": "...", 242 + "error": "" 243 + } 244 + ``` 245 + 246 + 这样父 agent 不需要知道子任务到底是本地 `spawn`,还是 ACP `acp_spawn`。 247 + 248 + ### 6.3 执行流程 249 + 250 + ```text 251 + main agent 252 + -> acp_spawn(agent, task, cwd?, output_schema?, observe_profile?) 253 + -> load ACP profile 254 + -> spawn ACP process (stdio) 255 + -> initialize 256 + -> authenticate? (if advertised by agent) 257 + -> session/new(cwd, mcpServers=[], session_options?) 258 + -> session/set_config_option* (only for option ids advertised by session/new) 259 + -> session/prompt(prompt[]) 260 + -> receive session/update* 261 + -> answer session/request_permission 262 + -> serve fs/* 263 + -> serve terminal/* 264 + -> collect final stopReason + final assistant text 265 + -> map to SubtaskResult 266 + -> close ACP session / process 267 + ``` 268 + 269 + ### 6.4 ACP client 侧能力映射 270 + 271 + #### A. `session/request_permission` 272 + 273 + 一期不做交互式确认。 274 + 275 + 处理原则: 276 + 277 + - 读类操作默认可放行。 278 + - 写类操作先看 profile 是否开放对应能力。 279 + - 执行类操作也优先选择 allow 选项。 280 + - 就算 permission 已放行,真正执行时仍要再过本地路径和命令约束。 281 + 282 + 这层的目标是兼容 ACP agent 的工作流,不是替代底层安全检查。 283 + 284 + #### B. `fs/read_text_file` 285 + 286 + 这里不直接复用 `read_file` tool 的 JSON 接口,但要复用同一套约束原则: 287 + 288 + - 路径必须落在 `read_roots` 内。 289 + - 返回文本内容。 290 + - 支持 ACP 的 `line` / `limit` 语义。 291 + - 继续遵守 deny path 之类的本地规则。 292 + 293 + #### C. `fs/write_text_file` 294 + 295 + 这里不直接调用 `write_file` tool,但应复用同一套写入边界: 296 + 297 + - 路径必须落在 `write_roots` 内。 298 + - 只支持 ACP 定义的整文件写入。 299 + - 继续复用现有写入大小限制。 300 + - 失败时返回明确错误,不做隐式回退。 301 + 302 + #### D. `terminal/*` 303 + 304 + 真实联调表明,这组能力对 Codex 适配器是必需的。 305 + 306 + 所以一期补一个最小实现: 307 + 308 + - `terminal/create` 309 + - 启本地 shell 命令。 310 + - `cwd` 只能落在 profile 的工作区约束内。 311 + - 输出按字节上限缓冲。 312 + - `terminal/output` 313 + - 返回当前累计输出和是否截断。 314 + - `terminal/wait_for_exit` 315 + - 等进程结束并返回退出状态。 316 + - `terminal/kill` 317 + - 终止进程。 318 + - `terminal/release` 319 + - 回收本地句柄。 320 + 321 + 这不是完整沙箱,只是最小兼容层。 322 + 323 + ### 6.5 `session/update` 到本地观察事件的映射 324 + 325 + 一期不追求把 ACP update 一比一塞进现有 `agent.Event`。 326 + 327 + 原因是当前 `agent.Event` 很轻,只够支撑本地 Console 文本预览。 328 + 329 + 所以一期做最小映射: 330 + 331 + - `tool_call(status=pending)` 332 + - 映射成 `tool_start` 333 + - `tool_call_update(status=in_progress)` 334 + - 维持运行中状态 335 + - `tool_call_update(content=...)` 336 + - 映射成 `tool_output` 337 + - `tool_call_update(status=completed|failed)` 338 + - 映射成 `tool_done` 339 + - `agent_message_chunk` 340 + - 追加到最终输出 buffer 341 + - 同时按观察策略决定是否推送到运行中预览 342 + 这套映射的目标是: 343 + 344 + - Console 能看到过程 345 + - 父 agent 不吞进大量原始噪声 346 + - 我们不用现在就重写整个事件协议 347 + 348 + ### 6.6 结果映射 349 + 350 + ACP 最终要落回现有 `SubtaskResult`。 351 + 352 + 建议规则如下: 353 + 354 + - `stopReason=end_turn` 355 + - 视为成功 356 + - `stopReason=cancelled` 357 + - 视为失败 358 + - `stopReason=refusal` 359 + - 视为失败 360 + - `stopReason=max_tokens` 361 + - 视为失败 362 + - `stopReason=max_turn_requests` 363 + - 视为失败 364 + 365 + 输出内容处理: 366 + 367 + - 最终 `output` 使用累积后的最终 assistant 文本。 368 + - 如果调用方声明了 `output_schema`,则沿用当前 `BuildSubtaskTask(...)` 的做法,在 prompt 里明确要求最终输出是 JSON。 369 + - 终态时复用现有 `SubtaskResultFromFinal(...)` 逻辑做 JSON 归一化。 370 + 371 + 中间过程数据处理: 372 + 373 + - tool call 原始内容 374 + - diff 375 + 376 + 这些只进入运行中观察和日志,不自动塞回最终 `output`。 377 + 378 + ### 6.7 MCP 379 + 380 + 仓库现在已经有 `mcp.servers` 配置和 `mcphost` 结构。 381 + 382 + 但 ACP 这边一期先不做透传。 383 + 384 + 所以: 385 + 386 + - `session/new.mcpServers` 固定传空列表。 387 + - `acp.agents` 一期不增加 `mcp_servers` 配置。 388 + - MCP 接入放到后续项。 389 + 390 + ## 7) 代码结构建议 391 + 392 + 建议新增一个独立包: 393 + 394 + - `internal/acpclient/` 395 + 396 + 这个包先只做四件事: 397 + 398 + - 传输层和 JSON-RPC client 399 + - ACP session 生命周期 400 + - client 能力实现: 401 + - permission 402 + - fs 403 + - terminal 404 + - 把 ACP prompt turn 跑成一个 `SubtaskResult` 405 + 406 + 仓库接线点建议保持克制: 407 + 408 + - `agent/registerEngineTools(...)` 409 + - 注册 `acp_spawn` 410 + - `internal/channelopts` 411 + - 加载 `acp.agents` 412 + - `taskruntime` / `integration` 413 + - 把 ACP profile 和 cleanup 接到 runtime assembly 414 + 415 + 一期先不要引入新的“通用外部 agent 调度总线”。 416 + 417 + 先把一个 ACP backend 跑通,再决定要不要抽象成更大的 dispatcher。 418 + 419 + ## 8) 实施顺序 420 + 421 + ### M1. 协议骨架 422 + 423 + - ACP profile 配置解析 424 + - `internal/acpclient` 传输层 425 + - `initialize` 426 + - `authenticate` 427 + - `session/new` 428 + - `session/set_config_option` 429 + - `session/prompt` 430 + - `session/update` 基础消费 431 + - fake ACP server 测试 432 + 433 + ### M2. 工具接线 434 + 435 + - engine-scoped `acp_spawn` 436 + - `SubtaskResult` 映射 437 + - `output_schema` 约束复用 438 + - context cancel -> `session/cancel` 439 + 440 + ### M3. client 能力 441 + 442 + - `session/request_permission` 443 + - `fs/read_text_file` 444 + - `fs/write_text_file` 445 + - `terminal/*` 446 + - Console 运行中预览接线 447 + 448 + ### M4. 真实联调 449 + 450 + - 针对 Codex / Claude wrapper 的 opt-in 集成测试 451 + - 记录兼容性差异 452 + 453 + ## 9) 主要风险 454 + 455 + - 不同 ACP wrapper 的能力覆盖不完全一致。 456 + - `stdio` 模式下,外部 ACP wrapper 自身仍是本地子进程。 457 + - 如果 wrapper 本身直接访问宿主文件系统或执行命令,这部分权限不受 ACP client 的 `fs/*` 方法约束。 458 + - `terminal/*` 现在已补上,但这层仍不是强沙箱。 459 + - 某些 wrapper 可能依赖更多 session mode / config option,当前只支持“agent 已声明的 option id”这一层。 460 + - 长输出和高频 `session/update` 可能让 Console 预览抖动。 461 + - ACP 的 permission 语义和我们现有 guard approval 不是一回事,后面要避免把两者混成一层。 462 + 463 + ## 10) 测试要求 464 + 465 + 至少要覆盖下面这些测试: 466 + 467 + - ACP client 和 fake server 的协议往返。 468 + - `authenticate` 的方法选择。 469 + - `stopReason` 到 `SubtaskResult` 的映射。 470 + - `output_schema` 的 JSON 输出约束。 471 + - 路径越界时 `fs/read_text_file` / `fs/write_text_file` 会拒绝。 472 + - `session_options` 会进入 `session/new._meta`,并对已声明 option id 发 `session/set_config_option`。 473 + - `terminal/*` 的 create / output / wait / kill / release 往返。 474 + - `session/cancel` 后连接和子进程会正确清理。 475 + - Console 预览能看到 ACP tool call 和 `agent_message_chunk`。 476 + 477 + 真实 ACP wrapper 联调测试建议做成 opt-in: 478 + 479 + - 默认 CI 不跑。 480 + - 本地有 wrapper 时再开。 481 + 482 + ## 11) 后续项 483 + 484 + 如果一期跑通,再考虑后面的事: 485 + 486 + - `session/load` 487 + - 更完整的 session mode / config option 抽象 488 + - HTTP ACP transport 489 + - MCP 透传 490 + - 评估 `spawn` 和 `acp_spawn` 是否需要在更高一层合并 491 + 492 + ## 12) 参考 493 + 494 + - ACP Protocol Overview: <https://agentclientprotocol.com/protocol/overview> 495 + - ACP Initialization: <https://agentclientprotocol.com/protocol/initialization> 496 + - ACP Session Setup: <https://agentclientprotocol.com/protocol/session-setup> 497 + - ACP Prompt Turn: <https://agentclientprotocol.com/protocol/prompt-turn> 498 + - ACP Tool Calls: <https://agentclientprotocol.com/protocol/tool-calls> 499 + - ACP File System: <https://agentclientprotocol.com/protocol/file-system> 500 + - ACP Terminals: <https://agentclientprotocol.com/protocol/terminals> 501 + - ACP Registry: <https://agentclientprotocol.com/registry>
+138
docs/feat/feat_20260410_acp_agent_support_impl.md
··· 1 + --- 2 + date: 2026-04-10 3 + title: ACP 外部 Agent 支持实现进度 4 + status: in_progress 5 + --- 6 + 7 + # ACP 外部 Agent 支持实现进度 8 + 9 + ## 当前范围 10 + 11 + 本轮先做一期最小实现: 12 + 13 + - 新增 `acp.agents` 配置读取。 14 + - 新增 `tools.acp_spawn.enabled` 开关,默认 `false`。 15 + - 新增 engine-scoped `acp_spawn`。 16 + - 新增 `internal/acpclient/`,实现: 17 + - `stdio` 传输 18 + - `initialize` 19 + - `authenticate` 20 + - `session/new` 21 + - `session/set_config_option` 22 + - `session/prompt` 23 + - `session/update` 基础消费 24 + - `session/request_permission` 25 + - `fs/read_text_file` 26 + - `fs/write_text_file` 27 + - `terminal/*` 28 + - 一期不实现: 29 + - MCP 透传 30 + - session 复用 31 + - `session_options` 先写入 `session/new._meta`,再按 `session/new.configOptions` 补 `session/set_config_option`。 32 + - 结果继续复用现有 `SubtaskResult`。 33 + 34 + ## 任务清单 35 + 36 + - [x] 建立实现跟踪文档 37 + - [x] 梳理当前接线点和最小改动路径 38 + - [x] 定义 ACP 配置结构与读取逻辑 39 + - [x] 定义 `internal/acpclient` 基础类型和 JSON-RPC client 40 + - [x] 跑通 `initialize -> session/new -> session/prompt` 41 + - [x] 接 `authenticate` 42 + - [x] 接 `session/set_config_option` 43 + - [x] 接 `session/request_permission` 44 + - [x] 接 `fs/read_text_file` 45 + - [x] 接 `fs/write_text_file` 46 + - [x] 接 `terminal/*` 47 + - [x] 新增 `acp_spawn` 48 + - [x] 接入 runtime / integration 装配 49 + - [x] 补 fake ACP server 测试 50 + - [x] 修正 timeout 竞争导致的假成功 51 + - [x] 新增 opt-in 的真实 Codex adapter 集成测试 52 + - [x] 跑相关测试并记录结果 53 + 54 + ## 进度记录 55 + 56 + ### 2026-04-10 57 + 58 + - 已建立 ACP 设计文档: 59 + - `docs/feat/feat_20260410_acp_agent_support.md` 60 + - 已新建实现分支: 61 + - `feat/acp` 62 + - 已确认一期口径: 63 + - 使用单独 `acp_spawn` 64 + - `tools.acp_spawn.enabled=false` 65 + - 不接现有 guard approval 66 + - 不做 MCP 透传 67 + - `session_options` 先保留透传口,再按真实 wrapper 需要补协议映射 68 + - 已完成最小实现: 69 + - 新增 `internal/acpclient/config.go` 70 + - 新增 `internal/acpclient/client.go` 71 + - 新增 engine-scoped `acp_spawn` 72 + - `EngineToolsConfig` 已扩展 `ACPSpawnEnabled` 73 + - `run` / `console` / `taskruntime` / `integration` 已接 ACP tool 开关和 profile 74 + - `assets/config/config.example.yaml` 已补 `tools.acp_spawn.enabled` 和 `acp.agents` 75 + - 已完成测试: 76 + - `go test ./internal/acpclient ./agent ./internal/channelruntime/taskruntime ./integration ./cmd/mistermorph/consolecmd` 77 + - `go test ./internal/channelopts ./cmd/mistermorph -run 'TestToolsCommand_IncludesRuntimeTools|TestBuildTelegramRunOptionsTaskTimeoutFallback|TestBuildSlackRunOptionsTaskTimeoutFallback|TestTelegramConfigFromReaderImageSources'` 78 + - 已确认一个实现边界: 79 + - `stdio` 模式下,外部 ACP wrapper 自身仍是本地子进程 80 + - 如果 wrapper 直接访问宿主文件系统或执行命令,这部分权限不受 ACP client 的 `fs/*` 方法约束 81 + - 这点已回写设计文档风险部分 82 + 83 + ### 2026-04-11 84 + 85 + - 已确定 Codex 的真实联调对象不是 `codex` CLI 本体,而是外部 ACP adapter: 86 + - 当前优先目标为 `zed-industries/codex-acp` 87 + - `codex` CLI 公开帮助里暴露的是 `app-server` / `mcp-server`,不是 ACP 入口 88 + - 已确认 `codex-acp` 在 `initialize` 后会声明认证方式: 89 + - 当前已兼容 `chatgpt` 90 + - 当前已兼容 `codex-api-key` 91 + - 当前已兼容 `openai-api-key` 92 + - 已确认 `codex-acp` 的 `session/new` 会返回 `configOptions`: 93 + - 当前实现会把 `session_options` 先放入 `session/new._meta` 94 + - 再对已声明的 option id 发 `session/set_config_option` 95 + - 真实联调发现仅有 `fs/*` 不够,已补最小 `terminal/*`: 96 + - `terminal/create` 97 + - `terminal/output` 98 + - `terminal/wait_for_exit` 99 + - `terminal/kill` 100 + - `terminal/release` 101 + - 权限策略已调整: 102 + - 对执行类 permission 不再预先拒绝 103 + - 优先选择 allow 选项,再把真正限制放在终端和文件实现里 104 + - 已修正一个关键竞争条件: 105 + - 外层 context 超时后会发 `session/cancel` 106 + - 旧实现可能把超时后的迟到响应误判成成功 107 + - 现在 `session/prompt` 返回后会再次检查 `ctx.Err()` 108 + - 已新增 opt-in 的真实集成测试: 109 + - `internal/acpclient/codex_integration_test.go` 110 + - 目标是直接验证 `initialize -> session/new -> session/prompt` 能否和真实 Codex ACP adapter 跑通 111 + - 集成测试约定: 112 + - 默认不跑,需显式设置 `MISTERMORPH_ACP_CODEX_INTEGRATION=1` 113 + - 默认优先查找 `codex-acp` 114 + - 也可通过 `MISTERMORPH_ACP_CODEX_COMMAND` 指定命令 115 + - 可通过 `MISTERMORPH_ACP_CODEX_ARGS` 指定参数,例如 `-y @zed-industries/codex-acp` 116 + - 可通过 `MISTERMORPH_ACP_CODEX_SESSION_OPTIONS` 传入 JSON 117 + - live test 对单次 `RunPrompt()` 额外设置了时长上限,避免静默挂死 118 + - 配置模板已补 Codex adapter 注释示例: 119 + - `assets/config/config.example.yaml` 120 + - 当前本地测试结论: 121 + - fake ACP server 往返已覆盖 auth / fs / terminal / session option 122 + - 真实 Codex adapter 是否最终跑通,仍以用户本机 shell 为准 123 + - 当前执行环境的网络限制会影响 `codex-acp` 连接外部服务,因此这里不把本地 sandbox 结果当成最终结论 124 + - 用户本机真实联调已通过: 125 + - `只允许调用 acp_spawn。agent 用 codex,让 codex 说 Hello。` 126 + - `读取 README 并总结` 这类文件任务也已恢复正常 127 + - 这轮真实联调修正了两个关键问题: 128 + - `terminal/create` 最初没有按 ACP 规范处理 `command + args[] + env[]` 129 + - `RunPrompt()` 成功路径里,cancel watcher 的收尾顺序会造成死锁,进而把成功请求拖到外层总超时 130 + - 为了定位真实 wrapper 问题,曾临时加过 `acp_*` 运行日志: 131 + - 联调完成后已降为 `debug` 132 + - 默认 `info` 日志下不再污染正常输出 133 + 134 + ## 待确认的实现细节 135 + 136 + 当前没有新的阻塞项。 137 + 138 + 后续如果发现某个 wrapper 需要更严格的 config typing 或更细的 terminal 沙箱,再单独记录并回写设计文档。
+3 -2
docs/tools.md
··· 10 10 - `read_file`, `write_file`, `bash`, `url_fetch`, `web_search`, `contacts_send`. 11 11 - `engine-scoped` tools: 12 12 - `spawn`: registered when an agent engine is assembled for a run; depends on the current subtask runner, parent tool lookup, and default model. 13 + - `acp_spawn`: registered when an agent engine is assembled for a run; depends on ACP agent profiles plus the current subtask runner. 13 14 - `runtime-dependent` tools: 14 15 - `todo_update`: runtime-injected, depends on active LLM client/model plus TODO/contacts paths from runtime config. 15 16 - `plan_create`: runtime-injected, depends on active LLM client/model. ··· 66 67 Flow notes: 67 68 68 69 - Phase A (static): build base registry via `RegisterStaticTools`. 69 - - Phase A.5 (engine tools): register engine-scoped tools such as `spawn` when `agent.New(...)` assembles a runnable engine. 70 + - Phase A.5 (engine tools): register engine-scoped tools such as `spawn` and `acp_spawn` when `agent.New(...)` assembles a runnable engine. 70 71 - Phase B (runtime deps): build `RuntimeToolsRegisterConfig`, then inject via `RegisterRuntimeTools`. 71 72 - Phase C (task shaping): 72 73 - `run`/`serve`/integration run-engine: inject runtime tools directly into execution registry. ··· 89 90 90 91 - `tools` command prints: 91 92 - `Core tools`: from base registry. 92 - - `Extra tools`: preview of engine-scoped and runtime-dependent tools (currently `spawn`, `plan_create`, `todo_update`). 93 + - `Extra tools`: preview of engine-scoped and runtime-dependent tools (currently `spawn`, `acp_spawn`, `plan_create`, `todo_update`). 93 94 - `Telegram tools`: static preview rows for Telegram runtime tools. 94 95 95 96 ## `read_file`
+9
integration/channel_bots.go
··· 10 10 11 11 "github.com/quailyquaily/mistermorph/agent" 12 12 "github.com/quailyquaily/mistermorph/guard" 13 + "github.com/quailyquaily/mistermorph/internal/acpclient" 13 14 "github.com/quailyquaily/mistermorph/internal/channelopts" 14 15 "github.com/quailyquaily/mistermorph/internal/channelruntime/depsutil" 15 16 slackruntime "github.com/quailyquaily/mistermorph/internal/channelruntime/slack" ··· 124 125 return err 125 126 } 126 127 runOpts.EngineToolsConfig.SpawnEnabled = runOpts.EngineToolsConfig.SpawnEnabled && r.rt.isBuiltinToolSelected(toolsutil.BuiltinSpawn) 128 + runOpts.EngineToolsConfig.ACPSpawnEnabled = runOpts.EngineToolsConfig.ACPSpawnEnabled && r.rt.isBuiltinToolSelected(toolsutil.BuiltinACPSpawn) 127 129 return telegramruntime.Run(runCtx, r.rt.telegramDependencies(snap), runOpts) 128 130 }) 129 131 } ··· 161 163 InspectRequest: r.rt.inspect.Request, 162 164 }) 163 165 runOpts.EngineToolsConfig.SpawnEnabled = runOpts.EngineToolsConfig.SpawnEnabled && r.rt.isBuiltinToolSelected(toolsutil.BuiltinSpawn) 166 + runOpts.EngineToolsConfig.ACPSpawnEnabled = runOpts.EngineToolsConfig.ACPSpawnEnabled && r.rt.isBuiltinToolSelected(toolsutil.BuiltinACPSpawn) 164 167 return slackruntime.Run(runCtx, r.rt.slackDependencies(snap), runOpts) 165 168 }) 166 169 } ··· 259 262 ResolveLLMRoute func(purpose string) (llmutil.ResolvedRoute, error) 260 263 CreateLLMClient func(route llmutil.ResolvedRoute) (llm.Client, error) 261 264 Registry func() *tools.Registry 265 + ACPAgents func() []acpclient.AgentConfig 262 266 RuntimeToolsConfig toolsutil.RuntimeToolsRegisterConfig 263 267 Guard func(logger *slog.Logger) *guard.Guard 264 268 PromptSpec func(ctx context.Context, logger *slog.Logger, logOpts agent.LogOptions, task string, client llm.Client, model string, stickySkills []string) (agent.PromptSpec, []string, error) ··· 289 293 return buildIntegrationLLMClient(route, snap.Logger, nil) 290 294 }, 291 295 Registry: func() *tools.Registry { return rt.buildRegistry(snap.Registry, snap.Logger) }, 296 + ACPAgents: func() []acpclient.AgentConfig { 297 + return cloneACPAgents(snap.ACPAgents) 298 + }, 292 299 RuntimeToolsConfig: toolsutil.RuntimeToolsRegisterConfig{ 293 300 PlanCreate: toolsutil.BuildPlanCreateRegisterConfig(planEnabled, snap.Registry.ToolsPlanCreateMaxSteps), 294 301 TodoUpdate: toolsutil.TodoUpdateRegisterConfig{ ··· 318 325 ResolveLLMRoute: base.ResolveLLMRoute, 319 326 CreateLLMClient: base.CreateLLMClient, 320 327 Registry: base.Registry, 328 + ACPAgents: base.ACPAgents, 321 329 RuntimeToolsConfig: base.RuntimeToolsConfig, 322 330 Guard: base.Guard, 323 331 PromptSpec: base.PromptSpec, ··· 336 344 ResolveLLMRoute: base.ResolveLLMRoute, 337 345 CreateLLMClient: base.CreateLLMClient, 338 346 Registry: base.Registry, 347 + ACPAgents: base.ACPAgents, 339 348 RuntimeToolsConfig: base.RuntimeToolsConfig, 340 349 Guard: base.Guard, 341 350 PromptSpec: base.PromptSpec,
+2
integration/defaults.go
··· 42 42 v.SetDefault("tools.write_file.enabled", true) 43 43 v.SetDefault("tools.write_file.max_bytes", 512*1024) 44 44 v.SetDefault("tools.spawn.enabled", true) 45 + v.SetDefault("tools.acp_spawn.enabled", false) 45 46 v.SetDefault("tools.bash.enabled", true) 46 47 v.SetDefault("tools.bash.timeout", 30*time.Second) 47 48 v.SetDefault("tools.bash.max_output_bytes", 256*1024) ··· 112 113 113 114 // MCP. 114 115 v.SetDefault("mcp.servers", []map[string]any{}) 116 + v.SetDefault("acp.agents", []map[string]any{}) 115 117 116 118 // Guard. 117 119 v.SetDefault("guard.enabled", true)
+5 -1
integration/runtime.go
··· 255 255 opts := []agent.Option{ 256 256 agent.WithLogger(logger), 257 257 agent.WithLogOptions(logOpts), 258 - agent.WithSpawnToolEnabled(snap.Registry.ToolsSpawnEnabled && rt.isBuiltinToolSelected(toolsutil.BuiltinSpawn)), 258 + agent.WithEngineToolsConfig(agent.EngineToolsConfig{ 259 + SpawnEnabled: snap.Registry.ToolsSpawnEnabled && rt.isBuiltinToolSelected(toolsutil.BuiltinSpawn), 260 + ACPSpawnEnabled: snap.Registry.ToolsACPSpawnEnabled && rt.isBuiltinToolSelected(toolsutil.BuiltinACPSpawn), 261 + }), 262 + agent.WithACPAgents(snap.ACPAgents), 259 263 } 260 264 if g := rt.buildGuard(snap.Guard, logger); g != nil { 261 265 opts = append(opts, agent.WithGuard(g))
+30
integration/runtime_snapshot.go
··· 6 6 7 7 "github.com/quailyquaily/mistermorph/agent" 8 8 "github.com/quailyquaily/mistermorph/guard" 9 + "github.com/quailyquaily/mistermorph/internal/acpclient" 9 10 "github.com/quailyquaily/mistermorph/internal/channelopts" 10 11 "github.com/quailyquaily/mistermorph/internal/llmutil" 11 12 "github.com/quailyquaily/mistermorph/internal/mcphost" ··· 26 27 Telegram channelopts.TelegramConfig 27 28 Slack channelopts.SlackConfig 28 29 MCPServers []mcphost.ServerConfig 30 + ACPAgents []acpclient.AgentConfig 29 31 } 30 32 31 33 type registrySnapshot struct { ··· 39 41 ToolsWriteFileEnabled bool 40 42 ToolsWriteFileMaxBytes int 41 43 ToolsSpawnEnabled bool 44 + ToolsACPSpawnEnabled bool 42 45 ToolsBashEnabled bool 43 46 ToolsBashTimeout time.Duration 44 47 ToolsBashMaxOutputBytes int ··· 84 87 out := in 85 88 out.Roots = append([]string(nil), in.Roots...) 86 89 out.Requested = append([]string(nil), in.Requested...) 90 + return out 91 + } 92 + 93 + func cloneACPAgents(in []acpclient.AgentConfig) []acpclient.AgentConfig { 94 + if len(in) == 0 { 95 + return nil 96 + } 97 + out := make([]acpclient.AgentConfig, 0, len(in)) 98 + for _, cfg := range in { 99 + item := cfg 100 + item.Args = append([]string(nil), cfg.Args...) 101 + item.ReadRoots = append([]string(nil), cfg.ReadRoots...) 102 + item.WriteRoots = append([]string(nil), cfg.WriteRoots...) 103 + if len(cfg.Env) > 0 { 104 + item.Env = make(map[string]string, len(cfg.Env)) 105 + for k, v := range cfg.Env { 106 + item.Env[k] = v 107 + } 108 + } 109 + if len(cfg.SessionOptions) > 0 { 110 + item.SessionOptions = make(map[string]any, len(cfg.SessionOptions)) 111 + for k, v := range cfg.SessionOptions { 112 + item.SessionOptions[k] = v 113 + } 114 + } 115 + out = append(out, item) 116 + } 87 117 return out 88 118 } 89 119
+3
integration/runtime_snapshot_loader.go
··· 7 7 8 8 "github.com/quailyquaily/mistermorph/agent" 9 9 "github.com/quailyquaily/mistermorph/guard" 10 + "github.com/quailyquaily/mistermorph/internal/acpclient" 10 11 "github.com/quailyquaily/mistermorph/internal/channelopts" 11 12 "github.com/quailyquaily/mistermorph/internal/llmutil" 12 13 "github.com/quailyquaily/mistermorph/internal/logutil" ··· 80 81 ToolsWriteFileEnabled: v.GetBool("tools.write_file.enabled"), 81 82 ToolsWriteFileMaxBytes: v.GetInt("tools.write_file.max_bytes"), 82 83 ToolsSpawnEnabled: v.GetBool("tools.spawn.enabled"), 84 + ToolsACPSpawnEnabled: v.GetBool("tools.acp_spawn.enabled"), 83 85 ToolsBashEnabled: v.GetBool("tools.bash.enabled"), 84 86 ToolsBashTimeout: v.GetDuration("tools.bash.timeout"), 85 87 ToolsBashMaxOutputBytes: v.GetInt("tools.bash.max_output_bytes"), ··· 137 139 Telegram: channelopts.TelegramConfigFromReader(v), 138 140 Slack: channelopts.SlackConfigFromReader(v), 139 141 MCPServers: mcphost.MCPConfigFromReader(v), 142 + ACPAgents: acpclient.AgentsFromReader(v), 140 143 } 141 144 } 142 145
+1092
internal/acpclient/client.go
··· 1 + package acpclient 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "io" 9 + "log/slog" 10 + "os" 11 + "os/exec" 12 + "path/filepath" 13 + "strings" 14 + "sync" 15 + "sync/atomic" 16 + "time" 17 + ) 18 + 19 + const ( 20 + protocolVersion = 1 21 + methodAuthenticate = "authenticate" 22 + methodInitialize = "initialize" 23 + methodSessionNew = "session/new" 24 + methodSessionPrompt = "session/prompt" 25 + methodSessionCancel = "session/cancel" 26 + methodSessionSetConfig = "session/set_config_option" 27 + methodSessionUpdate = "session/update" 28 + methodRequestPerm = "session/request_permission" 29 + methodReadTextFile = "fs/read_text_file" 30 + methodWriteTextFile = "fs/write_text_file" 31 + jsonRPCVersion = "2.0" 32 + rpcCodeMethodNotFound = -32601 33 + rpcCodeInvalidParams = -32602 34 + rpcCodeInternalError = -32603 35 + defaultClientName = "mistermorph" 36 + defaultClientTitle = "MisterMorph" 37 + defaultClientVersion = "1.0.0" 38 + permissionAllowOnce = "allow_once" 39 + permissionAllowAlways = "allow_always" 40 + permissionRejectOnce = "reject_once" 41 + permissionOutcomeSel = "selected" 42 + permissionOutcomeCancel = "cancelled" 43 + ) 44 + 45 + type EventKind string 46 + 47 + const ( 48 + EventKindAgentMessageChunk EventKind = "agent_message_chunk" 49 + EventKindToolCallStart EventKind = "tool_call_start" 50 + EventKindToolCallUpdate EventKind = "tool_call_update" 51 + EventKindToolCallDone EventKind = "tool_call_done" 52 + ) 53 + 54 + type Event struct { 55 + Kind EventKind 56 + ToolCallID string 57 + Title string 58 + ToolKind string 59 + Status string 60 + Text string 61 + } 62 + 63 + type Observer interface { 64 + HandleACPEvent(context.Context, Event) 65 + } 66 + 67 + type ObserverFunc func(context.Context, Event) 68 + 69 + func (fn ObserverFunc) HandleACPEvent(ctx context.Context, event Event) { 70 + if fn != nil { 71 + fn(ctx, event) 72 + } 73 + } 74 + 75 + type RunRequest struct { 76 + Prompt string 77 + Observer Observer 78 + } 79 + 80 + type RunResult struct { 81 + SessionID string 82 + StopReason string 83 + Output string 84 + } 85 + 86 + type initResult struct { 87 + AuthMethods []authMethod `json:"authMethods"` 88 + } 89 + 90 + type newSessionResult struct { 91 + SessionID string `json:"sessionId"` 92 + ConfigOptions []sessionConfigInfo `json:"configOptions"` 93 + } 94 + 95 + type authMethod struct { 96 + ID string `json:"id"` 97 + Type string `json:"type,omitempty"` 98 + Vars []authEnvVar `json:"vars,omitempty"` 99 + } 100 + 101 + type authEnvVar struct { 102 + Name string `json:"name"` 103 + } 104 + 105 + type sessionConfigInfo struct { 106 + ID string `json:"id"` 107 + } 108 + 109 + type rpcMessage struct { 110 + JSONRPC string `json:"jsonrpc,omitempty"` 111 + ID json.RawMessage `json:"id,omitempty"` 112 + Method string `json:"method,omitempty"` 113 + Params json.RawMessage `json:"params,omitempty"` 114 + Result json.RawMessage `json:"result,omitempty"` 115 + Error *rpcError `json:"error,omitempty"` 116 + } 117 + 118 + type rpcError struct { 119 + Code int `json:"code"` 120 + Message string `json:"message"` 121 + Data json.RawMessage `json:"data,omitempty"` 122 + } 123 + 124 + func (e *rpcError) Error() string { 125 + if e == nil { 126 + return "" 127 + } 128 + msg := strings.TrimSpace(e.Message) 129 + if msg == "" { 130 + msg = "json-rpc error" 131 + } 132 + return fmt.Sprintf("%s (code=%d)", msg, e.Code) 133 + } 134 + 135 + type pendingResponse struct { 136 + result json.RawMessage 137 + err *rpcError 138 + } 139 + 140 + type requestHandler func(context.Context, rpcMessage) (any, *rpcError) 141 + type notificationHandler func(context.Context, rpcMessage) 142 + type connFactory func(context.Context, PreparedAgentConfig) (*rpcConn, error) 143 + 144 + type rpcConn struct { 145 + ctx context.Context 146 + cancel context.CancelFunc 147 + 148 + cmd *exec.Cmd 149 + stdin io.WriteCloser 150 + stdout io.ReadCloser 151 + stderr io.ReadCloser 152 + 153 + writeMu sync.Mutex 154 + enc *json.Encoder 155 + 156 + nextID int64 157 + pendMu sync.Mutex 158 + pending map[string]chan pendingResponse 159 + 160 + reqHandler requestHandler 161 + noteHandler notificationHandler 162 + 163 + closeOnce sync.Once 164 + closeErr error 165 + done chan struct{} 166 + 167 + stderrMu sync.Mutex 168 + stderrBuf bytes.Buffer 169 + } 170 + 171 + func RunPrompt(ctx context.Context, cfg PreparedAgentConfig, req RunRequest) (RunResult, error) { 172 + return runPromptWithFactory(ctx, cfg, req, newStdioConn) 173 + } 174 + 175 + func runPromptWithFactory(ctx context.Context, cfg PreparedAgentConfig, req RunRequest, factory connFactory) (RunResult, error) { 176 + if ctx == nil { 177 + ctx = context.Background() 178 + } 179 + if strings.TrimSpace(req.Prompt) == "" { 180 + return RunResult{}, fmt.Errorf("empty acp prompt") 181 + } 182 + if factory == nil { 183 + factory = newStdioConn 184 + } 185 + conn, err := factory(ctx, cfg) 186 + if err != nil { 187 + return RunResult{}, err 188 + } 189 + defer func() { _ = conn.Close() }() 190 + terminals := newTerminalManager(cfg) 191 + defer terminals.Close() 192 + 193 + initResp, err := conn.initialize(ctx) 194 + if err != nil { 195 + return RunResult{}, err 196 + } 197 + if err := conn.authenticate(ctx, cfg, initResp.AuthMethods); err != nil { 198 + return RunResult{}, err 199 + } 200 + 201 + session, err := conn.newSession(ctx, cfg) 202 + if err != nil { 203 + return RunResult{}, err 204 + } 205 + if err := conn.applySessionOptions(ctx, session.SessionID, cfg.SessionOptionsMeta, session.ConfigOptions); err != nil { 206 + return RunResult{}, err 207 + } 208 + 209 + state := newRunState(req.Observer) 210 + conn.reqHandler = func(callCtx context.Context, msg rpcMessage) (any, *rpcError) { 211 + return handleIncomingRequest(callCtx, cfg, session.SessionID, terminals, msg) 212 + } 213 + conn.noteHandler = func(noteCtx context.Context, msg rpcMessage) { 214 + handleNotification(noteCtx, session.SessionID, msg, state) 215 + } 216 + 217 + stopCancelWatcher := make(chan struct{}) 218 + cancelDone := make(chan struct{}) 219 + go func() { 220 + defer close(cancelDone) 221 + select { 222 + case <-ctx.Done(): 223 + _ = conn.notify(context.Background(), methodSessionCancel, map[string]any{ 224 + "sessionId": session.SessionID, 225 + }) 226 + case <-stopCancelWatcher: 227 + case <-conn.done: 228 + } 229 + }() 230 + defer func() { 231 + close(stopCancelWatcher) 232 + <-cancelDone 233 + }() 234 + 235 + stopReason, err := conn.prompt(ctx, session.SessionID, req.Prompt) 236 + if err != nil { 237 + return RunResult{}, err 238 + } 239 + if err := ctx.Err(); err != nil { 240 + return RunResult{}, err 241 + } 242 + slog.Default().Debug("acp_prompt_done", "agent", cfg.Name, "stop_reason", stopReason) 243 + 244 + return RunResult{ 245 + SessionID: session.SessionID, 246 + StopReason: stopReason, 247 + Output: state.output(), 248 + }, nil 249 + } 250 + 251 + func newStdioConn(parent context.Context, cfg PreparedAgentConfig) (*rpcConn, error) { 252 + ctx, cancel := context.WithCancel(context.Background()) 253 + cmd := exec.CommandContext(ctx, cfg.Command, cfg.Args...) 254 + cmd.Dir = cfg.CWD 255 + cmd.Env = os.Environ() 256 + for k, v := range cfg.Env { 257 + key := strings.TrimSpace(k) 258 + if key == "" { 259 + continue 260 + } 261 + cmd.Env = append(cmd.Env, key+"="+v) 262 + } 263 + stdin, err := cmd.StdinPipe() 264 + if err != nil { 265 + cancel() 266 + return nil, fmt.Errorf("acp stdio stdin: %w", err) 267 + } 268 + stdout, err := cmd.StdoutPipe() 269 + if err != nil { 270 + cancel() 271 + return nil, fmt.Errorf("acp stdio stdout: %w", err) 272 + } 273 + stderr, err := cmd.StderrPipe() 274 + if err != nil { 275 + cancel() 276 + return nil, fmt.Errorf("acp stdio stderr: %w", err) 277 + } 278 + if err := cmd.Start(); err != nil { 279 + cancel() 280 + return nil, fmt.Errorf("acp stdio start: %w", err) 281 + } 282 + 283 + conn := &rpcConn{ 284 + ctx: parent, 285 + cancel: cancel, 286 + cmd: cmd, 287 + stdin: stdin, 288 + stdout: stdout, 289 + stderr: stderr, 290 + enc: json.NewEncoder(stdin), 291 + pending: map[string]chan pendingResponse{}, 292 + done: make(chan struct{}), 293 + } 294 + go conn.readLoop() 295 + go conn.drainStderr() 296 + return conn, nil 297 + } 298 + 299 + func (c *rpcConn) initialize(ctx context.Context) (initResult, error) { 300 + startedAt := timeNow() 301 + result, err := c.call(ctx, methodInitialize, map[string]any{ 302 + "protocolVersion": protocolVersion, 303 + "clientCapabilities": map[string]any{ 304 + "fs": map[string]any{ 305 + "readTextFile": true, 306 + "writeTextFile": true, 307 + }, 308 + "terminal": true, 309 + }, 310 + "clientInfo": map[string]any{ 311 + "name": defaultClientName, 312 + "title": defaultClientTitle, 313 + "version": defaultClientVersion, 314 + }, 315 + }) 316 + if err != nil { 317 + return initResult{}, fmt.Errorf("acp initialize: %w", err) 318 + } 319 + var resp struct { 320 + ProtocolVersion int `json:"protocolVersion"` 321 + AuthMethods []authMethod `json:"authMethods"` 322 + } 323 + if err := json.Unmarshal(result, &resp); err != nil { 324 + return initResult{}, fmt.Errorf("decode acp initialize response: %w", err) 325 + } 326 + if resp.ProtocolVersion != protocolVersion { 327 + return initResult{}, fmt.Errorf("unsupported acp protocol version %d", resp.ProtocolVersion) 328 + } 329 + slog.Default().Debug("acp_initialize_done", "duration_ms", sinceMillis(startedAt), "auth_methods", len(resp.AuthMethods)) 330 + return initResult{AuthMethods: resp.AuthMethods}, nil 331 + } 332 + 333 + func (c *rpcConn) authenticate(ctx context.Context, cfg PreparedAgentConfig, methods []authMethod) error { 334 + methodID := selectAuthMethod(cfg, methods) 335 + if methodID == "" { 336 + slog.Default().Debug("acp_authenticate_skipped", "agent", cfg.Name) 337 + return nil 338 + } 339 + startedAt := timeNow() 340 + _, err := c.call(ctx, methodAuthenticate, map[string]any{ 341 + "methodId": methodID, 342 + }) 343 + if err != nil { 344 + return fmt.Errorf("acp authenticate(%s): %w", methodID, err) 345 + } 346 + slog.Default().Debug("acp_authenticate_done", "agent", cfg.Name, "method_id", methodID, "duration_ms", sinceMillis(startedAt)) 347 + return nil 348 + } 349 + 350 + func (c *rpcConn) newSession(ctx context.Context, cfg PreparedAgentConfig) (newSessionResult, error) { 351 + startedAt := timeNow() 352 + params := map[string]any{ 353 + "cwd": cfg.CWD, 354 + "mcpServers": []any{}, 355 + } 356 + if len(cfg.SessionOptionsMeta) > 0 { 357 + params["_meta"] = cloneMeta(cfg.SessionOptionsMeta) 358 + } 359 + result, err := c.call(ctx, methodSessionNew, params) 360 + if err != nil { 361 + return newSessionResult{}, fmt.Errorf("acp session/new: %w", err) 362 + } 363 + var resp newSessionResult 364 + if err := json.Unmarshal(result, &resp); err != nil { 365 + return newSessionResult{}, fmt.Errorf("decode acp session/new response: %w", err) 366 + } 367 + resp.SessionID = strings.TrimSpace(resp.SessionID) 368 + if resp.SessionID == "" { 369 + return newSessionResult{}, fmt.Errorf("acp session/new returned empty sessionId") 370 + } 371 + slog.Default().Debug("acp_session_new_done", "agent", cfg.Name, "duration_ms", sinceMillis(startedAt), "config_options", len(resp.ConfigOptions)) 372 + return resp, nil 373 + } 374 + 375 + func (c *rpcConn) applySessionOptions(ctx context.Context, sessionID string, options map[string]any, configOptions []sessionConfigInfo) error { 376 + if len(options) == 0 { 377 + return nil 378 + } 379 + allowed := map[string]struct{}{} 380 + for _, option := range configOptions { 381 + id := strings.TrimSpace(option.ID) 382 + if id == "" { 383 + continue 384 + } 385 + allowed[id] = struct{}{} 386 + } 387 + if len(allowed) == 0 { 388 + return nil 389 + } 390 + for configID, value := range options { 391 + configID = strings.TrimSpace(configID) 392 + if configID == "" { 393 + continue 394 + } 395 + if _, ok := allowed[configID]; !ok { 396 + continue 397 + } 398 + startedAt := timeNow() 399 + params := map[string]any{ 400 + "sessionId": sessionID, 401 + "configId": configID, 402 + "value": value, 403 + } 404 + if _, ok := value.(bool); ok { 405 + params["type"] = "boolean" 406 + } 407 + if _, err := c.call(ctx, methodSessionSetConfig, params); err != nil { 408 + return fmt.Errorf("acp session/set_config_option(%s): %w", configID, err) 409 + } 410 + slog.Default().Debug("acp_session_config_done", "config_id", configID, "duration_ms", sinceMillis(startedAt)) 411 + } 412 + return nil 413 + } 414 + 415 + func selectAuthMethod(cfg PreparedAgentConfig, methods []authMethod) string { 416 + if len(methods) == 0 { 417 + return "" 418 + } 419 + byID := make(map[string]authMethod, len(methods)) 420 + for _, method := range methods { 421 + id := normalizeAuthMethodID(method.ID) 422 + if id == "" { 423 + continue 424 + } 425 + byID[id] = method 426 + } 427 + if _, ok := byID["codex-api-key"]; ok && hasEffectiveEnv(cfg, "CODEX_API_KEY") { 428 + return "codex-api-key" 429 + } 430 + if _, ok := byID["openai-api-key"]; ok && hasEffectiveEnv(cfg, "OPENAI_API_KEY") { 431 + return "openai-api-key" 432 + } 433 + if _, ok := byID["chatgpt"]; ok { 434 + return "chatgpt" 435 + } 436 + for _, method := range methods { 437 + id := normalizeAuthMethodID(method.ID) 438 + if id == "" { 439 + continue 440 + } 441 + if strings.EqualFold(strings.TrimSpace(method.Type), "env_var") && authVarsAvailable(cfg, method.Vars) { 442 + return id 443 + } 444 + } 445 + return "" 446 + } 447 + 448 + func normalizeAuthMethodID(raw string) string { 449 + return strings.ToLower(strings.TrimSpace(raw)) 450 + } 451 + 452 + func authVarsAvailable(cfg PreparedAgentConfig, vars []authEnvVar) bool { 453 + if len(vars) == 0 { 454 + return false 455 + } 456 + for _, variable := range vars { 457 + if !hasEffectiveEnv(cfg, variable.Name) { 458 + return false 459 + } 460 + } 461 + return true 462 + } 463 + 464 + func hasEffectiveEnv(cfg PreparedAgentConfig, name string) bool { 465 + name = strings.TrimSpace(name) 466 + if name == "" { 467 + return false 468 + } 469 + if value := strings.TrimSpace(cfg.Env[name]); value != "" { 470 + return true 471 + } 472 + value, ok := os.LookupEnv(name) 473 + return ok && strings.TrimSpace(value) != "" 474 + } 475 + 476 + func (c *rpcConn) prompt(ctx context.Context, sessionID string, prompt string) (string, error) { 477 + startedAt := timeNow() 478 + slog.Default().Debug("acp_prompt_start", "prompt_len", len(strings.TrimSpace(prompt))) 479 + result, err := c.call(ctx, methodSessionPrompt, map[string]any{ 480 + "sessionId": sessionID, 481 + "prompt": []map[string]any{ 482 + { 483 + "type": "text", 484 + "text": prompt, 485 + }, 486 + }, 487 + }) 488 + if err != nil { 489 + return "", fmt.Errorf("acp session/prompt: %w", err) 490 + } 491 + var resp struct { 492 + StopReason string `json:"stopReason"` 493 + } 494 + if err := json.Unmarshal(result, &resp); err != nil { 495 + return "", fmt.Errorf("decode acp session/prompt response: %w", err) 496 + } 497 + stopReason := strings.TrimSpace(resp.StopReason) 498 + if stopReason == "" { 499 + return "", fmt.Errorf("acp session/prompt returned empty stopReason") 500 + } 501 + slog.Default().Debug("acp_prompt_response", "duration_ms", sinceMillis(startedAt), "stop_reason", stopReason) 502 + return stopReason, nil 503 + } 504 + 505 + func (c *rpcConn) call(ctx context.Context, method string, params any) (json.RawMessage, error) { 506 + if ctx == nil { 507 + ctx = context.Background() 508 + } 509 + id := atomic.AddInt64(&c.nextID, 1) 510 + idRaw, _ := json.Marshal(id) 511 + key := string(idRaw) 512 + respCh := make(chan pendingResponse, 1) 513 + 514 + c.pendMu.Lock() 515 + c.pending[key] = respCh 516 + c.pendMu.Unlock() 517 + 518 + if err := c.send(rpcMessage{ 519 + JSONRPC: jsonRPCVersion, 520 + ID: idRaw, 521 + Method: method, 522 + Params: mustMarshalRaw(params), 523 + }); err != nil { 524 + c.deletePending(key) 525 + return nil, err 526 + } 527 + 528 + select { 529 + case <-ctx.Done(): 530 + c.deletePending(key) 531 + return nil, ctx.Err() 532 + case <-c.done: 533 + c.deletePending(key) 534 + if stderr := strings.TrimSpace(c.stderrString()); stderr != "" { 535 + return nil, fmt.Errorf("acp connection closed: %s", stderr) 536 + } 537 + return nil, io.EOF 538 + case resp := <-respCh: 539 + if err := ctx.Err(); err != nil { 540 + return nil, err 541 + } 542 + if resp.err != nil { 543 + return nil, resp.err 544 + } 545 + return resp.result, nil 546 + } 547 + } 548 + 549 + func (c *rpcConn) notify(ctx context.Context, method string, params any) error { 550 + if ctx == nil { 551 + ctx = context.Background() 552 + } 553 + select { 554 + case <-ctx.Done(): 555 + return ctx.Err() 556 + case <-c.done: 557 + return io.EOF 558 + default: 559 + } 560 + return c.send(rpcMessage{ 561 + JSONRPC: jsonRPCVersion, 562 + Method: method, 563 + Params: mustMarshalRaw(params), 564 + }) 565 + } 566 + 567 + func (c *rpcConn) send(msg rpcMessage) error { 568 + c.writeMu.Lock() 569 + defer c.writeMu.Unlock() 570 + return c.enc.Encode(msg) 571 + } 572 + 573 + func (c *rpcConn) readLoop() { 574 + defer close(c.done) 575 + dec := json.NewDecoder(c.stdout) 576 + for { 577 + var msg rpcMessage 578 + if err := dec.Decode(&msg); err != nil { 579 + if err == io.EOF { 580 + return 581 + } 582 + c.failPending(fmt.Errorf("acp decode: %w", err)) 583 + return 584 + } 585 + switch { 586 + case strings.TrimSpace(msg.Method) != "" && len(msg.ID) > 0: 587 + result, rpcErr := c.handleRequest(msg) 588 + response := rpcMessage{ 589 + JSONRPC: jsonRPCVersion, 590 + ID: append(json.RawMessage(nil), msg.ID...), 591 + } 592 + if rpcErr != nil { 593 + response.Error = rpcErr 594 + } else { 595 + response.Result = mustMarshalRaw(result) 596 + } 597 + _ = c.send(response) 598 + case strings.TrimSpace(msg.Method) != "": 599 + c.handleNotification(msg) 600 + default: 601 + c.handleResponse(msg) 602 + } 603 + } 604 + } 605 + 606 + func (c *rpcConn) handleRequest(msg rpcMessage) (any, *rpcError) { 607 + if c.reqHandler == nil { 608 + return nil, &rpcError{Code: rpcCodeMethodNotFound, Message: "method not found"} 609 + } 610 + return c.reqHandler(c.ctx, msg) 611 + } 612 + 613 + func (c *rpcConn) handleNotification(msg rpcMessage) { 614 + if c.noteHandler == nil { 615 + return 616 + } 617 + c.noteHandler(c.ctx, msg) 618 + } 619 + 620 + func (c *rpcConn) handleResponse(msg rpcMessage) { 621 + key := string(msg.ID) 622 + c.pendMu.Lock() 623 + ch, ok := c.pending[key] 624 + if ok { 625 + delete(c.pending, key) 626 + } 627 + c.pendMu.Unlock() 628 + if !ok { 629 + return 630 + } 631 + ch <- pendingResponse{result: msg.Result, err: msg.Error} 632 + } 633 + 634 + func (c *rpcConn) deletePending(key string) { 635 + c.pendMu.Lock() 636 + defer c.pendMu.Unlock() 637 + delete(c.pending, key) 638 + } 639 + 640 + func (c *rpcConn) failPending(err error) { 641 + c.pendMu.Lock() 642 + defer c.pendMu.Unlock() 643 + if len(c.pending) == 0 { 644 + return 645 + } 646 + rpcErr := &rpcError{Code: rpcCodeInternalError, Message: strings.TrimSpace(err.Error())} 647 + for key, ch := range c.pending { 648 + ch <- pendingResponse{err: rpcErr} 649 + delete(c.pending, key) 650 + } 651 + } 652 + 653 + func (c *rpcConn) drainStderr() { 654 + if c.stderr == nil { 655 + return 656 + } 657 + _, _ = io.Copy(&lockedWriter{mu: &c.stderrMu, buf: &c.stderrBuf}, c.stderr) 658 + } 659 + 660 + func (c *rpcConn) stderrString() string { 661 + c.stderrMu.Lock() 662 + defer c.stderrMu.Unlock() 663 + return c.stderrBuf.String() 664 + } 665 + 666 + func (c *rpcConn) Close() error { 667 + c.closeOnce.Do(func() { 668 + if c.stdin != nil { 669 + _ = c.stdin.Close() 670 + } 671 + if c.cancel != nil { 672 + c.cancel() 673 + } 674 + if c.cmd != nil { 675 + c.closeErr = c.cmd.Wait() 676 + } 677 + }) 678 + return c.closeErr 679 + } 680 + 681 + type runState struct { 682 + observer Observer 683 + mu sync.Mutex 684 + outputs []string 685 + toolInfo map[string]Event 686 + } 687 + 688 + func newRunState(observer Observer) *runState { 689 + return &runState{ 690 + observer: observer, 691 + toolInfo: map[string]Event{}, 692 + } 693 + } 694 + 695 + func (s *runState) emit(ctx context.Context, event Event) { 696 + if s == nil { 697 + return 698 + } 699 + if event.Kind == EventKindAgentMessageChunk && strings.TrimSpace(event.Text) != "" { 700 + s.mu.Lock() 701 + s.outputs = append(s.outputs, event.Text) 702 + s.mu.Unlock() 703 + } 704 + if strings.TrimSpace(event.ToolCallID) != "" { 705 + s.mu.Lock() 706 + prev := s.toolInfo[event.ToolCallID] 707 + if strings.TrimSpace(event.Title) == "" { 708 + event.Title = prev.Title 709 + } 710 + if strings.TrimSpace(event.ToolKind) == "" { 711 + event.ToolKind = prev.ToolKind 712 + } 713 + if strings.TrimSpace(event.Status) == "" { 714 + event.Status = prev.Status 715 + } 716 + s.toolInfo[event.ToolCallID] = event 717 + s.mu.Unlock() 718 + } 719 + if s.observer != nil { 720 + s.observer.HandleACPEvent(ctx, event) 721 + } 722 + } 723 + 724 + func (s *runState) output() string { 725 + if s == nil { 726 + return "" 727 + } 728 + s.mu.Lock() 729 + defer s.mu.Unlock() 730 + return strings.TrimSpace(strings.Join(s.outputs, "")) 731 + } 732 + 733 + func handleIncomingRequest(ctx context.Context, cfg PreparedAgentConfig, sessionID string, terminals *terminalManager, msg rpcMessage) (any, *rpcError) { 734 + slog.Default().Debug("acp_request_in", "method", strings.TrimSpace(msg.Method)) 735 + switch strings.TrimSpace(msg.Method) { 736 + case methodRequestPerm: 737 + return handlePermissionRequest(ctx, msg.Params) 738 + case methodReadTextFile: 739 + return handleReadTextFile(sessionID, cfg, msg.Params) 740 + case methodWriteTextFile: 741 + return handleWriteTextFile(sessionID, cfg, msg.Params) 742 + case methodTerminalCreate: 743 + return terminals.create(msg.Params) 744 + case methodTerminalOutput: 745 + return terminals.output(msg.Params) 746 + case methodTerminalWaitForExit: 747 + return terminals.waitForExit(ctx, msg.Params) 748 + case methodTerminalKill: 749 + return terminals.kill(msg.Params) 750 + case methodTerminalRelease: 751 + return terminals.release(msg.Params) 752 + default: 753 + return nil, &rpcError{Code: rpcCodeMethodNotFound, Message: "method not found"} 754 + } 755 + } 756 + 757 + func handleNotification(ctx context.Context, sessionID string, msg rpcMessage, state *runState) { 758 + if strings.TrimSpace(msg.Method) != methodSessionUpdate { 759 + return 760 + } 761 + var note struct { 762 + SessionID string `json:"sessionId"` 763 + Update json.RawMessage `json:"update"` 764 + } 765 + if err := json.Unmarshal(msg.Params, &note); err != nil { 766 + return 767 + } 768 + if strings.TrimSpace(note.SessionID) != strings.TrimSpace(sessionID) { 769 + return 770 + } 771 + var kind struct { 772 + SessionUpdate string `json:"sessionUpdate"` 773 + } 774 + if err := json.Unmarshal(note.Update, &kind); err != nil { 775 + return 776 + } 777 + slog.Default().Debug("acp_update_in", "kind", strings.TrimSpace(kind.SessionUpdate)) 778 + switch strings.TrimSpace(kind.SessionUpdate) { 779 + case string(EventKindAgentMessageChunk): 780 + var update struct { 781 + Content any `json:"content"` 782 + } 783 + if err := json.Unmarshal(note.Update, &update); err != nil { 784 + return 785 + } 786 + text := extractText(update.Content) 787 + if strings.TrimSpace(text) == "" { 788 + return 789 + } 790 + state.emit(ctx, Event{ 791 + Kind: EventKindAgentMessageChunk, 792 + Text: text, 793 + }) 794 + case "tool_call": 795 + var update struct { 796 + ToolCallID string `json:"toolCallId"` 797 + Title string `json:"title"` 798 + Kind string `json:"kind"` 799 + Status string `json:"status"` 800 + Content any `json:"content"` 801 + } 802 + if err := json.Unmarshal(note.Update, &update); err != nil { 803 + return 804 + } 805 + text := extractText(update.Content) 806 + state.emit(ctx, Event{ 807 + Kind: EventKindToolCallStart, 808 + ToolCallID: strings.TrimSpace(update.ToolCallID), 809 + Title: strings.TrimSpace(update.Title), 810 + ToolKind: strings.TrimSpace(update.Kind), 811 + Status: strings.TrimSpace(update.Status), 812 + Text: text, 813 + }) 814 + case "tool_call_update": 815 + var update struct { 816 + ToolCallID string `json:"toolCallId"` 817 + Title string `json:"title"` 818 + Kind string `json:"kind"` 819 + Status string `json:"status"` 820 + Content any `json:"content"` 821 + } 822 + if err := json.Unmarshal(note.Update, &update); err != nil { 823 + return 824 + } 825 + eventKind := EventKindToolCallUpdate 826 + status := strings.TrimSpace(update.Status) 827 + switch status { 828 + case "completed", "failed": 829 + eventKind = EventKindToolCallDone 830 + } 831 + state.emit(ctx, Event{ 832 + Kind: eventKind, 833 + ToolCallID: strings.TrimSpace(update.ToolCallID), 834 + Title: strings.TrimSpace(update.Title), 835 + ToolKind: strings.TrimSpace(update.Kind), 836 + Status: status, 837 + Text: extractText(update.Content), 838 + }) 839 + } 840 + } 841 + 842 + func handlePermissionRequest(ctx context.Context, raw json.RawMessage) (any, *rpcError) { 843 + var req struct { 844 + ToolCall struct { 845 + Kind string `json:"kind"` 846 + Title string `json:"title"` 847 + } `json:"toolCall"` 848 + Options []struct { 849 + OptionID string `json:"optionId"` 850 + Kind string `json:"kind"` 851 + } `json:"options"` 852 + } 853 + if err := json.Unmarshal(raw, &req); err != nil { 854 + return nil, &rpcError{Code: rpcCodeInvalidParams, Message: "invalid permission request"} 855 + } 856 + if ctx != nil && ctx.Err() != nil { 857 + return map[string]any{"outcome": permissionOutcomeCancel}, nil 858 + } 859 + if optionID, ok := choosePermissionOption(req.ToolCall.Kind, req.ToolCall.Title, req.Options); ok { 860 + return map[string]any{ 861 + "outcome": permissionOutcomeSel, 862 + "optionId": optionID, 863 + }, nil 864 + } 865 + return map[string]any{"outcome": permissionOutcomeCancel}, nil 866 + } 867 + 868 + func choosePermissionOption(toolKind string, title string, options []struct { 869 + OptionID string `json:"optionId"` 870 + Kind string `json:"kind"` 871 + }) (string, bool) { 872 + if len(options) == 0 { 873 + return "", false 874 + } 875 + for _, wanted := range []string{permissionAllowOnce, permissionAllowAlways, permissionRejectOnce} { 876 + for _, option := range options { 877 + if strings.TrimSpace(option.Kind) != wanted || strings.TrimSpace(option.OptionID) == "" { 878 + continue 879 + } 880 + return strings.TrimSpace(option.OptionID), true 881 + } 882 + } 883 + return "", false 884 + } 885 + 886 + func handleReadTextFile(sessionID string, cfg PreparedAgentConfig, raw json.RawMessage) (any, *rpcError) { 887 + var req struct { 888 + SessionID string `json:"sessionId"` 889 + Path string `json:"path"` 890 + Line int `json:"line"` 891 + Limit int `json:"limit"` 892 + } 893 + if err := json.Unmarshal(raw, &req); err != nil { 894 + return nil, &rpcError{Code: rpcCodeInvalidParams, Message: "invalid fs/read_text_file request"} 895 + } 896 + if strings.TrimSpace(req.SessionID) != strings.TrimSpace(sessionID) { 897 + return nil, &rpcError{Code: rpcCodeInvalidParams, Message: "sessionId mismatch"} 898 + } 899 + path, err := resolveAllowedPath(req.Path, cfg.ReadRoots) 900 + if err != nil { 901 + return nil, &rpcError{Code: rpcCodeInvalidParams, Message: err.Error()} 902 + } 903 + data, err := os.ReadFile(path) 904 + if err != nil { 905 + return nil, &rpcError{Code: rpcCodeInternalError, Message: err.Error()} 906 + } 907 + return map[string]any{ 908 + "content": sliceLines(string(data), req.Line, req.Limit), 909 + }, nil 910 + } 911 + 912 + func handleWriteTextFile(sessionID string, cfg PreparedAgentConfig, raw json.RawMessage) (any, *rpcError) { 913 + var req struct { 914 + SessionID string `json:"sessionId"` 915 + Path string `json:"path"` 916 + Content string `json:"content"` 917 + } 918 + if err := json.Unmarshal(raw, &req); err != nil { 919 + return nil, &rpcError{Code: rpcCodeInvalidParams, Message: "invalid fs/write_text_file request"} 920 + } 921 + if strings.TrimSpace(req.SessionID) != strings.TrimSpace(sessionID) { 922 + return nil, &rpcError{Code: rpcCodeInvalidParams, Message: "sessionId mismatch"} 923 + } 924 + path, err := resolveAllowedPath(req.Path, cfg.WriteRoots) 925 + if err != nil { 926 + return nil, &rpcError{Code: rpcCodeInvalidParams, Message: err.Error()} 927 + } 928 + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { 929 + return nil, &rpcError{Code: rpcCodeInternalError, Message: err.Error()} 930 + } 931 + if err := os.WriteFile(path, []byte(req.Content), 0o644); err != nil { 932 + return nil, &rpcError{Code: rpcCodeInternalError, Message: err.Error()} 933 + } 934 + return nil, nil 935 + } 936 + 937 + func resolveAllowedPath(rawPath string, roots []string) (string, error) { 938 + path := strings.TrimSpace(rawPath) 939 + if path == "" { 940 + return "", fmt.Errorf("path is required") 941 + } 942 + if !filepath.IsAbs(path) { 943 + return "", fmt.Errorf("path must be absolute") 944 + } 945 + absPath, err := filepath.Abs(path) 946 + if err != nil { 947 + return "", err 948 + } 949 + for _, root := range roots { 950 + root = strings.TrimSpace(root) 951 + if root == "" { 952 + continue 953 + } 954 + absRoot, err := filepath.Abs(root) 955 + if err != nil { 956 + continue 957 + } 958 + if isWithinRoot(absRoot, absPath) { 959 + return absPath, nil 960 + } 961 + } 962 + return "", fmt.Errorf("path %q is outside allowed roots", absPath) 963 + } 964 + 965 + func isWithinRoot(root string, target string) bool { 966 + root = filepath.Clean(root) 967 + target = filepath.Clean(target) 968 + if root == target { 969 + return true 970 + } 971 + rel, err := filepath.Rel(root, target) 972 + if err != nil { 973 + return false 974 + } 975 + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) 976 + } 977 + 978 + func sliceLines(content string, line int, limit int) string { 979 + if line <= 1 && limit <= 0 { 980 + return content 981 + } 982 + lines := strings.SplitAfter(content, "\n") 983 + if len(lines) == 1 && !strings.Contains(content, "\n") { 984 + lines = []string{content} 985 + } 986 + start := 0 987 + if line > 1 { 988 + start = line - 1 989 + if start >= len(lines) { 990 + return "" 991 + } 992 + } 993 + end := len(lines) 994 + if limit > 0 && start+limit < end { 995 + end = start + limit 996 + } 997 + return strings.Join(lines[start:end], "") 998 + } 999 + 1000 + func extractText(value any) string { 1001 + var parts []string 1002 + collectText(&parts, value) 1003 + return strings.TrimSpace(strings.Join(parts, "\n")) 1004 + } 1005 + 1006 + func collectText(parts *[]string, value any) { 1007 + switch v := value.(type) { 1008 + case nil: 1009 + return 1010 + case string: 1011 + text := strings.TrimSpace(v) 1012 + if text != "" { 1013 + *parts = append(*parts, text) 1014 + } 1015 + case []any: 1016 + for _, item := range v { 1017 + collectText(parts, item) 1018 + } 1019 + case map[string]any: 1020 + if typ, _ := v["type"].(string); strings.EqualFold(strings.TrimSpace(typ), "text") { 1021 + if text, _ := v["text"].(string); strings.TrimSpace(text) != "" { 1022 + *parts = append(*parts, strings.TrimSpace(text)) 1023 + } 1024 + } 1025 + if content, ok := v["content"]; ok { 1026 + collectText(parts, content) 1027 + } 1028 + for _, key := range []string{"rawOutput", "rawInput"} { 1029 + if item, ok := v[key]; ok { 1030 + collectText(parts, item) 1031 + } 1032 + } 1033 + } 1034 + } 1035 + 1036 + func mustMarshalRaw(value any) json.RawMessage { 1037 + if value == nil { 1038 + return json.RawMessage("null") 1039 + } 1040 + b, err := json.Marshal(value) 1041 + if err != nil { 1042 + return json.RawMessage("null") 1043 + } 1044 + return b 1045 + } 1046 + 1047 + func timeNow() int64 { 1048 + return timeNowFunc().UnixMilli() 1049 + } 1050 + 1051 + func sinceMillis(startedAt int64) int64 { 1052 + if startedAt <= 0 { 1053 + return 0 1054 + } 1055 + return timeNowFunc().UnixMilli() - startedAt 1056 + } 1057 + 1058 + var timeNowFunc = func() time.Time { 1059 + return time.Now() 1060 + } 1061 + 1062 + func cloneMeta(in map[string]any) map[string]any { 1063 + if len(in) == 0 { 1064 + return nil 1065 + } 1066 + out := make(map[string]any, len(in)) 1067 + for k, v := range in { 1068 + key := strings.TrimSpace(k) 1069 + if key == "" { 1070 + continue 1071 + } 1072 + out[key] = v 1073 + } 1074 + if len(out) == 0 { 1075 + return nil 1076 + } 1077 + return out 1078 + } 1079 + 1080 + type lockedWriter struct { 1081 + mu *sync.Mutex 1082 + buf *bytes.Buffer 1083 + } 1084 + 1085 + func (w *lockedWriter) Write(p []byte) (int, error) { 1086 + if w == nil || w.mu == nil || w.buf == nil { 1087 + return len(p), nil 1088 + } 1089 + w.mu.Lock() 1090 + defer w.mu.Unlock() 1091 + return w.buf.Write(p) 1092 + }
+742
internal/acpclient/client_test.go
··· 1 + package acpclient 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "errors" 8 + "io" 9 + "net" 10 + "os" 11 + "path/filepath" 12 + "runtime" 13 + "testing" 14 + "time" 15 + ) 16 + 17 + func TestRunPrompt_RoundTrip(t *testing.T) { 18 + t.Parallel() 19 + 20 + dir := t.TempDir() 21 + readPath := filepath.Join(dir, "input.txt") 22 + writePath := filepath.Join(dir, "output.txt") 23 + if err := os.WriteFile(readPath, []byte("alpha\nbravo\ncharlie\n"), 0o644); err != nil { 24 + t.Fatalf("WriteFile(readPath) error = %v", err) 25 + } 26 + 27 + cfg := AgentConfig{ 28 + Name: "helper", 29 + Enable: true, 30 + Type: "stdio", 31 + Command: "helper", 32 + CWD: dir, 33 + ReadRoots: []string{dir}, 34 + WriteRoots: []string{dir}, 35 + SessionOptions: map[string]any{"approvalMode": "manual"}, 36 + } 37 + prepared, err := PrepareAgentConfig(cfg, "") 38 + if err != nil { 39 + t.Fatalf("PrepareAgentConfig() error = %v", err) 40 + } 41 + 42 + var events []Event 43 + result, err := runPromptWithFactory(context.Background(), prepared, RunRequest{ 44 + Prompt: "summarize this change", 45 + Observer: ObserverFunc(func(_ context.Context, event Event) { 46 + events = append(events, event) 47 + }), 48 + }, fakeACPConnFactory(t, func(dec *json.Decoder, enc *json.Encoder) { 49 + initMsg := decodeTestMessage(t, dec) 50 + if initMsg.Method != methodInitialize { 51 + t.Fatalf("method = %q, want %q", initMsg.Method, methodInitialize) 52 + } 53 + encodeTestResponse(t, enc, initMsg.ID, map[string]any{"protocolVersion": protocolVersion}) 54 + 55 + newMsg := decodeTestMessage(t, dec) 56 + if newMsg.Method != methodSessionNew { 57 + t.Fatalf("method = %q, want %q", newMsg.Method, methodSessionNew) 58 + } 59 + var newParams map[string]any 60 + if err := json.Unmarshal(newMsg.Params, &newParams); err != nil { 61 + t.Fatalf("json.Unmarshal(session/new) error = %v", err) 62 + } 63 + if got := asString(newParams["cwd"]); got != dir { 64 + t.Fatalf("cwd = %q, want %q", got, dir) 65 + } 66 + if got := asString(newParams["_meta"].(map[string]any)["approvalMode"]); got != "manual" { 67 + t.Fatalf("_meta.approvalMode = %q, want manual", got) 68 + } 69 + encodeTestResponse(t, enc, newMsg.ID, map[string]any{"sessionId": "sess_helper"}) 70 + 71 + promptMsg := decodeTestMessage(t, dec) 72 + if promptMsg.Method != methodSessionPrompt { 73 + t.Fatalf("method = %q, want %q", promptMsg.Method, methodSessionPrompt) 74 + } 75 + 76 + encodeTestRequest(t, enc, mustMarshalRaw("perm-1"), methodRequestPerm, map[string]any{ 77 + "sessionId": "sess_helper", 78 + "toolCall": map[string]any{ 79 + "toolCallId": "call_edit", 80 + "title": "Edit file", 81 + "kind": "edit", 82 + "status": "pending", 83 + }, 84 + "options": []map[string]any{ 85 + {"optionId": "allow", "kind": permissionAllowOnce}, 86 + {"optionId": "reject", "kind": permissionRejectOnce}, 87 + }, 88 + }) 89 + permResp := decodeTestMessage(t, dec) 90 + var permResult map[string]any 91 + if err := json.Unmarshal(permResp.Result, &permResult); err != nil { 92 + t.Fatalf("json.Unmarshal(permission result) error = %v", err) 93 + } 94 + if got := asString(permResult["outcome"]); got != permissionOutcomeSel { 95 + t.Fatalf("permission outcome = %q, want %q", got, permissionOutcomeSel) 96 + } 97 + if got := asString(permResult["optionId"]); got != "allow" { 98 + t.Fatalf("permission optionId = %q, want allow", got) 99 + } 100 + 101 + encodeTestRequest(t, enc, mustMarshalRaw("read-1"), methodReadTextFile, map[string]any{ 102 + "sessionId": "sess_helper", 103 + "path": readPath, 104 + "line": 2, 105 + "limit": 1, 106 + }) 107 + readResp := decodeTestMessage(t, dec) 108 + var readResult map[string]any 109 + if err := json.Unmarshal(readResp.Result, &readResult); err != nil { 110 + t.Fatalf("json.Unmarshal(read result) error = %v", err) 111 + } 112 + if got := asString(readResult["content"]); got != "bravo\n" { 113 + t.Fatalf("read content = %q, want %q", got, "bravo\n") 114 + } 115 + 116 + encodeTestRequest(t, enc, mustMarshalRaw("write-1"), methodWriteTextFile, map[string]any{ 117 + "sessionId": "sess_helper", 118 + "path": writePath, 119 + "content": "updated by helper", 120 + }) 121 + writeResp := decodeTestMessage(t, dec) 122 + if string(writeResp.Result) != "null" { 123 + t.Fatalf("write result = %s, want null", string(writeResp.Result)) 124 + } 125 + 126 + encodeTestNotification(t, enc, methodSessionUpdate, map[string]any{ 127 + "sessionId": "sess_helper", 128 + "update": map[string]any{ 129 + "sessionUpdate": "tool_call", 130 + "toolCallId": "call_edit", 131 + "title": "Edit file", 132 + "kind": "edit", 133 + "status": "pending", 134 + "content": []map[string]any{ 135 + {"type": "text", "text": "editing file"}, 136 + }, 137 + }, 138 + }) 139 + encodeTestNotification(t, enc, methodSessionUpdate, map[string]any{ 140 + "sessionId": "sess_helper", 141 + "update": map[string]any{ 142 + "sessionUpdate": "tool_call_update", 143 + "toolCallId": "call_edit", 144 + "status": "completed", 145 + "content": []map[string]any{ 146 + {"type": "text", "text": "done editing"}, 147 + }, 148 + }, 149 + }) 150 + encodeTestNotification(t, enc, methodSessionUpdate, map[string]any{ 151 + "sessionId": "sess_helper", 152 + "update": map[string]any{ 153 + "sessionUpdate": "agent_message_chunk", 154 + "content": map[string]any{ 155 + "type": "text", 156 + "text": "done from helper", 157 + }, 158 + }, 159 + }) 160 + encodeTestResponse(t, enc, promptMsg.ID, map[string]any{"stopReason": "end_turn"}) 161 + })) 162 + if err != nil { 163 + t.Fatalf("RunPrompt() error = %v", err) 164 + } 165 + if result.StopReason != "end_turn" { 166 + t.Fatalf("StopReason = %q, want end_turn", result.StopReason) 167 + } 168 + if result.Output != "done from helper" { 169 + t.Fatalf("Output = %q, want %q", result.Output, "done from helper") 170 + } 171 + data, err := os.ReadFile(writePath) 172 + if err != nil { 173 + t.Fatalf("ReadFile(writePath) error = %v", err) 174 + } 175 + if string(data) != "updated by helper" { 176 + t.Fatalf("written content = %q, want %q", string(data), "updated by helper") 177 + } 178 + if len(events) < 3 { 179 + t.Fatalf("events len = %d, want >= 3", len(events)) 180 + } 181 + if got := events[len(events)-1].Kind; got != EventKindAgentMessageChunk { 182 + t.Fatalf("last event kind = %q, want %q", got, EventKindAgentMessageChunk) 183 + } 184 + } 185 + 186 + func TestRunPrompt_ContextCancelSendsSessionCancel(t *testing.T) { 187 + t.Parallel() 188 + 189 + dir := t.TempDir() 190 + cfg := AgentConfig{ 191 + Name: "helper", 192 + Enable: true, 193 + Type: "stdio", 194 + Command: "helper", 195 + CWD: dir, 196 + ReadRoots: []string{dir}, 197 + WriteRoots: []string{dir}, 198 + } 199 + prepared, err := PrepareAgentConfig(cfg, "") 200 + if err != nil { 201 + t.Fatalf("PrepareAgentConfig() error = %v", err) 202 + } 203 + 204 + ctx, cancel := context.WithCancel(context.Background()) 205 + defer cancel() 206 + 207 + ready := make(chan struct{}) 208 + cancelSeen := make(chan struct{}) 209 + errCh := make(chan error, 1) 210 + go func() { 211 + _, runErr := runPromptWithFactory(ctx, prepared, RunRequest{Prompt: "wait"}, fakeACPConnFactory(t, func(dec *json.Decoder, enc *json.Encoder) { 212 + initMsg := decodeTestMessage(t, dec) 213 + encodeTestResponse(t, enc, initMsg.ID, map[string]any{"protocolVersion": protocolVersion}) 214 + 215 + newMsg := decodeTestMessage(t, dec) 216 + encodeTestResponse(t, enc, newMsg.ID, map[string]any{"sessionId": "sess_cancel"}) 217 + 218 + _ = decodeTestMessage(t, dec) 219 + close(ready) 220 + 221 + cancelMsg := decodeTestMessage(t, dec) 222 + if cancelMsg.Method != methodSessionCancel { 223 + t.Fatalf("method = %q, want %q", cancelMsg.Method, methodSessionCancel) 224 + } 225 + close(cancelSeen) 226 + })) 227 + errCh <- runErr 228 + }() 229 + 230 + <-ready 231 + cancel() 232 + 233 + select { 234 + case <-cancelSeen: 235 + case <-time.After(5 * time.Second): 236 + t.Fatal("timed out waiting for session/cancel") 237 + } 238 + 239 + err = <-errCh 240 + if !errors.Is(err, context.Canceled) { 241 + t.Fatalf("RunPrompt() error = %v, want context canceled", err) 242 + } 243 + } 244 + 245 + func TestRunPrompt_ReturnsBeforeConnectionCloseAfterPrompt(t *testing.T) { 246 + t.Parallel() 247 + 248 + dir := t.TempDir() 249 + cfg := AgentConfig{ 250 + Name: "helper", 251 + Enable: true, 252 + Type: "stdio", 253 + Command: "helper", 254 + CWD: dir, 255 + ReadRoots: []string{dir}, 256 + WriteRoots: []string{dir}, 257 + } 258 + prepared, err := PrepareAgentConfig(cfg, "") 259 + if err != nil { 260 + t.Fatalf("PrepareAgentConfig() error = %v", err) 261 + } 262 + 263 + serverRelease := make(chan struct{}) 264 + defer close(serverRelease) 265 + 266 + ctx, cancel := context.WithTimeout(context.Background(), time.Second) 267 + defer cancel() 268 + 269 + startedAt := time.Now() 270 + result, err := runPromptWithFactory(ctx, prepared, RunRequest{ 271 + Prompt: "reply with ok", 272 + }, fakeACPConnFactory(t, func(dec *json.Decoder, enc *json.Encoder) { 273 + initMsg := decodeTestMessage(t, dec) 274 + encodeTestResponse(t, enc, initMsg.ID, map[string]any{"protocolVersion": protocolVersion}) 275 + 276 + newMsg := decodeTestMessage(t, dec) 277 + encodeTestResponse(t, enc, newMsg.ID, map[string]any{"sessionId": "sess_open"}) 278 + 279 + promptMsg := decodeTestMessage(t, dec) 280 + if promptMsg.Method != methodSessionPrompt { 281 + t.Fatalf("method = %q, want %q", promptMsg.Method, methodSessionPrompt) 282 + } 283 + encodeTestResponse(t, enc, promptMsg.ID, map[string]any{"stopReason": "end_turn"}) 284 + 285 + <-serverRelease 286 + })) 287 + if err != nil { 288 + t.Fatalf("RunPrompt() error = %v", err) 289 + } 290 + if result.StopReason != "end_turn" { 291 + t.Fatalf("StopReason = %q, want end_turn", result.StopReason) 292 + } 293 + if elapsed := time.Since(startedAt); elapsed > 300*time.Millisecond { 294 + t.Fatalf("RunPrompt() took too long: %v", elapsed) 295 + } 296 + } 297 + 298 + func TestRunPrompt_TerminalRoundTrip(t *testing.T) { 299 + t.Parallel() 300 + 301 + dir := t.TempDir() 302 + cfg := AgentConfig{ 303 + Name: "helper", 304 + Enable: true, 305 + Type: "stdio", 306 + Command: "helper", 307 + CWD: dir, 308 + ReadRoots: []string{dir}, 309 + WriteRoots: []string{dir}, 310 + } 311 + prepared, err := PrepareAgentConfig(cfg, "") 312 + if err != nil { 313 + t.Fatalf("PrepareAgentConfig() error = %v", err) 314 + } 315 + 316 + result, err := runPromptWithFactory(context.Background(), prepared, RunRequest{ 317 + Prompt: "use terminal", 318 + }, fakeACPConnFactory(t, func(dec *json.Decoder, enc *json.Encoder) { 319 + initMsg := decodeTestMessage(t, dec) 320 + encodeTestResponse(t, enc, initMsg.ID, map[string]any{"protocolVersion": protocolVersion}) 321 + 322 + newMsg := decodeTestMessage(t, dec) 323 + encodeTestResponse(t, enc, newMsg.ID, map[string]any{"sessionId": "sess_term"}) 324 + 325 + promptMsg := decodeTestMessage(t, dec) 326 + if promptMsg.Method != methodSessionPrompt { 327 + t.Fatalf("method = %q, want %q", promptMsg.Method, methodSessionPrompt) 328 + } 329 + 330 + encodeTestRequest(t, enc, mustMarshalRaw("term-create"), methodTerminalCreate, map[string]any{ 331 + "sessionId": "sess_term", 332 + "command": testTerminalEchoCommand(), 333 + "args": testTerminalEchoArgs(), 334 + "env": []map[string]any{ 335 + {"name": "MM_TERM_TEST", "value": "hello-from-terminal"}, 336 + }, 337 + "cwd": dir, 338 + "outputByteLimit": 4096, 339 + }) 340 + createResp := decodeTestMessage(t, dec) 341 + var createResult map[string]any 342 + if err := json.Unmarshal(createResp.Result, &createResult); err != nil { 343 + t.Fatalf("json.Unmarshal(create result) error = %v", err) 344 + } 345 + terminalID := asString(createResult["terminalId"]) 346 + if terminalID == "" { 347 + t.Fatal("terminalId is empty") 348 + } 349 + 350 + encodeTestRequest(t, enc, mustMarshalRaw("term-wait"), methodTerminalWaitForExit, map[string]any{ 351 + "sessionId": "sess_term", 352 + "terminalId": terminalID, 353 + }) 354 + waitResp := decodeTestMessage(t, dec) 355 + var waitResult map[string]any 356 + if err := json.Unmarshal(waitResp.Result, &waitResult); err != nil { 357 + t.Fatalf("json.Unmarshal(wait result) error = %v", err) 358 + } 359 + if got := intFromAny(waitResult["exitCode"]); got != 0 { 360 + t.Fatalf("exitCode = %d, want 0", got) 361 + } 362 + 363 + encodeTestRequest(t, enc, mustMarshalRaw("term-output"), methodTerminalOutput, map[string]any{ 364 + "sessionId": "sess_term", 365 + "terminalId": terminalID, 366 + }) 367 + outputResp := decodeTestMessage(t, dec) 368 + var outputResult map[string]any 369 + if err := json.Unmarshal(outputResp.Result, &outputResult); err != nil { 370 + t.Fatalf("json.Unmarshal(output result) error = %v", err) 371 + } 372 + if got := asString(outputResult["output"]); got != "hello-from-terminal\n" { 373 + t.Fatalf("output = %q, want %q", got, "hello-from-terminal\n") 374 + } 375 + if truncated, _ := outputResult["truncated"].(bool); truncated { 376 + t.Fatal("terminal output should not be truncated") 377 + } 378 + 379 + encodeTestRequest(t, enc, mustMarshalRaw("term-release"), methodTerminalRelease, map[string]any{ 380 + "sessionId": "sess_term", 381 + "terminalId": terminalID, 382 + }) 383 + releaseResp := decodeTestMessage(t, dec) 384 + if string(releaseResp.Result) != "{}" { 385 + t.Fatalf("release result = %s, want {}", string(releaseResp.Result)) 386 + } 387 + 388 + encodeTestNotification(t, enc, methodSessionUpdate, map[string]any{ 389 + "sessionId": "sess_term", 390 + "update": map[string]any{ 391 + "sessionUpdate": "agent_message_chunk", 392 + "content": map[string]any{ 393 + "type": "text", 394 + "text": "done", 395 + }, 396 + }, 397 + }) 398 + encodeTestResponse(t, enc, promptMsg.ID, map[string]any{"stopReason": "end_turn"}) 399 + })) 400 + if err != nil { 401 + t.Fatalf("RunPrompt() error = %v", err) 402 + } 403 + if result.Output != "done" { 404 + t.Fatalf("Output = %q, want %q", result.Output, "done") 405 + } 406 + } 407 + 408 + func TestChoosePermissionOption_PrefersAllowForTerminalRequests(t *testing.T) { 409 + optionID, ok := choosePermissionOption("terminal", "Run command", []struct { 410 + OptionID string `json:"optionId"` 411 + Kind string `json:"kind"` 412 + }{ 413 + {OptionID: "reject", Kind: permissionRejectOnce}, 414 + {OptionID: "allow", Kind: permissionAllowOnce}, 415 + }) 416 + if !ok { 417 + t.Fatal("choosePermissionOption() returned ok=false, want true") 418 + } 419 + if optionID != "allow" { 420 + t.Fatalf("optionID = %q, want %q", optionID, "allow") 421 + } 422 + } 423 + 424 + func TestRunPrompt_AuthenticatesWithConfiguredEnvVarMethod(t *testing.T) { 425 + t.Parallel() 426 + 427 + dir := t.TempDir() 428 + cfg := AgentConfig{ 429 + Name: "helper", 430 + Enable: true, 431 + Type: "stdio", 432 + Command: "helper", 433 + CWD: dir, 434 + ReadRoots: []string{dir}, 435 + WriteRoots: []string{dir}, 436 + Env: map[string]string{ 437 + "OPENAI_API_KEY": "sk-test", 438 + }, 439 + } 440 + prepared, err := PrepareAgentConfig(cfg, "") 441 + if err != nil { 442 + t.Fatalf("PrepareAgentConfig() error = %v", err) 443 + } 444 + 445 + var authenticateSeen bool 446 + result, err := runPromptWithFactory(context.Background(), prepared, RunRequest{ 447 + Prompt: "reply with ok", 448 + }, fakeACPConnFactory(t, func(dec *json.Decoder, enc *json.Encoder) { 449 + initMsg := decodeTestMessage(t, dec) 450 + encodeTestResponse(t, enc, initMsg.ID, map[string]any{ 451 + "protocolVersion": protocolVersion, 452 + "authMethods": []map[string]any{ 453 + { 454 + "id": "openai-api-key", 455 + "type": "env_var", 456 + "vars": []map[string]any{{"name": "OPENAI_API_KEY"}}, 457 + }, 458 + }, 459 + }) 460 + 461 + authMsg := decodeTestMessage(t, dec) 462 + if authMsg.Method != methodAuthenticate { 463 + t.Fatalf("method = %q, want %q", authMsg.Method, methodAuthenticate) 464 + } 465 + var authParams map[string]any 466 + if err := json.Unmarshal(authMsg.Params, &authParams); err != nil { 467 + t.Fatalf("json.Unmarshal(authenticate) error = %v", err) 468 + } 469 + if got := asString(authParams["methodId"]); got != "openai-api-key" { 470 + t.Fatalf("methodId = %q, want openai-api-key", got) 471 + } 472 + authenticateSeen = true 473 + encodeTestResponse(t, enc, authMsg.ID, map[string]any{}) 474 + 475 + newMsg := decodeTestMessage(t, dec) 476 + if newMsg.Method != methodSessionNew { 477 + t.Fatalf("method = %q, want %q", newMsg.Method, methodSessionNew) 478 + } 479 + encodeTestResponse(t, enc, newMsg.ID, map[string]any{"sessionId": "sess_auth"}) 480 + 481 + promptMsg := decodeTestMessage(t, dec) 482 + if promptMsg.Method != methodSessionPrompt { 483 + t.Fatalf("method = %q, want %q", promptMsg.Method, methodSessionPrompt) 484 + } 485 + encodeTestResponse(t, enc, promptMsg.ID, map[string]any{"stopReason": "end_turn"}) 486 + time.Sleep(10 * time.Millisecond) 487 + })) 488 + if err != nil { 489 + t.Fatalf("RunPrompt() error = %v", err) 490 + } 491 + if !authenticateSeen { 492 + t.Fatal("expected authenticate call before session/new") 493 + } 494 + if result.StopReason != "end_turn" { 495 + t.Fatalf("StopReason = %q, want end_turn", result.StopReason) 496 + } 497 + } 498 + 499 + func TestRunPrompt_AppliesSessionOptionsViaConfigRequests(t *testing.T) { 500 + t.Parallel() 501 + 502 + dir := t.TempDir() 503 + cfg := AgentConfig{ 504 + Name: "helper", 505 + Enable: true, 506 + Type: "stdio", 507 + Command: "helper", 508 + CWD: dir, 509 + ReadRoots: []string{dir}, 510 + WriteRoots: []string{dir}, 511 + SessionOptions: map[string]any{ 512 + "mode": "read-only", 513 + "reasoning_effort": "low", 514 + "brave_mode": true, 515 + }, 516 + } 517 + prepared, err := PrepareAgentConfig(cfg, "") 518 + if err != nil { 519 + t.Fatalf("PrepareAgentConfig() error = %v", err) 520 + } 521 + 522 + seen := map[string]any{} 523 + result, err := runPromptWithFactory(context.Background(), prepared, RunRequest{ 524 + Prompt: "reply with ok", 525 + }, fakeACPConnFactory(t, func(dec *json.Decoder, enc *json.Encoder) { 526 + initMsg := decodeTestMessage(t, dec) 527 + encodeTestResponse(t, enc, initMsg.ID, map[string]any{"protocolVersion": protocolVersion}) 528 + 529 + newMsg := decodeTestMessage(t, dec) 530 + encodeTestResponse(t, enc, newMsg.ID, map[string]any{ 531 + "sessionId": "sess_opts", 532 + "configOptions": []map[string]any{ 533 + {"id": "mode"}, 534 + {"id": "reasoning_effort"}, 535 + {"id": "brave_mode"}, 536 + }, 537 + }) 538 + 539 + for i := 0; i < 3; i++ { 540 + cfgMsg := decodeTestMessage(t, dec) 541 + if cfgMsg.Method != methodSessionSetConfig { 542 + t.Fatalf("method = %q, want %q", cfgMsg.Method, methodSessionSetConfig) 543 + } 544 + var params map[string]any 545 + if err := json.Unmarshal(cfgMsg.Params, &params); err != nil { 546 + t.Fatalf("json.Unmarshal(session/set_config_option) error = %v", err) 547 + } 548 + configID := asString(params["configId"]) 549 + seen[configID] = params["value"] 550 + if configID == "brave_mode" { 551 + if got := asString(params["type"]); got != "boolean" { 552 + t.Fatalf("type = %q, want boolean", got) 553 + } 554 + } 555 + encodeTestResponse(t, enc, cfgMsg.ID, map[string]any{"configOptions": []any{}}) 556 + } 557 + 558 + promptMsg := decodeTestMessage(t, dec) 559 + if promptMsg.Method != methodSessionPrompt { 560 + t.Fatalf("method = %q, want %q", promptMsg.Method, methodSessionPrompt) 561 + } 562 + encodeTestResponse(t, enc, promptMsg.ID, map[string]any{"stopReason": "end_turn"}) 563 + time.Sleep(10 * time.Millisecond) 564 + })) 565 + if err != nil { 566 + t.Fatalf("RunPrompt() error = %v", err) 567 + } 568 + if got := asString(seen["mode"]); got != "read-only" { 569 + t.Fatalf("mode = %q, want read-only", got) 570 + } 571 + if got := asString(seen["reasoning_effort"]); got != "low" { 572 + t.Fatalf("reasoning_effort = %q, want low", got) 573 + } 574 + if got, _ := seen["brave_mode"].(bool); !got { 575 + t.Fatalf("brave_mode = %v, want true", seen["brave_mode"]) 576 + } 577 + if result.StopReason != "end_turn" { 578 + t.Fatalf("StopReason = %q, want end_turn", result.StopReason) 579 + } 580 + } 581 + 582 + func TestRunPrompt_AuthenticatesWithChatGPTFallback(t *testing.T) { 583 + t.Parallel() 584 + 585 + dir := t.TempDir() 586 + cfg := AgentConfig{ 587 + Name: "helper", 588 + Enable: true, 589 + Type: "stdio", 590 + Command: "helper", 591 + CWD: dir, 592 + ReadRoots: []string{dir}, 593 + WriteRoots: []string{dir}, 594 + } 595 + prepared, err := PrepareAgentConfig(cfg, "") 596 + if err != nil { 597 + t.Fatalf("PrepareAgentConfig() error = %v", err) 598 + } 599 + 600 + result, err := runPromptWithFactory(context.Background(), prepared, RunRequest{ 601 + Prompt: "reply with ok", 602 + }, fakeACPConnFactory(t, func(dec *json.Decoder, enc *json.Encoder) { 603 + initMsg := decodeTestMessage(t, dec) 604 + encodeTestResponse(t, enc, initMsg.ID, map[string]any{ 605 + "protocolVersion": protocolVersion, 606 + "authMethods": []map[string]any{ 607 + {"id": "chatgpt"}, 608 + { 609 + "id": "openai-api-key", 610 + "type": "env_var", 611 + "vars": []map[string]any{{"name": "OPENAI_API_KEY"}}, 612 + }, 613 + }, 614 + }) 615 + 616 + authMsg := decodeTestMessage(t, dec) 617 + if authMsg.Method != methodAuthenticate { 618 + t.Fatalf("method = %q, want %q", authMsg.Method, methodAuthenticate) 619 + } 620 + var authParams map[string]any 621 + if err := json.Unmarshal(authMsg.Params, &authParams); err != nil { 622 + t.Fatalf("json.Unmarshal(authenticate) error = %v", err) 623 + } 624 + if got := asString(authParams["methodId"]); got != "chatgpt" { 625 + t.Fatalf("methodId = %q, want chatgpt", got) 626 + } 627 + encodeTestResponse(t, enc, authMsg.ID, map[string]any{}) 628 + 629 + newMsg := decodeTestMessage(t, dec) 630 + encodeTestResponse(t, enc, newMsg.ID, map[string]any{"sessionId": "sess_chatgpt"}) 631 + 632 + promptMsg := decodeTestMessage(t, dec) 633 + encodeTestResponse(t, enc, promptMsg.ID, map[string]any{"stopReason": "end_turn"}) 634 + time.Sleep(10 * time.Millisecond) 635 + })) 636 + if err != nil { 637 + t.Fatalf("RunPrompt() error = %v", err) 638 + } 639 + if result.StopReason != "end_turn" { 640 + t.Fatalf("StopReason = %q, want end_turn", result.StopReason) 641 + } 642 + } 643 + 644 + func fakeACPConnFactory(t *testing.T, server func(dec *json.Decoder, enc *json.Encoder)) connFactory { 645 + t.Helper() 646 + return func(parent context.Context, _ PreparedAgentConfig) (*rpcConn, error) { 647 + clientSide, serverSide := net.Pipe() 648 + conn := &rpcConn{ 649 + ctx: parent, 650 + cancel: func() {}, 651 + stdin: clientSide, 652 + stdout: clientSide, 653 + stderr: io.NopCloser(bytes.NewReader(nil)), 654 + enc: json.NewEncoder(clientSide), 655 + pending: map[string]chan pendingResponse{}, 656 + done: make(chan struct{}), 657 + } 658 + go conn.readLoop() 659 + go func() { 660 + defer serverSide.Close() 661 + server(json.NewDecoder(serverSide), json.NewEncoder(serverSide)) 662 + }() 663 + return conn, nil 664 + } 665 + } 666 + 667 + func decodeTestMessage(t *testing.T, dec *json.Decoder) rpcMessage { 668 + t.Helper() 669 + var msg rpcMessage 670 + if err := dec.Decode(&msg); err != nil { 671 + t.Fatalf("Decode() error = %v", err) 672 + } 673 + return msg 674 + } 675 + 676 + func encodeTestRequest(t *testing.T, enc *json.Encoder, id json.RawMessage, method string, params any) { 677 + t.Helper() 678 + if err := enc.Encode(rpcMessage{ 679 + JSONRPC: jsonRPCVersion, 680 + ID: id, 681 + Method: method, 682 + Params: mustMarshalRaw(params), 683 + }); err != nil { 684 + t.Fatalf("Encode(request) error = %v", err) 685 + } 686 + } 687 + 688 + func encodeTestNotification(t *testing.T, enc *json.Encoder, method string, params any) { 689 + t.Helper() 690 + if err := enc.Encode(rpcMessage{ 691 + JSONRPC: jsonRPCVersion, 692 + Method: method, 693 + Params: mustMarshalRaw(params), 694 + }); err != nil { 695 + t.Fatalf("Encode(notification) error = %v", err) 696 + } 697 + } 698 + 699 + func encodeTestResponse(t *testing.T, enc *json.Encoder, id json.RawMessage, result any) { 700 + t.Helper() 701 + if err := enc.Encode(rpcMessage{ 702 + JSONRPC: jsonRPCVersion, 703 + ID: id, 704 + Result: mustMarshalRaw(result), 705 + }); err != nil { 706 + t.Fatalf("Encode(response) error = %v", err) 707 + } 708 + } 709 + 710 + func asString(value any) string { 711 + s, _ := value.(string) 712 + return s 713 + } 714 + 715 + func intFromAny(value any) int { 716 + switch v := value.(type) { 717 + case float64: 718 + return int(v) 719 + case int: 720 + return v 721 + default: 722 + return -1 723 + } 724 + } 725 + 726 + func testTerminalEchoCommand() string { 727 + switch runtime.GOOS { 728 + case "windows": 729 + return "cmd" 730 + default: 731 + return "sh" 732 + } 733 + } 734 + 735 + func testTerminalEchoArgs() []string { 736 + switch runtime.GOOS { 737 + case "windows": 738 + return []string{"/C", "echo %MM_TERM_TEST%"} 739 + default: 740 + return []string{"-lc", `printf '%s\n' "$MM_TERM_TEST"`} 741 + } 742 + }
+107
internal/acpclient/codex_integration_test.go
··· 1 + package acpclient 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "os" 7 + "os/exec" 8 + "path/filepath" 9 + "strings" 10 + "testing" 11 + "time" 12 + ) 13 + 14 + const ( 15 + codexACPIntegrationEnv = "MISTERMORPH_ACP_CODEX_INTEGRATION" 16 + codexACPCommandEnv = "MISTERMORPH_ACP_CODEX_COMMAND" 17 + codexACPArgsEnv = "MISTERMORPH_ACP_CODEX_ARGS" 18 + codexACPSessionOptionsEnv = "MISTERMORPH_ACP_CODEX_SESSION_OPTIONS" 19 + codexACPProbeText = "ACP_CODEX_SMOKE_TOKEN_20260411" 20 + ) 21 + 22 + func TestRunPrompt_CodexACPIntegration(t *testing.T) { 23 + if testing.Short() { 24 + t.Skip("skipping live Codex ACP integration test in short mode") 25 + } 26 + if strings.TrimSpace(os.Getenv(codexACPIntegrationEnv)) != "1" { 27 + t.Skipf("set %s=1 to run the live Codex ACP integration test", codexACPIntegrationEnv) 28 + } 29 + 30 + command := strings.TrimSpace(os.Getenv(codexACPCommandEnv)) 31 + if command == "" { 32 + if _, err := exec.LookPath("codex-acp"); err == nil { 33 + command = "codex-acp" 34 + } 35 + } 36 + if command == "" { 37 + t.Skipf("set %s or install codex-acp to run this live integration test", codexACPCommandEnv) 38 + } 39 + 40 + sessionOptions, err := parseCodexACPSessionOptions(os.Getenv(codexACPSessionOptionsEnv)) 41 + if err != nil { 42 + t.Fatalf("parse %s: %v", codexACPSessionOptionsEnv, err) 43 + } 44 + 45 + dir := t.TempDir() 46 + probePath := filepath.Join(dir, "acp_probe.txt") 47 + if err := os.WriteFile(probePath, []byte(codexACPProbeText+"\n"), 0o644); err != nil { 48 + t.Fatalf("WriteFile(probePath) error = %v", err) 49 + } 50 + 51 + prepared, err := PrepareAgentConfig(AgentConfig{ 52 + Name: "codex", 53 + Enable: true, 54 + Type: "stdio", 55 + Command: command, 56 + Args: strings.Fields(strings.TrimSpace(os.Getenv(codexACPArgsEnv))), 57 + CWD: dir, 58 + ReadRoots: []string{dir}, 59 + WriteRoots: []string{dir}, 60 + SessionOptions: sessionOptions, 61 + }, "") 62 + if err != nil { 63 + t.Fatalf("PrepareAgentConfig() error = %v", err) 64 + } 65 + 66 + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) 67 + defer cancel() 68 + startedAt := time.Now() 69 + 70 + var events []Event 71 + result, err := RunPrompt(ctx, prepared, RunRequest{ 72 + Prompt: "Read ./acp_probe.txt and reply with exactly its full contents. " + 73 + "Do not add quotes, labels, explanations, or any extra text.", 74 + Observer: ObserverFunc(func(_ context.Context, event Event) { 75 + events = append(events, event) 76 + }), 77 + }) 78 + if err != nil { 79 + t.Fatalf("RunPrompt() error = %v", err) 80 + } 81 + if elapsed := time.Since(startedAt); elapsed > 45*time.Second { 82 + t.Fatalf("RunPrompt() took too long: %v", elapsed) 83 + } 84 + if result.StopReason != "end_turn" { 85 + t.Fatalf("StopReason = %q, want %q", result.StopReason, "end_turn") 86 + } 87 + 88 + output := strings.ReplaceAll(result.Output, "\r\n", "\n") 89 + if strings.TrimSpace(output) != codexACPProbeText { 90 + t.Fatalf("Output = %q, want %q", result.Output, codexACPProbeText) 91 + } 92 + if len(events) == 0 { 93 + t.Fatal("expected at least one ACP event from Codex adapter") 94 + } 95 + } 96 + 97 + func parseCodexACPSessionOptions(raw string) (map[string]any, error) { 98 + raw = strings.TrimSpace(raw) 99 + if raw == "" { 100 + return nil, nil 101 + } 102 + var options map[string]any 103 + if err := json.Unmarshal([]byte(raw), &options); err != nil { 104 + return nil, err 105 + } 106 + return options, nil 107 + }
+240
internal/acpclient/config.go
··· 1 + package acpclient 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "path/filepath" 7 + "strings" 8 + 9 + "github.com/spf13/cast" 10 + "github.com/spf13/viper" 11 + ) 12 + 13 + type AgentConfig struct { 14 + Name string 15 + Enable bool 16 + Type string 17 + Command string 18 + Args []string 19 + Env map[string]string 20 + CWD string 21 + ReadRoots []string 22 + WriteRoots []string 23 + SessionOptions map[string]any 24 + } 25 + 26 + type PreparedAgentConfig struct { 27 + Name string 28 + Type string 29 + Command string 30 + Args []string 31 + Env map[string]string 32 + CWD string 33 + ReadRoots []string 34 + WriteRoots []string 35 + SessionOptionsMeta map[string]any 36 + } 37 + 38 + func (c AgentConfig) Validate() error { 39 + if strings.TrimSpace(c.Name) == "" { 40 + return fmt.Errorf("acp agent name is required") 41 + } 42 + typ := strings.ToLower(strings.TrimSpace(c.Type)) 43 + if typ == "" { 44 + typ = "stdio" 45 + } 46 + switch typ { 47 + case "stdio": 48 + if strings.TrimSpace(c.Command) == "" { 49 + return fmt.Errorf("acp agent %q: command is required for stdio transport", c.Name) 50 + } 51 + default: 52 + return fmt.Errorf("acp agent %q: unsupported type %q (supported: stdio)", c.Name, typ) 53 + } 54 + return nil 55 + } 56 + 57 + func AgentsFromViper() []AgentConfig { 58 + return parseAgents(viper.Get("acp.agents")) 59 + } 60 + 61 + func AgentsFromReader(v *viper.Viper) []AgentConfig { 62 + if v == nil { 63 + return nil 64 + } 65 + return parseAgents(v.Get("acp.agents")) 66 + } 67 + 68 + func FindAgent(configs []AgentConfig, name string) (AgentConfig, bool) { 69 + name = strings.ToLower(strings.TrimSpace(name)) 70 + if name == "" { 71 + return AgentConfig{}, false 72 + } 73 + for _, cfg := range configs { 74 + if strings.ToLower(strings.TrimSpace(cfg.Name)) != name { 75 + continue 76 + } 77 + return cfg, true 78 + } 79 + return AgentConfig{}, false 80 + } 81 + 82 + func PrepareAgentConfig(cfg AgentConfig, overrideCWD string) (PreparedAgentConfig, error) { 83 + if err := cfg.Validate(); err != nil { 84 + return PreparedAgentConfig{}, err 85 + } 86 + if !cfg.Enable { 87 + return PreparedAgentConfig{}, fmt.Errorf("acp agent %q is disabled", strings.TrimSpace(cfg.Name)) 88 + } 89 + 90 + cwd := strings.TrimSpace(overrideCWD) 91 + if cwd == "" { 92 + cwd = strings.TrimSpace(cfg.CWD) 93 + } 94 + if cwd == "" { 95 + cwd = "." 96 + } 97 + resolvedCWD, err := filepath.Abs(cwd) 98 + if err != nil { 99 + return PreparedAgentConfig{}, fmt.Errorf("resolve acp cwd: %w", err) 100 + } 101 + info, err := os.Stat(resolvedCWD) 102 + if err != nil { 103 + return PreparedAgentConfig{}, fmt.Errorf("stat acp cwd: %w", err) 104 + } 105 + if !info.IsDir() { 106 + return PreparedAgentConfig{}, fmt.Errorf("acp cwd %q is not a directory", resolvedCWD) 107 + } 108 + 109 + readRoots, err := resolveRoots(resolvedCWD, cfg.ReadRoots) 110 + if err != nil { 111 + return PreparedAgentConfig{}, err 112 + } 113 + writeRoots, err := resolveRoots(resolvedCWD, cfg.WriteRoots) 114 + if err != nil { 115 + return PreparedAgentConfig{}, err 116 + } 117 + 118 + return PreparedAgentConfig{ 119 + Name: strings.TrimSpace(cfg.Name), 120 + Type: strings.ToLower(strings.TrimSpace(cfg.Type)), 121 + Command: strings.TrimSpace(cfg.Command), 122 + Args: append([]string(nil), cfg.Args...), 123 + Env: cloneStringMap(cfg.Env), 124 + CWD: resolvedCWD, 125 + ReadRoots: readRoots, 126 + WriteRoots: writeRoots, 127 + SessionOptionsMeta: cloneAnyMap(cfg.SessionOptions), 128 + }, nil 129 + } 130 + 131 + func parseAgents(raw any) []AgentConfig { 132 + if raw == nil { 133 + return nil 134 + } 135 + items, ok := raw.([]any) 136 + if !ok { 137 + return nil 138 + } 139 + configs := make([]AgentConfig, 0, len(items)) 140 + for _, item := range items { 141 + m, ok := item.(map[string]any) 142 + if !ok { 143 + continue 144 + } 145 + configs = append(configs, AgentConfig{ 146 + Name: strings.TrimSpace(cast.ToString(m["name"])), 147 + Enable: m["enable"] == nil || cast.ToBool(m["enable"]), 148 + Type: strings.TrimSpace(cast.ToString(m["type"])), 149 + Command: strings.TrimSpace(cast.ToString(m["command"])), 150 + Args: cleanStrings(cast.ToStringSlice(m["args"])), 151 + Env: cast.ToStringMapString(m["env"]), 152 + CWD: strings.TrimSpace(cast.ToString(m["cwd"])), 153 + ReadRoots: cleanStrings(cast.ToStringSlice(m["read_roots"])), 154 + WriteRoots: cleanStrings(cast.ToStringSlice(m["write_roots"])), 155 + SessionOptions: cloneAnyMap(cast.ToStringMap(m["session_options"])), 156 + }) 157 + } 158 + return configs 159 + } 160 + 161 + func resolveRoots(cwd string, roots []string) ([]string, error) { 162 + if len(roots) == 0 { 163 + return []string{cwd}, nil 164 + } 165 + out := make([]string, 0, len(roots)) 166 + seen := map[string]struct{}{} 167 + for _, raw := range roots { 168 + root := strings.TrimSpace(raw) 169 + if root == "" { 170 + continue 171 + } 172 + if !filepath.IsAbs(root) { 173 + root = filepath.Join(cwd, root) 174 + } 175 + absRoot, err := filepath.Abs(root) 176 + if err != nil { 177 + return nil, fmt.Errorf("resolve acp root %q: %w", raw, err) 178 + } 179 + if _, ok := seen[absRoot]; ok { 180 + continue 181 + } 182 + seen[absRoot] = struct{}{} 183 + out = append(out, absRoot) 184 + } 185 + if len(out) == 0 { 186 + return []string{cwd}, nil 187 + } 188 + return out, nil 189 + } 190 + 191 + func cleanStrings(values []string) []string { 192 + if len(values) == 0 { 193 + return nil 194 + } 195 + out := make([]string, 0, len(values)) 196 + for _, raw := range values { 197 + value := strings.TrimSpace(raw) 198 + if value == "" { 199 + continue 200 + } 201 + out = append(out, value) 202 + } 203 + return out 204 + } 205 + 206 + func cloneStringMap(in map[string]string) map[string]string { 207 + if len(in) == 0 { 208 + return nil 209 + } 210 + out := make(map[string]string, len(in)) 211 + for k, v := range in { 212 + key := strings.TrimSpace(k) 213 + if key == "" { 214 + continue 215 + } 216 + out[key] = v 217 + } 218 + if len(out) == 0 { 219 + return nil 220 + } 221 + return out 222 + } 223 + 224 + func cloneAnyMap(in map[string]any) map[string]any { 225 + if len(in) == 0 { 226 + return nil 227 + } 228 + out := make(map[string]any, len(in)) 229 + for k, v := range in { 230 + key := strings.TrimSpace(k) 231 + if key == "" { 232 + continue 233 + } 234 + out[key] = v 235 + } 236 + if len(out) == 0 { 237 + return nil 238 + } 239 + return out 240 + }
+35
internal/acpclient/config_test.go
··· 1 + package acpclient 2 + 3 + import ( 4 + "path/filepath" 5 + "testing" 6 + ) 7 + 8 + func TestPrepareAgentConfig_ResolvesRelativePathsFromCWD(t *testing.T) { 9 + t.Parallel() 10 + 11 + base := t.TempDir() 12 + cfg := AgentConfig{ 13 + Name: "codex", 14 + Enable: true, 15 + Type: "stdio", 16 + Command: "helper", 17 + CWD: base, 18 + ReadRoots: []string{"src"}, 19 + WriteRoots: []string{"out"}, 20 + } 21 + 22 + prepared, err := PrepareAgentConfig(cfg, "") 23 + if err != nil { 24 + t.Fatalf("PrepareAgentConfig() error = %v", err) 25 + } 26 + if prepared.CWD != base { 27 + t.Fatalf("prepared.CWD = %q, want %q", prepared.CWD, base) 28 + } 29 + if got := prepared.ReadRoots[0]; got != filepath.Join(base, "src") { 30 + t.Fatalf("prepared.ReadRoots[0] = %q, want %q", got, filepath.Join(base, "src")) 31 + } 32 + if got := prepared.WriteRoots[0]; got != filepath.Join(base, "out") { 33 + t.Fatalf("prepared.WriteRoots[0] = %q, want %q", got, filepath.Join(base, "out")) 34 + } 35 + }
+465
internal/acpclient/terminal.go
··· 1 + package acpclient 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "io" 9 + "log/slog" 10 + "os" 11 + "os/exec" 12 + "path/filepath" 13 + "strings" 14 + "sync" 15 + "sync/atomic" 16 + ) 17 + 18 + const ( 19 + methodTerminalCreate = "terminal/create" 20 + methodTerminalOutput = "terminal/output" 21 + methodTerminalWaitForExit = "terminal/wait_for_exit" 22 + methodTerminalKill = "terminal/kill" 23 + methodTerminalRelease = "terminal/release" 24 + defaultTerminalOutputSize = 256 * 1024 25 + ) 26 + 27 + type terminalManager struct { 28 + cfg PreparedAgentConfig 29 + nextID uint64 30 + mu sync.Mutex 31 + terminals map[string]*managedTerminal 32 + } 33 + 34 + type managedTerminal struct { 35 + id string 36 + sessionID string 37 + cmd *exec.Cmd 38 + output *terminalOutputBuffer 39 + done chan struct{} 40 + 41 + mu sync.Mutex 42 + exited bool 43 + exit terminalExitStatus 44 + closeMu sync.Mutex 45 + } 46 + 47 + type terminalExitStatus struct { 48 + ExitCode *int 49 + Signal *string 50 + } 51 + 52 + type terminalOutputBuffer struct { 53 + limit int 54 + 55 + mu sync.Mutex 56 + data []byte 57 + truncated bool 58 + } 59 + 60 + type terminalEnvVar struct { 61 + Name string `json:"name"` 62 + Value string `json:"value"` 63 + } 64 + 65 + func newTerminalManager(cfg PreparedAgentConfig) *terminalManager { 66 + return &terminalManager{ 67 + cfg: cfg, 68 + terminals: map[string]*managedTerminal{}, 69 + } 70 + } 71 + 72 + func (m *terminalManager) Close() { 73 + if m == nil { 74 + return 75 + } 76 + m.mu.Lock() 77 + terminals := make([]*managedTerminal, 0, len(m.terminals)) 78 + for _, term := range m.terminals { 79 + terminals = append(terminals, term) 80 + } 81 + m.terminals = map[string]*managedTerminal{} 82 + m.mu.Unlock() 83 + for _, term := range terminals { 84 + term.release() 85 + } 86 + } 87 + 88 + func (m *terminalManager) create(raw json.RawMessage) (any, *rpcError) { 89 + if m == nil { 90 + return nil, &rpcError{Code: rpcCodeMethodNotFound, Message: "method not found"} 91 + } 92 + var req struct { 93 + SessionID string `json:"sessionId"` 94 + Command string `json:"command"` 95 + Args []string `json:"args"` 96 + Env []terminalEnvVar `json:"env"` 97 + CWD string `json:"cwd"` 98 + OutputByteLimit int `json:"outputByteLimit"` 99 + } 100 + if err := json.Unmarshal(raw, &req); err != nil { 101 + return nil, &rpcError{Code: rpcCodeInvalidParams, Message: "invalid terminal/create request"} 102 + } 103 + if strings.TrimSpace(req.SessionID) == "" { 104 + return nil, &rpcError{Code: rpcCodeInvalidParams, Message: "sessionId is required"} 105 + } 106 + command := strings.TrimSpace(req.Command) 107 + if command == "" { 108 + return nil, &rpcError{Code: rpcCodeInvalidParams, Message: "command is required"} 109 + } 110 + cwd, err := resolveTerminalCWD(strings.TrimSpace(req.CWD), m.cfg) 111 + if err != nil { 112 + return nil, &rpcError{Code: rpcCodeInvalidParams, Message: err.Error()} 113 + } 114 + outputLimit := req.OutputByteLimit 115 + if outputLimit <= 0 { 116 + outputLimit = defaultTerminalOutputSize 117 + } 118 + 119 + cmd := exec.Command(command, cleanStrings(req.Args)...) 120 + cmd.Dir = cwd 121 + cmd.Env = mergeTerminalEnv(req.Env) 122 + slog.Default().Debug("acp_terminal_create_start", "command", command, "args_count", len(req.Args), "output_limit", outputLimit) 123 + 124 + stdout, err := cmd.StdoutPipe() 125 + if err != nil { 126 + return nil, &rpcError{Code: rpcCodeInternalError, Message: err.Error()} 127 + } 128 + stderr, err := cmd.StderrPipe() 129 + if err != nil { 130 + return nil, &rpcError{Code: rpcCodeInternalError, Message: err.Error()} 131 + } 132 + 133 + terminalID := m.nextTerminalID() 134 + term := &managedTerminal{ 135 + id: terminalID, 136 + sessionID: strings.TrimSpace(req.SessionID), 137 + cmd: cmd, 138 + output: &terminalOutputBuffer{limit: outputLimit}, 139 + done: make(chan struct{}), 140 + } 141 + 142 + if err := cmd.Start(); err != nil { 143 + return nil, &rpcError{Code: rpcCodeInternalError, Message: err.Error()} 144 + } 145 + 146 + m.mu.Lock() 147 + m.terminals[terminalID] = term 148 + m.mu.Unlock() 149 + 150 + go term.capture(stdout) 151 + go term.capture(stderr) 152 + go term.wait() 153 + slog.Default().Debug("acp_terminal_create_done", "terminal_id", terminalID, "command", command) 154 + 155 + return map[string]any{"terminalId": terminalID}, nil 156 + } 157 + 158 + func (m *terminalManager) output(raw json.RawMessage) (any, *rpcError) { 159 + term, rpcErr := m.lookup(raw, methodTerminalOutput) 160 + if rpcErr != nil { 161 + return nil, rpcErr 162 + } 163 + output, truncated := term.output.snapshot() 164 + resp := map[string]any{ 165 + "output": output, 166 + "truncated": truncated, 167 + } 168 + if exit, ok := term.exitSnapshot(); ok { 169 + resp["exitStatus"] = terminalExitStatusMap(exit) 170 + } 171 + return resp, nil 172 + } 173 + 174 + func (m *terminalManager) waitForExit(ctx context.Context, raw json.RawMessage) (any, *rpcError) { 175 + term, rpcErr := m.lookup(raw, methodTerminalWaitForExit) 176 + if rpcErr != nil { 177 + return nil, rpcErr 178 + } 179 + startedAt := timeNow() 180 + slog.Default().Debug("acp_terminal_wait_start", "terminal_id", term.id) 181 + if err := term.waitContext(ctx); err != nil { 182 + return nil, &rpcError{Code: rpcCodeInternalError, Message: err.Error()} 183 + } 184 + exit, _ := term.exitSnapshot() 185 + slog.Default().Debug("acp_terminal_wait_done", "terminal_id", term.id, "duration_ms", sinceMillis(startedAt)) 186 + return terminalExitStatusMap(exit), nil 187 + } 188 + 189 + func (m *terminalManager) kill(raw json.RawMessage) (any, *rpcError) { 190 + term, rpcErr := m.lookup(raw, methodTerminalKill) 191 + if rpcErr != nil { 192 + return nil, rpcErr 193 + } 194 + slog.Default().Debug("acp_terminal_kill", "terminal_id", term.id) 195 + if err := term.kill(); err != nil { 196 + return nil, &rpcError{Code: rpcCodeInternalError, Message: err.Error()} 197 + } 198 + return map[string]any{}, nil 199 + } 200 + 201 + func (m *terminalManager) release(raw json.RawMessage) (any, *rpcError) { 202 + term, terminalID, rpcErr := m.lookupWithID(raw, methodTerminalRelease) 203 + if rpcErr != nil { 204 + return nil, rpcErr 205 + } 206 + m.mu.Lock() 207 + delete(m.terminals, terminalID) 208 + m.mu.Unlock() 209 + if err := term.release(); err != nil { 210 + return nil, &rpcError{Code: rpcCodeInternalError, Message: err.Error()} 211 + } 212 + slog.Default().Debug("acp_terminal_release", "terminal_id", terminalID) 213 + return map[string]any{}, nil 214 + } 215 + 216 + func (m *terminalManager) lookup(raw json.RawMessage, method string) (*managedTerminal, *rpcError) { 217 + term, _, rpcErr := m.lookupWithID(raw, method) 218 + return term, rpcErr 219 + } 220 + 221 + func (m *terminalManager) lookupWithID(raw json.RawMessage, method string) (*managedTerminal, string, *rpcError) { 222 + if m == nil { 223 + return nil, "", &rpcError{Code: rpcCodeMethodNotFound, Message: "method not found"} 224 + } 225 + var req struct { 226 + SessionID string `json:"sessionId"` 227 + TerminalID string `json:"terminalId"` 228 + } 229 + if err := json.Unmarshal(raw, &req); err != nil { 230 + return nil, "", &rpcError{Code: rpcCodeInvalidParams, Message: fmt.Sprintf("invalid %s request", method)} 231 + } 232 + sessionID := strings.TrimSpace(req.SessionID) 233 + if sessionID == "" { 234 + return nil, "", &rpcError{Code: rpcCodeInvalidParams, Message: "sessionId is required"} 235 + } 236 + terminalID := strings.TrimSpace(req.TerminalID) 237 + if terminalID == "" { 238 + return nil, "", &rpcError{Code: rpcCodeInvalidParams, Message: "terminalId is required"} 239 + } 240 + m.mu.Lock() 241 + term, ok := m.terminals[terminalID] 242 + m.mu.Unlock() 243 + if !ok { 244 + return nil, "", &rpcError{Code: rpcCodeInvalidParams, Message: "unknown terminalId"} 245 + } 246 + if term.sessionID != sessionID { 247 + return nil, "", &rpcError{Code: rpcCodeInvalidParams, Message: "sessionId mismatch"} 248 + } 249 + return term, terminalID, nil 250 + } 251 + 252 + func (m *terminalManager) nextTerminalID() string { 253 + id := atomic.AddUint64(&m.nextID, 1) 254 + return fmt.Sprintf("term_%d", id) 255 + } 256 + 257 + func (t *managedTerminal) capture(r io.ReadCloser) { 258 + defer func() { _ = r.Close() }() 259 + _, _ = io.Copy(t.output, r) 260 + } 261 + 262 + func (t *managedTerminal) wait() { 263 + err := t.cmd.Wait() 264 + exit := terminalExitStatus{} 265 + if state := t.cmd.ProcessState; state != nil { 266 + if code := state.ExitCode(); code >= 0 { 267 + exitCode := code 268 + exit.ExitCode = &exitCode 269 + } 270 + } 271 + if err != nil && exit.ExitCode == nil { 272 + msg := strings.TrimSpace(err.Error()) 273 + if msg != "" { 274 + exit.Signal = stringPtr(msg) 275 + } 276 + } 277 + t.mu.Lock() 278 + t.exited = true 279 + t.exit = exit 280 + t.mu.Unlock() 281 + if exit.ExitCode != nil { 282 + slog.Default().Debug("acp_terminal_exit", "terminal_id", t.id, "exit_code", *exit.ExitCode) 283 + } else if exit.Signal != nil { 284 + slog.Default().Debug("acp_terminal_exit", "terminal_id", t.id, "signal", *exit.Signal) 285 + } else { 286 + slog.Default().Debug("acp_terminal_exit", "terminal_id", t.id) 287 + } 288 + close(t.done) 289 + } 290 + 291 + func (t *managedTerminal) waitContext(ctx context.Context) error { 292 + if ctx == nil { 293 + ctx = context.Background() 294 + } 295 + select { 296 + case <-ctx.Done(): 297 + return ctx.Err() 298 + case <-t.done: 299 + return nil 300 + } 301 + } 302 + 303 + func (t *managedTerminal) exitSnapshot() (terminalExitStatus, bool) { 304 + t.mu.Lock() 305 + defer t.mu.Unlock() 306 + if !t.exited { 307 + return terminalExitStatus{}, false 308 + } 309 + return t.exit, true 310 + } 311 + 312 + func (t *managedTerminal) kill() error { 313 + t.closeMu.Lock() 314 + defer t.closeMu.Unlock() 315 + select { 316 + case <-t.done: 317 + return nil 318 + default: 319 + } 320 + if t.cmd == nil || t.cmd.Process == nil { 321 + return nil 322 + } 323 + err := t.cmd.Process.Kill() 324 + if err != nil && !strings.Contains(strings.ToLower(err.Error()), "process already finished") { 325 + return err 326 + } 327 + return nil 328 + } 329 + 330 + func (t *managedTerminal) release() error { 331 + if err := t.kill(); err != nil { 332 + return err 333 + } 334 + select { 335 + case <-t.done: 336 + default: 337 + } 338 + return nil 339 + } 340 + 341 + func (b *terminalOutputBuffer) Write(p []byte) (int, error) { 342 + if len(p) == 0 { 343 + return 0, nil 344 + } 345 + b.mu.Lock() 346 + defer b.mu.Unlock() 347 + if b.limit <= 0 { 348 + b.data = append(b.data, p...) 349 + return len(p), nil 350 + } 351 + b.data = append(b.data, p...) 352 + if len(b.data) > b.limit { 353 + b.truncated = true 354 + b.data = append([]byte(nil), b.data[len(b.data)-b.limit:]...) 355 + } 356 + return len(p), nil 357 + } 358 + 359 + func (b *terminalOutputBuffer) snapshot() (string, bool) { 360 + b.mu.Lock() 361 + defer b.mu.Unlock() 362 + out := bytes.ToValidUTF8(b.data, []byte("\n[non-utf8 output]\n")) 363 + return string(out), b.truncated 364 + } 365 + 366 + func resolveTerminalCWD(raw string, cfg PreparedAgentConfig) (string, error) { 367 + cwd := strings.TrimSpace(raw) 368 + if cwd == "" { 369 + cwd = cfg.CWD 370 + } 371 + if cwd == "" { 372 + return "", fmt.Errorf("cwd is required") 373 + } 374 + if !filepath.IsAbs(cwd) { 375 + cwd = filepath.Join(cfg.CWD, cwd) 376 + } 377 + absCWD, err := filepath.Abs(cwd) 378 + if err != nil { 379 + return "", err 380 + } 381 + info, err := os.Stat(absCWD) 382 + if err != nil { 383 + return "", err 384 + } 385 + if !info.IsDir() { 386 + return "", fmt.Errorf("cwd %q is not a directory", absCWD) 387 + } 388 + for _, root := range terminalAllowedRoots(cfg) { 389 + if isWithinRoot(root, absCWD) { 390 + return absCWD, nil 391 + } 392 + } 393 + return "", fmt.Errorf("cwd %q is outside allowed roots", absCWD) 394 + } 395 + 396 + func terminalAllowedRoots(cfg PreparedAgentConfig) []string { 397 + seen := map[string]struct{}{} 398 + var roots []string 399 + for _, root := range append([]string{cfg.CWD}, append(cfg.ReadRoots, cfg.WriteRoots...)...) { 400 + root = strings.TrimSpace(root) 401 + if root == "" { 402 + continue 403 + } 404 + absRoot, err := filepath.Abs(root) 405 + if err != nil { 406 + continue 407 + } 408 + if _, ok := seen[absRoot]; ok { 409 + continue 410 + } 411 + seen[absRoot] = struct{}{} 412 + roots = append(roots, absRoot) 413 + } 414 + return roots 415 + } 416 + 417 + func mergeTerminalEnv(extra []terminalEnvVar) []string { 418 + env := append([]string(nil), os.Environ()...) 419 + if len(extra) == 0 { 420 + return env 421 + } 422 + index := map[string]int{} 423 + for i, entry := range env { 424 + key, _, ok := strings.Cut(entry, "=") 425 + if !ok { 426 + continue 427 + } 428 + index[key] = i 429 + } 430 + for _, item := range extra { 431 + key := strings.TrimSpace(item.Name) 432 + if key == "" { 433 + continue 434 + } 435 + entry := key + "=" + item.Value 436 + if i, ok := index[key]; ok { 437 + env[i] = entry 438 + continue 439 + } 440 + env = append(env, entry) 441 + } 442 + return env 443 + } 444 + 445 + func terminalExitStatusMap(status terminalExitStatus) map[string]any { 446 + out := map[string]any{ 447 + "exitCode": nil, 448 + "signal": nil, 449 + } 450 + if status.ExitCode != nil { 451 + out["exitCode"] = *status.ExitCode 452 + } 453 + if status.Signal != nil && strings.TrimSpace(*status.Signal) != "" { 454 + out["signal"] = *status.Signal 455 + } 456 + return out 457 + } 458 + 459 + func stringPtr(value string) *string { 460 + value = strings.TrimSpace(value) 461 + if value == "" { 462 + return nil 463 + } 464 + return &value 465 + }
+8 -4
internal/channelopts/options.go
··· 99 99 ToolRepeatLimit: r.GetInt("tool_repeat_limit"), 100 100 }, 101 101 EngineToolsConfig: agent.EngineToolsConfig{ 102 - SpawnEnabled: r.GetBool("tools.spawn.enabled"), 102 + SpawnEnabled: r.GetBool("tools.spawn.enabled"), 103 + ACPSpawnEnabled: r.GetBool("tools.acp_spawn.enabled"), 103 104 }, 104 105 FileCacheMaxAge: r.GetDuration("file_cache.max_age"), 105 106 FileCacheMaxFiles: r.GetInt("file_cache.max_files"), ··· 309 310 ToolRepeatLimit: r.GetInt("tool_repeat_limit"), 310 311 }, 311 312 EngineToolsConfig: agent.EngineToolsConfig{ 312 - SpawnEnabled: r.GetBool("tools.spawn.enabled"), 313 + SpawnEnabled: r.GetBool("tools.spawn.enabled"), 314 + ACPSpawnEnabled: r.GetBool("tools.acp_spawn.enabled"), 313 315 }, 314 316 MemoryEnabled: r.GetBool("memory.enabled"), 315 317 MemoryShortTermDays: r.GetInt("memory.short_term_days"), ··· 509 511 ToolRepeatLimit: r.GetInt("tool_repeat_limit"), 510 512 }, 511 513 EngineToolsConfig: agent.EngineToolsConfig{ 512 - SpawnEnabled: r.GetBool("tools.spawn.enabled"), 514 + SpawnEnabled: r.GetBool("tools.spawn.enabled"), 515 + ACPSpawnEnabled: r.GetBool("tools.acp_spawn.enabled"), 513 516 }, 514 517 MemoryEnabled: r.GetBool("memory.enabled"), 515 518 MemoryShortTermDays: r.GetInt("memory.short_term_days"), ··· 552 555 ToolRepeatLimit: r.GetInt("tool_repeat_limit"), 553 556 }, 554 557 EngineToolsConfig: agent.EngineToolsConfig{ 555 - SpawnEnabled: r.GetBool("tools.spawn.enabled"), 558 + SpawnEnabled: r.GetBool("tools.spawn.enabled"), 559 + ACPSpawnEnabled: r.GetBool("tools.acp_spawn.enabled"), 556 560 }, 557 561 MemoryEnabled: r.GetBool("memory.enabled"), 558 562 MemoryShortTermDays: r.GetInt("memory.short_term_days"),
+15
internal/channelruntime/depsutil/depsutil.go
··· 9 9 10 10 "github.com/quailyquaily/mistermorph/agent" 11 11 "github.com/quailyquaily/mistermorph/guard" 12 + "github.com/quailyquaily/mistermorph/internal/acpclient" 12 13 "github.com/quailyquaily/mistermorph/internal/llmutil" 13 14 "github.com/quailyquaily/mistermorph/internal/outputfmt" 14 15 "github.com/quailyquaily/mistermorph/internal/toolsutil" ··· 24 25 ResolveLLMRoute func(purpose string) (llmutil.ResolvedRoute, error) 25 26 CreateLLMClient func(route llmutil.ResolvedRoute) (llm.Client, error) 26 27 Registry func() *tools.Registry 28 + ACPAgents func() []acpclient.AgentConfig 27 29 RuntimeToolsConfig toolsutil.RuntimeToolsRegisterConfig 28 30 Guard func(logger *slog.Logger) *guard.Guard 29 31 PromptSpec PromptSpecFunc ··· 36 38 ResolveLLMRoute func(purpose string) (llmutil.ResolvedRoute, error) 37 39 CreateLLMClient func(route llmutil.ResolvedRoute) (llm.Client, error) 38 40 Registry func() *tools.Registry 41 + ACPAgents func() []acpclient.AgentConfig 39 42 RuntimeToolsConfig toolsutil.RuntimeToolsRegisterConfig 40 43 Guard func(logger *slog.Logger) *guard.Guard 41 44 PromptSpec PromptSpecFunc ··· 51 54 ResolveLLMRoute: d.ResolveLLMRoute, 52 55 CreateLLMClient: d.CreateLLMClient, 53 56 Registry: d.Registry, 57 + ACPAgents: d.ACPAgents, 54 58 RuntimeToolsConfig: d.RuntimeToolsConfig, 55 59 Guard: d.Guard, 56 60 PromptSpec: d.PromptSpec, ··· 93 97 return fn() 94 98 } 95 99 100 + func ACPAgents(fn func() []acpclient.AgentConfig) []acpclient.AgentConfig { 101 + if fn == nil { 102 + return nil 103 + } 104 + return fn() 105 + } 106 + 96 107 func Guard(gf func(logger *slog.Logger) *guard.Guard, logger *slog.Logger) *guard.Guard { 97 108 if gf == nil { 98 109 return nil ··· 161 172 162 173 func RegistryFromCommon(d CommonDependencies) *tools.Registry { 163 174 return Registry(d.Registry) 175 + } 176 + 177 + func ACPAgentsFromCommon(d CommonDependencies) []acpclient.AgentConfig { 178 + return ACPAgents(d.ACPAgents) 164 179 } 165 180 166 181 func GuardFromCommon(d CommonDependencies, logger *slog.Logger) *guard.Guard {
+1
internal/channelruntime/heartbeat/run.go
··· 265 265 agent.WithLogger(opts.Logger), 266 266 agent.WithLogOptions(opts.LogOptions), 267 267 agent.WithEngineToolsConfig(opts.EngineToolsConfig), 268 + agent.WithACPAgents(depsutil.ACPAgentsFromCommon(depsutil.CommonFromHeartbeat(d))), 268 269 agent.WithGuard(opts.SharedGuard), 269 270 ) 270 271 final, _, err := engine.Run(runCtx, task, agent.RunOptions{
+9 -2
internal/channelruntime/taskruntime/runtime.go
··· 9 9 10 10 "github.com/quailyquaily/mistermorph/agent" 11 11 "github.com/quailyquaily/mistermorph/guard" 12 + "github.com/quailyquaily/mistermorph/internal/acpclient" 12 13 "github.com/quailyquaily/mistermorph/internal/channelruntime/depsutil" 13 14 "github.com/quailyquaily/mistermorph/internal/llmutil" 14 15 "github.com/quailyquaily/mistermorph/internal/promptprofile" ··· 45 46 PlanRoute llmutil.ResolvedRoute 46 47 PlanClient llm.Client 47 48 PlanModel string 49 + ACPAgents []acpclient.AgentConfig 48 50 } 49 51 50 52 type MemoryHooks struct { ··· 144 146 PlanRoute: planRoute, 145 147 PlanClient: planClient, 146 148 PlanModel: strings.TrimSpace(planRoute.ClientConfig.Model), 149 + ACPAgents: depsutil.ACPAgentsFromCommon(d), 147 150 }, nil 148 151 } 149 152 ··· 230 233 agent.WithLogOptions(rt.LogOptions), 231 234 agent.WithSubtaskRunner(rt), 232 235 agent.WithEngineToolsConfig(engineToolsConfig), 236 + agent.WithACPAgents(rt.ACPAgents), 233 237 } 234 238 if rt.SharedGuard != nil { 235 239 engineOpts = append(engineOpts, agent.WithGuard(rt.SharedGuard)) ··· 340 344 Scene: "spawn.subtask", 341 345 Registry: req.Registry, 342 346 DisableRuntimeTools: true, 343 - EngineToolsConfig: &agent.EngineToolsConfig{SpawnEnabled: false}, 344 - Meta: meta, 347 + EngineToolsConfig: &agent.EngineToolsConfig{ 348 + SpawnEnabled: false, 349 + ACPSpawnEnabled: false, 350 + }, 351 + Meta: meta, 345 352 }) 346 353 if err != nil { 347 354 failed := agent.FailedSubtaskResult(taskID, err)
+3
internal/configdefaults/defaults.go
··· 147 147 v.SetDefault("tools.write_file.max_bytes", 512*1024) 148 148 149 149 v.SetDefault("tools.spawn.enabled", true) 150 + v.SetDefault("tools.acp_spawn.enabled", false) 150 151 v.SetDefault("tools.bash.enabled", true) 151 152 v.SetDefault("tools.bash.timeout", 30*time.Second) 152 153 v.SetDefault("tools.bash.max_output_bytes", 256*1024) ··· 165 166 166 167 v.SetDefault("tools.contacts_send.enabled", true) 167 168 v.SetDefault("tools.todo_update.enabled", true) 169 + 170 + v.SetDefault("acp.agents", []map[string]any{}) 168 171 }
+2
internal/toolsutil/static_register.go
··· 18 18 BuiltinTodoUpdate = "todo_update" 19 19 BuiltinContactsSend = "contacts_send" 20 20 BuiltinSpawn = "spawn" 21 + BuiltinACPSpawn = "acp_spawn" 21 22 ) 22 23 23 24 var builtinToolNameSet = map[string]struct{}{ ··· 30 31 BuiltinTodoUpdate: {}, 31 32 BuiltinContactsSend: {}, 32 33 BuiltinSpawn: {}, 34 + BuiltinACPSpawn: {}, 33 35 } 34 36 35 37 type StaticRegistryConfig struct {