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 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.5.1",
4
- "description": "AI 에이전트의 내부 동작을 단계별로 드러내는 교육용 터미널 하네스. OpenAI/Claude/OpenRouter + MCP(다른 에이전트와 공용) + npm 플러그인·크로스포맷 스킬.",
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
- function makePrinter(cfg) {
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 (d.latencyMs != null) meta.push(`응답 ${d.latencyMs}ms`);
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
@@ -38,6 +38,7 @@ const DEFAULTS = {
38
38
  temperature: 0.2,
39
39
  max_tokens: 1024,
40
40
  teach_mode: true,
41
+ stream: true, // 모델 응답을 실시간(토큰 단위)으로 출력
41
42
  plugins: [], // 추가로 불러올 npm 플러그인 패키지 이름(이름 규칙과 무관하게 강제 로드)
42
43
  mcpServers: {}, // MCP 서버 설정 (Claude Code/Cursor 와 동일한 형식)
43
44
  };
package/src/llm.js CHANGED
@@ -23,13 +23,43 @@ export class LLMClient {
23
23
  this.timeout = timeout;
24
24
  }
25
25
 
26
- async chat(messages, tools) {
27
- if (this.provider === "mock") return mockChat(messages);
28
- if (this.provider === "anthropic") return this._anthropicChat(messages, tools);
29
- if (ENDPOINTS[this.provider]) return this._openaiChat(messages, tools);
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
- let msg = `API 오류 ${res.status}: ${trim(text) || res.statusText}`;
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