deel-local-cli 1.0.2 → 1.1.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 +381 -7
- package/README.md +407 -7
- package/bin/deel.js +266 -234
- package/package.json +4 -3
- package/src/agent/budget.js +167 -0
- package/src/agent/grade.js +207 -0
- package/src/agent/loop.js +778 -574
- package/src/agent/modes.js +109 -28
- package/src/agent/project.js +171 -0
- package/src/agent/session.js +131 -12
- package/src/backend/learn.js +46 -4
- package/src/commands.js +73 -0
- package/src/oneshot.js +390 -327
- package/src/repl.js +1074 -887
- package/src/safety/audit.js +3 -1
- package/src/tools/index.js +500 -107
- package/src/tools/jobs.js +670 -0
- package/src/tools/outline.js +331 -0
- package/src/tools/task.js +153 -0
- package/src/tools/verify.js +306 -0
- package/src/tools/webfetch.js +152 -16
- package/src/ui/inputbox.js +97 -5
- package/src/ui/screen.js +51 -3
- package/src/ui/status.js +69 -4
- package/src/ui/working.js +8 -1
package/src/agent/loop.js
CHANGED
|
@@ -1,574 +1,778 @@
|
|
|
1
|
-
// 에이전트 루프. 모델 → 도구 → 결과 → 모델 을 답이 나올 때까지 돈다.
|
|
2
|
-
// 화면에 그릴 것은 이벤트로 흘려보낸다 — 화면 코드와 섞지 않는다.
|
|
3
|
-
import { chat, chatStream, assistantMessage, toolMessage } from '../backend/adapter.js';
|
|
4
|
-
import { toolSchemas, runTool, TOOLS, 파일현황 } from '../tools/index.js';
|
|
5
|
-
import { isMutating } from '../safety/guard.js';
|
|
6
|
-
import { effortFor, tokensFor, fullCap, wasCut, shiftLevel } from './effort.js';
|
|
7
|
-
import { 살린쓰기 } from './salvage.js';
|
|
8
|
-
import { 배울것, 길이문제인가 } from '../backend/learn.js';
|
|
9
|
-
import { compact, shouldCompact } from './compact.js';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
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
|
-
//
|
|
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
|
-
const
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
yield {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
yield
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
1
|
+
// 에이전트 루프. 모델 → 도구 → 결과 → 모델 을 답이 나올 때까지 돈다.
|
|
2
|
+
// 화면에 그릴 것은 이벤트로 흘려보낸다 — 화면 코드와 섞지 않는다.
|
|
3
|
+
import { chat, chatStream, assistantMessage, toolMessage } from '../backend/adapter.js';
|
|
4
|
+
import { toolSchemas, runTool, TOOLS, 파일현황 } from '../tools/index.js';
|
|
5
|
+
import { isMutating } from '../safety/guard.js';
|
|
6
|
+
import { effortFor, tokensFor, fullCap, wasCut, shiftLevel } from './effort.js';
|
|
7
|
+
import { 살린쓰기 } from './salvage.js';
|
|
8
|
+
import { 배울것, 길이문제인가 } from '../backend/learn.js';
|
|
9
|
+
import { compact, shouldCompact } from './compact.js';
|
|
10
|
+
import { 걸음수, 하위걸음수, 요약길이 } from './budget.js';
|
|
11
|
+
import { Session } from './session.js';
|
|
12
|
+
import { 최대깊이, 하위모드, 하위요약 } from '../tools/task.js';
|
|
13
|
+
import { isOffline } from '../safety/network.js';
|
|
14
|
+
import { get as workMode } from './modes.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 아무 내용도 없는 답인가.
|
|
18
|
+
*
|
|
19
|
+
* 글도 없고 도구 호출도 없으면 아무 일도 안 일어난 것이다. 이걸 '성공' 으로
|
|
20
|
+
* 넘기면 화면에 걸린 시간만 찍히고 끝난다 — 오류도 없으니 사용자는 무엇이
|
|
21
|
+
* 잘못됐는지 알 길이 없다. 생각(thinking)만 있는 것도 마찬가지다.
|
|
22
|
+
*/
|
|
23
|
+
function 빈답인가(msg) {
|
|
24
|
+
if (!msg) return true;
|
|
25
|
+
if ((msg.toolCalls ?? []).length) return false;
|
|
26
|
+
return !String(msg.content ?? '').trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// think 값을 규격에 맞게. 'off' 는 사고를 끈다.
|
|
30
|
+
function thinkFor(conn, level) {
|
|
31
|
+
if (level === 'off') return conn.kind === 'ollama' ? false : undefined;
|
|
32
|
+
return level;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 아무것도 안 바꾸는 도구들. 이것들만 동시에 돌린다.
|
|
36
|
+
//
|
|
37
|
+
// 왜 이것만인가:
|
|
38
|
+
// Read 세 개를 동시에 하는 것은 안전하다 — 서로 안 건드린다.
|
|
39
|
+
// Write·Edit 을 동시에 돌리면 같은 파일을 두 갈래로 고칠 수 있고,
|
|
40
|
+
// 되돌리기 스냅샷 순서도 뒤엉킨다. Bash 는 무슨 짓을 할지 알 수 없다.
|
|
41
|
+
// 그래서 '읽기만 하는 것' 이라고 확실한 도구만 묶는다.
|
|
42
|
+
const 읽기전용 = new Set(['Read', 'Glob', 'Grep', 'Skill', 'WebFetch']);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 호출 목록을 '같이 돌려도 되는 덩어리' 로 자른다.
|
|
46
|
+
* 읽기 전용이 이어지면 한 덩어리, 그 밖의 것은 하나씩 따로.
|
|
47
|
+
*/
|
|
48
|
+
export function 묶기(calls) {
|
|
49
|
+
const out = [];
|
|
50
|
+
for (const call of calls) {
|
|
51
|
+
const 안전 = 읽기전용.has(call.name);
|
|
52
|
+
const 끝 = out.at(-1);
|
|
53
|
+
if (안전 && 끝?.parallel) 끝.calls.push(call);
|
|
54
|
+
else out.push({ parallel: 안전, calls: [call] });
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 } = {}) {
|
|
60
|
+
session.push({ role: 'user', content: userText });
|
|
61
|
+
ctx.audit.turn(깊이 ? `[하위작업 ${깊이}겹] ${userText}` : userText);
|
|
62
|
+
/*
|
|
63
|
+
* 되돌리기 턴은 **부모만** 연다.
|
|
64
|
+
*
|
|
65
|
+
* 하위 작업이 제 턴을 열면 `/undo` 한 번이 하위가 만든 것만 되돌리고
|
|
66
|
+
* 부모가 만든 것은 남긴다 — 반쪽만 되돌아간 폴더가 된다. 사람 눈에는
|
|
67
|
+
* 한 번 시킨 일이니 한 번에 되돌아가야 맞다.
|
|
68
|
+
*/
|
|
69
|
+
if (!깊이) ctx.history.nextTurn();
|
|
70
|
+
|
|
71
|
+
/*
|
|
72
|
+
* 중단 신호를 도구도 볼 수 있게 여기 걸어 둔다.
|
|
73
|
+
*
|
|
74
|
+
* 없으면 Ctrl+C 가 '도는 도구' 를 못 멈춘다 — 루프는 끊겼는데 Bash 가 띄운
|
|
75
|
+
* 프로세스는 계속 돌고, 화면은 `▶ Bash(npm run dev)` 에서 멈춘 채다.
|
|
76
|
+
* 터미널을 닫는 수밖에 없었다.
|
|
77
|
+
*
|
|
78
|
+
* ctx 를 복사해서 넘기지 않고 그대로 두는 데는 이유가 있다. Read 가 알아낸
|
|
79
|
+
* 인코딩을 ctx.enc 에 적어 두고 Edit 이 그걸 본다. 복사본에 적으면 그 기억이
|
|
80
|
+
* 사라져서, 사내 CP949 문서가 한 번 고치는 것만으로 UTF-8 이 되어 버린다.
|
|
81
|
+
*/
|
|
82
|
+
ctx.signal = signal;
|
|
83
|
+
|
|
84
|
+
const conn = session.conn;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 같은 실패가 되풀이되는지 센다.
|
|
88
|
+
*
|
|
89
|
+
* 모델은 같은 것을 다시 해도 다른 결과가 나올 거라고 믿는 쪽으로 잘 기운다.
|
|
90
|
+
* 특히 오류 문구가 원인을 안 짚어 줄 때 그렇다. 그러면 걸음 수 상한에 닿을
|
|
91
|
+
* 때까지 같은 호출을 반복하는데, 그동안 컨텍스트는 계속 차오르고 사람은
|
|
92
|
+
* 화면만 보며 기다린다. 71초 동안 열세 번을 부르고 아무것도 안 만든 적이 있다.
|
|
93
|
+
*
|
|
94
|
+
* 걸음 수 상한(maxSteps)으로는 못 막는다 — 그건 '잘 되고 있는 긴 작업' 과
|
|
95
|
+
* '헛도는 작업' 을 구분하지 못한다. 여기서는 같은 자리를 반복하는 것만 본다.
|
|
96
|
+
*/
|
|
97
|
+
const 막힘 = new Map();
|
|
98
|
+
const MAX_SAME = 3;
|
|
99
|
+
let 멈출까 = null;
|
|
100
|
+
const 막힘셈 = (call, 이유) => {
|
|
101
|
+
// 파일 하나가 아니라 **그 파일** 을 센다. 이름만 세면 서로 다른 파일 세 개를
|
|
102
|
+
// 고치다 실패한 것이 한 덩어리로 뭉쳐 턴이 죽는다. 다섯 군데 중 두 군데만
|
|
103
|
+
// 고쳐 놓고 '헛돌고 있어 멈췄습니다' 가 되는 것이 그 모습이다.
|
|
104
|
+
const 어디 = call.args?.file_path ?? call.args?.path ?? call.args?.pattern ?? call.args?.command ?? call.args?.목적 ?? '';
|
|
105
|
+
const 서명 = `${call.name}|${이유}|${String(어디).slice(0, 200)}`;
|
|
106
|
+
const n = (막힘.get(서명) ?? 0) + 1;
|
|
107
|
+
막힘.set(서명, n);
|
|
108
|
+
return n >= MAX_SAME;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 일이 나아갔다고 표시한다.
|
|
113
|
+
*
|
|
114
|
+
* 잘린 것을 살려 파일이 자라는 중이면 헛도는 게 아니다. 그런데 겉모습은 똑같다 —
|
|
115
|
+
* 같은 도구를, 같은 파일에, 여러 번 부른다. 그 표시만 보고 막으면 세 번째
|
|
116
|
+
* 이어붙이기에서 턴이 죽고, 파일은 또 반쪽으로 남는다.
|
|
117
|
+
* 그래서 '나아가고 있다' 는 사실이 확인되면 셈을 되돌린다.
|
|
118
|
+
*/
|
|
119
|
+
const 나아감 = () => 막힘.clear();
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 이번 턴에 손댄 파일들. 턴이 끝날 때 **디스크를 보고** 사실을 말해 준다.
|
|
123
|
+
*
|
|
124
|
+
* 모델은 도구가 실패해도 "만들었습니다" 로 답을 맺는 일이 있다. 화면에는
|
|
125
|
+
* 그 말만 남고, 사용자는 믿고 다음 일로 넘어간다 — 파일은 없는데.
|
|
126
|
+
* 말이 아니라 파일을 보고 확인해야 그 틈이 안 생긴다.
|
|
127
|
+
*/
|
|
128
|
+
const 손댄파일 = new Set();
|
|
129
|
+
const 마무리 = () => [...손댄파일].map(파일현황);
|
|
130
|
+
|
|
131
|
+
// 살려 쓴 파일이 지난번보다 실제로 커졌는지 보려고 크기를 기억해 둔다.
|
|
132
|
+
const 살린크기 = new Map();
|
|
133
|
+
|
|
134
|
+
// 이번 턴에 서버에게서 한계를 배웠나. 배웠는데도 또 거절당하면 다른 문제다 —
|
|
135
|
+
// 그때는 끝없이 다시 부르지 않고 오류를 그대로 보여 준다.
|
|
136
|
+
let 배운적 = false;
|
|
137
|
+
const 짧게말 = (s) => String(s ?? '').replace(/\s+/g, ' ').slice(0, 200);
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 이미 부른 것을 똑같이 또 부르는지 본다.
|
|
141
|
+
*
|
|
142
|
+
* 실패만 세면 이 자리를 못 잡는다. 실제 화면에 이렇게 찍혔다 —
|
|
143
|
+
* ☰ TodoWrite(3건) 같은 목록, 다섯 번
|
|
144
|
+
* ❋ Glob(**\/*) 4개 같은 패턴, 네 번
|
|
145
|
+
* 전부 '성공' 이다. 그런데 아무것도 안 나아가고, 같은 결과가 매번 컨텍스트에
|
|
146
|
+
* 한 벌씩 더 쌓인다. 파일 목록 하나가 네 번 들어가면 그만큼 밀려난다.
|
|
147
|
+
*
|
|
148
|
+
* 그래서 두 번째부터는 결과를 다시 안 싣는다 — 짧게 '앞에서 부른 것과 같다'
|
|
149
|
+
* 고만 알려 준다. 짝은 맞아야 하니 답 자체는 반드시 보낸다.
|
|
150
|
+
*
|
|
151
|
+
* 결과가 달라졌으면 그대로 싣는다. 고치고 나서 다시 읽는 것은 정상이다.
|
|
152
|
+
*/
|
|
153
|
+
const 부른것 = new Map();
|
|
154
|
+
const 서명만들기 = (call) => {
|
|
155
|
+
try { return `${call.name}(${JSON.stringify(call.args ?? {})})`; }
|
|
156
|
+
catch { return `${call.name}(?)`; }
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 중단하고 나갈 때 대화를 성한 상태로 남긴다.
|
|
161
|
+
*
|
|
162
|
+
* 모델이 도구를 부르겠다고 해 놓고 결과가 안 들어간 채로 끝나면, 다음에 이어할 때
|
|
163
|
+
* 그 배열을 그대로 보낼 수 없다 — 서버가 400 을 낸다. 그래서 부른 만큼
|
|
164
|
+
* '중단됨' 결과를 채워 짝을 맞춘다.
|
|
165
|
+
*/
|
|
166
|
+
const 짝맞추기 = () => {
|
|
167
|
+
const last = session.messages.at(-1);
|
|
168
|
+
const calls = last?.tool_calls ?? [];
|
|
169
|
+
if (!calls.length) return;
|
|
170
|
+
const 이미 = session.messages.filter((m) => m.role === 'tool').length;
|
|
171
|
+
for (const t of calls) {
|
|
172
|
+
session.push(toolMessage(conn.kind, {
|
|
173
|
+
callId: t.id ?? `call_${이미 + 1}`,
|
|
174
|
+
name: t.function?.name ?? t.name ?? '?',
|
|
175
|
+
content: '사용자가 중단했습니다. 실행하지 않았습니다.',
|
|
176
|
+
}));
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
/*
|
|
180
|
+
* 이 턴에 모델에게 보여 줄 도구.
|
|
181
|
+
*
|
|
182
|
+
* 하위 작업이면 `도구제한` 이 차 있다 — **부모가 가졌던 것** 이다. 그것을
|
|
183
|
+
* 그대로 넘기면 toolSchemas 가 거기에 다시 하위의 작업 모드를 걸러 얹으므로,
|
|
184
|
+
* 하위가 가질 수 있는 것은 언제나 부모가 가졌던 것의 부분집합이 된다.
|
|
185
|
+
*
|
|
186
|
+
* 이게 없으면 구멍이 하나 생긴다. 설계 모드는 "파일을 안 바꾼다" 는 약속인데,
|
|
187
|
+
* 하위가 제 모드를 code 로 골라 버리면 그 약속이 하위에서 깨진다. 화면에는
|
|
188
|
+
* 여전히 설계 모드라고 떠 있는 채로 파일이 바뀐다. task.js 의 하위모드() 가
|
|
189
|
+
* 모드 쪽에서 한 겹 막고, 여기가 도구 쪽에서 한 겹 더 막는다.
|
|
190
|
+
*/
|
|
191
|
+
const tools = toolSchemas(session.도구제한 ?? null, {
|
|
192
|
+
hasSkills: (session.skills?.length ?? 0) > 0,
|
|
193
|
+
web: session.web !== false && !isOffline(), // 오프라인이면 웹 도구는 아예 안 보여 준다
|
|
194
|
+
work: session.effectiveWork(), // 작업 모드가 쓰는 것만 (modes.js)
|
|
195
|
+
// 밖에서 붙인 도구(MCP). 붙은 것이 없으면 아무것도 안 는다.
|
|
196
|
+
mcp: ctx.mcp ?? null,
|
|
197
|
+
// 창이 좁으면 도구 설명을 줄여 싣는다 (budget.js 의 설명길이).
|
|
198
|
+
// 도구를 빼는 게 아니라 설명만 줄이므로 할 수 있는 일은 안 달라진다.
|
|
199
|
+
ctx: conn.ctx ?? null,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
/*
|
|
203
|
+
* 하위 작업에게 물려줄 도구 이름들.
|
|
204
|
+
*
|
|
205
|
+
* MCP 도구는 뺀다. toolSchemas 는 이름을 받으면 TOOLS 표에서 찾는데, MCP
|
|
206
|
+
* 이름은 그 표에 없어서 undefined 를 읽다 죽는다. MCP 는 이름 목록이 아니라
|
|
207
|
+
* ctx.mcp 로 따로 넘어가므로 하위도 그 길로 똑같이 받는다.
|
|
208
|
+
*/
|
|
209
|
+
const 내도구 = tools.map((t) => t.function.name).filter((n) => !n.startsWith('mcp__'));
|
|
210
|
+
/*
|
|
211
|
+
* 깊이 상한. 부모(0) → 하위(1) → 하위의 하위(2) 까지다.
|
|
212
|
+
*
|
|
213
|
+
* 상한에 닿으면 목록에서 Task 를 뺀다. "더 쪼개지 마라" 고 부탁하지 않는다 —
|
|
214
|
+
* 모델은 부탁을 잊고, 잊으면 하위가 하위를 끝없이 낳는다.
|
|
215
|
+
*/
|
|
216
|
+
const 자식도구 = 깊이 + 1 >= 최대깊이 ? 내도구.filter((n) => n !== 'Task') : 내도구;
|
|
217
|
+
// 모드마다 생각의 배분과 걸음 수가 다르다. 사용자가 따로 정했으면 그걸 존중한다.
|
|
218
|
+
const 모드 = workMode(session.effectiveWork());
|
|
219
|
+
const effort = session.effortSet ? session.effort : (모드.effort ?? session.effort);
|
|
220
|
+
const think = session.thinkSet ? session.think : (모드.think ?? session.think);
|
|
221
|
+
/*
|
|
222
|
+
* 걸음 수 상한은 **모델에 맞춰** 정한다 (budget.js).
|
|
223
|
+
*
|
|
224
|
+
* 전에는 모드마다 숫자가 박혀 있었다(코드 24회). 그 값이 맞는 모델은 하나도
|
|
225
|
+
* 없다 — 8k 모델에는 너무 크고, 655k 모델은 여유가 96% 남았는데도 만들다
|
|
226
|
+
* 만 채로 끊겼다. 사람이 직접 준 값이 있으면 그것이 먼저다.
|
|
227
|
+
*/
|
|
228
|
+
const maxSteps = session.stepsSet ? session.maxSteps : 걸음수(모드.id, conn.ctx);
|
|
229
|
+
const attempted = new Set(); // 같은 변경성 명령을 두 번 실행하지 않기 위한 기록
|
|
230
|
+
let steps = 0;
|
|
231
|
+
let lastToolFailed = false; // 직전 단계에서 도구가 오류를 냈나 → 다음 판단은 세게
|
|
232
|
+
|
|
233
|
+
while (steps < maxSteps) {
|
|
234
|
+
steps++;
|
|
235
|
+
/*
|
|
236
|
+
* 이 모델이 실제로 어떻게 하고 있는지 센다 (agent/grade.js).
|
|
237
|
+
*
|
|
238
|
+
* 이름에 70B 라고 적혀 있어도 걸음마다 인자가 잘리면 그건 붙들어 줘야
|
|
239
|
+
* 하는 상태다. 이름은 첫 어림일 뿐이고, 여기서 세는 것이 진짜 근거다.
|
|
240
|
+
* 세기만 한다 — 판단은 grade.js 가 하고, 프롬프트는 다음 걸음부터 달라진다.
|
|
241
|
+
*/
|
|
242
|
+
session.본것?.걸음셈();
|
|
243
|
+
// 단계마다 필요한 생각의 양이 다르다. effort.js 가 그 배분을 갖고 있다.
|
|
244
|
+
const stage = steps === 1 ? 'plan' : lastToolFailed ? 'fix' : 'work';
|
|
245
|
+
const level = effortFor(think, effort, stage);
|
|
246
|
+
// 상한은 모델 컨텍스트와 지금 찬 양에서 계산한다. 고정 숫자가 아니다.
|
|
247
|
+
// 출력 상한을 아는 값이 둘 있다 — 사용자가 정한 것(/out)과 서버에서 알아낸 것.
|
|
248
|
+
// 사람이 정한 것이 먼저다. 둘 다 없으면 null 이고, 그때만 effort.js 의 MAX_CAP 에 선다.
|
|
249
|
+
const room = { ctx: conn.ctx ?? 0, used: session.breakdown().used, max: conn.maxTokens ?? conn.maxOut ?? null };
|
|
250
|
+
const cap = tokensFor(effort, stage, room);
|
|
251
|
+
yield { type: 'stage', stage, level, cap, step: steps };
|
|
252
|
+
|
|
253
|
+
const ask = (maxTokens, think) => ({
|
|
254
|
+
messages: session.wire(),
|
|
255
|
+
tools,
|
|
256
|
+
think: thinkFor(conn, think),
|
|
257
|
+
maxTokens,
|
|
258
|
+
signal,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
let msg;
|
|
262
|
+
// 흘러온 글을 우리도 모아 둔다. 중간에 끊기면 이것만이 남는 전부다 —
|
|
263
|
+
// chatStream 은 끝까지 가야 message 를 주므로, 끊긴 순간에는 msg 가 비어 있다.
|
|
264
|
+
let 흘린것 = '';
|
|
265
|
+
const askModel = async function* (maxTokens, think, { 한번에 = false } = {}) {
|
|
266
|
+
if (conn.streaming && !한번에) {
|
|
267
|
+
for await (const ev of chatStream(conn, ask(maxTokens, think))) {
|
|
268
|
+
if (ev.type === 'done') msg = ev.message;
|
|
269
|
+
else {
|
|
270
|
+
if (ev.type === 'content') 흘린것 += ev.text ?? '';
|
|
271
|
+
yield ev;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
} else {
|
|
275
|
+
yield { type: 'waiting' };
|
|
276
|
+
msg = await chat(conn, ask(maxTokens, think));
|
|
277
|
+
if (msg.thinking) yield { type: 'thinking', text: msg.thinking };
|
|
278
|
+
if (msg.content) yield { type: 'content', text: msg.content };
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
yield* askModel(cap, level);
|
|
284
|
+
|
|
285
|
+
/*
|
|
286
|
+
* 대답이 잘렸다면 한 번은 다시 부른다.
|
|
287
|
+
*
|
|
288
|
+
* 전에는 '상한을 올릴 수 있을 때만' 다시 불렀다. 그런데 컨텍스트가 큰
|
|
289
|
+
* 모델에서는 아낀 상한과 풀어 준 상한이 둘 다 같은 천장(16,384)에 닿아서
|
|
290
|
+
* cap < full 이 거짓이 됐다. **큰 파일을 쓰는 바로 그 기계에서만** 안전망이
|
|
291
|
+
* 꺼져 있었던 것이다.
|
|
292
|
+
*
|
|
293
|
+
* 그리고 상한을 못 올리는 자리에서도 할 수 있는 일이 하나 더 있다 —
|
|
294
|
+
* 생각을 줄이는 것이다. 추론 토큰은 답과 **같은 예산**에서 나간다.
|
|
295
|
+
* 상한이 8,000인데 생각에 6,000을 쓰면 답에 쓸 수 있는 것은 2,000뿐이다.
|
|
296
|
+
* 그래서 다시 부를 때는 생각을 한 칸 낮춘다. 예산이 실제로 남는다.
|
|
297
|
+
*
|
|
298
|
+
* 둘 다 못 하면(이미 천장이고 생각도 꺼져 있으면) 다시 부르지 않는다.
|
|
299
|
+
* 같은 조건으로 또 부르면 같은 자리에서 또 잘린다 — 그건 그냥 낭비다.
|
|
300
|
+
* 그 경우는 아래 '살려 쓰기' 가 받는다.
|
|
301
|
+
*/
|
|
302
|
+
const full = Math.max(cap, fullCap(room));
|
|
303
|
+
const 낮춘생각 = level === 'off' || level === 'low' ? level : shiftLevel(level, -1);
|
|
304
|
+
if (wasCut(msg) && (full > cap || 낮춘생각 !== level)) {
|
|
305
|
+
yield {
|
|
306
|
+
type: 'retry',
|
|
307
|
+
why: full > cap ? '대답이 상한에서 잘렸습니다' : '대답이 잘렸습니다 — 생각을 줄여 자리를 냅니다',
|
|
308
|
+
from: cap, to: full, think: 낮춘생각,
|
|
309
|
+
};
|
|
310
|
+
yield* askModel(full, 낮춘생각);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/*
|
|
314
|
+
* 빈 답을 성공으로 넘기지 않는다.
|
|
315
|
+
*
|
|
316
|
+
* 스트리밍을 무시하는 서버, HTML 오류 페이지를 200 으로 주는 프록시,
|
|
317
|
+
* 규격이 안 맞는 게이트웨이 — 이럴 때 content 가 조용히 '' 가 된다.
|
|
318
|
+
* 화면에는 `── 1.9초` 만 찍히고 끝난다. 오류도 없다. 몇 번을 다시 물어도
|
|
319
|
+
* 같으니, 사용자는 프로그램이 고장 난 줄 안다.
|
|
320
|
+
*
|
|
321
|
+
* 스트리밍을 껐다가 한 번 다시 부른다. 원인이 그쪽이면 그 자리에서 낫는다.
|
|
322
|
+
*/
|
|
323
|
+
if (빈답인가(msg) && conn.streaming) {
|
|
324
|
+
yield { type: 'retry', why: '빈 답이 왔습니다 — 스트리밍을 끄고 다시 부릅니다', from: cap, to: cap };
|
|
325
|
+
흘린것 = '';
|
|
326
|
+
yield* askModel(cap, level, { 한번에: true });
|
|
327
|
+
// 껐더니 되면 이 서버는 스트리밍이 안 맞는 것이다. 이번 세션은 계속 꺼 둔다.
|
|
328
|
+
if (!빈답인가(msg)) {
|
|
329
|
+
conn.streaming = false;
|
|
330
|
+
yield { type: 'note', text: '이 서버는 스트리밍 응답이 비어서 이번 세션은 꺼 두고 씁니다.' };
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (빈답인가(msg)) {
|
|
334
|
+
session.본것?.본것('빈답');
|
|
335
|
+
yield {
|
|
336
|
+
type: 'error',
|
|
337
|
+
text: '서버가 빈 답을 보냈습니다.\n'
|
|
338
|
+
+ ' 글도 도구 호출도 없습니다. 대개 셋 중 하나입니다 —\n'
|
|
339
|
+
+ ' · 모델 이름이 이 서버에 없는 것 (deel status 로 확인)\n'
|
|
340
|
+
+ ' · 프록시가 중간에서 응답을 바꿔 놓는 것\n'
|
|
341
|
+
+ ' · 컨텍스트가 꽉 차 답할 자리가 없는 것 (/context 로 확인)',
|
|
342
|
+
};
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
} catch (err) {
|
|
346
|
+
if (err?.name === 'Aborted' || signal?.aborted) {
|
|
347
|
+
/*
|
|
348
|
+
* 중단해도 **여기까지 한 말은 남긴다.**
|
|
349
|
+
*
|
|
350
|
+
* 전에는 "여기까지는 대화에 남아 있으니 이어서 말씀하세요" 라고 해 놓고
|
|
351
|
+
* 실제로는 아무것도 안 남겼다. 그래서 "이어서 해줘" 라고 하면 모델이
|
|
352
|
+
* 방금 제가 한 말을 몰랐다. 안내가 거짓이면 안 하느니만 못하다.
|
|
353
|
+
*/
|
|
354
|
+
const 남길것 = 흘린것.trim();
|
|
355
|
+
if (!msg && 남길것) session.push({ role: 'assistant', content: 남길것 });
|
|
356
|
+
짝맞추기();
|
|
357
|
+
yield { type: 'aborted', steps, kept: !!남길것 || !!msg };
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/*
|
|
362
|
+
* 거절당했으면 그 말에서 배운다.
|
|
363
|
+
*
|
|
364
|
+
* "This model's maximum context length is 8192 tokens, however you requested 41003"
|
|
365
|
+
*
|
|
366
|
+
* 서버가 정답을 알려 준 것이다. 이 방식은 **처음 보는 서버에서도 통한다** —
|
|
367
|
+
* 규격을 몰라도 되고, 새 서버가 나와도 코드를 안 고쳐도 된다.
|
|
368
|
+
*
|
|
369
|
+
* 배운 값을 conn 에 적고 곧바로 다시 부른다. 사용자는 실패를 안 본다.
|
|
370
|
+
* 한 걸음에 한 번만 배운다 — 배웠는데도 또 거절당하면 다른 문제다.
|
|
371
|
+
*/
|
|
372
|
+
const 배운 = 배울것(err.serverMessage ?? err.message);
|
|
373
|
+
if (배운 && !배운적) {
|
|
374
|
+
배운적 = true;
|
|
375
|
+
if (배운.kind === 'ctx') {
|
|
376
|
+
conn.ctx = 배운.limit;
|
|
377
|
+
if (ctx.연결저장) { try { ctx.연결저장({ ctx: 배운.limit }); } catch {} }
|
|
378
|
+
} else {
|
|
379
|
+
conn.maxOut = 배운.limit;
|
|
380
|
+
if (conn.maxTokens && conn.maxTokens > 배운.limit) conn.maxTokens = 배운.limit;
|
|
381
|
+
if (ctx.연결저장) { try { ctx.연결저장({ maxTokens: 배운.limit }); } catch {} }
|
|
382
|
+
}
|
|
383
|
+
yield { type: 'learned', what: 배운.kind, limit: 배운.limit, asked: 배운.asked, from: 배운.text };
|
|
384
|
+
steps--; // 배우느라 쓴 걸음은 안 센다. 일은 아직 시작도 안 했다.
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// 숫자는 못 뽑았지만 '길어서' 인 것은 알겠으면, 줄여서 한 번 해 본다.
|
|
389
|
+
if (!배운적 && 길이문제인가(err.serverMessage ?? err.message) && (conn.ctx ?? 0) > 8192) {
|
|
390
|
+
배운적 = true;
|
|
391
|
+
const 줄인것 = Math.max(8192, Math.floor((conn.ctx ?? 32768) / 2));
|
|
392
|
+
conn.ctx = 줄인것;
|
|
393
|
+
yield { type: 'learned', what: 'ctx', limit: 줄인것, guessed: true, from: 짧게말(err.serverMessage ?? err.message) };
|
|
394
|
+
steps--;
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
yield { type: 'error', text: err.message };
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
session.usage.in += msg.usage?.in ?? 0;
|
|
403
|
+
session.usage.out += msg.usage?.out ?? 0;
|
|
404
|
+
session.usage.calls++;
|
|
405
|
+
|
|
406
|
+
session.push(assistantMessage(conn.kind, msg));
|
|
407
|
+
|
|
408
|
+
if (!msg.toolCalls?.length) {
|
|
409
|
+
yield { type: 'done', steps, text: msg.content, files: 마무리() };
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// 도구를 돌린다. 읽기만 하는 것들이 이어지면 한꺼번에, 나머지는 하나씩.
|
|
414
|
+
lastToolFailed = false;
|
|
415
|
+
const 거절 = (call, note) => {
|
|
416
|
+
lastToolFailed = true;
|
|
417
|
+
session.push(toolMessage(conn.kind, { callId: call.id, name: call.name, content: note }));
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
for (const 덩어리 of 묶기(msg.toolCalls)) {
|
|
421
|
+
// 돌리는 중에 끊었다면, 남은 것은 실행하지 않고 결과 자리만 채운다.
|
|
422
|
+
// 자리를 비우면 짝이 깨져 다음에 이어할 수 없다.
|
|
423
|
+
if (signal?.aborted) {
|
|
424
|
+
for (const call of 덩어리.calls) {
|
|
425
|
+
session.push(toolMessage(conn.kind, {
|
|
426
|
+
callId: call.id, name: call.name,
|
|
427
|
+
content: '사용자가 중단했습니다. 실행하지 않았습니다.',
|
|
428
|
+
}));
|
|
429
|
+
}
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// 먼저 하나씩 걸러 낸다 — 물어보는 것도 여기서. 실제 실행은 통과한 것만.
|
|
434
|
+
const 실행할것 = [];
|
|
435
|
+
for (const call of 덩어리.calls) {
|
|
436
|
+
// 인자가 잘려 온 호출. 도구에 넘기면 안 된다 —
|
|
437
|
+
// 도구는 '경로가 비었다' 같은, 원인과 상관없는 말을 하게 되고
|
|
438
|
+
// 모델은 고칠 게 없다고 보고 똑같이 다시 시도한다. 그래서 끝없이 돈다.
|
|
439
|
+
if (call.argsBroken) {
|
|
440
|
+
session.본것?.본것('잘린인자');
|
|
441
|
+
// ── 버리기 전에, 건질 수 있는지 먼저 본다 ──────────────────────
|
|
442
|
+
//
|
|
443
|
+
// 잘린 JSON 안에는 이미 받아 놓은 내용이 들어 있다. 경로도 대개 온전하다.
|
|
444
|
+
// 그걸 통째로 버리고 '다시 하세요' 라고 하면, 모델은 똑같이 다시 보내고
|
|
445
|
+
// 똑같은 자리에서 또 잘린다. 그 되풀이가 71초와 파일 0개를 만들었다.
|
|
446
|
+
//
|
|
447
|
+
// 건져서 **실제로 쓰면** 다음 호출은 나머지만 보내면 된다 — 잘릴 일이 없다.
|
|
448
|
+
// 그러니 이건 오류 처리가 아니라 일을 끝내는 길이다.
|
|
449
|
+
//
|
|
450
|
+
// Write 만 살린다. Edit 의 old_string 이 반쪽이면 엉뚱한 자리를 고치거나
|
|
451
|
+
// 안 맞아 실패한다. 그건 안 하느니만 못하다.
|
|
452
|
+
const 건진것 = (call.name === 'Write' || call.name === 'Append') ? 살린쓰기(call.rawArgs) : null;
|
|
453
|
+
if (건진것) {
|
|
454
|
+
const 도구 = call.name === 'Append' ? TOOLS.Append : TOOLS.Write;
|
|
455
|
+
let r;
|
|
456
|
+
try { r = await 도구.run({ file_path: 건진것.path, content: 건진것.content }, ctx); }
|
|
457
|
+
catch (err) { r = { error: err.message }; }
|
|
458
|
+
|
|
459
|
+
if (!r.error) {
|
|
460
|
+
/*
|
|
461
|
+
* 나아가고 있나 — 이건 **파일 크기로 판단한다.** 말이 아니라 사실로.
|
|
462
|
+
*
|
|
463
|
+
* 살려 쓰기가 성공했다는 것만으로 나아간 것이라고 보면 안 된다.
|
|
464
|
+
* 모델이 똑같이 잘린 호출을 계속 보내면 매번 같은 내용을 같은 자리에
|
|
465
|
+
* 다시 쓴다 — 성공은 하는데 파일은 그대로다. 그건 헛도는 것이다.
|
|
466
|
+
* 그때 반복 감지를 꺼 버리면 걸음 수를 다 쓸 때까지 안 멈춘다.
|
|
467
|
+
*
|
|
468
|
+
* 파일이 지난번보다 커졌을 때만 나아간 것으로 본다.
|
|
469
|
+
*/
|
|
470
|
+
const 지금크기 = r.changed ? (파일현황(r.changed).bytes ?? 0) : 0;
|
|
471
|
+
const 앞크기 = 살린크기.get(r.changed) ?? -1;
|
|
472
|
+
if (지금크기 > 앞크기) {
|
|
473
|
+
살린크기.set(r.changed, 지금크기);
|
|
474
|
+
나아감();
|
|
475
|
+
} else if (막힘셈(call, '살려 써도 파일이 안 자람')) {
|
|
476
|
+
멈출까 = '같은 내용이 계속 잘려서 옵니다 — 더 짧게 나눠 보내야 합니다';
|
|
477
|
+
}
|
|
478
|
+
const note = `인자가 잘려서, **받은 데까지만 파일에 썼습니다.**\n`
|
|
479
|
+
+ ` ${건진것.path} · ${건진것.lines}줄까지 저장됨\n`
|
|
480
|
+
+ ` 마지막 줄: ${건진것.lastLine}\n`
|
|
481
|
+
+ ` 이어서 Append 로 **그 다음 줄부터** 보내세요. 앞부분은 다시 보내지 마세요 —\n`
|
|
482
|
+
+ ` 다시 보내면 또 같은 자리에서 잘립니다. 한 번에 300줄 안쪽으로 끊어 보내세요.`;
|
|
483
|
+
session.push(toolMessage(conn.kind, { callId: call.id, name: call.name, content: note }));
|
|
484
|
+
if (r.changed) { session.noteChange(r.changed, r.diff); 손댄파일.add(r.changed); }
|
|
485
|
+
ctx.audit.tool(call.name, { file_path: 건진것.path }, `잘린 것을 살려 ${건진것.lines}줄 씀`);
|
|
486
|
+
yield {
|
|
487
|
+
type: 'tool', name: call.name, args: { file_path: 건진것.path },
|
|
488
|
+
result: { ...r, summary: `${건진것.lines}줄 · 잘린 데까지`, warn: `잘린 데까지만 썼습니다 — ${건진것.lines}줄` },
|
|
489
|
+
showLabel: true,
|
|
490
|
+
};
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
// 살려 쓰는 것마저 실패했으면(범위 밖 경로 등) 아래 평범한 거절로 내려간다.
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const note = `${call.name} 의 인자(JSON)가 중간에서 잘려 읽을 수 없습니다.`
|
|
497
|
+
+ ' 한 번에 보내기엔 내용이 너무 큽니다.\n'
|
|
498
|
+
+ ' 통째로 다시 보내지 마세요 — 또 같은 자리에서 잘립니다.\n'
|
|
499
|
+
+ ' 파일을 만드는 중이라면: Write 로 앞부분만 300줄 안쪽으로 만들고,'
|
|
500
|
+
+ ' 나머지는 Append 를 여러 번 불러 이어 붙이세요.';
|
|
501
|
+
거절(call, note);
|
|
502
|
+
// 감사기록에 남긴다. 자율 실행을 사내에 설득할 때 근거가 되는 파일이라,
|
|
503
|
+
// '스스로 안 한 일' 도 남아야 한다.
|
|
504
|
+
ctx.audit.blocked('인자가 잘려 실행하지 않음', `${call.name} · ${String(call.rawArgs ?? '').length}자`);
|
|
505
|
+
if (막힘셈(call, '인자잘림')) 멈출까 = '같은 도구 호출이 계속 잘립니다';
|
|
506
|
+
yield { type: 'tool', name: call.name, args: {}, result: { error: '인자가 잘렸습니다 — 한 번에 보내기엔 너무 큽니다' }, showLabel: true };
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (!TOOLS[call.name]) {
|
|
511
|
+
거절(call, `모르는 도구입니다. 쓸 수 있는 것: ${Object.keys(TOOLS).join(', ')}`);
|
|
512
|
+
// 이것도 세야 한다. 없는 이름을 계속 부르며 걸음 수를 다 쓰는 길이 있었다 —
|
|
513
|
+
// 71초 증상과 겉모습이 똑같은데 문만 다르다. 아래 거부·중복도 마찬가지다.
|
|
514
|
+
if (막힘셈(call, '모르는 도구')) 멈출까 = `${call.name} 은 없는 도구인데 계속 부르고 있습니다`;
|
|
515
|
+
yield { type: 'tool', name: call.name, args: call.args, result: { error: '모르는 도구' }, showLabel: true };
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// 변경성 명령은 실패해도 다시 실행하지 않는다 — 두 번 돌면 사고다.
|
|
520
|
+
if (call.name === 'Bash' && isMutating(call.args?.command)) {
|
|
521
|
+
const key = String(call.args.command).trim();
|
|
522
|
+
if (attempted.has(key)) {
|
|
523
|
+
const note = '같은 변경성 명령을 다시 실행하지 않습니다. 두 번 실행되면 사고가 납니다.';
|
|
524
|
+
거절(call, note);
|
|
525
|
+
if (막힘셈(call, '두 번째 실행 거부')) 멈출까 = '같은 명령을 계속 다시 부르고 있습니다';
|
|
526
|
+
yield { type: 'tool', name: call.name, args: call.args, result: { error: note }, showLabel: true };
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
attempted.add(key);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// 모드에 따라 물어본다. 기본(auto)은 안 묻고 되돌리기로 대응한다.
|
|
533
|
+
const needsOk = session.mode === 'strict'
|
|
534
|
+
? ['Write', 'Edit', 'Bash'].includes(call.name)
|
|
535
|
+
: session.mode === 'confirm'
|
|
536
|
+
? (call.name === 'Bash' && isMutating(call.args?.command))
|
|
537
|
+
: false;
|
|
538
|
+
if (needsOk && ctx.confirm) {
|
|
539
|
+
const ok = await ctx.confirm(call.name, call.args);
|
|
540
|
+
if (!ok) {
|
|
541
|
+
거절(call, '사용자가 거부했습니다. 다른 방법을 찾거나 이유를 물어보세요.');
|
|
542
|
+
// 거부를 못 알아듣고 같은 것을 계속 물으면 사람만 계속 n 을 치게 된다.
|
|
543
|
+
if (막힘셈(call, '사용자 거부')) 멈출까 = '거부하셨는데도 같은 것을 계속 물어보고 있습니다';
|
|
544
|
+
yield { type: 'tool', name: call.name, args: call.args, result: { error: '거부됨' }, showLabel: true };
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
실행할것.push(call);
|
|
549
|
+
}
|
|
550
|
+
if (!실행할것.length) continue;
|
|
551
|
+
|
|
552
|
+
/*
|
|
553
|
+
* ── 하위 작업 ─────────────────────────────────────────────────────
|
|
554
|
+
*
|
|
555
|
+
* 여기서 가로챈다. 평범한 도구 경로(runTool)로 보내면 안 된다.
|
|
556
|
+
*
|
|
557
|
+
* 도구는 `{content, summary}` 만 돌려줄 뿐 이벤트를 못 흘린다. 하위
|
|
558
|
+
* 작업은 몇 분씩 도는 일이라, 그 길로 보내면 그동안 화면이 완전히
|
|
559
|
+
* 죽는다 — 사람은 멈춘 건지 도는 건지 알 수 없어 Ctrl+C 를 누른다.
|
|
560
|
+
* 그래서 여기서 직접 돌리고, 하위 루프의 이벤트를 깊이만 붙여 그대로
|
|
561
|
+
* 위로 올려보낸다. 화면은 그걸 한 단 들여 그린다 (ui/screen.js).
|
|
562
|
+
*
|
|
563
|
+
* Task 는 읽기전용이 아니므로 묶기()가 언제나 혼자 한 덩어리로 낸다.
|
|
564
|
+
*/
|
|
565
|
+
if (실행할것.length === 1 && 실행할것[0].name === 'Task') {
|
|
566
|
+
const call = 실행할것[0];
|
|
567
|
+
const 목적 = String(call.args?.목적 ?? call.args?.purpose ?? '').trim() || '이름 없는 작업';
|
|
568
|
+
const 할일 = String(call.args?.할일 ?? call.args?.task ?? '').trim();
|
|
569
|
+
|
|
570
|
+
// 할 일이 비면 하위는 아무것도 모른 채로 시작한다. 하위는 이 대화를
|
|
571
|
+
// 못 보므로, 여기서 통과시키면 걸음만 태우고 빈손으로 돌아온다.
|
|
572
|
+
if (!할일) {
|
|
573
|
+
const note = '할일 이 비어 있습니다. 하위 작업은 지금 대화를 볼 수 없으니,'
|
|
574
|
+
+ ' 필요한 배경·정한 것·파일 경로와 "무엇이 끝나면 다 된 것인지" 를 할일 에 다 적어 주세요.';
|
|
575
|
+
거절(call, note);
|
|
576
|
+
if (막힘셈(call, '할일 비었음')) 멈출까 = '하위 작업을 할 일 없이 계속 부르고 있습니다';
|
|
577
|
+
yield { type: 'tool', name: 'Task', args: call.args, result: { error: '할일이 비었습니다' }, showLabel: true };
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const 자식모드 = 하위모드(call.args?.모드 ?? call.args?.mode, 모드.id);
|
|
582
|
+
const 자식 = new Session(conn, {
|
|
583
|
+
root: session.root,
|
|
584
|
+
// 승인 방식은 그대로 물려준다. 하위가 승인을 우회하면 strict 가 거짓말이 된다.
|
|
585
|
+
mode: session.mode,
|
|
586
|
+
work: 자식모드,
|
|
587
|
+
level: session.level,
|
|
588
|
+
think: session.think,
|
|
589
|
+
effort: session.effort,
|
|
590
|
+
web: session.web,
|
|
591
|
+
// 부모보다 적게 준다 (budget.js). 사람이 직접 정한 값이 있으면 그 절반.
|
|
592
|
+
maxSteps: session.stepsSet
|
|
593
|
+
? Math.max(4, Math.floor(session.maxSteps / 2))
|
|
594
|
+
: 하위걸음수(자식모드, conn.ctx),
|
|
595
|
+
});
|
|
596
|
+
// 스킬·명령·기억은 부모가 켤 때 한 번 찾아 든 것이다. 하위도 같은 것을 본다.
|
|
597
|
+
자식.skills = session.skills;
|
|
598
|
+
자식.commands = session.commands;
|
|
599
|
+
자식.plugins = session.plugins;
|
|
600
|
+
자식.memory = session.memory;
|
|
601
|
+
자식.도구제한 = 자식도구;
|
|
602
|
+
|
|
603
|
+
/*
|
|
604
|
+
* 여닫는 줄에는 깊이를 안 붙인다.
|
|
605
|
+
*
|
|
606
|
+
* 이벤트가 들고 다니는 깊이는 **그 이벤트를 낸 자리 기준**이고, 위로
|
|
607
|
+
* 한 겹 올라갈 때마다 1씩 붙어 절대 깊이가 된다. 그런데 이 줄은 하위가
|
|
608
|
+
* 낸 것이 아니라 **하위를 떼어 주는 쪽**이 내는 경계선이다. 여기서
|
|
609
|
+
* 미리 +1 을 해 두면 두 겹째에서 1이 두 번 붙어 3이 된다.
|
|
610
|
+
* 경계선은 부모 자리에 그어야 어디부터가 떼어 낸 일인지 보인다.
|
|
611
|
+
*/
|
|
612
|
+
yield { type: 'task_start', 목적, 모드: 자식모드, steps: 자식.maxSteps };
|
|
613
|
+
ctx.audit.tool('Task', { 목적, 모드: 자식모드 }, { summary: `하위 작업 시작 (${깊이 + 1}겹)` });
|
|
614
|
+
|
|
615
|
+
let 끝 = null;
|
|
616
|
+
try {
|
|
617
|
+
/*
|
|
618
|
+
* ctx 를 **그대로** 넘긴다. 새로 만들면 안 된다.
|
|
619
|
+
*
|
|
620
|
+
* scope 를 새로 만들면 "이 폴더 밖은 못 건드린다" 가 하위에서 거짓이
|
|
621
|
+
* 되고, history 를 새로 만들면 하위가 고친 파일이 `/undo` 에 안 잡히며,
|
|
622
|
+
* audit 을 새로 만들면 하위가 한 일이 감사기록에서 통째로 사라진다.
|
|
623
|
+
* 셋 다 "자율 실행을 사내에 설득할 때 근거가 되는" 것들이다.
|
|
624
|
+
*/
|
|
625
|
+
for await (const ev of run(자식, ctx, 할일, { signal, 깊이: 깊이 + 1 })) {
|
|
626
|
+
// 하위의 '끝' 은 부모의 끝이 아니다. 여기서 삼키고 요약으로 바꾼다 —
|
|
627
|
+
// 그대로 올리면 화면이 이번 턴이 다 끝난 줄 알고 입력 상자를 세운다.
|
|
628
|
+
if (ev.type === 'done' || ev.type === 'limit' || ev.type === 'stuck' || ev.type === 'aborted') {
|
|
629
|
+
끝 = ev;
|
|
630
|
+
break;
|
|
631
|
+
}
|
|
632
|
+
yield { ...ev, depth: (ev.depth ?? 0) + 1 };
|
|
633
|
+
}
|
|
634
|
+
} catch (err) {
|
|
635
|
+
// 하위가 터져도 부모 턴은 살린다. 무엇 때문에 터졌는지는 요약에 실린다.
|
|
636
|
+
끝 = { type: 'stuck', why: String(err?.message ?? err), steps: 0, files: [] };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/*
|
|
640
|
+
* 하위가 건드린 파일은 부모의 것이기도 하다. 턴 끝 목록에 같이 올린다.
|
|
641
|
+
*
|
|
642
|
+
* `/diff` 쪽은 따로 안 챙겨도 된다 — 하위의 도구 이벤트를 그대로 위로
|
|
643
|
+
* 올려보내므로, repl.js 의 도구 처리가 부모 세션에 그대로 적어 넣는다.
|
|
644
|
+
* 여기서 또 합치면 같은 변경이 두 번 세어진다.
|
|
645
|
+
*/
|
|
646
|
+
for (const f of 끝?.files ?? []) if (f?.path) 손댄파일.add(f.path);
|
|
647
|
+
// 하위가 쓴 토큰·시간도 이번 턴의 셈에 들어가야 한다. 안 그러면 /context 가 거짓말을 한다.
|
|
648
|
+
session.usage.in += 자식.usage.in;
|
|
649
|
+
session.usage.out += 자식.usage.out;
|
|
650
|
+
session.usage.calls += 자식.usage.calls;
|
|
651
|
+
session.usage.ms += 자식.usage.ms;
|
|
652
|
+
|
|
653
|
+
const 글 = 하위요약({
|
|
654
|
+
목적, 모드: 자식모드, 끝,
|
|
655
|
+
글자수: 요약길이(conn.ctx),
|
|
656
|
+
보인이름: (경로) => ctx.scope?.show?.(경로) ?? 경로,
|
|
657
|
+
});
|
|
658
|
+
session.push(toolMessage(conn.kind, { callId: call.id, name: 'Task', content: 글 }));
|
|
659
|
+
ctx.audit.tool('Task', { 목적 }, { summary: 글.slice(0, 300) });
|
|
660
|
+
yield { type: 'task_done', 목적, 모드: 자식모드, 끝 };
|
|
661
|
+
|
|
662
|
+
// 사용자가 중단했으면 부모도 여기서 멈춘다. 하위만 끊고 이어가면
|
|
663
|
+
// 무엇이 중단된 것인지 알 수 없는 화면이 된다.
|
|
664
|
+
if (끝?.type === 'aborted') { yield { type: 'aborted', steps, kept: true }; return; }
|
|
665
|
+
if (끝?.type !== 'done') {
|
|
666
|
+
lastToolFailed = true;
|
|
667
|
+
// 하위가 계속 못 끝내면 부모가 같은 덩이를 또 떼어 준다. 그건 헛도는 것이다.
|
|
668
|
+
if (막힘셈(call, `하위 ${끝?.type ?? '실패'}`)) {
|
|
669
|
+
멈출까 = `하위 작업 "${목적}" 이 계속 끝을 못 봅니다`;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// 여럿을 같이 돌릴 때는 '시작' 을 따로 알리지 않는다.
|
|
676
|
+
// 화면에서 이름 셋이 먼저 뜨고 결과 셋이 뒤에 몰려 붙으면, 어느 결과가
|
|
677
|
+
// 어느 파일 것인지 읽을 수 없다. 그럴 때는 끝난 것부터 이름과 결과를 함께 그린다.
|
|
678
|
+
const 함께 = 실행할것.length > 1;
|
|
679
|
+
if (!함께) yield { type: 'tool_start', name: 실행할것[0].name, args: 실행할것[0].args };
|
|
680
|
+
else yield { type: 'tools_start', names: 실행할것.map((x) => x.name), count: 실행할것.length };
|
|
681
|
+
|
|
682
|
+
const 한개 = async (call) => {
|
|
683
|
+
const t0 = Date.now();
|
|
684
|
+
let result;
|
|
685
|
+
try { result = await runTool(call.name, call.args, ctx); }
|
|
686
|
+
catch (err) { result = { error: String(err?.message ?? err) }; }
|
|
687
|
+
return { call, result, ms: Date.now() - t0 };
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
// 여럿이면 동시에. 읽기만 하는 것들이라 서로 방해하지 않는다.
|
|
691
|
+
const 결과들 = 함께
|
|
692
|
+
? await Promise.all(실행할것.map(한개))
|
|
693
|
+
: [await 한개(실행할것[0])];
|
|
694
|
+
|
|
695
|
+
for (const { call, result, ms } of 결과들) {
|
|
696
|
+
session.usage.ms += ms;
|
|
697
|
+
/*
|
|
698
|
+
* 이 모델이 실제로 어떻게 하고 있는지 (agent/grade.js).
|
|
699
|
+
*
|
|
700
|
+
* Edit 실패는 '파일에 있는 글을 그대로 옮겨 적는' 능력을 그대로 잰다 —
|
|
701
|
+
* 작은 모델이 제일 자주 걸리는 자리다. 여기서 세어 두면 다음 걸음의
|
|
702
|
+
* 프롬프트가 "Read 한 바로 다음 걸음에 고쳐라" 로 바뀐다.
|
|
703
|
+
*/
|
|
704
|
+
if (call.name === 'Edit' && result.error) session.본것?.본것('편집실패');
|
|
705
|
+
else if (!result.error) session.본것?.본것('도구성공');
|
|
706
|
+
|
|
707
|
+
if (result.error) {
|
|
708
|
+
lastToolFailed = true;
|
|
709
|
+
// 같은 도구가 같은 이유로 계속 실패하면 헛돌고 있는 것이다.
|
|
710
|
+
// 오류의 첫 줄만 본다 — 뒤에 붙는 경로·숫자는 매번 달라도 원인은 같다.
|
|
711
|
+
const 이유 = String(result.error).split('\n')[0].slice(0, 60);
|
|
712
|
+
if (막힘셈(call, 이유)) 멈출까 = `${call.name} 이 같은 이유로 계속 실패합니다 — ${이유}`;
|
|
713
|
+
}
|
|
714
|
+
if (call.name === 'Read' && result.content) session.noteRead(call.args.file_path, result.content);
|
|
715
|
+
// 실제로 파일이 바뀐 것만 적는다. 턴 끝에 이 목록을 디스크와 견준다.
|
|
716
|
+
if (result.changed) 손댄파일.add(result.changed);
|
|
717
|
+
// 한 번에 여러 개를 쓴 경우. changed 하나만 보면 나머지가 조용히 빠져서,
|
|
718
|
+
// 턴 끝에 "만들어졌다" 고 확인해 주는 파일이 넷 중 하나만 나온다.
|
|
719
|
+
for (const f of result.여럿 ?? []) if (f.ok && f.path) 손댄파일.add(f.path);
|
|
720
|
+
|
|
721
|
+
// 앞에서 똑같이 부른 적이 있고 결과도 같으면, 결과를 다시 싣지 않는다.
|
|
722
|
+
let 실을것 = result.error ? `오류: ${result.error}` : result.content ?? '';
|
|
723
|
+
if (!result.error) {
|
|
724
|
+
const 서명 = 서명만들기(call);
|
|
725
|
+
const 앞것 = 부른것.get(서명);
|
|
726
|
+
if (앞것 !== undefined && 앞것 === 실을것) {
|
|
727
|
+
session.본것?.본것('되풀이');
|
|
728
|
+
const n = (막힘.get(`반복|${서명}`) ?? 0) + 1;
|
|
729
|
+
막힘.set(`반복|${서명}`, n);
|
|
730
|
+
실을것 = `앞에서 부른 ${call.name} 과 인자도 결과도 같습니다. 결과는 그대로라 다시 싣지 않습니다.`
|
|
731
|
+
+ ' 같은 것을 또 부르지 말고 다음 일로 넘어가세요.';
|
|
732
|
+
if (n + 1 >= MAX_SAME) 멈출까 = `${call.name} 을 같은 인자로 계속 부르고 있습니다 — 결과가 매번 같습니다`;
|
|
733
|
+
} else {
|
|
734
|
+
부른것.set(서명, 실을것);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
session.push(toolMessage(conn.kind, {
|
|
739
|
+
callId: call.id,
|
|
740
|
+
name: call.name,
|
|
741
|
+
content: 실을것,
|
|
742
|
+
}));
|
|
743
|
+
yield { type: 'tool', name: call.name, args: call.args, result, ms, parallel: 함께 };
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// 도구를 돌리다 끕어졌어도 모델이 한 말은 이미 대화에 들어가 있다. 그건 남는다.
|
|
748
|
+
if (signal?.aborted) { yield { type: 'aborted', steps, kept: true }; return; }
|
|
749
|
+
|
|
750
|
+
// 헛돌고 있으면 여기서 끊는다. 걸음 수가 남았다고 계속 두면
|
|
751
|
+
// 컨텍스트만 채우고 사람은 기다리기만 한다.
|
|
752
|
+
if (멈출까) {
|
|
753
|
+
ctx.audit.blocked('같은 자리를 반복해 스스로 멈춤', 멈출까);
|
|
754
|
+
yield { type: 'stuck', why: 멈출까, steps, files: 마무리() };
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// 컨텍스트가 차오르면 오래된 대화를 '요약해서' 접는다. 그냥 자르면 하던 일을 잊는다.
|
|
759
|
+
if (shouldCompact(session)) {
|
|
760
|
+
yield { type: 'compacting' };
|
|
761
|
+
const r = await compact(session, { auto: true, signal });
|
|
762
|
+
if (r.aborted) { yield { type: 'aborted', steps, kept: true }; return; }
|
|
763
|
+
if (r.ok) yield { type: 'compacted', ...r };
|
|
764
|
+
else yield { type: 'compact_failed', why: r.why };
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/*
|
|
769
|
+
* 걸음을 다 썼다.
|
|
770
|
+
*
|
|
771
|
+
* 전에는 `도구 호출 24회에서 멈췄습니다` 한 줄이 전부였다. 무엇이 끝났고
|
|
772
|
+
* 무엇이 안 끝났는지는 사람이 위로 스크롤해 도구 줄을 세어 봐야 알았다.
|
|
773
|
+
* 할 일 목록을 이미 들고 있으니 그걸 그대로 보여 준다 — "이어서 해줘" 라고
|
|
774
|
+
* 할 때 무엇을 이어야 하는지가 화면에 있어야 한다.
|
|
775
|
+
*/
|
|
776
|
+
const 남은할일 = (ctx.todos ?? []).filter((x) => x.state !== 'done');
|
|
777
|
+
yield { type: 'limit', steps, files: 마무리(), 남은할일 };
|
|
778
|
+
}
|