diff --git a/apps/edge/README.md b/apps/edge/README.md index a1fe49b..da5853d 100644 --- a/apps/edge/README.md +++ b/apps/edge/README.md @@ -14,11 +14,11 @@ node 등록/레지스트리/transport와 edge 콘솔 기반 수동 통신 테스 **전제 조건**: node 호스트의 PATH에 `claude` 명령이 있어야 한다. -같은 `cli` 어댑터 안에 `gemini`, `codex` profile도 포함할 수 있으며, 각각 `gemini --approval-mode yolo -p `, `codex exec --dangerously-bypass-approvals-and-sandbox`처럼 headless+bypass 조합으로 설정한다. Claude는 `claude -p --dangerously-skip-permissions --output-format stream-json --include-partial-messages --verbose ` 형태로 호출되어 token-level streaming이 활성화된다. +같은 `cli` 어댑터 안에 `gemini`, `codex`, `opencode` profile도 포함할 수 있으며, 각각 `gemini --approval-mode yolo -p `, `codex exec --dangerously-bypass-approvals-and-sandbox`, `opencode run --model ollama-dgx/qwen3.6:35b-a3b-bf16 --format default --dangerously-skip-permissions `처럼 headless 조합으로 설정한다. Claude는 `claude -p --dangerously-skip-permissions --output-format stream-json --include-partial-messages --verbose ` 형태로 호출되어 token-level streaming이 활성화된다. 실행 순서: -1. edge 호스트에서 `configs/edge.yaml`의 `server.listen`, `nodes[].token`을 확인한다. `console.adapter=cli`, `console.model=claude`, `console.session_id=default`가 기본값이다. +1. edge 호스트에서 `configs/edge.yaml`의 `server.listen`, `nodes[].token`을 확인한다. `console.adapter=cli`, `console.agent=claude`, `console.session_id=default`가 기본값이다. 2. node 호스트에서 `configs/node.yaml`의 `transport.edge_addr`를 edge 호스트 주소로, `transport.token`을 edge token과 같게 맞춘다. 3. edge 호스트의 `9090/tcp` 포트를 node 호스트에서 접근 가능하게 연다. 4. edge 호스트에서 `./bin/edge.sh` @@ -32,7 +32,7 @@ node 등록/레지스트리/transport와 edge 콘솔 기반 수동 통신 테스 ```text edge> hello -[edge] sent run_id=manual-... node=local-node adapter=cli model=claude session=default background=false +[edge] sent run_id=manual-... node=local-node adapter=cli agent=claude session=default background=false [node-local-node-event] start run_id=manual-... [node-local-node-event] complete run_id=manual-... detail="cli execution complete" [node-local-node-message] @@ -44,7 +44,7 @@ edge> hello |---|---| | `/session ` | 현재 console이 사용할 logical session 변경 | | `/background on\|off` | background 실행 모드 토글 (on: 응답 기다리지 않음) | -| `/terminate-session` | 현재 `adapter/model/session_id`의 worker process 종료 | +| `/terminate-session` | 현재 `adapter/agent/session_id`의 worker process 종료 | | `/nodes` | 연결된 node 목록 확인 | | `/exit` | 콘솔 종료 | diff --git a/apps/edge/cmd/edge/console.go b/apps/edge/cmd/edge/console.go index 54ac0a0..6bc5cff 100644 --- a/apps/edge/cmd/edge/console.go +++ b/apps/edge/cmd/edge/console.go @@ -49,15 +49,15 @@ func runConsole(ctx context.Context, cfg *config.EdgeConfig, in io.Reader, out i } }() - // Console state: adapter, model, session, background mode. + // Console state: adapter, agent, session, background mode. adapter := cfg.Console.Adapter - model := cfg.Console.Model + agent := cfg.Console.ResolveAgent() sessionID := normalizeConsoleSessionID(cfg.Console.SessionID) background := cfg.Console.Background timeoutSec := cfg.Console.TimeoutSec fmt.Fprintf(out, "IOP Edge console listening on %s\n", cfg.Server.Listen) - fmt.Fprintf(out, "Console target adapter=%s model=%s session=%s background=%v\n", adapter, model, sessionID, background) + fmt.Fprintf(out, "Console target adapter=%s agent=%s session=%s background=%v\n", adapter, agent, sessionID, background) fmt.Fprintln(out, "Start node.sh on another host, then type a message here.") fmt.Fprintln(out, "Commands: /nodes, /session , /background on|off, /terminate-session, /exit") @@ -104,13 +104,13 @@ func runConsole(ctx context.Context, cfg *config.EdgeConfig, in io.Reader, out i fmt.Fprintln(out, "usage: /background on|off") } case lower == "/terminate-session": - if err := sendTerminateSession(ctx, registry, adapter, model, sessionID); err != nil { + if err := sendTerminateSession(ctx, registry, adapter, agent, sessionID); err != nil { fmt.Fprintf(out, "error: %v\n", err) } else { fmt.Fprintf(out, "terminated session %s\n", sessionID) } default: - if err := sendConsoleRun(ctx, registry, events, out, adapter, model, sessionID, background, timeoutSec, message); err != nil { + if err := sendConsoleRun(ctx, registry, events, out, adapter, agent, sessionID, background, timeoutSec, message); err != nil { fmt.Fprintf(out, "error: %v\n", err) } } @@ -129,7 +129,9 @@ func printNodes(out io.Writer, registry *edgenode.Registry) { } // buildRunRequest constructs the RunRequest for a console send. -func buildRunRequest(adapter, model, sessionID string, background bool, timeoutSec int, message string) (*iop.RunRequest, string, error) { +// The wire schema still uses RunRequest.model; for the CLI adapter this value +// semantically carries the selected agent/profile name. +func buildRunRequest(adapter, agent, sessionID string, background bool, timeoutSec int, message string) (*iop.RunRequest, string, error) { input, err := structpb.NewStruct(map[string]any{"prompt": message}) if err != nil { return nil, "", err @@ -141,7 +143,7 @@ func buildRunRequest(adapter, model, sessionID string, background bool, timeoutS req := &iop.RunRequest{ RunId: runID, Adapter: adapter, - Model: model, + Model: agent, SessionId: normalizeConsoleSessionID(sessionID), SessionMode: iop.RunSessionMode_RUN_SESSION_MODE_CREATE_IF_MISSING, Background: background, @@ -154,13 +156,13 @@ func buildRunRequest(adapter, model, sessionID string, background bool, timeoutS return req, runID, nil } -func sendConsoleRun(ctx context.Context, registry *edgenode.Registry, events *consoleEventRouter, out io.Writer, adapter, model, sessionID string, background bool, timeoutSec int, message string) error { +func sendConsoleRun(ctx context.Context, registry *edgenode.Registry, events *consoleEventRouter, out io.Writer, adapter, agent, sessionID string, background bool, timeoutSec int, message string) error { entry, err := registry.Pick() if err != nil { return err } - req, runID, err := buildRunRequest(adapter, model, sessionID, background, timeoutSec, message) + req, runID, err := buildRunRequest(adapter, agent, sessionID, background, timeoutSec, message) if err != nil { return err } @@ -173,8 +175,8 @@ func sendConsoleRun(ctx context.Context, registry *edgenode.Registry, events *co } nodeAlias := entry.Alias - fmt.Fprintf(out, "[edge] sent run_id=%s node=%s adapter=%s model=%s session=%s background=%v\n", - runID, nodeAlias, adapter, model, req.GetSessionId(), background) + fmt.Fprintf(out, "[edge] sent run_id=%s node=%s adapter=%s agent=%s session=%s background=%v\n", + runID, nodeAlias, adapter, agent, req.GetSessionId(), background) if err := entry.Client.Send(req); err != nil { return err } diff --git a/apps/edge/cmd/edge/console_test.go b/apps/edge/cmd/edge/console_test.go index bd7f945..f028b1b 100644 --- a/apps/edge/cmd/edge/console_test.go +++ b/apps/edge/cmd/edge/console_test.go @@ -20,7 +20,7 @@ func TestBuildRunRequest_SessionAndBackground(t *testing.T) { t.Errorf("Adapter: got %q want %q", req.GetAdapter(), "cli") } if req.GetModel() != "codex" { - t.Errorf("Model: got %q want %q", req.GetModel(), "codex") + t.Errorf("wire model: got %q want %q", req.GetModel(), "codex") } if req.GetSessionId() != "session-a" { t.Errorf("SessionId: got %q want %q", req.GetSessionId(), "session-a") diff --git a/apps/edge/internal/transport/server.go b/apps/edge/internal/transport/server.go index 6389bb6..1b84105 100644 --- a/apps/edge/internal/transport/server.go +++ b/apps/edge/internal/transport/server.go @@ -198,6 +198,7 @@ func buildConfigPayload(rec *edgenode.NodeRecord) (*iop.NodeConfigPayload, error "terminal": p.Terminal, "response_idle_timeout_ms": p.ResponseIdleTimeoutMS, "startup_idle_timeout_ms": p.StartupIdleTimeoutMS, + "output_format": p.OutputFormat, } } if err := addAdapter("cli", true, map[string]any{"profiles": profiles}); err != nil { diff --git a/apps/edge/internal/transport/server_test.go b/apps/edge/internal/transport/server_test.go index 307bf1a..6ec26f0 100644 --- a/apps/edge/internal/transport/server_test.go +++ b/apps/edge/internal/transport/server_test.go @@ -23,6 +23,7 @@ func TestBuildConfigPayload_CLIArgsRoundtrip(t *testing.T) { Terminal: true, ResponseIdleTimeoutMS: 1500, StartupIdleTimeoutMS: 300, + OutputFormat: "codex-json", }, }, }, @@ -86,4 +87,7 @@ func TestBuildConfigPayload_CLIArgsRoundtrip(t *testing.T) { if got, ok := profile["startup_idle_timeout_ms"].(float64); !ok || got != 300 { t.Fatalf("expected startup_idle_timeout_ms=300, got %v", profile["startup_idle_timeout_ms"]) } + if got, ok := profile["output_format"].(string); !ok || got != "codex-json" { + t.Fatalf("expected output_format=%q, got %v", "codex-json", profile["output_format"]) + } } diff --git a/apps/node/README.md b/apps/node/README.md index 7ce2b96..5c1ef09 100644 --- a/apps/node/README.md +++ b/apps/node/README.md @@ -61,7 +61,7 @@ edge-node transport 연결은 **호스트당 1개**를 유지한다. 그 연결 | 개념 | 설명 | |---|---| | transport 연결 | edge-node 호스트 쌍당 1개 TCP 연결 | -| logical session | `(adapter, model, session_id)` 로 식별되는 장수 worker process | +| logical session | `(adapter, agent, session_id)` 로 식별되는 장수 worker process | | run | session 위에서 실행되는 단일 요청 | **cancel vs terminate:** @@ -83,7 +83,7 @@ edge-node transport 연결은 **호스트당 1개**를 유지한다. 그 연결 | `vllm` | vLLM OpenAI-compatible API | TODO | | `cli` | claude/gemini/codex/opencode CLI 실행 | 구현 완료 | -`claude`, `gemini`, `codex`처럼 기본이 interactive TUI인 CLI는 이 파이프라인에서 non-interactive 모드로 설정해야 한다. 예: `claude -p --dangerously-skip-permissions`, `gemini --approval-mode yolo -p `, `codex exec --dangerously-bypass-approvals-and-sandbox`. +`claude`, `gemini`, `codex`, `opencode`처럼 기본이 interactive TUI인 CLI는 이 파이프라인에서 non-interactive 모드로 설정해야 한다. 예: `claude -p --dangerously-skip-permissions`, `gemini --approval-mode yolo -p `, `codex exec --dangerously-bypass-approvals-and-sandbox`, `opencode run --model ollama-dgx/qwen3.6:35b-a3b-bf16 --format default --dangerously-skip-permissions `. ## Transport diff --git a/apps/node/internal/adapters/cli/cli.go b/apps/node/internal/adapters/cli/cli.go index c8883cd..06be4d7 100644 --- a/apps/node/internal/adapters/cli/cli.go +++ b/apps/node/internal/adapters/cli/cli.go @@ -21,8 +21,8 @@ import ( const Name = "cli" -// known profiles — must match CLI tool names -var knownProfiles = []string{"claude", "gemini", "codex", "opencode"} +// known agents — must match CLI tool names +var knownAgents = []string{"claude", "gemini", "codex", "opencode"} type cliOutput struct { line string @@ -30,7 +30,7 @@ type cliOutput struct { // sessionKey uniquely identifies a logical worker session. type sessionKey struct { - model string + agent string sessionID string } @@ -76,9 +76,9 @@ func (c *CLI) Capabilities(_ context.Context) (runtime.Capabilities, error) { for name := range c.profiles { models = append(models, name) } - for _, p := range knownProfiles { - if _, ok := c.profiles[p]; !ok { - models = append(models, p) + for _, agent := range knownAgents { + if _, ok := c.profiles[agent]; !ok { + models = append(models, agent) } } return runtime.Capabilities{ @@ -101,19 +101,19 @@ func (c *CLI) Start(ctx context.Context) error { for _, name := range names { profile := c.profiles[name] - key := sessionKey{model: name, sessionID: runtime.DefaultSessionID} + key := sessionKey{agent: name, sessionID: runtime.DefaultSessionID} sess, err := startProfileSession(ctx, key, profile, c.logger) if err != nil { c.mu.Lock() _ = c.stopAllSessions(context.Background()) c.sessions = make(map[sessionKey]*profileSession) c.mu.Unlock() - return fmt.Errorf("cli adapter: start profile %q: %w", name, err) + return fmt.Errorf("cli adapter: start agent %q: %w", name, err) } c.mu.Lock() c.sessions[key] = sess c.mu.Unlock() - c.logger.Info("cli adapter: persistent session started", zap.String("profile", name)) + c.logger.Info("cli adapter: persistent session started", zap.String("agent", name)) } return nil } @@ -124,7 +124,7 @@ func (c *CLI) stopAllSessions(_ context.Context) error { var firstErr error for key, sess := range c.sessions { if err := closeProfileSession(context.Background(), sess); err != nil && firstErr == nil { - firstErr = fmt.Errorf("cli adapter: close session %q/%q: %w", key.model, key.sessionID, err) + firstErr = fmt.Errorf("cli adapter: close session %q/%q: %w", key.agent, key.sessionID, err) } } c.sessions = make(map[sessionKey]*profileSession) @@ -145,16 +145,17 @@ func (c *CLI) Stop(_ context.Context) error { var firstErr error for key, sess := range sessionsCopy { if err := closeProfileSession(context.Background(), sess); err != nil && firstErr == nil { - firstErr = fmt.Errorf("cli adapter: close session %q/%q: %w", key.model, key.sessionID, err) + firstErr = fmt.Errorf("cli adapter: close session %q/%q: %w", key.agent, key.sessionID, err) } } return firstErr } func (c *CLI) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink runtime.EventSink) error { - profile, ok := c.profiles[spec.Model] + agent := cliAgentName(spec) + profile, ok := c.profiles[agent] if !ok { - return fmt.Errorf("cli adapter: unknown profile %q", spec.Model) + return fmt.Errorf("cli adapter: unknown agent %q", agent) } if profile.Persistent { if isCodexExecProfile(profile) { @@ -166,9 +167,9 @@ func (c *CLI) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink runt } // TerminateSession implements runtime.SessionTerminator. -func (c *CLI) TerminateSession(_ context.Context, model, sessionID string) error { - key := sessionKey{model: model, sessionID: normalizeSessionID(sessionID)} - if profile, ok := c.profiles[model]; ok && isCodexExecProfile(profile) { +func (c *CLI) TerminateSession(_ context.Context, agent, sessionID string) error { + key := sessionKey{agent: agent, sessionID: normalizeSessionID(sessionID)} + if profile, ok := c.profiles[agent]; ok && isCodexExecProfile(profile) { c.mu.Lock() _, ok := c.codexSessions[key] if ok { @@ -176,7 +177,7 @@ func (c *CLI) TerminateSession(_ context.Context, model, sessionID string) error } c.mu.Unlock() if !ok { - return fmt.Errorf("cli adapter: no session %q for profile %q", key.sessionID, model) + return fmt.Errorf("cli adapter: no session %q for agent %q", key.sessionID, agent) } return nil } @@ -188,11 +189,15 @@ func (c *CLI) TerminateSession(_ context.Context, model, sessionID string) error } c.mu.Unlock() if !ok { - return fmt.Errorf("cli adapter: no session %q for profile %q", key.sessionID, model) + return fmt.Errorf("cli adapter: no session %q for agent %q", key.sessionID, agent) } return closeProfileSession(context.Background(), sess) } +func cliAgentName(spec runtime.ExecutionSpec) string { + return spec.Model +} + func normalizeSessionID(id string) string { if id == "" { return runtime.DefaultSessionID diff --git a/apps/node/internal/adapters/cli/codex_exec.go b/apps/node/internal/adapters/cli/codex_exec.go index 2d4f46c..a82f31d 100644 --- a/apps/node/internal/adapters/cli/codex_exec.go +++ b/apps/node/internal/adapters/cli/codex_exec.go @@ -47,7 +47,8 @@ func (c *CLI) executeCodexExec(ctx context.Context, spec runtime.ExecutionSpec, } func (c *CLI) resolveCodexExecSession(spec runtime.ExecutionSpec) (*codexExecSession, error) { - key := sessionKey{model: spec.Model, sessionID: normalizeSessionID(spec.SessionID)} + agent := cliAgentName(spec) + key := sessionKey{agent: agent, sessionID: normalizeSessionID(spec.SessionID)} c.mu.Lock() defer c.mu.Unlock() @@ -56,7 +57,7 @@ func (c *CLI) resolveCodexExecSession(spec runtime.ExecutionSpec) (*codexExecSes return sess, nil } if spec.SessionMode == runtime.SessionModeRequireExisting { - return nil, fmt.Errorf("cli adapter: no persistent session for profile %q session %q", spec.Model, key.sessionID) + return nil, fmt.Errorf("cli adapter: no persistent session for agent %q session %q", agent, key.sessionID) } sess := &codexExecSession{key: key} c.codexSessions[key] = sess diff --git a/apps/node/internal/adapters/cli/oneshot.go b/apps/node/internal/adapters/cli/oneshot.go index fc848f0..286ec62 100644 --- a/apps/node/internal/adapters/cli/oneshot.go +++ b/apps/node/internal/adapters/cli/oneshot.go @@ -71,6 +71,8 @@ func (c *CLI) executeCommand(ctx context.Context, spec runtime.ExecutionSpec, pr outputTokens, readErr = emitCodexJSONLines(ctx, stdout, sink, spec.RunID, &outBuf) case "claude-json": outputTokens, readErr = emitClaudeJSONLines(ctx, stdout, sink, spec.RunID, &outBuf) + case "opencode-json": + outputTokens, readErr = emitOpencodeJSON(ctx, stdout, sink, spec.RunID, &outBuf) default: outputTokens, readErr = emitStdoutChunks(ctx, stdout, sink, spec.RunID, &outBuf) } @@ -338,6 +340,41 @@ func emitCodexJSONLines(ctx context.Context, stdout io.Reader, sink runtime.Even return outputTokens, nil } +// emitOpencodeJSON parses `opencode -p ... -f json` output. The current +// opencode CLI wraps the final assistant text in a single JSON object: +// {"response":"..."}. +func emitOpencodeJSON(ctx context.Context, stdout io.Reader, sink runtime.EventSink, runID string, outBuf *strings.Builder) (int, error) { + body, err := io.ReadAll(stdout) + if err != nil { + return 0, err + } + outBuf.Write(body) + + trimmed := strings.TrimSpace(string(body)) + if trimmed == "" || trimmed[0] != '{' { + return 0, nil + } + + var resp struct { + Response string `json:"response"` + } + if err := json.Unmarshal([]byte(trimmed), &resp); err != nil { + return 0, nil + } + if resp.Response == "" { + return 0, nil + } + + outputTokens := len(strings.Fields(resp.Response)) + _ = sink.Emit(ctx, runtime.RuntimeEvent{ + RunID: runID, + Type: runtime.EventTypeDelta, + Delta: resp.Response, + Timestamp: time.Now(), + }) + return outputTokens, nil +} + func extractPrompt(input map[string]any) string { if input == nil { return "" diff --git a/apps/node/internal/adapters/cli/oneshot/cli_test.go b/apps/node/internal/adapters/cli/oneshot/cli_test.go index f4fb365..21a3a40 100644 --- a/apps/node/internal/adapters/cli/oneshot/cli_test.go +++ b/apps/node/internal/adapters/cli/oneshot/cli_test.go @@ -168,6 +168,40 @@ EOF` } } +func TestCLIExecuteOneShotOpencodeJSONParsesResponse(t *testing.T) { + testutil.RequireUnixShell(t) + + script := `cat <<'EOF' +{ + "response": "OpenCode says hello." +} +EOF` + cfg := config.CLIConf{ + Enabled: true, + Profiles: map[string]config.CLIProfileConf{ + "opencode": { + Command: "sh", + Args: []string{"-c", script, "sh"}, + OutputFormat: "opencode-json", + }, + }, + } + c := clipkg.New(cfg, zap.NewNop()) + sink := &testutil.FakeSink{} + + if err := c.Execute(context.Background(), noderuntime.ExecutionSpec{ + RunID: "run-oc", + Model: "opencode", + Input: map[string]any{"prompt": "hi"}, + }, sink); err != nil { + t.Fatalf("execute: %v", err) + } + combined := testutil.CollectDeltas(sink.Events()) + if combined != "OpenCode says hello." { + t.Fatalf("expected opencode-json deltas to be %q, got %q", "OpenCode says hello.", combined) + } +} + func TestCLIExecuteOneShotDrainsStderrConcurrently(t *testing.T) { testutil.RequireUnixShell(t) diff --git a/apps/node/internal/adapters/cli/persistent.go b/apps/node/internal/adapters/cli/persistent.go index e74db06..056e5ca 100644 --- a/apps/node/internal/adapters/cli/persistent.go +++ b/apps/node/internal/adapters/cli/persistent.go @@ -130,7 +130,8 @@ func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec, // resolveSession returns an existing session for the given key or creates one // when SessionMode allows it. func (c *CLI) resolveSession(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf) (*profileSession, error) { - key := sessionKey{model: spec.Model, sessionID: normalizeSessionID(spec.SessionID)} + agent := cliAgentName(spec) + key := sessionKey{agent: agent, sessionID: normalizeSessionID(spec.SessionID)} c.mu.Lock() defer c.mu.Unlock() @@ -138,7 +139,7 @@ func (c *CLI) resolveSession(ctx context.Context, spec runtime.ExecutionSpec, pr return sess, nil } if spec.SessionMode == runtime.SessionModeRequireExisting { - return nil, fmt.Errorf("cli adapter: no persistent session for profile %q session %q", spec.Model, key.sessionID) + return nil, fmt.Errorf("cli adapter: no persistent session for agent %q session %q", agent, key.sessionID) } sess, err := startProfileSession(ctx, key, profile, c.logger) if err != nil { @@ -150,7 +151,7 @@ func (c *CLI) resolveSession(ctx context.Context, spec runtime.ExecutionSpec, pr func startProfileSession(_ context.Context, key sessionKey, profile config.CLIProfileConf, logger *zap.Logger) (*profileSession, error) { if profile.Command == "" { - return nil, fmt.Errorf("profile %q has no command", key.model) + return nil, fmt.Errorf("agent %q has no command", key.agent) } outputCh := make(chan cliOutput, 1024) @@ -211,7 +212,7 @@ func startProfileSession(_ context.Context, key sessionKey, profile config.CLIPr } if profile.StartupIdleTimeoutMS > 0 { - drainUntilIdle(outputCh, time.Duration(profile.StartupIdleTimeoutMS)*time.Millisecond, logger, key.model) + drainUntilIdle(outputCh, time.Duration(profile.StartupIdleTimeoutMS)*time.Millisecond, logger, key.agent) } select { @@ -229,7 +230,7 @@ func startProfileSession(_ context.Context, key sessionKey, profile config.CLIPr return &profileSession{ key: key, - name: key.model, + name: key.agent, profile: profile, cmd: cmd, input: input, @@ -278,7 +279,7 @@ func drainSessionUntilIdle(outputCh <-chan cliOutput, timeout time.Duration, log } } timer.Reset(timeout) - logger.Debug("cli adapter: cancel drain", zap.String("profile", key.model), zap.String("session", key.sessionID)) + logger.Debug("cli adapter: cancel drain", zap.String("agent", key.agent), zap.String("session", key.sessionID)) case <-timer.C: return } diff --git a/apps/node/internal/adapters/factory.go b/apps/node/internal/adapters/factory.go index 87b3b09..2d1c19a 100644 --- a/apps/node/internal/adapters/factory.go +++ b/apps/node/internal/adapters/factory.go @@ -108,6 +108,9 @@ func cliConfFromStruct(s *structpb.Struct) config.CLIConf { if v, ok := intFromAny(p["startup_idle_timeout_ms"]); ok { prof.StartupIdleTimeoutMS = v } + if v, ok := p["output_format"].(string); ok { + prof.OutputFormat = v + } cfg.Profiles[name] = prof } } diff --git a/apps/node/internal/adapters/factory_internal_test.go b/apps/node/internal/adapters/factory_internal_test.go index 000c1ca..007c656 100644 --- a/apps/node/internal/adapters/factory_internal_test.go +++ b/apps/node/internal/adapters/factory_internal_test.go @@ -10,11 +10,12 @@ func TestCLIConfFromStruct_ProfileRuntimeOptions(t *testing.T) { st, err := structpb.NewStruct(map[string]any{ "profiles": map[string]any{ "claude": map[string]any{ - "command": "claude", - "args": []any{"-p"}, - "env": []any{}, - "persistent": false, - "terminal": false, + "command": "claude", + "args": []any{"-p"}, + "env": []any{}, + "persistent": false, + "terminal": false, + "output_format": "claude-json", }, }, }) @@ -34,6 +35,9 @@ func TestCLIConfFromStruct_ProfileRuntimeOptions(t *testing.T) { if len(prof.Args) != 1 || prof.Args[0] != "-p" { t.Fatalf("args: got %#v", prof.Args) } + if prof.OutputFormat != "claude-json" { + t.Fatalf("output_format: got %q", prof.OutputFormat) + } } func TestCLIConfFromStruct_ClaudeHeadlessBypassProfile(t *testing.T) { diff --git a/configs/edge.yaml b/configs/edge.yaml index 2d5a0e2..2c38486 100644 --- a/configs/edge.yaml +++ b/configs/edge.yaml @@ -13,7 +13,7 @@ metrics: console: adapter: "cli" - model: "claude" + agent: "opencode" session_id: "default" background: false timeout_sec: 120 @@ -71,6 +71,19 @@ nodes: persistent: true terminal: false output_format: "codex-json" + opencode: + command: "opencode" + args: + - "run" + - "--model" + - "ollama-dgx/qwen3.6:35b-a3b-bf16" + - "--format" + - "default" + - "--dangerously-skip-permissions" + env: [] + persistent: false + terminal: false + output_format: "" runtime: concurrency: 4 workspace_root: "/tmp/iop/workspace" diff --git a/packages/config/config.go b/packages/config/config.go index b726e94..d032e77 100644 --- a/packages/config/config.go +++ b/packages/config/config.go @@ -38,12 +38,20 @@ type EdgeServerConf struct { type EdgeConsoleConf struct { Adapter string `mapstructure:"adapter" yaml:"adapter"` + Agent string `mapstructure:"agent" yaml:"agent"` Model string `mapstructure:"model" yaml:"model"` SessionID string `mapstructure:"session_id" yaml:"session_id"` Background bool `mapstructure:"background" yaml:"background"` TimeoutSec int `mapstructure:"timeout_sec" yaml:"timeout_sec"` } +func (c EdgeConsoleConf) ResolveAgent() string { + if c.Agent != "" { + return c.Agent + } + return c.Model +} + type TransportConf struct { EdgeAddr string `mapstructure:"edge_addr" yaml:"edge_addr"` Token string `mapstructure:"token" yaml:"token"` @@ -135,6 +143,9 @@ func LoadEdge(cfgFile string) (*EdgeConfig, error) { if err := v.Unmarshal(&cfg); err != nil { return nil, err } + if !v.InConfig("console.agent") && v.InConfig("console.model") { + cfg.Console.Agent = cfg.Console.Model + } return &cfg, nil } @@ -150,7 +161,7 @@ func setEdgeDefaults(v *viper.Viper) { v.SetDefault("metrics.port", 9092) v.SetDefault("tls.enabled", false) v.SetDefault("console.adapter", "cli") - v.SetDefault("console.model", "claude") + v.SetDefault("console.agent", "claude") v.SetDefault("console.session_id", "default") v.SetDefault("console.background", false) v.SetDefault("console.timeout_sec", 120) diff --git a/packages/config/config_test.go b/packages/config/config_test.go index c496475..9228661 100644 --- a/packages/config/config_test.go +++ b/packages/config/config_test.go @@ -25,8 +25,8 @@ func TestLoadEdge_ConsoleTimeoutDefault(t *testing.T) { if cfg.Console.Adapter != "cli" { t.Fatalf("expected default console.adapter=%q, got %q", "cli", cfg.Console.Adapter) } - if cfg.Console.Model != "claude" { - t.Fatalf("expected default console.model=%q, got %q", "claude", cfg.Console.Model) + if cfg.Console.ResolveAgent() != "claude" { + t.Fatalf("expected default console.agent=%q, got %q", "claude", cfg.Console.ResolveAgent()) } } @@ -83,3 +83,20 @@ func TestLoadEdge_ConsoleTimeoutOverride(t *testing.T) { t.Fatalf("expected timeout_sec=45, got %d", cfg.Console.TimeoutSec) } } + +func TestLoadEdge_ConsoleAgentFallbackFromLegacyModel(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "edge.yaml") + yaml := "server:\n listen: \"0.0.0.0:9090\"\nconsole:\n model: \"codex\"\n" + if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + + cfg, err := config.LoadEdge(f) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.Console.ResolveAgent() != "codex" { + t.Fatalf("expected legacy console.model fallback to resolve to %q, got %q", "codex", cfg.Console.ResolveAgent()) + } +}