node adapter CLI 수정 및 edge.yaml 설정 업데이트

This commit is contained in:
toki 2026-05-04 11:43:56 +09:00
parent 428ac8b04c
commit 224b649d23
6 changed files with 208 additions and 5 deletions

View file

@ -17,7 +17,7 @@ internal/
mock/ — 에코 테스트 어댑터
ollama/ — Ollama API 어댑터 (TODO)
vllm/ — vLLM OpenAI-compatible 어댑터 (TODO)
cli/ — CLI 프로세스 어댑터 (claude/gemini/codex/opencode)
cli/ — CLI 프로세스 어댑터 (claude/gemini/codex/opencode/cline)
store/ — SQLite 실행 이력
```
@ -81,9 +81,11 @@ edge-node transport 연결은 **호스트당 1개**를 유지한다. 그 연결
| `mock` | 입력 에코, 스트리밍 테스트용 | 구현 완료 |
| `ollama` | 로컬 Ollama 서버 연동 | TODO |
| `vllm` | vLLM OpenAI-compatible API | TODO |
| `cli` | claude/gemini/codex/opencode CLI 실행 | 구현 완료 |
| `cli` | claude/gemini/codex/opencode/cline CLI 실행 | 구현 완료 |
`claude`, `gemini`, `codex`, `opencode`처럼 기본이 interactive TUI인 CLI는 이 파이프라인에서 non-interactive 모드로 설정해야 한다. 예: `claude -p --dangerously-skip-permissions`, `gemini --approval-mode yolo -p <prompt>`, `codex exec --dangerously-bypass-approvals-and-sandbox`, `opencode run --model ollama-dgx/qwen3.6:35b-a3b-bf16 --format default --dangerously-skip-permissions <prompt>`.
`claude`, `gemini`, `codex`, `opencode`, `cline`처럼 기본이 interactive TUI인 CLI는 이 파이프라인에서 non-interactive 모드로 설정해야 한다. 예: `claude -p --dangerously-skip-permissions`, `gemini --approval-mode yolo -p <prompt>`, `codex exec --dangerously-bypass-approvals-and-sandbox`, `opencode run --model ollama-dgx/qwen3.6:35b-a3b-bf16 --format json --dangerously-skip-permissions <prompt>`, `cline -y --json --config /config/.cline/profiles/ollama-dgx <prompt>`.
Cline은 mutable `--config` 디렉터리를 프로필처럼 나눈다. `/config/.cline/profiles/ollama-dgx``/config/.cline/profiles/ollama-m1``opencode``ollama-dgx`/`ollama-m1` provider 설정을 Cline의 `ollama` provider 설정으로 변환한 값이다.
## Transport

View file

@ -1,5 +1,5 @@
// Package cli provides an Adapter that runs external CLI tools as inference
// backends. Profiles (claude, gemini, codex, opencode) are configured in
// backends. Profiles (claude, gemini, codex, opencode, cline) are configured in
// configs/node.yaml and select the command + args to execute. Interactive CLIs
// should be configured with their non-interactive/headless flags for use in the
// request/response node pipeline.
@ -22,7 +22,7 @@ import (
const Name = "cli"
// known agents — must match CLI tool names
var knownAgents = []string{"claude", "gemini", "codex", "opencode"}
var knownAgents = []string{"claude", "gemini", "codex", "opencode", "cline"}
type cliOutput struct {
line string

View file

@ -73,6 +73,8 @@ func (c *CLI) executeCommand(ctx context.Context, spec runtime.ExecutionSpec, pr
outputTokens, readErr = emitClaudeJSONLines(ctx, stdout, sink, spec.RunID, &outBuf)
case "opencode-json":
outputTokens, readErr = emitOpencodeJSON(ctx, stdout, sink, spec.RunID, &outBuf)
case "cline-json":
outputTokens, readErr = emitClineJSON(ctx, stdout, sink, spec.RunID, &outBuf)
default:
outputTokens, readErr = emitStdoutChunks(ctx, stdout, sink, spec.RunID, &outBuf)
}
@ -398,6 +400,74 @@ func emitOpencodeJSON(ctx context.Context, stdout io.Reader, sink runtime.EventS
return outputTokens, nil
}
// emitClineJSON parses `cline --json` headless output. Cline emits lifecycle
// events as JSONL; assistant text is carried by say=="text" events.
func emitClineJSON(ctx context.Context, stdout io.Reader, sink runtime.EventSink, runID string, outBuf *strings.Builder) (int, error) {
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 64*1024), 8*1024*1024)
outputTokens := 0
for scanner.Scan() {
line := scanner.Bytes()
outBuf.Write(line)
outBuf.WriteByte('\n')
trimmed := strings.TrimSpace(string(line))
if trimmed == "" || trimmed[0] != '{' {
continue
}
var ev struct {
Type string `json:"type"`
Say string `json:"say"`
Ask string `json:"ask"`
Text string `json:"text"`
Message string `json:"message"`
Status string `json:"status"`
Error string `json:"error"`
}
if err := json.Unmarshal(line, &ev); err != nil {
continue
}
switch {
case ev.Type == "say" && ev.Say == "text" && ev.Text != "":
outputTokens += len(strings.Fields(ev.Text))
_ = sink.Emit(ctx, runtime.RuntimeEvent{RunID: runID, Type: runtime.EventTypeDelta, Delta: ev.Text, Timestamp: time.Now()})
case ev.Type == "say" && ev.Say == "error":
msg := ev.Text
if msg == "" {
msg = ev.Message
}
if msg != "" {
_ = sink.Emit(ctx, runtime.RuntimeEvent{RunID: runID, Type: runtime.EventTypeError, Error: msg, Timestamp: time.Now()})
}
case ev.Type == "completion" && ev.Status == "error":
msg := ev.Error
if msg == "" {
msg = ev.Message
}
if msg == "" {
msg = "cline task failed"
}
_ = sink.Emit(ctx, runtime.RuntimeEvent{RunID: runID, Type: runtime.EventTypeError, Error: msg, Timestamp: time.Now()})
case ev.Type == "ask" && ev.Ask == "api_req_failed":
msg := ev.Text
if msg == "" {
msg = ev.Message
}
if msg != "" {
_ = sink.Emit(ctx, runtime.RuntimeEvent{RunID: runID, Type: runtime.EventTypeError, Error: msg, Timestamp: time.Now()})
}
}
}
if err := scanner.Err(); err != nil {
return outputTokens, err
}
return outputTokens, nil
}
func extractPrompt(input map[string]any) string {
if input == nil {
return ""

View file

@ -317,6 +317,81 @@ EOF`
}
}
func TestCLIExecuteOneShotClineJSONParsesTextEvents(t *testing.T) {
testutil.RequireUnixShell(t)
script := `cat <<'EOF'
{"type":"task_started","taskId":"1777860858386"}
{"ts":1777860858391,"type":"say","say":"task","text":"Hi","modelInfo":{"providerId":"ollama","modelId":"qwen3.6:35b-a3b-bf16","mode":"act"}}
{"ts":1777860858965,"type":"say","say":"api_req_started","text":"{\"request\":\"<task>Hi</task>\"}"}
{"ts":1777860864009,"type":"say","say":"text","text":"Hello from Cline.","partial":false}
{"type":"completion","status":"success","timestamp":1777860865000}
EOF`
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"cline-dgx": {
Command: "sh",
Args: []string{"-c", script, "sh"},
OutputFormat: "cline-json",
},
},
}
c := clipkg.New(cfg, zap.NewNop())
sink := &testutil.FakeSink{}
if err := c.Execute(context.Background(), noderuntime.ExecutionSpec{
RunID: "run-cline",
Model: "cline-dgx",
Input: map[string]any{"prompt": "hi"},
}, sink); err != nil {
t.Fatalf("execute: %v", err)
}
combined := testutil.CollectDeltas(sink.Events())
if combined != "Hello from Cline." {
t.Fatalf("expected cline-json deltas to be %q, got %q", "Hello from Cline.", combined)
}
}
func TestCLIExecuteOneShotClineJSONParsesErrorEvents(t *testing.T) {
testutil.RequireUnixShell(t)
script := `cat <<'EOF'
{"type":"task_started","taskId":"1777860858386"}
{"ts":1777860858965,"type":"ask","ask":"api_req_failed","text":"model unavailable"}
EOF`
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"cline-dgx": {
Command: "sh",
Args: []string{"-c", script, "sh"},
OutputFormat: "cline-json",
},
},
}
c := clipkg.New(cfg, zap.NewNop())
sink := &testutil.FakeSink{}
if err := c.Execute(context.Background(), noderuntime.ExecutionSpec{
RunID: "run-cline-err",
Model: "cline-dgx",
Input: map[string]any{"prompt": "hi"},
}, sink); err != nil {
t.Fatalf("execute: %v", err)
}
var got string
for _, e := range sink.Events() {
if e.Type == noderuntime.EventTypeError {
got = e.Error
break
}
}
if got != "model unavailable" {
t.Fatalf("expected cline error message, got %q", got)
}
}
func TestCLIExecuteOneShotDrainsStderrConcurrently(t *testing.T) {
testutil.RequireUnixShell(t)

View file

@ -112,6 +112,40 @@ func TestCLIConfFromStruct_GeminiHeadlessYoloProfile(t *testing.T) {
}
}
func TestCLIConfFromStruct_ClineConfigProfile(t *testing.T) {
st, err := structpb.NewStruct(map[string]any{
"profiles": map[string]any{
"cline-dgx": map[string]any{
"command": "/config/.npm-global/bin/cline",
"args": []any{"-y", "--json", "--config", "/config/.cline/profiles/ollama-dgx"},
"env": []any{},
"persistent": false,
"terminal": false,
"output_format": "cline-json",
},
},
})
if err != nil {
t.Fatalf("structpb: %v", err)
}
cfg := cliConfFromStruct(st)
prof, ok := cfg.Profiles["cline-dgx"]
if !ok {
t.Fatal("expected cline-dgx profile")
}
if prof.Command != "/config/.npm-global/bin/cline" {
t.Fatalf("command: got %q", prof.Command)
}
if len(prof.Args) != 4 || prof.Args[0] != "-y" || prof.Args[1] != "--json" || prof.Args[2] != "--config" || prof.Args[3] != "/config/.cline/profiles/ollama-dgx" {
t.Fatalf("args: got %#v", prof.Args)
}
if prof.OutputFormat != "cline-json" {
t.Fatalf("output_format: got %q", prof.OutputFormat)
}
}
func TestCLIConfFromStruct_NilSettings(t *testing.T) {
cfg := cliConfFromStruct(nil)
if cfg.Enabled {

View file

@ -86,6 +86,28 @@ nodes:
persistent: false
terminal: false
output_format: "opencode-json"
cline-dgx:
command: "/config/.npm-global/bin/cline"
args:
- "-y"
- "--json"
- "--config"
- "/config/.cline/profiles/ollama-dgx"
env: []
persistent: false
terminal: false
output_format: "cline-json"
cline-m1:
command: "/config/.npm-global/bin/cline"
args:
- "-y"
- "--json"
- "--config"
- "/config/.cline/profiles/ollama-m1"
env: []
persistent: false
terminal: false
output_format: "cline-json"
runtime:
concurrency: 4
workspace_root: "/tmp/iop/workspace"