cdsa-harness 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -47
- package/package.json +2 -2
- package/src/cli.js +239 -63
- package/src/config.js +22 -4
- package/src/llm.js +188 -49
- package/src/loop.js +60 -3
package/README.md
CHANGED
|
@@ -1,81 +1,93 @@
|
|
|
1
|
-
# CDSA Harness (Node.js CLI)
|
|
1
|
+
# CDSA Harness (Node.js CLI) 🎓⌨️
|
|
2
2
|
|
|
3
|
-
>
|
|
4
|
-
>
|
|
5
|
-
|
|
6
|
-
시작하면 ASCII 배너가 뜨고, **Agent Loop** 의 모든 단계가 색으로 흐릅니다.
|
|
3
|
+
> **AI 에이전트가 내부에서 실제로 뭘 하는지** 단계별로 드러내는 교육용 터미널 하네스.
|
|
4
|
+
> Claude Code·Codex 는 과정을 숨기지만, CDSA Harness 는 **컨텍스트 구성 → API 요청 → 모델의 판단 → 토큰/응답시간 → 도구 실행 → 결과 되먹임**을 전부 펼쳐 보여줍니다.
|
|
7
5
|
|
|
8
6
|
```
|
|
9
|
-
입력 →
|
|
7
|
+
① 입력 → ② LLM 호출(보낼 컨텍스트·도구) → ③ 모델 응답(토큰·지연·tool_call)
|
|
8
|
+
→ ④ 도구 판단 → ⑤ 실행/승인 → ⑥ 결과 되먹임 → (반복)
|
|
10
9
|
```
|
|
11
10
|
|
|
12
|
-
- **의존성 0개** — Node 18+ 내장 기능만
|
|
13
|
-
-
|
|
11
|
+
- **의존성 0개** — Node 18+ 내장 기능만(`fetch`/`readline`/`node:test`)
|
|
12
|
+
- **실제 LLM 연결** — OpenAI · Anthropic(Claude) · OpenRouter, 또는 키 없이 `mock`
|
|
13
|
+
- **교육 모드** — 매 반복마다 모델에 보내는 메시지 구성·추정 토큰·시스템 프롬프트, 실제 토큰 사용량/응답시간까지 그대로 표시
|
|
14
14
|
|
|
15
15
|
---
|
|
16
16
|
|
|
17
17
|
## 설치 / 실행
|
|
18
18
|
|
|
19
|
-
### npx (설치 없이 즉시)
|
|
20
|
-
|
|
21
19
|
```bash
|
|
22
|
-
#
|
|
23
|
-
|
|
24
|
-
npx . # API Key 없으면 자동 mock 모드
|
|
20
|
+
npx cdsa-harness # 설치 없이 즉시 (키 없으면 mock)
|
|
21
|
+
npm install -g cdsa-harness # 전역 설치 → 'cdsa-harness' / 'cdsa'
|
|
25
22
|
```
|
|
26
23
|
|
|
27
|
-
|
|
24
|
+
## 실제 AI 연결하기
|
|
28
25
|
|
|
26
|
+
가장 쉬운 길 — 실행 후 `/setup` 입력(대화형으로 제공자·키·모델 선택):
|
|
29
27
|
```bash
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
cdsa-harness # 어디서나 실행 (별칭: cdsa)
|
|
28
|
+
cdsa-harness
|
|
29
|
+
› /setup
|
|
33
30
|
```
|
|
34
31
|
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
또는 플래그/환경변수로:
|
|
37
33
|
```bash
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
```
|
|
34
|
+
cdsa-harness --provider openai --model gpt-4o-mini
|
|
35
|
+
cdsa-harness --provider anthropic --model claude-3-5-haiku-latest
|
|
41
36
|
|
|
42
|
-
|
|
37
|
+
# 키는 환경변수로도 자동 인식(파일에 저장 안 함)
|
|
38
|
+
export OPENAI_API_KEY=sk-...
|
|
39
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
40
|
+
export OPENROUTER_API_KEY=sk-or-...
|
|
41
|
+
```
|
|
43
42
|
|
|
44
43
|
---
|
|
45
44
|
|
|
46
|
-
##
|
|
45
|
+
## 슬래시 명령
|
|
47
46
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
47
|
+
| 명령 | 설명 |
|
|
48
|
+
|------|------|
|
|
49
|
+
| `/setup` | 제공자·API 키·모델 대화형 연결 |
|
|
50
|
+
| `/provider <이름>` | openai · anthropic · openrouter · mock 전환 |
|
|
51
|
+
| `/model <이름>` | 모델 변경 |
|
|
52
|
+
| `/teach` | 교육 모드 켜기/끄기 |
|
|
53
|
+
| `/context` | 지금 모델에 보내는 컨텍스트 들여다보기 |
|
|
54
|
+
| `/reset` | 대화/컨텍스트 초기화 |
|
|
55
|
+
| `/config` | 현재 설정값 |
|
|
56
|
+
| `/quit` | 종료 (Ctrl+D) |
|
|
57
|
+
|
|
58
|
+
## 플래그
|
|
54
59
|
|
|
55
|
-
|
|
60
|
+
```
|
|
61
|
+
--provider <openai|anthropic|openrouter|mock>
|
|
62
|
+
--model <모델명>
|
|
63
|
+
--workspace <폴더경로>
|
|
64
|
+
--setup 대화형 연결 설정
|
|
65
|
+
--no-teach 교육 모드 끄고 간결 출력
|
|
66
|
+
--auto 승인 자동
|
|
67
|
+
```
|
|
56
68
|
|
|
57
69
|
---
|
|
58
70
|
|
|
59
71
|
## 설정 (config.json)
|
|
60
72
|
|
|
61
|
-
`~/.cdsa_harness/config.json`
|
|
73
|
+
`~/.cdsa_harness/config.json` (실행 폴더에 `config.json` 있으면 우선).
|
|
62
74
|
|
|
63
75
|
```json
|
|
64
76
|
{
|
|
65
77
|
"provider": "openai",
|
|
66
78
|
"api_key": "",
|
|
67
|
-
"model": "gpt-
|
|
79
|
+
"model": "gpt-4o-mini",
|
|
68
80
|
"workspace": "./workspace",
|
|
69
81
|
"approval_mode": "manual",
|
|
70
82
|
"allow_shell": false,
|
|
71
83
|
"max_steps": 8,
|
|
72
|
-
"temperature": 0.2
|
|
84
|
+
"temperature": 0.2,
|
|
85
|
+
"max_tokens": 1024,
|
|
86
|
+
"teach_mode": true
|
|
73
87
|
}
|
|
74
88
|
```
|
|
75
89
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
---
|
|
90
|
+
> `api_key` 가 비어 있으면 해당 provider 의 환경변수를 자동으로 찾습니다.
|
|
79
91
|
|
|
80
92
|
## 도구 & 안전장치
|
|
81
93
|
|
|
@@ -94,23 +106,20 @@ cdsa-harness --auto # 승인 자동
|
|
|
94
106
|
|
|
95
107
|
```
|
|
96
108
|
node-cli/
|
|
97
|
-
├── bin/cdsa-harness.js # npm/npx 진입점
|
|
109
|
+
├── bin/cdsa-harness.js # npm/npx 진입점
|
|
98
110
|
├── src/
|
|
99
|
-
│ ├── config.js # 설정
|
|
100
|
-
│ ├── llm.js # OpenAI/OpenRouter
|
|
101
|
-
│ ├── tools.js # 도구 + sandbox + diff
|
|
102
|
-
│ ├── loop.js # ⭐ Agent Loop
|
|
111
|
+
│ ├── config.js # 설정 + 환경변수 키 감지
|
|
112
|
+
│ ├── llm.js # OpenAI/Anthropic/OpenRouter + mock, 응답 정규화(토큰·지연)
|
|
113
|
+
│ ├── tools.js # 도구 + sandbox + diff
|
|
114
|
+
│ ├── loop.js # ⭐ Agent Loop + 교육용 이벤트(컨텍스트/되먹임)
|
|
103
115
|
│ ├── session.js # 세션 로그(JSONL)
|
|
104
|
-
│ ├── banner.js
|
|
105
|
-
│
|
|
106
|
-
│ └── cli.js # 터미널 REPL
|
|
107
|
-
├── workspace/ # 샘플 작업 폴더
|
|
116
|
+
│ ├── banner.js / ui.js # 배너 · ANSI/박스/diff 렌더
|
|
117
|
+
│ └── cli.js # REPL + 교육 모드 렌더 + /setup
|
|
108
118
|
└── test/core.test.js # node --test
|
|
109
119
|
```
|
|
110
120
|
|
|
111
121
|
## 테스트
|
|
112
122
|
|
|
113
123
|
```bash
|
|
114
|
-
cd node-cli
|
|
115
124
|
npm test # node --test
|
|
116
125
|
```
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cdsa-harness",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "AI 에이전트의 내부 동작(컨텍스트·API요청·토큰·도구·되먹임)을 단계별로 드러내는 교육용 터미널 하네스. OpenAI/Claude/OpenRouter 지원.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"cdsa-harness": "bin/cdsa-harness.js",
|
package/src/cli.js
CHANGED
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
// CDSA Harness TUI 본체 — 터미널 REPL.
|
|
2
|
-
//
|
|
2
|
+
// 핵심 차별점: '교육 모드' — 실제 API 를 붙여도 에이전트 내부에서 벌어지는 일
|
|
3
|
+
// (컨텍스트 구성 → API 요청 → 모델 판단 → 토큰/지연 → 도구 실행 → 결과 되먹임)을 단계별로 드러낸다.
|
|
3
4
|
import readline from "node:readline/promises";
|
|
4
5
|
import { stdin, stdout } from "node:process";
|
|
5
6
|
|
|
6
7
|
import { renderBanner } from "./banner.js";
|
|
7
|
-
import {
|
|
8
|
-
|
|
8
|
+
import {
|
|
9
|
+
ENV_KEYS,
|
|
10
|
+
PROVIDERS,
|
|
11
|
+
SUGGESTED_MODELS,
|
|
12
|
+
configPath,
|
|
13
|
+
loadConfig,
|
|
14
|
+
saveConfig,
|
|
15
|
+
} from "./config.js";
|
|
16
|
+
import { AgentLoop, Step } from "./loop.js";
|
|
9
17
|
import { LLMClient } from "./llm.js";
|
|
10
18
|
import { SessionLog, sessionsDir } from "./session.js";
|
|
11
19
|
import { Toolbox } from "./tools.js";
|
|
12
20
|
import { c, panel, renderDiff } from "./ui.js";
|
|
13
21
|
|
|
14
|
-
const VERSION = "0.
|
|
22
|
+
const VERSION = "0.2.0";
|
|
15
23
|
|
|
16
24
|
const STEP_STYLE = {
|
|
17
25
|
[Step.USER_INPUT]: ["🧑", "cyan"],
|
|
@@ -22,39 +30,118 @@ const STEP_STYLE = {
|
|
|
22
30
|
[Step.APPROVAL]: ["🔐", "yellow"],
|
|
23
31
|
[Step.TOOL_RUN]: ["🔧", "blue"],
|
|
24
32
|
[Step.TOOL_RESULT]: ["📄", "grey"],
|
|
33
|
+
[Step.FEEDBACK]: ["↩️", "grey"],
|
|
25
34
|
[Step.DONE]: ["✅", "green"],
|
|
26
35
|
[Step.ERROR]: ["❌", "red"],
|
|
27
36
|
};
|
|
28
37
|
|
|
29
|
-
function
|
|
38
|
+
function clip(s, n) {
|
|
39
|
+
s = String(s ?? "");
|
|
40
|
+
return s.length > n ? s.slice(0, n) + " …" : s;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// cfg.teach_mode 를 실행 중 토글할 수 있으므로 closure 로 cfg 를 잡아둔다.
|
|
44
|
+
function makePrinter(cfg) {
|
|
30
45
|
return (ev) => {
|
|
31
|
-
|
|
32
|
-
|
|
46
|
+
if (cfg.teach_mode) return printTeach(ev);
|
|
47
|
+
return printCompact(ev);
|
|
48
|
+
};
|
|
49
|
+
}
|
|
33
50
|
|
|
34
|
-
|
|
35
|
-
|
|
51
|
+
// ---- 교육(teach) 렌더: 내부 과정을 패널로 펼쳐 보여준다 ----
|
|
52
|
+
function printTeach(ev) {
|
|
53
|
+
const d = ev.data || {};
|
|
54
|
+
switch (ev.step) {
|
|
55
|
+
case Step.USER_INPUT:
|
|
56
|
+
console.log(`${c.cyan("🧑 ①")} ${c.bold("사용자 입력")} ${c.grey(clip(ev.detail, 200))}`);
|
|
57
|
+
return;
|
|
58
|
+
|
|
59
|
+
case Step.BUILD_CONTEXT:
|
|
60
|
+
console.log(`${c.grey("🧱")} ${c.dim(ev.detail)}`);
|
|
61
|
+
return;
|
|
36
62
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
63
|
+
case Step.MODEL_CALL: {
|
|
64
|
+
const lines = [];
|
|
65
|
+
lines.push(`${c.grey("provider/model")} ${c.bold(`${d.provider} · ${d.model}`)}`);
|
|
66
|
+
lines.push(c.grey(`모델에 보내는 메시지 ${d.messages?.length || 0}개 · 추정 ${d.estTokens} 토큰 · ${d.totalChars}자`));
|
|
67
|
+
for (const m of d.messages || []) {
|
|
68
|
+
const roleColor = m.role === "system" ? c.magenta : m.role === "user" ? c.cyan : m.role === "assistant" ? c.green : c.yellow;
|
|
69
|
+
lines.push(` ${roleColor(m.role.padEnd(9))} ${c.grey(`${m.chars}자${m.extra || ""}`)}`);
|
|
70
|
+
}
|
|
71
|
+
lines.push(c.grey(`제공 도구(${d.tools?.length || 0}): ${(d.tools || []).join(", ")}`));
|
|
72
|
+
console.log(panel(lines, { title: `🧠 ② LLM 호출 — 반복 ${d.iteration}`, color: "magenta" }));
|
|
73
|
+
if (d.systemPrompt) {
|
|
74
|
+
console.log(panel(clip(d.systemPrompt, 600).split("\n"), {
|
|
75
|
+
title: "📜 시스템 프롬프트 (규칙+폴더가 여기 주입됨)",
|
|
76
|
+
color: "grey",
|
|
77
|
+
}));
|
|
40
78
|
}
|
|
41
79
|
return;
|
|
42
80
|
}
|
|
43
|
-
|
|
44
|
-
|
|
81
|
+
|
|
82
|
+
case Step.MODEL_REPLY: {
|
|
83
|
+
const lines = [];
|
|
84
|
+
if (ev.detail && ev.detail !== "(텍스트 없음)") lines.push(...clip(ev.detail, 1200).split("\n"));
|
|
85
|
+
for (const tc of d.toolCalls || []) {
|
|
86
|
+
lines.push(c.yellow(`↳ 도구 호출 요청: ${c.bold(tc.name)}(${clip(JSON.stringify(tc.args), 200)})`));
|
|
87
|
+
}
|
|
88
|
+
const meta = [];
|
|
89
|
+
if (d.latencyMs != null) meta.push(`응답 ${d.latencyMs}ms`);
|
|
90
|
+
if (d.usage) meta.push(`토큰 입력 ${d.usage.input ?? "?"}/출력 ${d.usage.output ?? "?"}/합계 ${d.usage.total ?? "?"}`);
|
|
91
|
+
if (d.request?.bodyBytes) meta.push(`요청 ${d.request.bodyBytes}B`);
|
|
92
|
+
if (meta.length) lines.push(c.grey("─ " + meta.join(" · ")));
|
|
93
|
+
else lines.push(c.dim("(mock: 토큰/지연 측정 없음)"));
|
|
94
|
+
console.log(panel(lines.length ? lines : ["(빈 응답)"], { title: "🤖 ③ 모델 응답 (원본 판단)", color: "green" }));
|
|
45
95
|
return;
|
|
46
96
|
}
|
|
47
|
-
|
|
97
|
+
|
|
98
|
+
case Step.TOOL_DECISION:
|
|
99
|
+
console.log(`${c.yellow("🤔 ④")} ${c.bold("도구 판단")} ${c.grey(clip(ev.detail, 200))}`);
|
|
100
|
+
return;
|
|
101
|
+
|
|
102
|
+
case Step.TOOL_RUN:
|
|
103
|
+
console.log(`${c.blue("🔧 ⑤")} ${c.bold(ev.title)} ${c.grey(clip(ev.detail, 200))}`);
|
|
104
|
+
return;
|
|
105
|
+
|
|
106
|
+
case Step.TOOL_RESULT:
|
|
107
|
+
console.log(panel(clip(ev.detail, 1500).split("\n"), { title: `📄 ${ev.title}`, color: "grey" }));
|
|
108
|
+
return;
|
|
109
|
+
|
|
110
|
+
case Step.FEEDBACK:
|
|
111
|
+
console.log(`${c.grey("↩️ ⑥ 결과 되먹임")} ${c.dim(clip(ev.detail, 200))}`);
|
|
112
|
+
console.log(c.dim(" └ 도구 결과가 컨텍스트에 더해진 채로 ②부터 다시 — 이 반복이 'Agent Loop' 입니다."));
|
|
113
|
+
return;
|
|
114
|
+
|
|
115
|
+
case Step.APPROVAL:
|
|
116
|
+
if (ev.title.includes("자동 승인")) console.log(`${c.yellow("🔓")} ${c.dim(ev.title)}`);
|
|
117
|
+
return;
|
|
118
|
+
|
|
119
|
+
case Step.DONE:
|
|
120
|
+
console.log(panel((ev.detail || "완료").split("\n"), { title: "✅ 완료", color: "green" }));
|
|
121
|
+
return;
|
|
122
|
+
|
|
123
|
+
case Step.ERROR:
|
|
48
124
|
console.log(panel((ev.detail || "").split("\n"), { title: `❌ ${ev.title}`, color: "red" }));
|
|
49
125
|
return;
|
|
50
|
-
|
|
126
|
+
}
|
|
127
|
+
}
|
|
51
128
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
129
|
+
// ---- 간결(compact) 렌더: 한 줄 위주 ----
|
|
130
|
+
function printCompact(ev) {
|
|
131
|
+
const [icon, color] = STEP_STYLE[ev.step] || ["•", "cyan"];
|
|
132
|
+
const paint = c[color] || ((x) => x);
|
|
133
|
+
if (ev.step === Step.APPROVAL && !ev.title.includes("자동 승인")) return;
|
|
134
|
+
if (ev.step === Step.MODEL_REPLY) {
|
|
135
|
+
if (ev.detail && ev.detail !== "(텍스트 없음)") console.log(panel(ev.detail.split("\n"), { title: "🤖 모델", color: "green" }));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (ev.step === Step.DONE) return console.log(panel((ev.detail || "완료").split("\n"), { title: "✅ 완료", color: "green" }));
|
|
139
|
+
if (ev.step === Step.ERROR) return console.log(panel((ev.detail || "").split("\n"), { title: `❌ ${ev.title}`, color: "red" }));
|
|
140
|
+
if (ev.step === Step.FEEDBACK) return;
|
|
141
|
+
let detail = clip((ev.detail || "").trim().replace(/\s+/g, " "), 110);
|
|
142
|
+
let line = `${paint(icon)} ${paint(c.bold(ev.title))}`;
|
|
143
|
+
if (detail && ev.step !== Step.USER_INPUT) line += ` ${c.grey(detail)}`;
|
|
144
|
+
console.log(line);
|
|
58
145
|
}
|
|
59
146
|
|
|
60
147
|
function makeApproval(rl) {
|
|
@@ -80,23 +167,36 @@ function makeApproval(rl) {
|
|
|
80
167
|
};
|
|
81
168
|
}
|
|
82
169
|
|
|
170
|
+
function makeClient(cfg) {
|
|
171
|
+
return new LLMClient({
|
|
172
|
+
provider: cfg.provider,
|
|
173
|
+
apiKey: cfg.resolvedKey(),
|
|
174
|
+
model: cfg.model,
|
|
175
|
+
temperature: cfg.temperature,
|
|
176
|
+
maxTokens: cfg.max_tokens,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
83
180
|
function printIntro(cfg) {
|
|
84
181
|
console.log(renderBanner());
|
|
85
|
-
console.log(c.dim("
|
|
182
|
+
console.log(c.dim("AI 에이전트의 내부 동작을 단계별로 드러내는 교육용 하네스"));
|
|
86
183
|
console.log();
|
|
184
|
+
const keySource = cfg.provider === "mock" ? "-" : cfg.api_key ? "config.json" : ENV_KEYS[cfg.provider] && process.env[ENV_KEYS[cfg.provider]] ? `환경변수 ${ENV_KEYS[cfg.provider]}` : c.red("없음");
|
|
87
185
|
const rows = [
|
|
88
186
|
["버전", `v${VERSION}`],
|
|
89
187
|
["provider", cfg.provider],
|
|
90
188
|
["model", cfg.model],
|
|
189
|
+
["API 키", keySource],
|
|
190
|
+
["교육 모드", cfg.teach_mode ? c.green("ON (과정 펼쳐보기)") : "OFF"],
|
|
91
191
|
["작업 폴더", cfg.workspacePath()],
|
|
92
192
|
["승인 모드", cfg.approval_mode],
|
|
93
193
|
["셸 실행", cfg.allow_shell ? "허용" : "차단"],
|
|
94
194
|
];
|
|
95
|
-
const lines = rows.map(([k, v]) => `${c.grey(k)}
|
|
195
|
+
const lines = rows.map(([k, v]) => `${c.grey(k.padEnd(9))} ${c.bold(v)}`);
|
|
96
196
|
console.log(panel(lines, { title: "⚙️ CDSA Harness 설정", color: "cyan" }));
|
|
97
197
|
console.log(
|
|
98
198
|
c.dim("명령: ") +
|
|
99
|
-
`${c.cyan("/
|
|
199
|
+
`${c.cyan("/setup")} 연결 · ${c.cyan("/teach")} 교육모드 · ${c.cyan("/context")} 컨텍스트 · ${c.cyan("/help")} 도움말 · ${c.cyan("/quit")} 종료\n`
|
|
100
200
|
);
|
|
101
201
|
}
|
|
102
202
|
|
|
@@ -105,29 +205,85 @@ function printHelp() {
|
|
|
105
205
|
panel(
|
|
106
206
|
[
|
|
107
207
|
c.bold("사용법"),
|
|
108
|
-
|
|
208
|
+
`시키고 싶은 일을 한국어로 입력하세요. 예) ${c.cyan("notes.txt 맨 아래에 할 일 3개 추가해줘")}`,
|
|
109
209
|
"",
|
|
110
210
|
c.bold("슬래시 명령"),
|
|
111
|
-
` ${c.cyan("/
|
|
112
|
-
` ${c.cyan("/
|
|
113
|
-
` ${c.cyan("/
|
|
114
|
-
` ${c.cyan("/
|
|
115
|
-
` ${c.cyan("/
|
|
211
|
+
` ${c.cyan("/setup")} 제공자·API 키·모델 연결(대화형)`,
|
|
212
|
+
` ${c.cyan("/provider")} <openai|anthropic|openrouter|mock> 제공자 변경`,
|
|
213
|
+
` ${c.cyan("/model")} <이름> 모델 변경`,
|
|
214
|
+
` ${c.cyan("/teach")} 교육 모드 켜기/끄기(내부 과정 펼쳐보기)`,
|
|
215
|
+
` ${c.cyan("/context")} 지금 모델에 보내는 컨텍스트 들여다보기`,
|
|
216
|
+
` ${c.cyan("/reset")} 대화/컨텍스트 초기화`,
|
|
217
|
+
` ${c.cyan("/config")} 현재 설정값`,
|
|
218
|
+
` ${c.cyan("/quit")} 종료 (Ctrl+D)`,
|
|
116
219
|
"",
|
|
117
|
-
c.bold("
|
|
118
|
-
" 입력 →
|
|
220
|
+
c.bold("교육 모드에서 보이는 단계"),
|
|
221
|
+
" ① 입력 → ② LLM 호출(컨텍스트·도구) → ③ 모델 응답(토큰·지연) →",
|
|
222
|
+
" ④ 도구 판단 → ⑤ 실행/승인 → ⑥ 결과 되먹임 → (반복)",
|
|
119
223
|
],
|
|
120
224
|
{ title: "도움말", color: "cyan" }
|
|
121
225
|
)
|
|
122
226
|
);
|
|
123
227
|
}
|
|
124
228
|
|
|
229
|
+
// 대화형 연결 설정. 키는 환경변수가 있으면 그걸 우선 안내(파일 저장 안 함).
|
|
230
|
+
async function runSetup(rl, cfg) {
|
|
231
|
+
console.log(panel(
|
|
232
|
+
[
|
|
233
|
+
"어떤 AI 에 연결할까요? 번호를 입력하세요.",
|
|
234
|
+
` ${c.bold("1")}) openai (GPT, 키: ${ENV_KEYS.openai})`,
|
|
235
|
+
` ${c.bold("2")}) anthropic (Claude, 키: ${ENV_KEYS.anthropic})`,
|
|
236
|
+
` ${c.bold("3")}) openrouter (여러 모델 중계, 키: ${ENV_KEYS.openrouter})`,
|
|
237
|
+
` ${c.bold("4")}) mock (키 없이 연습)`,
|
|
238
|
+
],
|
|
239
|
+
{ title: "🔌 연결 설정 (/setup)", color: "cyan" }
|
|
240
|
+
));
|
|
241
|
+
const pick = (await rl.question(c.cyan("제공자 번호 [1-4]: "))).trim();
|
|
242
|
+
const provider = { "1": "openai", "2": "anthropic", "3": "openrouter", "4": "mock" }[pick];
|
|
243
|
+
if (!provider) {
|
|
244
|
+
console.log(c.yellow("취소했습니다."));
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
cfg.provider = provider;
|
|
248
|
+
|
|
249
|
+
if (provider === "mock") {
|
|
250
|
+
cfg.model = "mock-agent";
|
|
251
|
+
} else {
|
|
252
|
+
const envName = ENV_KEYS[provider];
|
|
253
|
+
const envVal = (process.env[envName] || "").trim();
|
|
254
|
+
if (envVal) {
|
|
255
|
+
const useEnv = (await rl.question(c.cyan(`환경변수 ${envName} 에서 키를 찾았어요. 사용할까요? [Y/n] `))).trim().toLowerCase();
|
|
256
|
+
if (useEnv === "" || useEnv === "y" || useEnv === "yes") {
|
|
257
|
+
cfg.api_key = ""; // 환경변수 사용 → 파일엔 저장 안 함
|
|
258
|
+
} else {
|
|
259
|
+
const k = (await rl.question(c.cyan("API 키 붙여넣기(입력이 보일 수 있음): "))).trim();
|
|
260
|
+
cfg.api_key = k;
|
|
261
|
+
}
|
|
262
|
+
} else {
|
|
263
|
+
console.log(c.dim(`(또는 종료 후 환경변수 ${envName} 에 키를 넣어두면 파일에 저장하지 않아도 됩니다)`));
|
|
264
|
+
const k = (await rl.question(c.cyan("API 키 붙여넣기(입력이 보일 수 있음): "))).trim();
|
|
265
|
+
cfg.api_key = k;
|
|
266
|
+
}
|
|
267
|
+
const sugg = SUGGESTED_MODELS[provider] || [];
|
|
268
|
+
const def = sugg[0] || "";
|
|
269
|
+
const m = (await rl.question(c.cyan(`모델 [${def}] (추천: ${sugg.join(", ")}): `))).trim();
|
|
270
|
+
cfg.model = m || def;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const saved = saveConfig(cfg);
|
|
274
|
+
console.log(c.green(`설정을 저장했습니다 → ${saved}`));
|
|
275
|
+
if (!cfg.isReady()) console.log(c.yellow("⚠️ 아직 키가 없어 호출이 실패할 수 있어요. 환경변수나 /setup 으로 키를 넣어주세요."));
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
|
|
125
279
|
function parseArgs(argv) {
|
|
126
280
|
const out = { _: [] };
|
|
127
281
|
for (let i = 0; i < argv.length; i++) {
|
|
128
282
|
const a = argv[i];
|
|
129
283
|
if (a === "--auto") out.auto = true;
|
|
130
284
|
else if (a === "--help" || a === "-h") out.help = true;
|
|
285
|
+
else if (a === "--setup") out.setup = true;
|
|
286
|
+
else if (a === "--no-teach") out.noTeach = true;
|
|
131
287
|
else if (a === "--provider") out.provider = argv[++i];
|
|
132
288
|
else if (a === "--model") out.model = argv[++i];
|
|
133
289
|
else if (a === "--workspace") out.workspace = argv[++i];
|
|
@@ -140,13 +296,16 @@ export async function main(argv = []) {
|
|
|
140
296
|
const args = parseArgs(argv);
|
|
141
297
|
if (args.help) {
|
|
142
298
|
console.log(
|
|
143
|
-
"CDSA Harness —
|
|
299
|
+
"CDSA Harness — AI 에이전트 내부를 드러내는 교육용 하네스 (터미널)\n\n" +
|
|
144
300
|
"사용법: cdsa-harness [옵션]\n" +
|
|
145
|
-
" --provider <openai|openrouter|mock>\n" +
|
|
301
|
+
" --provider <openai|anthropic|openrouter|mock>\n" +
|
|
146
302
|
" --model <모델명>\n" +
|
|
147
303
|
" --workspace <폴더경로>\n" +
|
|
304
|
+
" --setup 대화형 연결 설정 실행\n" +
|
|
305
|
+
" --no-teach 교육 모드 끄고 간결하게\n" +
|
|
148
306
|
" --auto 승인 자동(approval_mode=auto)\n" +
|
|
149
|
-
" -h, --help 도움말\n"
|
|
307
|
+
" -h, --help 도움말\n\n" +
|
|
308
|
+
"API 키는 환경변수로도 인식됩니다: OPENAI_API_KEY / ANTHROPIC_API_KEY / OPENROUTER_API_KEY\n"
|
|
150
309
|
);
|
|
151
310
|
return 0;
|
|
152
311
|
}
|
|
@@ -156,33 +315,26 @@ export async function main(argv = []) {
|
|
|
156
315
|
if (args.model) cfg.model = args.model;
|
|
157
316
|
if (args.workspace) cfg.workspace = args.workspace;
|
|
158
317
|
if (args.auto) cfg.approval_mode = "auto";
|
|
318
|
+
if (args.noTeach) cfg.teach_mode = false;
|
|
159
319
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
cfg.provider
|
|
166
|
-
|
|
320
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
321
|
+
|
|
322
|
+
if (args.setup) {
|
|
323
|
+
await runSetup(rl, cfg);
|
|
324
|
+
} else if (!cfg.isReady() && cfg.provider !== "mock") {
|
|
325
|
+
console.log(c.yellow(`provider=${cfg.provider} 인데 API 키가 없습니다. 연결 설정을 시작합니다.\n`));
|
|
326
|
+
await runSetup(rl, cfg);
|
|
167
327
|
}
|
|
168
328
|
|
|
169
329
|
printIntro(cfg);
|
|
170
330
|
|
|
171
|
-
const client = new LLMClient({
|
|
172
|
-
provider: cfg.provider,
|
|
173
|
-
apiKey: cfg.api_key,
|
|
174
|
-
model: cfg.model,
|
|
175
|
-
temperature: cfg.temperature,
|
|
176
|
-
});
|
|
177
331
|
const toolbox = new Toolbox(cfg.workspacePath(), cfg.allow_shell);
|
|
178
332
|
const session = SessionLog.create();
|
|
179
|
-
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
180
|
-
|
|
181
333
|
const loop = new AgentLoop({
|
|
182
334
|
config: cfg,
|
|
183
|
-
client,
|
|
335
|
+
client: makeClient(cfg),
|
|
184
336
|
toolbox,
|
|
185
|
-
onEvent: makePrinter(),
|
|
337
|
+
onEvent: makePrinter(cfg),
|
|
186
338
|
approvalCallback: makeApproval(rl),
|
|
187
339
|
session,
|
|
188
340
|
});
|
|
@@ -195,27 +347,51 @@ export async function main(argv = []) {
|
|
|
195
347
|
try {
|
|
196
348
|
user = (await rl.question(c.bold(c.cyan("› ")))).trim();
|
|
197
349
|
} catch {
|
|
198
|
-
break;
|
|
350
|
+
break;
|
|
199
351
|
}
|
|
200
352
|
if (!user) continue;
|
|
201
353
|
const low = user.toLowerCase();
|
|
354
|
+
|
|
202
355
|
if (["/quit", "/exit", "quit", "exit", ":q"].includes(low)) break;
|
|
203
|
-
if (low === "/help") {
|
|
204
|
-
|
|
356
|
+
if (low === "/help") { printHelp(); continue; }
|
|
357
|
+
if (low === "/reset") { loop.reset(); console.log(c.green("컨텍스트를 초기화했습니다.")); continue; }
|
|
358
|
+
if (low === "/config") { printIntro(cfg); console.log(c.dim(`config.json: ${configPath()}`)); continue; }
|
|
359
|
+
if (low === "/sessions") { console.log(c.dim(`세션 로그: ${sessionsDir()}`)); continue; }
|
|
360
|
+
if (low === "/teach") {
|
|
361
|
+
cfg.teach_mode = !cfg.teach_mode;
|
|
362
|
+
console.log(c.green(`교육 모드 ${cfg.teach_mode ? "ON" : "OFF"}.`));
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (low === "/setup" || low === "/login") {
|
|
366
|
+
await runSetup(rl, cfg);
|
|
367
|
+
loop.client = makeClient(cfg);
|
|
368
|
+
loop.reset();
|
|
205
369
|
continue;
|
|
206
370
|
}
|
|
207
|
-
if (low
|
|
371
|
+
if (low.startsWith("/provider")) {
|
|
372
|
+
const p = user.split(/\s+/)[1];
|
|
373
|
+
if (!PROVIDERS.includes(p)) { console.log(c.yellow(`provider 는 ${PROVIDERS.join("/")} 중 하나.`)); continue; }
|
|
374
|
+
cfg.provider = p;
|
|
375
|
+
if (SUGGESTED_MODELS[p]?.length) cfg.model = SUGGESTED_MODELS[p][0];
|
|
376
|
+
loop.client = makeClient(cfg);
|
|
208
377
|
loop.reset();
|
|
209
|
-
console.log(c.green(
|
|
378
|
+
console.log(c.green(`provider=${p}, model=${cfg.model} (키: ${cfg.isReady() ? "OK" : c.red("없음 — /setup")})`));
|
|
210
379
|
continue;
|
|
211
380
|
}
|
|
212
|
-
if (low
|
|
213
|
-
|
|
214
|
-
console.log(c.dim(
|
|
381
|
+
if (low.startsWith("/model")) {
|
|
382
|
+
const m = user.split(/\s+/).slice(1).join(" ").trim();
|
|
383
|
+
if (!m) { console.log(c.dim(`현재 모델: ${cfg.model} · 추천: ${(SUGGESTED_MODELS[cfg.provider] || []).join(", ")}`)); continue; }
|
|
384
|
+
cfg.model = m;
|
|
385
|
+
loop.client = makeClient(cfg);
|
|
386
|
+
console.log(c.green(`model=${m}`));
|
|
215
387
|
continue;
|
|
216
388
|
}
|
|
217
|
-
if (low === "/
|
|
218
|
-
|
|
389
|
+
if (low === "/context") {
|
|
390
|
+
const ctx = loop.contextSummary();
|
|
391
|
+
const lines = [c.grey(`메시지 ${ctx.rows.length}개 · 추정 ${ctx.estTokens} 토큰 · ${ctx.totalChars}자`)];
|
|
392
|
+
for (const m of ctx.rows) lines.push(` ${c.bold(m.role.padEnd(9))} ${c.grey(`${m.chars}자${m.extra || ""}`)}`);
|
|
393
|
+
console.log(panel(lines, { title: "🧩 현재 컨텍스트(다음 호출에 전송됨)", color: "magenta" }));
|
|
394
|
+
console.log(panel(clip(ctx.systemPrompt, 800).split("\n"), { title: "📜 시스템 프롬프트", color: "grey" }));
|
|
219
395
|
continue;
|
|
220
396
|
}
|
|
221
397
|
|
package/src/config.js
CHANGED
|
@@ -4,18 +4,26 @@ import fs from "node:fs";
|
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
|
|
7
|
-
export const PROVIDERS = ["openai", "openrouter", "mock"];
|
|
7
|
+
export const PROVIDERS = ["openai", "anthropic", "openrouter", "mock"];
|
|
8
8
|
|
|
9
9
|
export const SUGGESTED_MODELS = {
|
|
10
|
-
openai: ["gpt-
|
|
10
|
+
openai: ["gpt-4o-mini", "gpt-4.1-mini", "gpt-4.1"],
|
|
11
|
+
anthropic: ["claude-3-5-haiku-latest", "claude-3-5-sonnet-latest", "claude-sonnet-4-5"],
|
|
11
12
|
openrouter: [
|
|
12
|
-
"openai/gpt-
|
|
13
|
+
"openai/gpt-4o-mini",
|
|
13
14
|
"anthropic/claude-3.5-sonnet",
|
|
14
15
|
"google/gemini-2.5-flash",
|
|
15
16
|
],
|
|
16
17
|
mock: ["mock-agent"],
|
|
17
18
|
};
|
|
18
19
|
|
|
20
|
+
// provider 별로 자동 감지하는 환경변수(파일에 키를 저장하지 않아도 됨)
|
|
21
|
+
export const ENV_KEYS = {
|
|
22
|
+
openai: "OPENAI_API_KEY",
|
|
23
|
+
anthropic: "ANTHROPIC_API_KEY",
|
|
24
|
+
openrouter: "OPENROUTER_API_KEY",
|
|
25
|
+
};
|
|
26
|
+
|
|
19
27
|
export const APPROVAL_MODES = ["manual", "auto"];
|
|
20
28
|
|
|
21
29
|
const DEFAULTS = {
|
|
@@ -27,6 +35,8 @@ const DEFAULTS = {
|
|
|
27
35
|
allow_shell: false,
|
|
28
36
|
max_steps: 8,
|
|
29
37
|
temperature: 0.2,
|
|
38
|
+
max_tokens: 1024,
|
|
39
|
+
teach_mode: true,
|
|
30
40
|
};
|
|
31
41
|
|
|
32
42
|
export function configDir() {
|
|
@@ -54,9 +64,17 @@ export class Config {
|
|
|
54
64
|
return p;
|
|
55
65
|
}
|
|
56
66
|
|
|
67
|
+
// 파일에 저장된 키가 없으면 환경변수에서 찾는다.
|
|
68
|
+
resolvedKey() {
|
|
69
|
+
const direct = (this.api_key || "").trim();
|
|
70
|
+
if (direct) return direct;
|
|
71
|
+
const envName = ENV_KEYS[this.provider];
|
|
72
|
+
return envName ? (process.env[envName] || "").trim() : "";
|
|
73
|
+
}
|
|
74
|
+
|
|
57
75
|
isReady() {
|
|
58
76
|
if (this.provider === "mock") return true;
|
|
59
|
-
return Boolean(
|
|
77
|
+
return Boolean(this.resolvedKey());
|
|
60
78
|
}
|
|
61
79
|
|
|
62
80
|
toJSON() {
|
package/src/llm.js
CHANGED
|
@@ -1,31 +1,58 @@
|
|
|
1
|
-
// LLM 호출 계층. OpenAI / OpenRouter (OpenAI 호환)
|
|
1
|
+
// LLM 호출 계층. OpenAI / OpenRouter (OpenAI 호환) / Anthropic(Claude) + mock.
|
|
2
2
|
// Node 18+ 내장 fetch 사용 → 외부 의존성 없음.
|
|
3
|
+
//
|
|
4
|
+
// 모든 provider 의 응답을 아래 한 가지 형태로 정규화해서 돌려준다(교육 모드용 메타 포함):
|
|
5
|
+
// { content, toolCalls:[{id,name,args}], usage:{input,output,total}|null,
|
|
6
|
+
// latencyMs, request:{ provider, endpoint, model, temperature, toolCount, bodyBytes } }
|
|
3
7
|
|
|
4
8
|
export class LLMError extends Error {}
|
|
5
9
|
|
|
6
10
|
const ENDPOINTS = {
|
|
7
11
|
openai: "https://api.openai.com/v1/chat/completions",
|
|
8
12
|
openrouter: "https://openrouter.ai/api/v1/chat/completions",
|
|
13
|
+
anthropic: "https://api.anthropic.com/v1/messages",
|
|
9
14
|
};
|
|
10
15
|
|
|
11
16
|
export class LLMClient {
|
|
12
|
-
constructor({ provider, apiKey, model, temperature = 0.2, timeout = 60000 }) {
|
|
17
|
+
constructor({ provider, apiKey, model, temperature = 0.2, maxTokens = 1024, timeout = 60000 }) {
|
|
13
18
|
this.provider = provider;
|
|
14
19
|
this.apiKey = apiKey;
|
|
15
20
|
this.model = model;
|
|
16
21
|
this.temperature = temperature;
|
|
22
|
+
this.maxTokens = maxTokens;
|
|
17
23
|
this.timeout = timeout;
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
async chat(messages, tools) {
|
|
21
27
|
if (this.provider === "mock") return mockChat(messages);
|
|
22
|
-
if (
|
|
23
|
-
|
|
28
|
+
if (this.provider === "anthropic") return this._anthropicChat(messages, tools);
|
|
29
|
+
if (ENDPOINTS[this.provider]) return this._openaiChat(messages, tools);
|
|
30
|
+
throw new LLMError(`지원하지 않는 provider 입니다: ${this.provider}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async _post(url, headers, body) {
|
|
34
|
+
const json = JSON.stringify(body);
|
|
35
|
+
const ctrl = new AbortController();
|
|
36
|
+
const timer = setTimeout(() => ctrl.abort(), this.timeout);
|
|
37
|
+
const started = Date.now();
|
|
38
|
+
let res;
|
|
39
|
+
try {
|
|
40
|
+
res = await fetch(url, { method: "POST", headers, body: json, signal: ctrl.signal });
|
|
41
|
+
} catch (e) {
|
|
42
|
+
throw new LLMError(`네트워크 오류: ${e.message}`);
|
|
43
|
+
} finally {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
}
|
|
46
|
+
const latencyMs = Date.now() - started;
|
|
47
|
+
if (!res.ok) {
|
|
48
|
+
const text = await res.text().catch(() => "");
|
|
49
|
+
throw new LLMError(`API 오류 ${res.status}: ${trim(text) || res.statusText}`);
|
|
24
50
|
}
|
|
25
|
-
return
|
|
51
|
+
return { payload: await res.json(), latencyMs, bodyBytes: Buffer.byteLength(json, "utf8") };
|
|
26
52
|
}
|
|
27
53
|
|
|
28
|
-
|
|
54
|
+
// --- OpenAI / OpenRouter (chat/completions 형식) ---
|
|
55
|
+
async _openaiChat(messages, tools) {
|
|
29
56
|
const url = ENDPOINTS[this.provider];
|
|
30
57
|
const body = { model: this.model, messages, temperature: this.temperature };
|
|
31
58
|
if (tools && tools.length) {
|
|
@@ -37,96 +64,206 @@ export class LLMClient {
|
|
|
37
64
|
"Content-Type": "application/json",
|
|
38
65
|
};
|
|
39
66
|
if (this.provider === "openrouter") {
|
|
40
|
-
headers["HTTP-Referer"] = "https://github.com/
|
|
67
|
+
headers["HTTP-Referer"] = "https://github.com/cdsassj00/miniharness";
|
|
41
68
|
headers["X-Title"] = "CDSA Harness";
|
|
42
69
|
}
|
|
70
|
+
const { payload, latencyMs, bodyBytes } = await this._post(url, headers, body);
|
|
71
|
+
const parsed = parseOpenAiReply(payload);
|
|
72
|
+
return {
|
|
73
|
+
...parsed,
|
|
74
|
+
latencyMs,
|
|
75
|
+
request: this._meta(url, tools, bodyBytes),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
43
78
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
79
|
+
// --- Anthropic (messages 형식) ---
|
|
80
|
+
async _anthropicChat(messages, tools) {
|
|
81
|
+
const url = ENDPOINTS.anthropic;
|
|
82
|
+
const body = toAnthropicBody(messages, tools, this.model, this.temperature, this.maxTokens);
|
|
83
|
+
const headers = {
|
|
84
|
+
"x-api-key": this.apiKey,
|
|
85
|
+
"anthropic-version": "2023-06-01",
|
|
86
|
+
"Content-Type": "application/json",
|
|
87
|
+
};
|
|
88
|
+
const { payload, latencyMs, bodyBytes } = await this._post(url, headers, body);
|
|
89
|
+
const parsed = parseAnthropicReply(payload);
|
|
90
|
+
return {
|
|
91
|
+
...parsed,
|
|
92
|
+
latencyMs,
|
|
93
|
+
request: this._meta(url, tools, bodyBytes),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
59
96
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
97
|
+
_meta(endpoint, tools, bodyBytes) {
|
|
98
|
+
return {
|
|
99
|
+
provider: this.provider,
|
|
100
|
+
endpoint,
|
|
101
|
+
model: this.model,
|
|
102
|
+
temperature: this.temperature,
|
|
103
|
+
toolCount: tools ? tools.length : 0,
|
|
104
|
+
bodyBytes,
|
|
105
|
+
};
|
|
66
106
|
}
|
|
67
107
|
}
|
|
68
108
|
|
|
109
|
+
function trim(s) {
|
|
110
|
+
s = String(s || "");
|
|
111
|
+
return s.length > 400 ? s.slice(0, 400) + " …" : s;
|
|
112
|
+
}
|
|
113
|
+
|
|
69
114
|
function parseOpenAiReply(payload) {
|
|
70
115
|
const msg = payload?.choices?.[0]?.message;
|
|
71
116
|
if (!msg) {
|
|
72
|
-
throw new LLMError(
|
|
73
|
-
`예상치 못한 응답 형식입니다: ${JSON.stringify(payload).slice(0, 300)}`
|
|
74
|
-
);
|
|
117
|
+
throw new LLMError(`예상치 못한 응답 형식입니다: ${JSON.stringify(payload).slice(0, 300)}`);
|
|
75
118
|
}
|
|
76
119
|
const toolCalls = [];
|
|
77
120
|
for (const tc of msg.tool_calls || []) {
|
|
78
121
|
let args = {};
|
|
79
122
|
try {
|
|
80
|
-
args =
|
|
81
|
-
|
|
82
|
-
|
|
123
|
+
args =
|
|
124
|
+
typeof tc.function?.arguments === "string"
|
|
125
|
+
? JSON.parse(tc.function.arguments || "{}")
|
|
126
|
+
: tc.function?.arguments || {};
|
|
83
127
|
} catch {
|
|
84
128
|
args = { _raw: tc.function?.arguments };
|
|
85
129
|
}
|
|
86
130
|
toolCalls.push({ id: tc.id || `call_${toolCalls.length}`, name: tc.function?.name || "", args });
|
|
87
131
|
}
|
|
88
|
-
|
|
132
|
+
const u = payload.usage;
|
|
133
|
+
const usage = u
|
|
134
|
+
? { input: u.prompt_tokens ?? null, output: u.completion_tokens ?? null, total: u.total_tokens ?? null }
|
|
135
|
+
: null;
|
|
136
|
+
return { content: msg.content ?? null, toolCalls, usage };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 내부(OpenAI 형식) 메시지를 Anthropic messages 형식으로 변환.
|
|
140
|
+
// - system 메시지 → 최상위 system 필드
|
|
141
|
+
// - assistant tool_calls → content 의 tool_use 블록
|
|
142
|
+
// - tool 메시지 → user 의 tool_result 블록 (연속된 것은 하나의 user 로 합침)
|
|
143
|
+
export function toAnthropicBody(messages, tools, model, temperature, maxTokens) {
|
|
144
|
+
let system = "";
|
|
145
|
+
const out = [];
|
|
146
|
+
for (const m of messages) {
|
|
147
|
+
if (m.role === "system") {
|
|
148
|
+
system += (system ? "\n" : "") + (m.content || "");
|
|
149
|
+
} else if (m.role === "user") {
|
|
150
|
+
pushUserBlock(out, { type: "text", text: m.content || "" });
|
|
151
|
+
} else if (m.role === "assistant") {
|
|
152
|
+
const content = [];
|
|
153
|
+
if (m.content) content.push({ type: "text", text: m.content });
|
|
154
|
+
for (const tc of m.tool_calls || []) {
|
|
155
|
+
let input = {};
|
|
156
|
+
try {
|
|
157
|
+
input = JSON.parse(tc.function?.arguments || "{}");
|
|
158
|
+
} catch {
|
|
159
|
+
input = {};
|
|
160
|
+
}
|
|
161
|
+
content.push({ type: "tool_use", id: tc.id, name: tc.function?.name, input });
|
|
162
|
+
}
|
|
163
|
+
out.push({ role: "assistant", content: content.length ? content : [{ type: "text", text: "" }] });
|
|
164
|
+
} else if (m.role === "tool") {
|
|
165
|
+
pushUserBlock(out, { type: "tool_result", tool_use_id: m.tool_call_id, content: m.content || "" });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const body = { model, max_tokens: maxTokens, system, messages: out, temperature };
|
|
169
|
+
if (tools && tools.length) {
|
|
170
|
+
body.tools = tools.map((t) => ({
|
|
171
|
+
name: t.function.name,
|
|
172
|
+
description: t.function.description,
|
|
173
|
+
input_schema: t.function.parameters,
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
return body;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 직전 메시지가 user 면 블록을 이어붙이고, 아니면 새 user 메시지를 만든다.
|
|
180
|
+
function pushUserBlock(out, block) {
|
|
181
|
+
const last = out[out.length - 1];
|
|
182
|
+
if (last && last.role === "user") last.content.push(block);
|
|
183
|
+
else out.push({ role: "user", content: [block] });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function parseAnthropicReply(payload) {
|
|
187
|
+
if (payload?.type === "error") {
|
|
188
|
+
throw new LLMError(`Anthropic 오류: ${payload.error?.message || JSON.stringify(payload)}`);
|
|
189
|
+
}
|
|
190
|
+
const blocks = payload?.content || [];
|
|
191
|
+
let text = "";
|
|
192
|
+
const toolCalls = [];
|
|
193
|
+
for (const b of blocks) {
|
|
194
|
+
if (b.type === "text") text += b.text;
|
|
195
|
+
else if (b.type === "tool_use") toolCalls.push({ id: b.id, name: b.name, args: b.input || {} });
|
|
196
|
+
}
|
|
197
|
+
const u = payload?.usage;
|
|
198
|
+
const usage = u
|
|
199
|
+
? {
|
|
200
|
+
input: u.input_tokens ?? null,
|
|
201
|
+
output: u.output_tokens ?? null,
|
|
202
|
+
total: (u.input_tokens || 0) + (u.output_tokens || 0),
|
|
203
|
+
}
|
|
204
|
+
: null;
|
|
205
|
+
return { content: text || null, toolCalls, usage };
|
|
89
206
|
}
|
|
90
207
|
|
|
91
208
|
// ---------------------------------------------------------------------------
|
|
92
209
|
// Mock 에이전트: 키 없이 Agent Loop 전체를 체험.
|
|
93
|
-
//
|
|
210
|
+
// 인사/잡담엔 그냥 대화로 답하고, 파일 작업을 시킬 때만 도구 데모를 돌린다.
|
|
94
211
|
// ---------------------------------------------------------------------------
|
|
212
|
+
const FILE_TASK_RE =
|
|
213
|
+
/(파일|폴더|디렉|notes|\.txt|\.md|\.js|읽|쓰|수정|고치|만들|생성|추가|삭제|편집|list|read|write|만들어|적어|기록)/i;
|
|
214
|
+
|
|
215
|
+
function mockReturn(extra) {
|
|
216
|
+
return { usage: null, latencyMs: 1, request: { provider: "mock", endpoint: "(mock)", model: "mock-agent", temperature: 0, toolCount: 0, bodyBytes: 0 }, ...extra };
|
|
217
|
+
}
|
|
218
|
+
|
|
95
219
|
function mockChat(messages) {
|
|
96
220
|
const toolMsgs = messages.filter((m) => m.role === "tool");
|
|
97
221
|
const lastUser = [...messages].reverse().find((m) => m.role === "user")?.content || "";
|
|
98
222
|
const phase = toolMsgs.length;
|
|
99
223
|
|
|
224
|
+
// 아직 도구를 쓰기 전 + 파일 작업 요청이 아니면 → 그냥 대화로 응답(도구 X)
|
|
225
|
+
if (phase === 0 && !FILE_TASK_RE.test(lastUser)) {
|
|
226
|
+
return mockReturn({
|
|
227
|
+
content:
|
|
228
|
+
"안녕하세요! 저는 CDSA Harness 의 mock(연습) 에이전트예요. 실제 AI 는 아니고, " +
|
|
229
|
+
"에이전트가 도구로 파일을 다루는 흐름을 보여주는 데모입니다.\n" +
|
|
230
|
+
"예) \"notes.txt 맨 아래에 할 일 3개 추가해줘\" 처럼 파일 작업을 시켜보세요. " +
|
|
231
|
+
"진짜 AI 와 대화하려면 /setup 으로 OpenAI·Claude 키를 연결하면 됩니다.",
|
|
232
|
+
toolCalls: [],
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
100
236
|
if (phase === 0) {
|
|
101
|
-
return {
|
|
237
|
+
return mockReturn({
|
|
102
238
|
content: "작업 폴더 구조부터 확인하겠습니다.",
|
|
103
239
|
toolCalls: [{ id: "mock_1", name: "list_dir", args: { path: "." } }],
|
|
104
|
-
};
|
|
240
|
+
});
|
|
105
241
|
}
|
|
106
242
|
if (phase === 1) {
|
|
107
243
|
const target = guessFile(toolMsgs[0].content || "", lastUser);
|
|
108
|
-
return {
|
|
244
|
+
return mockReturn({
|
|
109
245
|
content: `\`${target}\` 파일을 읽어 현재 내용을 확인하겠습니다.`,
|
|
110
246
|
toolCalls: [{ id: "mock_2", name: "read_file", args: { path: target } }],
|
|
111
|
-
};
|
|
247
|
+
});
|
|
112
248
|
}
|
|
113
249
|
if (phase === 2) {
|
|
114
250
|
const target = guessFile(toolMsgs[0].content || "", lastUser);
|
|
115
251
|
const current = toolMsgs[1].content || "";
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
252
|
+
const base = /파일이 없습니다|경로가 없습니다/.test(current) ? "" : current;
|
|
253
|
+
const addition = `# (CDSA Harness mock 에이전트가 추가) 요청: ${lastUser.trim().slice(0, 60)}`;
|
|
254
|
+
const newContent = (base.replace(/\n+$/, "") + "\n\n" + addition + "\n").replace(/^\n+/, "");
|
|
255
|
+
return mockReturn({
|
|
256
|
+
content: `\`${target}\` 에 메모 한 줄을 추가하는 수정을 제안합니다.`,
|
|
120
257
|
toolCalls: [{ id: "mock_3", name: "write_file", args: { path: target, content: newContent } }],
|
|
121
|
-
};
|
|
258
|
+
});
|
|
122
259
|
}
|
|
123
|
-
return {
|
|
260
|
+
return mockReturn({
|
|
124
261
|
content:
|
|
125
262
|
"완료했습니다. 방금까지의 흐름이 바로 하네스의 Agent Loop 입니다:\n" +
|
|
126
|
-
"①
|
|
127
|
-
"실제 LLM
|
|
263
|
+
"① 폴더 보기 → ② 파일 읽기 → ③ 수정 제안 → ④ 승인 → ⑤ 저장 → ⑥ 결과를 다시 모델에 전달.\n" +
|
|
264
|
+
"실제 LLM 으로 같은 흐름을 보려면 /setup 으로 키를 연결하세요.",
|
|
128
265
|
toolCalls: [],
|
|
129
|
-
};
|
|
266
|
+
});
|
|
130
267
|
}
|
|
131
268
|
|
|
132
269
|
function guessFile(listing, userText) {
|
|
@@ -142,5 +279,7 @@ function guessFile(listing, userText) {
|
|
|
142
279
|
for (const name of files) {
|
|
143
280
|
if (userText.includes(name)) return name;
|
|
144
281
|
}
|
|
282
|
+
const m = userText.match(/[\w./-]+\.(txt|md|js|json|py|ts|csv)/i);
|
|
283
|
+
if (m) return m[0];
|
|
145
284
|
return files[0] || "notes.txt";
|
|
146
285
|
}
|
package/src/loop.js
CHANGED
|
@@ -19,6 +19,7 @@ export const Step = {
|
|
|
19
19
|
APPROVAL: "approval",
|
|
20
20
|
TOOL_RUN: "tool_run",
|
|
21
21
|
TOOL_RESULT: "tool_result",
|
|
22
|
+
FEEDBACK: "feedback",
|
|
22
23
|
DONE: "done",
|
|
23
24
|
ERROR: "error",
|
|
24
25
|
};
|
|
@@ -32,10 +33,35 @@ export const STEP_LABELS = {
|
|
|
32
33
|
approval: "사용자 승인",
|
|
33
34
|
tool_run: "도구 실행",
|
|
34
35
|
tool_result: "결과 반영",
|
|
36
|
+
feedback: "결과 되먹임",
|
|
35
37
|
done: "완료",
|
|
36
38
|
error: "오류",
|
|
37
39
|
};
|
|
38
40
|
|
|
41
|
+
// 아주 거친 토큰 추정(영문 ~4자/토큰, 한글은 더 많지만 교육용 어림값).
|
|
42
|
+
export function estimateTokens(text) {
|
|
43
|
+
return Math.max(1, Math.round((text || "").length / 4));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// messages 를 교육용으로 요약: 역할별 글자수 + 도구호출 수.
|
|
47
|
+
function summarizeMessages(messages) {
|
|
48
|
+
let totalChars = 0;
|
|
49
|
+
const rows = messages.map((m) => {
|
|
50
|
+
const text = m.content || "";
|
|
51
|
+
let chars = text.length;
|
|
52
|
+
let extra = "";
|
|
53
|
+
if (m.tool_calls && m.tool_calls.length) {
|
|
54
|
+
const j = JSON.stringify(m.tool_calls);
|
|
55
|
+
chars += j.length;
|
|
56
|
+
extra = ` +tool_calls(${m.tool_calls.length})`;
|
|
57
|
+
}
|
|
58
|
+
if (m.role === "tool") extra = " (도구 결과)";
|
|
59
|
+
totalChars += chars;
|
|
60
|
+
return { role: m.role, chars, extra };
|
|
61
|
+
});
|
|
62
|
+
return { rows, totalChars, estTokens: estimateTokens(messages.map((m) => (m.content || "") + JSON.stringify(m.tool_calls || "")).join("")) };
|
|
63
|
+
}
|
|
64
|
+
|
|
39
65
|
const RULES_FILENAMES = ["AGENT.md", "AGENTS.md", "CLAUDE.md", "rules.md", "RULES.md"];
|
|
40
66
|
|
|
41
67
|
function findRules(workspace) {
|
|
@@ -98,7 +124,13 @@ export class AgentLoop {
|
|
|
98
124
|
}
|
|
99
125
|
|
|
100
126
|
reset() {
|
|
101
|
-
this.
|
|
127
|
+
this.systemPromptText = this._systemPrompt();
|
|
128
|
+
this.messages = [{ role: "system", content: this.systemPromptText }];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// /context 명령용: 현재 대화 컨텍스트 요약.
|
|
132
|
+
contextSummary() {
|
|
133
|
+
return { ...summarizeMessages(this.messages), systemPrompt: this.systemPromptText };
|
|
102
134
|
}
|
|
103
135
|
|
|
104
136
|
async run(userInput) {
|
|
@@ -114,13 +146,26 @@ export class AgentLoop {
|
|
|
114
146
|
);
|
|
115
147
|
|
|
116
148
|
const tools = toolSchemas(this.config.allow_shell);
|
|
149
|
+
const toolNames = tools.map((t) => t.function.name);
|
|
117
150
|
let finalText = "";
|
|
118
151
|
|
|
119
152
|
for (let stepNo = 1; stepNo <= this.config.max_steps; stepNo++) {
|
|
153
|
+
// ② 모델에 보내는 컨텍스트를 그대로 드러낸다(교육 모드 핵심).
|
|
154
|
+
const ctx = summarizeMessages(this.messages);
|
|
120
155
|
this._emit(
|
|
121
156
|
Step.MODEL_CALL,
|
|
122
|
-
`LLM 호출 (반복 ${stepNo})`,
|
|
123
|
-
`메시지 ${this.messages.length}
|
|
157
|
+
`LLM 호출 (반복 ${stepNo}/${this.config.max_steps})`,
|
|
158
|
+
`메시지 ${this.messages.length}개(추정 ${ctx.estTokens} 토큰)를 모델에 전송합니다.`,
|
|
159
|
+
{
|
|
160
|
+
iteration: stepNo,
|
|
161
|
+
provider: this.config.provider,
|
|
162
|
+
model: this.config.model,
|
|
163
|
+
messages: ctx.rows,
|
|
164
|
+
totalChars: ctx.totalChars,
|
|
165
|
+
estTokens: ctx.estTokens,
|
|
166
|
+
tools: toolNames,
|
|
167
|
+
systemPrompt: stepNo === 1 ? this.systemPromptText : null,
|
|
168
|
+
}
|
|
124
169
|
);
|
|
125
170
|
|
|
126
171
|
let reply;
|
|
@@ -134,8 +179,12 @@ export class AgentLoop {
|
|
|
134
179
|
throw e;
|
|
135
180
|
}
|
|
136
181
|
|
|
182
|
+
// ③ 모델의 원본 판단 + 실측 메타(응답시간/토큰/요청크기)를 드러낸다.
|
|
137
183
|
this._emit(Step.MODEL_REPLY, "모델 응답", reply.content || "(텍스트 없음)", {
|
|
138
184
|
toolCalls: reply.toolCalls.map((tc) => ({ name: tc.name, args: tc.args })),
|
|
185
|
+
usage: reply.usage || null,
|
|
186
|
+
latencyMs: reply.latencyMs ?? null,
|
|
187
|
+
request: reply.request || null,
|
|
139
188
|
});
|
|
140
189
|
if (reply.content) finalText = reply.content;
|
|
141
190
|
|
|
@@ -163,6 +212,14 @@ export class AgentLoop {
|
|
|
163
212
|
const resultText = await this._handleToolCall(tc);
|
|
164
213
|
this.messages.push({ role: "tool", tool_call_id: tc.id, content: resultText });
|
|
165
214
|
}
|
|
215
|
+
|
|
216
|
+
// ⑤ 도구 결과를 messages 에 넣고 다시 ②로 — 이 되먹임이 'Loop' 의 정체.
|
|
217
|
+
this._emit(
|
|
218
|
+
Step.FEEDBACK,
|
|
219
|
+
"결과 되먹임",
|
|
220
|
+
`도구 결과를 대화에 추가했습니다(현재 메시지 ${this.messages.length}개). 같은 컨텍스트로 다시 모델을 호출합니다.`,
|
|
221
|
+
{ messageCount: this.messages.length }
|
|
222
|
+
);
|
|
166
223
|
}
|
|
167
224
|
|
|
168
225
|
this._emit(Step.DONE, "반복 한도 도달", `max_steps(${this.config.max_steps})에 도달해 종료했습니다.`);
|