cdsa-harness 0.5.1 → 0.6.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 +2 -0
- package/package.json +2 -2
- package/src/cli.js +56 -11
- package/src/config.js +1 -0
- package/src/llm.js +161 -14
- package/src/loop.js +6 -2
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
- **의존성 0개** — Node 18+ 내장 기능만(`fetch`/`readline`/`node:test`)
|
|
12
|
+
- **실시간 스트리밍** — 모델 응답이 토큰 단위로 흐름(`/stream` 토글, OpenAI·Claude·mock)
|
|
12
13
|
- **실제 LLM 연결** — OpenAI · Anthropic(Claude) · OpenRouter, 또는 키 없이 `mock`
|
|
13
14
|
- **교육 모드** — 매 반복마다 모델에 보내는 메시지 구성·추정 토큰·시스템 프롬프트, 실제 토큰 사용량/응답시간까지 그대로 표시
|
|
14
15
|
- **MCP 클라이언트** — Claude Code·Cursor 등과 **공용 표준**. MCP 서버를 그대로 붙여 도구로 사용
|
|
@@ -53,6 +54,7 @@ export OPENROUTER_API_KEY=sk-or-...
|
|
|
53
54
|
| `/provider <이름>` | openai · anthropic · openrouter · mock 전환 |
|
|
54
55
|
| `/model <이름>` | 모델 변경 |
|
|
55
56
|
| `/teach` | 교육 모드 켜기/끄기 |
|
|
57
|
+
| `/stream` | 실시간 스트리밍 출력 켜기/끄기 |
|
|
56
58
|
| `/context` | 지금 모델에 보내는 컨텍스트 들여다보기 |
|
|
57
59
|
| `/reset` | 대화/컨텍스트 초기화 |
|
|
58
60
|
| `/config` | 현재 설정값 |
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cdsa-harness",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "AI 에이전트의 내부 동작을 단계별로 드러내는 교육용 터미널 하네스. OpenAI/Claude/OpenRouter + MCP
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "AI 에이전트의 내부 동작을 단계별로 드러내는 교육용 터미널 하네스. 실시간 스트리밍 + OpenAI/Claude/OpenRouter + MCP + npm 플러그인·크로스포맷 스킬.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"cdsa-harness": "bin/cdsa-harness.js",
|
package/src/cli.js
CHANGED
|
@@ -46,15 +46,24 @@ function clip(s, n) {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
// cfg.teach_mode 를 실행 중 토글할 수 있으므로 closure 로 cfg 를 잡아둔다.
|
|
49
|
-
|
|
49
|
+
// stream.active 는 onToken 과 공유하는 스트리밍 상태.
|
|
50
|
+
function makePrinter(cfg, stream) {
|
|
50
51
|
return (ev) => {
|
|
51
|
-
if (cfg.teach_mode) return printTeach(ev);
|
|
52
|
-
return printCompact(ev);
|
|
52
|
+
if (cfg.teach_mode) return printTeach(ev, stream);
|
|
53
|
+
return printCompact(ev, stream);
|
|
53
54
|
};
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
function replyMetaLine(d) {
|
|
58
|
+
const meta = [];
|
|
59
|
+
if (d.latencyMs != null) meta.push(`응답 ${d.latencyMs}ms`);
|
|
60
|
+
if (d.usage) meta.push(`토큰 입력 ${d.usage.input ?? "?"}/출력 ${d.usage.output ?? "?"}/합계 ${d.usage.total ?? "?"}`);
|
|
61
|
+
if (d.request?.bodyBytes) meta.push(`요청 ${d.request.bodyBytes}B`);
|
|
62
|
+
return meta.length ? meta.join(" · ") : "";
|
|
63
|
+
}
|
|
64
|
+
|
|
56
65
|
// ---- 교육(teach) 렌더: 내부 과정을 패널로 펼쳐 보여준다 ----
|
|
57
|
-
function printTeach(ev) {
|
|
66
|
+
function printTeach(ev, stream) {
|
|
58
67
|
const d = ev.data || {};
|
|
59
68
|
switch (ev.step) {
|
|
60
69
|
case Step.USER_INPUT:
|
|
@@ -85,16 +94,26 @@ function printTeach(ev) {
|
|
|
85
94
|
}
|
|
86
95
|
|
|
87
96
|
case Step.MODEL_REPLY: {
|
|
97
|
+
// 스트리밍으로 이미 본문이 출력된 경우: 줄바꿈 후 메타/도구호출만 덧붙인다.
|
|
98
|
+
if (d.streamed) {
|
|
99
|
+
if (stream && stream.active) {
|
|
100
|
+
process.stdout.write("\n");
|
|
101
|
+
stream.active = false;
|
|
102
|
+
}
|
|
103
|
+
for (const tc of d.toolCalls || []) {
|
|
104
|
+
console.log(c.yellow(` ↳ 도구 호출 요청: ${c.bold(tc.name)}(${clip(JSON.stringify(tc.args), 200)})`));
|
|
105
|
+
}
|
|
106
|
+
const meta = replyMetaLine(d);
|
|
107
|
+
if (meta) console.log(c.grey(" ─ " + meta));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
88
110
|
const lines = [];
|
|
89
111
|
if (ev.detail && ev.detail !== "(텍스트 없음)") lines.push(...clip(ev.detail, 1200).split("\n"));
|
|
90
112
|
for (const tc of d.toolCalls || []) {
|
|
91
113
|
lines.push(c.yellow(`↳ 도구 호출 요청: ${c.bold(tc.name)}(${clip(JSON.stringify(tc.args), 200)})`));
|
|
92
114
|
}
|
|
93
|
-
const meta =
|
|
94
|
-
if (
|
|
95
|
-
if (d.usage) meta.push(`토큰 입력 ${d.usage.input ?? "?"}/출력 ${d.usage.output ?? "?"}/합계 ${d.usage.total ?? "?"}`);
|
|
96
|
-
if (d.request?.bodyBytes) meta.push(`요청 ${d.request.bodyBytes}B`);
|
|
97
|
-
if (meta.length) lines.push(c.grey("─ " + meta.join(" · ")));
|
|
115
|
+
const meta = replyMetaLine(d);
|
|
116
|
+
if (meta) lines.push(c.grey("─ " + meta));
|
|
98
117
|
else lines.push(c.dim("(mock: 토큰/지연 측정 없음)"));
|
|
99
118
|
console.log(panel(lines.length ? lines : ["(빈 응답)"], { title: "🤖 ③ 모델 응답 (원본 판단)", color: "green" }));
|
|
100
119
|
return;
|
|
@@ -132,11 +151,17 @@ function printTeach(ev) {
|
|
|
132
151
|
}
|
|
133
152
|
|
|
134
153
|
// ---- 간결(compact) 렌더: 한 줄 위주 ----
|
|
135
|
-
function printCompact(ev) {
|
|
154
|
+
function printCompact(ev, stream) {
|
|
136
155
|
const [icon, color] = STEP_STYLE[ev.step] || ["•", "cyan"];
|
|
137
156
|
const paint = c[color] || ((x) => x);
|
|
138
157
|
if (ev.step === Step.APPROVAL && !ev.title.includes("자동 승인")) return;
|
|
139
158
|
if (ev.step === Step.MODEL_REPLY) {
|
|
159
|
+
const d = ev.data || {};
|
|
160
|
+
if (d.streamed) {
|
|
161
|
+
if (stream && stream.active) { process.stdout.write("\n"); stream.active = false; }
|
|
162
|
+
for (const tc of d.toolCalls || []) console.log(c.yellow(` ↳ ${tc.name}(${clip(JSON.stringify(tc.args), 120)})`));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
140
165
|
if (ev.detail && ev.detail !== "(텍스트 없음)") console.log(panel(ev.detail.split("\n"), { title: "🤖 모델", color: "green" }));
|
|
141
166
|
return;
|
|
142
167
|
}
|
|
@@ -189,6 +214,7 @@ function printIntro(cfg) {
|
|
|
189
214
|
["model", cfg.model],
|
|
190
215
|
["API 키", keySource],
|
|
191
216
|
["교육 모드", cfg.teach_mode ? c.green("ON (과정 펼쳐보기)") : "OFF"],
|
|
217
|
+
["스트리밍", cfg.stream ? c.green("ON (실시간)") : "OFF"],
|
|
192
218
|
["작업 폴더", cfg.workspacePath()],
|
|
193
219
|
["승인 모드", cfg.approval_mode],
|
|
194
220
|
["셸 실행", cfg.allow_shell ? "허용" : "차단"],
|
|
@@ -213,6 +239,7 @@ function printHelp() {
|
|
|
213
239
|
` ${c.cyan("/provider")} <openai|anthropic|openrouter|mock> 제공자 변경`,
|
|
214
240
|
` ${c.cyan("/model")} <이름> 모델 변경`,
|
|
215
241
|
` ${c.cyan("/teach")} 교육 모드 켜기/끄기(내부 과정 펼쳐보기)`,
|
|
242
|
+
` ${c.cyan("/stream")} 실시간 스트리밍 출력 켜기/끄기`,
|
|
216
243
|
` ${c.cyan("/context")} 지금 모델에 보내는 컨텍스트 들여다보기`,
|
|
217
244
|
` ${c.cyan("/skills")} 스킬 목록(.cdsa/skills 의 /명령들)`,
|
|
218
245
|
` ${c.cyan("/plugins")} 플러그인 목록(파일·npm 추가 도구)`,
|
|
@@ -304,6 +331,7 @@ function parseArgs(argv) {
|
|
|
304
331
|
else if (a === "--help" || a === "-h") out.help = true;
|
|
305
332
|
else if (a === "--setup") out.setup = true;
|
|
306
333
|
else if (a === "--no-teach") out.noTeach = true;
|
|
334
|
+
else if (a === "--no-stream") out.noStream = true;
|
|
307
335
|
else if (a === "--provider") out.provider = argv[++i];
|
|
308
336
|
else if (a === "--model") out.model = argv[++i];
|
|
309
337
|
else if (a === "--workspace") out.workspace = argv[++i];
|
|
@@ -324,6 +352,7 @@ export async function main(argv = []) {
|
|
|
324
352
|
" --workspace <폴더경로>\n" +
|
|
325
353
|
" --setup 대화형 연결 설정 실행\n" +
|
|
326
354
|
" --no-teach 교육 모드 끄고 간결하게\n" +
|
|
355
|
+
" --no-stream 실시간 스트리밍 끄기\n" +
|
|
327
356
|
" --auto 승인 자동(approval_mode=auto)\n" +
|
|
328
357
|
" -h, --help 도움말\n\n" +
|
|
329
358
|
"API 키는 환경변수로도 인식됩니다: OPENAI_API_KEY / ANTHROPIC_API_KEY / OPENROUTER_API_KEY\n"
|
|
@@ -355,6 +384,7 @@ export async function main(argv = []) {
|
|
|
355
384
|
if (args.workspace) cfg.workspace = args.workspace;
|
|
356
385
|
if (args.auto) cfg.approval_mode = "auto";
|
|
357
386
|
if (args.noTeach) cfg.teach_mode = false;
|
|
387
|
+
if (args.noStream) cfg.stream = false;
|
|
358
388
|
|
|
359
389
|
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
360
390
|
let session = null;
|
|
@@ -418,13 +448,23 @@ export async function main(argv = []) {
|
|
|
418
448
|
}
|
|
419
449
|
|
|
420
450
|
session = SessionLog.create();
|
|
451
|
+
// 스트리밍: 토큰이 도착하는 대로 실시간 출력(첫 토큰에 헤더 1회).
|
|
452
|
+
const stream = { active: false };
|
|
453
|
+
const onToken = (chunk) => {
|
|
454
|
+
if (!stream.active) {
|
|
455
|
+
process.stdout.write("\n" + c.green("🤖 ③ 모델 응답 (스트리밍)") + "\n");
|
|
456
|
+
stream.active = true;
|
|
457
|
+
}
|
|
458
|
+
process.stdout.write(c.green(chunk));
|
|
459
|
+
};
|
|
421
460
|
const loop = new AgentLoop({
|
|
422
461
|
config: cfg,
|
|
423
462
|
client: makeClient(cfg),
|
|
424
463
|
toolbox,
|
|
425
|
-
onEvent: makePrinter(cfg),
|
|
464
|
+
onEvent: makePrinter(cfg, stream),
|
|
426
465
|
approvalCallback: makeApproval(ask),
|
|
427
466
|
session,
|
|
467
|
+
onToken,
|
|
428
468
|
});
|
|
429
469
|
loop.reset();
|
|
430
470
|
|
|
@@ -447,6 +487,11 @@ export async function main(argv = []) {
|
|
|
447
487
|
console.log(c.green(`교육 모드 ${cfg.teach_mode ? "ON" : "OFF"}.`));
|
|
448
488
|
continue;
|
|
449
489
|
}
|
|
490
|
+
if (low === "/stream") {
|
|
491
|
+
cfg.stream = !cfg.stream;
|
|
492
|
+
console.log(c.green(`스트리밍 ${cfg.stream ? "ON (실시간 출력)" : "OFF"}.`));
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
450
495
|
if (low === "/setup" || low === "/login") {
|
|
451
496
|
await runSetup(ask, cfg);
|
|
452
497
|
loop.client = makeClient(cfg);
|
package/src/config.js
CHANGED
package/src/llm.js
CHANGED
|
@@ -23,13 +23,43 @@ export class LLMClient {
|
|
|
23
23
|
this.timeout = timeout;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (this.provider === "
|
|
29
|
-
|
|
26
|
+
// onToken(chunk) 를 주면 텍스트가 도착하는 대로 콜백한다(스트리밍).
|
|
27
|
+
async chat(messages, tools, onToken = null) {
|
|
28
|
+
if (this.provider === "mock") {
|
|
29
|
+
const r = mockChat(messages);
|
|
30
|
+
if (onToken && r.content) await streamText(r.content, onToken);
|
|
31
|
+
return r;
|
|
32
|
+
}
|
|
33
|
+
if (this.provider === "anthropic") {
|
|
34
|
+
return onToken ? this._anthropicStream(messages, tools, onToken) : this._anthropicChat(messages, tools);
|
|
35
|
+
}
|
|
36
|
+
if (ENDPOINTS[this.provider]) {
|
|
37
|
+
return onToken ? this._openaiStream(messages, tools, onToken) : this._openaiChat(messages, tools);
|
|
38
|
+
}
|
|
30
39
|
throw new LLMError(`지원하지 않는 provider 입니다: ${this.provider}`);
|
|
31
40
|
}
|
|
32
41
|
|
|
42
|
+
// 스트리밍용: 응답 객체(본문 스트림)를 그대로 받는다.
|
|
43
|
+
async _openStream(url, headers, body) {
|
|
44
|
+
const json = JSON.stringify(body);
|
|
45
|
+
const ctrl = new AbortController();
|
|
46
|
+
const timer = setTimeout(() => ctrl.abort(), this.timeout);
|
|
47
|
+
const started = Date.now();
|
|
48
|
+
let res;
|
|
49
|
+
try {
|
|
50
|
+
res = await fetch(url, { method: "POST", headers, body: json, signal: ctrl.signal });
|
|
51
|
+
} catch (e) {
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
throw new LLMError(`네트워크 오류: ${e.message}`);
|
|
54
|
+
}
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
const text = await res.text().catch(() => "");
|
|
58
|
+
throw new LLMError(httpErrorMessage(res.status, text || res.statusText));
|
|
59
|
+
}
|
|
60
|
+
return { res, started, timer, bodyBytes: Buffer.byteLength(json, "utf8") };
|
|
61
|
+
}
|
|
62
|
+
|
|
33
63
|
async _post(url, headers, body) {
|
|
34
64
|
const json = JSON.stringify(body);
|
|
35
65
|
const ctrl = new AbortController();
|
|
@@ -46,16 +76,7 @@ export class LLMClient {
|
|
|
46
76
|
const latencyMs = Date.now() - started;
|
|
47
77
|
if (!res.ok) {
|
|
48
78
|
const text = await res.text().catch(() => "");
|
|
49
|
-
|
|
50
|
-
if (res.status === 404) {
|
|
51
|
-
msg += "\n ↳ 모델 이름을 확인하세요. /model 로 변경 가능. " +
|
|
52
|
-
"OpenRouter 는 'provider/model' 형식이어야 합니다 (예: openai/gpt-4o-mini, anthropic/claude-3.7-sonnet).";
|
|
53
|
-
} else if (res.status === 401 || res.status === 403) {
|
|
54
|
-
msg += "\n ↳ API 키가 잘못되었거나 권한이 없어요. /setup 으로 다시 연결하세요.";
|
|
55
|
-
} else if (res.status === 429) {
|
|
56
|
-
msg += "\n ↳ 요청이 너무 많거나 크레딧이 부족할 수 있어요(잠시 후 재시도).";
|
|
57
|
-
}
|
|
58
|
-
throw new LLMError(msg);
|
|
79
|
+
throw new LLMError(httpErrorMessage(res.status, text || res.statusText));
|
|
59
80
|
}
|
|
60
81
|
return { payload: await res.json(), latencyMs, bodyBytes: Buffer.byteLength(json, "utf8") };
|
|
61
82
|
}
|
|
@@ -103,6 +124,87 @@ export class LLMClient {
|
|
|
103
124
|
};
|
|
104
125
|
}
|
|
105
126
|
|
|
127
|
+
// --- OpenAI/OpenRouter 스트리밍 ---
|
|
128
|
+
async _openaiStream(messages, tools, onToken) {
|
|
129
|
+
const url = ENDPOINTS[this.provider];
|
|
130
|
+
const body = { model: this.model, messages, temperature: this.temperature, stream: true, stream_options: { include_usage: true } };
|
|
131
|
+
if (tools && tools.length) {
|
|
132
|
+
body.tools = tools;
|
|
133
|
+
body.tool_choice = "auto";
|
|
134
|
+
}
|
|
135
|
+
const headers = { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json" };
|
|
136
|
+
if (this.provider === "openrouter") {
|
|
137
|
+
headers["HTTP-Referer"] = "https://github.com/cdsassj00/miniharness";
|
|
138
|
+
headers["X-Title"] = "CDSA Harness";
|
|
139
|
+
}
|
|
140
|
+
const { res, started, timer, bodyBytes } = await this._openStream(url, headers, body);
|
|
141
|
+
let content = "";
|
|
142
|
+
const tcMap = new Map(); // index -> {id,name,args}
|
|
143
|
+
let usage = null;
|
|
144
|
+
try {
|
|
145
|
+
await readSSE(res, (data) => {
|
|
146
|
+
if (data === "[DONE]") return;
|
|
147
|
+
let json;
|
|
148
|
+
try { json = JSON.parse(data); } catch { return; }
|
|
149
|
+
if (json.usage) usage = { input: json.usage.prompt_tokens ?? null, output: json.usage.completion_tokens ?? null, total: json.usage.total_tokens ?? null };
|
|
150
|
+
const delta = json.choices?.[0]?.delta;
|
|
151
|
+
if (!delta) return;
|
|
152
|
+
if (delta.content) { content += delta.content; onToken(delta.content); }
|
|
153
|
+
for (const tc of delta.tool_calls || []) {
|
|
154
|
+
const i = tc.index ?? 0;
|
|
155
|
+
const cur = tcMap.get(i) || { id: tc.id || `call_${i}`, name: "", args: "" };
|
|
156
|
+
if (tc.id) cur.id = tc.id;
|
|
157
|
+
if (tc.function?.name) cur.name += tc.function.name;
|
|
158
|
+
if (tc.function?.arguments) cur.args += tc.function.arguments;
|
|
159
|
+
tcMap.set(i, cur);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
} finally {
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
}
|
|
165
|
+
const toolCalls = [...tcMap.values()].map((t) => ({ id: t.id, name: t.name, args: safeParse(t.args) }));
|
|
166
|
+
return { content: content || null, toolCalls, usage, latencyMs: Date.now() - started, request: this._meta(url, tools, bodyBytes) };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --- Anthropic 스트리밍 ---
|
|
170
|
+
async _anthropicStream(messages, tools, onToken) {
|
|
171
|
+
const url = ENDPOINTS.anthropic;
|
|
172
|
+
const body = toAnthropicBody(messages, tools, this.model, this.temperature, this.maxTokens);
|
|
173
|
+
body.stream = true;
|
|
174
|
+
const headers = { "x-api-key": this.apiKey, "anthropic-version": "2023-06-01", "Content-Type": "application/json" };
|
|
175
|
+
const { res, started, timer, bodyBytes } = await this._openStream(url, headers, body);
|
|
176
|
+
let content = "";
|
|
177
|
+
const blocks = new Map(); // index -> {type,name,id,json}
|
|
178
|
+
let usage = { input: null, output: null, total: 0 };
|
|
179
|
+
try {
|
|
180
|
+
await readSSE(res, (data) => {
|
|
181
|
+
let ev;
|
|
182
|
+
try { ev = JSON.parse(data); } catch { return; }
|
|
183
|
+
if (ev.type === "message_start" && ev.message?.usage) usage.input = ev.message.usage.input_tokens ?? null;
|
|
184
|
+
else if (ev.type === "content_block_start") {
|
|
185
|
+
const b = ev.content_block || {};
|
|
186
|
+
blocks.set(ev.index, { type: b.type, name: b.name, id: b.id, json: "" });
|
|
187
|
+
} else if (ev.type === "content_block_delta") {
|
|
188
|
+
const d = ev.delta || {};
|
|
189
|
+
if (d.type === "text_delta") { content += d.text; onToken(d.text); }
|
|
190
|
+
else if (d.type === "input_json_delta") {
|
|
191
|
+
const cur = blocks.get(ev.index);
|
|
192
|
+
if (cur) cur.json += d.partial_json || "";
|
|
193
|
+
}
|
|
194
|
+
} else if (ev.type === "message_delta" && ev.usage) {
|
|
195
|
+
usage.output = ev.usage.output_tokens ?? usage.output;
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
} finally {
|
|
199
|
+
clearTimeout(timer);
|
|
200
|
+
}
|
|
201
|
+
usage.total = (usage.input || 0) + (usage.output || 0);
|
|
202
|
+
const toolCalls = [...blocks.values()]
|
|
203
|
+
.filter((b) => b.type === "tool_use")
|
|
204
|
+
.map((b) => ({ id: b.id, name: b.name, args: safeParse(b.json) }));
|
|
205
|
+
return { content: content || null, toolCalls, usage, latencyMs: Date.now() - started, request: this._meta(url, tools, bodyBytes) };
|
|
206
|
+
}
|
|
207
|
+
|
|
106
208
|
_meta(endpoint, tools, bodyBytes) {
|
|
107
209
|
return {
|
|
108
210
|
provider: this.provider,
|
|
@@ -120,6 +222,51 @@ function trim(s) {
|
|
|
120
222
|
return s.length > 400 ? s.slice(0, 400) + " …" : s;
|
|
121
223
|
}
|
|
122
224
|
|
|
225
|
+
function safeParse(jsonStr) {
|
|
226
|
+
try {
|
|
227
|
+
return JSON.parse(jsonStr || "{}");
|
|
228
|
+
} catch {
|
|
229
|
+
return { _raw: jsonStr };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function httpErrorMessage(status, text) {
|
|
234
|
+
let msg = `API 오류 ${status}: ${trim(text)}`;
|
|
235
|
+
if (status === 404) {
|
|
236
|
+
msg += "\n ↳ 모델 이름을 확인하세요. /model 로 변경 가능. " +
|
|
237
|
+
"OpenRouter 는 'provider/model' 형식이어야 합니다 (예: openai/gpt-4o-mini, anthropic/claude-3.7-sonnet).";
|
|
238
|
+
} else if (status === 401 || status === 403) {
|
|
239
|
+
msg += "\n ↳ API 키가 잘못되었거나 권한이 없어요. /setup 으로 다시 연결하세요.";
|
|
240
|
+
} else if (status === 429) {
|
|
241
|
+
msg += "\n ↳ 요청이 너무 많거나 크레딧이 부족할 수 있어요(잠시 후 재시도).";
|
|
242
|
+
}
|
|
243
|
+
return msg;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// SSE(data: ...) 본문 스트림을 줄 단위로 콜백. onEvent(dataString) 호출(‘[DONE]’ 포함).
|
|
247
|
+
async function readSSE(res, onEvent) {
|
|
248
|
+
let buf = "";
|
|
249
|
+
for await (const chunk of res.body) {
|
|
250
|
+
buf += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
|
251
|
+
let nl;
|
|
252
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
253
|
+
const line = buf.slice(0, nl).trim();
|
|
254
|
+
buf = buf.slice(nl + 1);
|
|
255
|
+
if (line.startsWith("data:")) onEvent(line.slice(5).trim());
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (buf.trim().startsWith("data:")) onEvent(buf.trim().slice(5).trim());
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// mock/공통: 텍스트를 토큰처럼 쪼개 콜백(TTY 면 살짝 지연해 흐르는 효과).
|
|
262
|
+
async function streamText(text, onToken) {
|
|
263
|
+
const parts = String(text).match(/\S+\s*|\s+/g) || [String(text)];
|
|
264
|
+
for (const p of parts) {
|
|
265
|
+
onToken(p);
|
|
266
|
+
if (process.stdout.isTTY) await new Promise((r) => setTimeout(r, 10));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
123
270
|
function parseOpenAiReply(payload) {
|
|
124
271
|
const msg = payload?.choices?.[0]?.message;
|
|
125
272
|
if (!msg) {
|
package/src/loop.js
CHANGED
|
@@ -79,13 +79,14 @@ function findRules(workspace) {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
export class AgentLoop {
|
|
82
|
-
constructor({ config, client, toolbox, onEvent, approvalCallback, session = null }) {
|
|
82
|
+
constructor({ config, client, toolbox, onEvent, approvalCallback, session = null, onToken = null }) {
|
|
83
83
|
this.config = config;
|
|
84
84
|
this.client = client;
|
|
85
85
|
this.toolbox = toolbox;
|
|
86
86
|
this.onEvent = onEvent;
|
|
87
87
|
this.approvalCallback = approvalCallback;
|
|
88
88
|
this.session = session;
|
|
89
|
+
this.onToken = onToken; // 스트리밍 토큰 콜백(있으면 실시간 출력)
|
|
89
90
|
this.messages = [];
|
|
90
91
|
}
|
|
91
92
|
|
|
@@ -168,9 +169,10 @@ export class AgentLoop {
|
|
|
168
169
|
}
|
|
169
170
|
);
|
|
170
171
|
|
|
172
|
+
const streaming = Boolean(this.onToken && this.config.stream);
|
|
171
173
|
let reply;
|
|
172
174
|
try {
|
|
173
|
-
reply = await this.client.chat(this.messages, tools);
|
|
175
|
+
reply = await this.client.chat(this.messages, tools, streaming ? this.onToken : null);
|
|
174
176
|
} catch (e) {
|
|
175
177
|
if (e instanceof LLMError) {
|
|
176
178
|
this._emit(Step.ERROR, "LLM 오류", e.message);
|
|
@@ -180,11 +182,13 @@ export class AgentLoop {
|
|
|
180
182
|
}
|
|
181
183
|
|
|
182
184
|
// ③ 모델의 원본 판단 + 실측 메타(응답시간/토큰/요청크기)를 드러낸다.
|
|
185
|
+
// streamed=true 면 텍스트는 이미 실시간 출력됨 → UI 는 메타만 덧붙인다.
|
|
183
186
|
this._emit(Step.MODEL_REPLY, "모델 응답", reply.content || "(텍스트 없음)", {
|
|
184
187
|
toolCalls: reply.toolCalls.map((tc) => ({ name: tc.name, args: tc.args })),
|
|
185
188
|
usage: reply.usage || null,
|
|
186
189
|
latencyMs: reply.latencyMs ?? null,
|
|
187
190
|
request: reply.request || null,
|
|
191
|
+
streamed: streaming && Boolean(reply.content),
|
|
188
192
|
});
|
|
189
193
|
if (reply.content) finalText = reply.content;
|
|
190
194
|
|