deel-local-cli 0.8.0 → 1.0.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.en.md +686 -28
- package/README.md +571 -31
- package/bin/deel.js +234 -160
- package/package.json +3 -2
- package/src/agent/compact.js +131 -138
- package/src/agent/effort.js +44 -11
- package/src/agent/loop.js +574 -251
- package/src/agent/memory.js +152 -0
- package/src/agent/mention.js +164 -0
- package/src/agent/modes.js +127 -20
- package/src/agent/recall.js +209 -0
- package/src/agent/route.js +156 -0
- package/src/agent/salvage.js +182 -0
- package/src/agent/session.js +155 -11
- package/src/agent/store.js +36 -11
- package/src/backend/adapter.js +266 -183
- package/src/backend/ctxsize.js +246 -0
- package/src/backend/detect.js +10 -1
- package/src/backend/http.js +23 -0
- package/src/backend/learn.js +102 -0
- package/src/backend/mcp.js +304 -0
- package/src/backend/probe.js +15 -14
- package/src/backend/scan.js +97 -3
- package/src/commands.js +825 -50
- package/src/oneshot.js +327 -0
- package/src/repl.js +715 -414
- package/src/report.js +20 -6
- package/src/safety/guard.js +123 -7
- package/src/safety/undo.js +96 -11
- package/src/tools/encoding.js +24 -2
- package/src/tools/fsutil.js +70 -0
- package/src/tools/index.js +471 -27
- package/src/tools/webfetch.js +37 -1
- package/src/ui/ansi.js +32 -2
- package/src/ui/diff.js +255 -0
- package/src/ui/level.js +6 -2
- package/src/ui/prompt.js +13 -1
- package/src/ui/screen.js +169 -0
- package/src/ui/status.js +98 -24
- package/src/ui/tui.js +419 -0
- package/src/version.js +28 -0
package/src/backend/adapter.js
CHANGED
|
@@ -1,183 +1,266 @@
|
|
|
1
|
-
// 규격 차이(OpenAI 호환 / Ollama)를 여기 한 곳에서만 흡수한다.
|
|
2
|
-
// 진단(probe)과 에이전트 루프가 같은 함수를 쓴다.
|
|
3
|
-
import { req, headersFor, serverMessage, Aborted } from './http.js';
|
|
4
|
-
|
|
5
|
-
export function endpoint(shape) {
|
|
6
|
-
return shape === 'ollama' ? '/api/chat' : '/chat/completions';
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function buildBody(shape, { model, messages, tools, stream, json, think, maxTokens = 4096 }) {
|
|
10
|
-
if (shape === 'ollama') {
|
|
11
|
-
const body = { model, messages, stream: !!stream, options: { num_predict: maxTokens } };
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if (
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
1
|
+
// 규격 차이(OpenAI 호환 / Ollama)를 여기 한 곳에서만 흡수한다.
|
|
2
|
+
// 진단(probe)과 에이전트 루프가 같은 함수를 쓴다.
|
|
3
|
+
import { req, headersFor, serverMessage, Aborted } from './http.js';
|
|
4
|
+
|
|
5
|
+
export function endpoint(shape) {
|
|
6
|
+
return shape === 'ollama' ? '/api/chat' : '/chat/completions';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function buildBody(shape, { model, messages, tools, stream, json, think, maxTokens = 4096, ctx = null }) {
|
|
10
|
+
if (shape === 'ollama') {
|
|
11
|
+
const body = { model, messages, stream: !!stream, options: { num_predict: maxTokens } };
|
|
12
|
+
/*
|
|
13
|
+
* 원하는 컨텍스트를 **지시한다.** 읽기만 하는 게 아니라 정해 준다.
|
|
14
|
+
*
|
|
15
|
+
* Ollama 는 num_ctx 를 안 보내면 제 기본값(대개 4,096 또는 8,192)으로 올린다.
|
|
16
|
+
* 모델이 131,072 까지 되더라도 그렇다. 그런데 /api/show 는 131,072 라고 답한다 —
|
|
17
|
+
* 그 말을 믿고 긴 대화를 보내면 앞부분이 조용히 잘려 나간다. 오류도 안 난다.
|
|
18
|
+
* 모델이 앞을 잊을 뿐이라, 왜 이상한지 알아낼 방법이 없다.
|
|
19
|
+
*
|
|
20
|
+
* 지금까지 이 값을 **한 번도 안 보냈다.** 보내면 그 길이로 올려 준다.
|
|
21
|
+
*/
|
|
22
|
+
if (ctx) body.options.num_ctx = ctx;
|
|
23
|
+
if (tools?.length) body.tools = tools;
|
|
24
|
+
if (json) body.format = json;
|
|
25
|
+
if (think !== undefined) body.think = think;
|
|
26
|
+
return body;
|
|
27
|
+
}
|
|
28
|
+
// 출력 상한을 **두 이름으로 같이** 보낸다.
|
|
29
|
+
//
|
|
30
|
+
// 옛 규격은 max_tokens 하나였다. 그런데 GPT-5 계열을 붙여 놓은 게이트웨이는
|
|
31
|
+
// 그 이름을 아예 안 본다 — max_completion_tokens 만 본다. 그런 서버에
|
|
32
|
+
// max_tokens 만 보내면 상한이 안 걸린 것처럼 제 기본값으로 답하고, 우리가
|
|
33
|
+
// 셈해 둔 자리와 어긋난다. 사용자 게이트웨이가 바로 그 경우였다.
|
|
34
|
+
//
|
|
35
|
+
// 둘 다 보내도 탈이 없다. 옛 서버는 모르는 이름을 무시하고, 새 서버는
|
|
36
|
+
// 제가 보는 이름을 골라 쓴다. 둘 중 무엇을 보는지 우리가 알 필요가 없어진다.
|
|
37
|
+
const body = { model, messages, stream: !!stream, max_tokens: maxTokens, max_completion_tokens: maxTokens };
|
|
38
|
+
if (tools?.length) { body.tools = tools; body.tool_choice = 'auto'; }
|
|
39
|
+
if (json) {
|
|
40
|
+
body.response_format = { type: 'json_schema', json_schema: { name: 'out', schema: json, strict: true } };
|
|
41
|
+
}
|
|
42
|
+
if (think !== undefined && think !== false) body.reasoning_effort = think;
|
|
43
|
+
return body;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function extractMessage(shape, json) {
|
|
47
|
+
if (shape === 'ollama') {
|
|
48
|
+
const m = json?.message ?? {};
|
|
49
|
+
return {
|
|
50
|
+
content: m.content ?? '',
|
|
51
|
+
thinking: m.thinking ?? '',
|
|
52
|
+
toolCalls: normalizeCalls(m.tool_calls ?? []),
|
|
53
|
+
usage: { in: json?.prompt_eval_count ?? 0, out: json?.eval_count ?? 0 },
|
|
54
|
+
stopped: json?.done_reason ?? null,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const m = json?.choices?.[0]?.message ?? {};
|
|
58
|
+
// 생각에 쓴 토큰. 이것도 출력 예산에서 나간다 — 상한이 8,000인데 생각에 6,000을
|
|
59
|
+
// 쓰면 실제로 쓸 수 있는 답은 2,000뿐이다. 잘리는 이유가 여기 있을 때가 많은데,
|
|
60
|
+
// 전에는 이 숫자를 읽지도 않아서 화면에도 셈에도 안 나타났다.
|
|
61
|
+
const 생각 = json?.usage?.completion_tokens_details?.reasoning_tokens
|
|
62
|
+
?? json?.usage?.reasoning_tokens ?? 0;
|
|
63
|
+
return {
|
|
64
|
+
content: m.content ?? '',
|
|
65
|
+
thinking: m.reasoning_content ?? '',
|
|
66
|
+
toolCalls: normalizeCalls(m.tool_calls ?? []),
|
|
67
|
+
usage: { in: json?.usage?.prompt_tokens ?? 0, out: json?.usage?.completion_tokens ?? 0, reasoning: 생각 },
|
|
68
|
+
stopped: json?.choices?.[0]?.finish_reason ?? null,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 도구 호출을 한 가지 모양으로 맞춘다: { id, name, args(객체) }
|
|
74
|
+
*
|
|
75
|
+
* 인자 JSON 이 안 읽히면 **읽혔다고 치지 않는다.**
|
|
76
|
+
*
|
|
77
|
+
* 예전에는 조용히 { _raw: '...' } 로 바꿔 넘겼다. 그러면 도구는 file_path 가
|
|
78
|
+
* 없다고 "경로가 비었습니다" 라고 답한다 — 원인과 아무 상관 없는 말이다.
|
|
79
|
+
* 모델은 경로를 안 빠뜨렸다. 인자를 쓰다가 출력 한도에서 잘렸을 뿐이다.
|
|
80
|
+
* 그러니 고칠 게 없다고 보고 똑같이 다시 시도하고, 또 잘린다.
|
|
81
|
+
*
|
|
82
|
+
* 실제로 그렇게 됐다 — "Write 경로가 비었습니다" 가 아홉 번 찍히고, 71초 동안
|
|
83
|
+
* 도구를 열세 번 부르고, 컨텍스트가 다 차서 대화를 접었고, 파일은 안 생겼다.
|
|
84
|
+
* 조용히 삼킨 값 하나가 그 전부를 만들었다.
|
|
85
|
+
*/
|
|
86
|
+
export function normalizeCalls(list) {
|
|
87
|
+
return list.map((tc, i) => {
|
|
88
|
+
const fn = tc.function ?? tc;
|
|
89
|
+
let args = fn.arguments ?? fn.args ?? {};
|
|
90
|
+
let 깨짐 = false;
|
|
91
|
+
let 원문 = null;
|
|
92
|
+
if (typeof args === 'string') {
|
|
93
|
+
const s = args.trim();
|
|
94
|
+
// 인자가 아예 없는 도구도 있다. 빈 것은 깨진 것이 아니다.
|
|
95
|
+
if (!s) args = {};
|
|
96
|
+
else {
|
|
97
|
+
try { args = JSON.parse(s); }
|
|
98
|
+
catch { 깨짐 = true; 원문 = args; args = {}; }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const call = { id: tc.id ?? `call_${i + 1}`, name: fn.name, args };
|
|
102
|
+
if (깨짐) { call.argsBroken = true; call.rawArgs = 원문; }
|
|
103
|
+
return call;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 대화 이력에 되돌려 넣을 메시지 만들기 — 규격마다 모양이 다르다.
|
|
108
|
+
export function assistantMessage(shape, { content = '', thinking = '', toolCalls = [] }) {
|
|
109
|
+
if (shape === 'ollama') {
|
|
110
|
+
const m = { role: 'assistant', content };
|
|
111
|
+
if (thinking) m.thinking = thinking;
|
|
112
|
+
if (toolCalls.length) m.tool_calls = toolCalls.map((t) => ({ function: { name: t.name, arguments: t.args } }));
|
|
113
|
+
return m;
|
|
114
|
+
}
|
|
115
|
+
const m = { role: 'assistant', content: content || null };
|
|
116
|
+
if (toolCalls.length) {
|
|
117
|
+
m.tool_calls = toolCalls.map((t) => ({
|
|
118
|
+
id: t.id, type: 'function',
|
|
119
|
+
function: { name: t.name, arguments: JSON.stringify(t.args) },
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
return m;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function toolMessage(shape, { callId, name, content }) {
|
|
126
|
+
return shape === 'ollama'
|
|
127
|
+
? { role: 'tool', tool_name: name, content: String(content) }
|
|
128
|
+
: { role: 'tool', tool_call_id: callId, content: String(content) };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 한 번에 받기.
|
|
132
|
+
export async function chat(conn, opts) {
|
|
133
|
+
const body = buildBody(conn.kind, { model: conn.model, ctx: conn.ctx ?? null, ...opts });
|
|
134
|
+
const r = await req(`${conn.base}${endpoint(conn.kind)}`, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: headersFor(conn.auth, conn.key ?? ''),
|
|
137
|
+
body,
|
|
138
|
+
timeout: opts.timeout ?? 300000,
|
|
139
|
+
signal: opts.signal ?? null,
|
|
140
|
+
});
|
|
141
|
+
if (!r.ok) {
|
|
142
|
+
// 서버가 한 말을 그대로 달아 둔다. 루프가 이걸 읽고 한계를 배운다(backend/learn.js).
|
|
143
|
+
const err = new Error(serverMessage(r));
|
|
144
|
+
err.status = r.status;
|
|
145
|
+
err.serverMessage = serverMessage(r);
|
|
146
|
+
throw err;
|
|
147
|
+
}
|
|
148
|
+
return extractMessage(conn.kind, r.json);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 흘려 받기. { type:'thinking'|'content', text } 를 내보내고 마지막에 { type:'done', message } 를 준다.
|
|
152
|
+
export async function* chatStream(conn, opts) {
|
|
153
|
+
const body = buildBody(conn.kind, { model: conn.model, ctx: conn.ctx ?? null, ...opts, stream: true });
|
|
154
|
+
const r = await req(`${conn.base}${endpoint(conn.kind)}`, {
|
|
155
|
+
method: 'POST',
|
|
156
|
+
headers: headersFor(conn.auth, conn.key ?? ''),
|
|
157
|
+
body,
|
|
158
|
+
timeout: opts.timeout ?? 300000,
|
|
159
|
+
stream: true,
|
|
160
|
+
signal: opts.signal ?? null,
|
|
161
|
+
});
|
|
162
|
+
if (!r.ok || !r.res?.body) {
|
|
163
|
+
/*
|
|
164
|
+
* 거절당했으면 **본문을 읽는다.**
|
|
165
|
+
*
|
|
166
|
+
* 전에는 여기서 `HTTP 400` 만 던졌다. 스트리밍이라 본문을 안 읽고 넘어간
|
|
167
|
+
* 것인데, 정작 그 본문에 답이 들어 있다 —
|
|
168
|
+
* "This model's maximum context length is 8192 tokens, however you requested 41003"
|
|
169
|
+
* 사용자를 구할 수 있었던 문장이 그 자리에서 사라졌다. 화면에는 ✗ HTTP 400
|
|
170
|
+
* 한 줄만 남고, 왜 그런지 알아낼 방법이 없었다.
|
|
171
|
+
*
|
|
172
|
+
* 실패한 응답은 흘려 받을 것도 없으니 통째로 읽어도 된다.
|
|
173
|
+
*/
|
|
174
|
+
let 말 = r.error ?? `HTTP ${r.status}`;
|
|
175
|
+
if (r.res) {
|
|
176
|
+
try {
|
|
177
|
+
const text = await r.res.text();
|
|
178
|
+
let json = null;
|
|
179
|
+
try { json = JSON.parse(text); } catch { /* 글로만 오는 서버도 있다 */ }
|
|
180
|
+
말 = serverMessage({ status: r.status, json, text });
|
|
181
|
+
} catch { /* 본문마저 못 읽으면 위의 말 그대로 */ }
|
|
182
|
+
}
|
|
183
|
+
const err = new Error(말);
|
|
184
|
+
err.status = r.status;
|
|
185
|
+
err.serverMessage = 말;
|
|
186
|
+
throw err;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const acc = { content: '', thinking: '', toolCalls: [], usage: { in: 0, out: 0 }, stopped: null };
|
|
190
|
+
const reader = r.res.body.getReader();
|
|
191
|
+
const dec = new TextDecoder();
|
|
192
|
+
let buf = '';
|
|
193
|
+
|
|
194
|
+
while (true) {
|
|
195
|
+
// 사용자가 끊었으면 흘러오는 것을 더 받지 않는다. 읽던 연결도 닫는다.
|
|
196
|
+
if (opts.signal?.aborted) {
|
|
197
|
+
try { await reader.cancel(); } catch {}
|
|
198
|
+
throw new Aborted();
|
|
199
|
+
}
|
|
200
|
+
let done;
|
|
201
|
+
let value;
|
|
202
|
+
try { ({ done, value } = await reader.read()); }
|
|
203
|
+
catch (err) { if (opts.signal?.aborted) throw new Aborted(); throw err; }
|
|
204
|
+
if (done) break;
|
|
205
|
+
buf += dec.decode(value, { stream: true });
|
|
206
|
+
|
|
207
|
+
// OpenAI 는 SSE(data: ...), Ollama 는 줄바꿈 JSON. 둘 다 줄 단위로 처리된다.
|
|
208
|
+
let nl;
|
|
209
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
210
|
+
const line = buf.slice(0, nl).trim();
|
|
211
|
+
buf = buf.slice(nl + 1);
|
|
212
|
+
if (!line) continue;
|
|
213
|
+
const payload = line.startsWith('data:') ? line.slice(5).trim() : line;
|
|
214
|
+
if (payload === '[DONE]') continue;
|
|
215
|
+
let obj;
|
|
216
|
+
try { obj = JSON.parse(payload); } catch { continue; }
|
|
217
|
+
for (const ev of absorb(conn.kind, obj, acc)) yield ev;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
yield { type: 'done', message: acc };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 조각 하나를 누적하고, 화면에 흘릴 것만 내보낸다.
|
|
224
|
+
function absorb(shape, obj, acc) {
|
|
225
|
+
const out = [];
|
|
226
|
+
if (shape === 'ollama') {
|
|
227
|
+
const m = obj.message ?? {};
|
|
228
|
+
if (m.thinking) { acc.thinking += m.thinking; out.push({ type: 'thinking', text: m.thinking }); }
|
|
229
|
+
if (m.content) { acc.content += m.content; out.push({ type: 'content', text: m.content }); }
|
|
230
|
+
if (m.tool_calls?.length) acc.toolCalls.push(...normalizeCalls(m.tool_calls));
|
|
231
|
+
if (obj.done) {
|
|
232
|
+
acc.usage = { in: obj.prompt_eval_count ?? 0, out: obj.eval_count ?? 0 };
|
|
233
|
+
acc.stopped = obj.done_reason ?? 'stop';
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
const d = obj.choices?.[0]?.delta ?? {};
|
|
238
|
+
if (d.reasoning_content) { acc.thinking += d.reasoning_content; out.push({ type: 'thinking', text: d.reasoning_content }); }
|
|
239
|
+
if (d.content) { acc.content += d.content; out.push({ type: 'content', text: d.content }); }
|
|
240
|
+
if (d.tool_calls?.length) mergeDeltaCalls(acc, d.tool_calls);
|
|
241
|
+
if (obj.usage) acc.usage = { in: obj.usage.prompt_tokens ?? 0, out: obj.usage.completion_tokens ?? 0 };
|
|
242
|
+
const fin = obj.choices?.[0]?.finish_reason;
|
|
243
|
+
if (fin) acc.stopped = fin;
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// OpenAI 스트리밍은 도구 호출 인자를 글자 단위로 쪼개 보낸다. 인덱스별로 이어 붙인다.
|
|
248
|
+
function mergeDeltaCalls(acc, deltas) {
|
|
249
|
+
acc._raw ??= [];
|
|
250
|
+
for (const d of deltas) {
|
|
251
|
+
const i = d.index ?? 0;
|
|
252
|
+
acc._raw[i] ??= { id: d.id, name: '', args: '' };
|
|
253
|
+
if (d.id) acc._raw[i].id = d.id;
|
|
254
|
+
if (d.function?.name) acc._raw[i].name += d.function.name;
|
|
255
|
+
if (d.function?.arguments) acc._raw[i].args += d.function.arguments;
|
|
256
|
+
}
|
|
257
|
+
// 인자가 안 읽히면 읽혔다고 치지 않는다 — normalizeCalls 머리말 참고.
|
|
258
|
+
// 스트리밍은 마지막 조각이 안 오면 여기서 늘 깨진 채로 끝난다.
|
|
259
|
+
acc.toolCalls = acc._raw.filter(Boolean).map((c, i) => {
|
|
260
|
+
const call = { id: c.id ?? `call_${i + 1}`, name: c.name, args: {} };
|
|
261
|
+
if (!c.args) return call;
|
|
262
|
+
try { call.args = JSON.parse(c.args); }
|
|
263
|
+
catch { call.argsBroken = true; call.rawArgs = c.args; }
|
|
264
|
+
return call;
|
|
265
|
+
});
|
|
266
|
+
}
|