node adapter CLI 수정 및 edge.yaml 설정 업데이트
This commit is contained in:
parent
428ac8b04c
commit
224b649d23
6 changed files with 208 additions and 5 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue