jaitus 0.3.12 → 0.3.14
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/bin/jaitus.js +86 -73
- package/package.json +2 -2
- package/src/agent/loop.js +998 -980
- package/src/agent/tools.js +11 -5
- package/src/api.js +55 -21
- package/src/commands/update.js +75 -49
- package/src/errors.js +120 -106
- package/src/index.js +3152 -3142
- package/src/rewind.js +149 -148
- package/src/serviceNotice.js +151 -0
- package/src/sessions.js +8 -0
- package/src/tui/clipboard.js +172 -161
- package/src/tui-ink.js +2841 -2605
- package/src/tui.js +2560 -2559
package/src/agent/loop.js
CHANGED
|
@@ -1,980 +1,998 @@
|
|
|
1
|
-
// ─── The agent loop ─────────────────────────────────────────────────────────────
|
|
2
|
-
//
|
|
3
|
-
// Send the conversation to Jaitus, stream the reply, run any tools it asked for, append
|
|
4
|
-
// the results, and go again — until it stops asking for tools. That is the whole idea;
|
|
5
|
-
// everything else here is making it behave under real conditions.
|
|
6
|
-
//
|
|
7
|
-
// Notes on the parts that are easy to get wrong:
|
|
8
|
-
//
|
|
9
|
-
// • Streaming is parsed as server-sent events. Tool arguments arrive as `input_json_delta`
|
|
10
|
-
// fragments that only become valid JSON once concatenated, so they are buffered per
|
|
11
|
-
// content block and parsed at the end, never incrementally.
|
|
12
|
-
// • The assistant turn is appended EXACTLY as the model produced it, tool_use blocks
|
|
13
|
-
// included. Paraphrasing it breaks the pairing with tool_result and the next turn
|
|
14
|
-
// is rejected.
|
|
15
|
-
// • Every tool_use must get a tool_result, including ones that failed or were refused.
|
|
16
|
-
// A missing result is a malformed conversation, and the error text is also how the
|
|
17
|
-
// model learns to try something else.
|
|
18
|
-
// • A stuck detector, not a step count. A model caught in a loop would otherwise spend
|
|
19
|
-
// real money until someone notices — but a fixed cap of 40 steps also stopped every
|
|
20
|
-
// long, perfectly healthy task halfway and made the user type "continue". A run now
|
|
21
|
-
// ends when the model finishes, or when it repeats the same call with the same input
|
|
22
|
-
// STUCK_REPEATS times with no file changed in between (see `stuck` below). A very
|
|
23
|
-
// high backstop remains so no run is unbounded.
|
|
24
|
-
|
|
25
|
-
import { byName, toolSchemas, ToolError, readImageForModel } from './tools.js';
|
|
26
|
-
import { PermissionDenied } from './permissions.js';
|
|
27
|
-
import path from 'node:path';
|
|
28
|
-
import fs from 'node:fs';
|
|
29
|
-
import os from 'node:os';
|
|
30
|
-
import { loadContext, loadIgnore, CONTEXT_FILE } from '../project.js';
|
|
31
|
-
import { c, out, write, spinner, glyph } from '../ui.js';
|
|
32
|
-
import { discoverMcpTools } from '../mcp.js';
|
|
33
|
-
import { unifiedDiff } from '../diff.js';
|
|
34
|
-
import { fromResponse, fromGatewayBody, JaitusError } from '../errors.js';
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
//
|
|
42
|
-
// answer,
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
export const
|
|
50
|
-
|
|
51
|
-
+ '
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
const
|
|
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
|
-
const
|
|
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
|
-
export
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
for (const m of messages)
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
m.content
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
// the
|
|
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
|
-
case '
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
if (
|
|
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
|
-
if (
|
|
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
|
-
const
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
//
|
|
583
|
-
//
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
//
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
if (
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
//
|
|
720
|
-
//
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
}
|
|
725
|
-
if (
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
//
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
const
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
1
|
+
// ─── The agent loop ─────────────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Send the conversation to Jaitus, stream the reply, run any tools it asked for, append
|
|
4
|
+
// the results, and go again — until it stops asking for tools. That is the whole idea;
|
|
5
|
+
// everything else here is making it behave under real conditions.
|
|
6
|
+
//
|
|
7
|
+
// Notes on the parts that are easy to get wrong:
|
|
8
|
+
//
|
|
9
|
+
// • Streaming is parsed as server-sent events. Tool arguments arrive as `input_json_delta`
|
|
10
|
+
// fragments that only become valid JSON once concatenated, so they are buffered per
|
|
11
|
+
// content block and parsed at the end, never incrementally.
|
|
12
|
+
// • The assistant turn is appended EXACTLY as the model produced it, tool_use blocks
|
|
13
|
+
// included. Paraphrasing it breaks the pairing with tool_result and the next turn
|
|
14
|
+
// is rejected.
|
|
15
|
+
// • Every tool_use must get a tool_result, including ones that failed or were refused.
|
|
16
|
+
// A missing result is a malformed conversation, and the error text is also how the
|
|
17
|
+
// model learns to try something else.
|
|
18
|
+
// • A stuck detector, not a step count. A model caught in a loop would otherwise spend
|
|
19
|
+
// real money until someone notices — but a fixed cap of 40 steps also stopped every
|
|
20
|
+
// long, perfectly healthy task halfway and made the user type "continue". A run now
|
|
21
|
+
// ends when the model finishes, or when it repeats the same call with the same input
|
|
22
|
+
// STUCK_REPEATS times with no file changed in between (see `stuck` below). A very
|
|
23
|
+
// high backstop remains so no run is unbounded.
|
|
24
|
+
|
|
25
|
+
import { byName, toolSchemas, ToolError, readImageForModel } from './tools.js';
|
|
26
|
+
import { PermissionDenied } from './permissions.js';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import os from 'node:os';
|
|
30
|
+
import { loadContext, loadIgnore, CONTEXT_FILE } from '../project.js';
|
|
31
|
+
import { c, out, write, spinner, glyph } from '../ui.js';
|
|
32
|
+
import { discoverMcpTools } from '../mcp.js';
|
|
33
|
+
import { unifiedDiff } from '../diff.js';
|
|
34
|
+
import { fromResponse, fromGatewayBody, JaitusError } from '../errors.js';
|
|
35
|
+
import { explainFailure } from '../serviceNotice.js';
|
|
36
|
+
|
|
37
|
+
const MAX_ITERATIONS = 1000;
|
|
38
|
+
const STUCK_REPEATS = 4;
|
|
39
|
+
const POLLING_TOOLS = new Set(['background_read', 'background_list']);
|
|
40
|
+
|
|
41
|
+
// Room for a long answer or a large file in one reply. The models behind Jaitus reason
|
|
42
|
+
// before they answer, and that reasoning is spent from the same budget — at 8192 a long
|
|
43
|
+
// answer, or any file of a few hundred lines, was cut off partway.
|
|
44
|
+
export const MAX_OUTPUT_TOKENS = 32000;
|
|
45
|
+
|
|
46
|
+
// A reply that stopped before it finished — the output limit, or a connection dropped
|
|
47
|
+
// somewhere between the provider and here — is continued, not presented as the answer.
|
|
48
|
+
// Bounded, so a model that never finishes cannot loop on the user's allowance.
|
|
49
|
+
export const MAX_CONTINUATIONS = 4;
|
|
50
|
+
export const CONTINUE_NOTE = 'Your previous reply was cut off before it finished (an output limit or a dropped connection). '
|
|
51
|
+
+ 'Continue from exactly where it stopped: do not repeat or summarise what you already wrote, and do not mention this note. '
|
|
52
|
+
+ 'If you were in the middle of a tool call, make that call again in full.';
|
|
53
|
+
|
|
54
|
+
// A request that failed before anything arrived (a network blip) is tried again this
|
|
55
|
+
// many times before it is reported.
|
|
56
|
+
const NETWORK_RETRIES = 2;
|
|
57
|
+
|
|
58
|
+
// Gateway answers that mean "not right now", not "no".
|
|
59
|
+
const TRANSIENT_CODES = new Set(['busy', 'unavailable', 'timeout', 'interrupted', 'internal', 'maintenance']);
|
|
60
|
+
const BUSY_RETRIES = 3;
|
|
61
|
+
const MAX_RETRY_WAIT_MS = 30_000;
|
|
62
|
+
// A blip (busy, unavailable, timed out) is retried quickly or not at all. The gateway's
|
|
63
|
+
// Retry-After for these is 30-60 s, and waiting that out three times kept the user on
|
|
64
|
+
// "Working…" for a minute and a half before saying anything — with every provider
|
|
65
|
+
// switched off, the "Jaitus is unavailable" that used to appear at once took 90 s.
|
|
66
|
+
const BLIP_MAX_WAIT_MS = 8_000;
|
|
67
|
+
|
|
68
|
+
/** How long to wait before retrying `e`, or null when it must not be retried. */
|
|
69
|
+
export function retryDelay(e, attempt = 0) {
|
|
70
|
+
const code = e?.code;
|
|
71
|
+
// The gateway knows when trying again cannot help (nothing is switched on to answer).
|
|
72
|
+
if (e?.noRetry) return null;
|
|
73
|
+
const hinted = Number(e?.retryAfter) > 0 ? Number(e.retryAfter) * 1000 : null;
|
|
74
|
+
if (code === 'rate_limited') {
|
|
75
|
+
// A short window is waited out; a cap that resets in hours is a limit, not a blip.
|
|
76
|
+
if (hinted == null) return 2000 * (attempt + 1);
|
|
77
|
+
return hinted <= MAX_RETRY_WAIT_MS ? hinted : null;
|
|
78
|
+
}
|
|
79
|
+
if (!TRANSIENT_CODES.has(code) || code === 'internal') return null;
|
|
80
|
+
if (hinted != null) return Math.min(BLIP_MAX_WAIT_MS, hinted);
|
|
81
|
+
// ±25% spread: every CLI cut off by the same restart would otherwise retry in the
|
|
82
|
+
// same instant and arrive together.
|
|
83
|
+
return Math.min(BLIP_MAX_WAIT_MS, Math.round(2000 * 2 ** attempt * (0.75 + Math.random() * 0.5)));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function sleepUnlessAborted(ms, signal) {
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
if (signal?.aborted) return resolve(false);
|
|
89
|
+
const t = setTimeout(() => { signal?.removeEventListener?.('abort', onAbort); resolve(true); }, ms);
|
|
90
|
+
const onAbort = () => { clearTimeout(t); resolve(false); };
|
|
91
|
+
signal?.addEventListener?.('abort', onAbort, { once: true });
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** opencode tool icon per tool, muted when done. ASCII twins included. */
|
|
96
|
+
const TOOL_MARKS = {
|
|
97
|
+
run_command: ['$', '$'], read_file: ['→', '>'], read_image: ['→', '>'],
|
|
98
|
+
list_files: ['→', '>'], write_file: ['←', '<'], edit_file: ['←', '<'],
|
|
99
|
+
search: ['✱', '*'], web_fetch: ['%', '%'], web_search: ['◈', 'o'],
|
|
100
|
+
background_run: ['◉', 'o'], todo_write: ['⚙', '*'],
|
|
101
|
+
};
|
|
102
|
+
const toolMark = (name) => {
|
|
103
|
+
const [uni, ascii] = TOOL_MARKS[name] || ['⚙', '*'];
|
|
104
|
+
return c.dim(glyph(uni, ascii));
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const SYSTEM_PROMPT = `You are Jaitus, a coding agent running in a developer's terminal, inside their project directory.
|
|
108
|
+
|
|
109
|
+
You work directly on this computer. Your tools read, search, create and edit the files in
|
|
110
|
+
the project and run shell commands in it, and you can fetch web pages. When the user asks
|
|
111
|
+
about their files, their code or their machine, use the tools and answer from what you
|
|
112
|
+
find — never say you cannot access local files, because you can.
|
|
113
|
+
|
|
114
|
+
How to work:
|
|
115
|
+
- Investigate before you change anything. Read the files you are about to edit; do not guess at their contents.
|
|
116
|
+
- Prefer edit_file over write_file for existing files. Keep changes minimal and in the style of the surrounding code.
|
|
117
|
+
- Run the project's own tests or build after a change when one exists, and report the real result — including when it fails.
|
|
118
|
+
- Use run_command for anything the shell does better: git, tests, package managers, linters.
|
|
119
|
+
- Never invent a file path, a function name, or a test result. If you are unsure, look.
|
|
120
|
+
- Every turn re-sends the whole conversation, so make independent reads, searches and
|
|
121
|
+
listings together in ONE turn rather than one per turn. Read the part of a large file you
|
|
122
|
+
need (offset/limit) rather than all of it.
|
|
123
|
+
- For current news or other web facts, report only details grounded in web_search or
|
|
124
|
+
web_fetch output. Include the supplied source URLs; never fill a missing headline or
|
|
125
|
+
date from memory.
|
|
126
|
+
|
|
127
|
+
How to talk:
|
|
128
|
+
- Be concise. This is a terminal, not a document.
|
|
129
|
+
- Say what you did and what happened, not what you are about to do.
|
|
130
|
+
- If something is blocked or you are missing information, say so plainly and ask.
|
|
131
|
+
- No preamble, no restating the question back.`;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The project's own briefing, appended to the system prompt.
|
|
135
|
+
*
|
|
136
|
+
* Fenced and labelled rather than concatenated, so instructions written by whoever owns
|
|
137
|
+
* the repo are visibly the repo's and cannot be mistaken for part of Jaitus's own rules.
|
|
138
|
+
*/
|
|
139
|
+
/**
|
|
140
|
+
* Where the agent is: the facts it would otherwise guess at, and guess wrong.
|
|
141
|
+
*
|
|
142
|
+
* Without these, the model did not know which folder it was in, which OS its commands
|
|
143
|
+
* would run on (cmd.exe and /bin/sh disagree about almost everything), or today's date —
|
|
144
|
+
* and a model that does not know where it is tends to say it cannot see your files. The
|
|
145
|
+
* listing is the top level only, names not contents, and respects .jaitusignore.
|
|
146
|
+
*/
|
|
147
|
+
export function environmentBlock(cwd, { now = new Date(), platform = process.platform } = {}) {
|
|
148
|
+
if (!cwd) return '';
|
|
149
|
+
const osName = { win32: 'Windows', darwin: 'macOS', linux: 'Linux' }[platform] || platform;
|
|
150
|
+
const shell = platform === 'win32' ? 'cmd.exe (run_command uses it)' : '/bin/sh (run_command uses it)';
|
|
151
|
+
// The repository may start above the working directory, and .git may be a file that
|
|
152
|
+
// points elsewhere (a worktree or submodule).
|
|
153
|
+
let branch = null;
|
|
154
|
+
for (let dir = cwd, i = 0; i < 40; i++) {
|
|
155
|
+
const dotGit = path.join(dir, '.git');
|
|
156
|
+
try {
|
|
157
|
+
let gitDir = dotGit;
|
|
158
|
+
if (fs.statSync(dotGit).isFile()) {
|
|
159
|
+
const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dotGit, 'utf8'));
|
|
160
|
+
if (m) gitDir = path.resolve(dir, m[1].trim());
|
|
161
|
+
}
|
|
162
|
+
const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim();
|
|
163
|
+
branch = head.startsWith('ref: refs/heads/') ? head.slice(16) : `detached at ${head.slice(0, 7)}`;
|
|
164
|
+
break;
|
|
165
|
+
} catch { /* not here; look one level up */ }
|
|
166
|
+
const parent = path.dirname(dir);
|
|
167
|
+
if (parent === dir) break;
|
|
168
|
+
dir = parent;
|
|
169
|
+
}
|
|
170
|
+
let listing = '';
|
|
171
|
+
try {
|
|
172
|
+
const ignore = loadIgnore(cwd);
|
|
173
|
+
const SKIP = new Set(['.git', 'node_modules']);
|
|
174
|
+
const names = fs.readdirSync(cwd, { withFileTypes: true })
|
|
175
|
+
.filter((d) => !SKIP.has(d.name) && !ignore.matches(d.isDirectory() ? `${d.name}/` : d.name) && !ignore.matches(d.name))
|
|
176
|
+
.map((d) => (d.isDirectory() ? `${d.name}/` : d.name))
|
|
177
|
+
.sort((a, b) => (b.endsWith('/') - a.endsWith('/')) || a.localeCompare(b));
|
|
178
|
+
const MAX = 60;
|
|
179
|
+
listing = names.slice(0, MAX).join('\n') + (names.length > MAX ? `\n… and ${names.length - MAX} more` : '');
|
|
180
|
+
} catch { /* unreadable: the tools will say why */ }
|
|
181
|
+
return `
|
|
182
|
+
|
|
183
|
+
<environment>
|
|
184
|
+
Working directory: ${cwd}
|
|
185
|
+
Platform: ${osName} (${os.release()})
|
|
186
|
+
Shell: ${shell}
|
|
187
|
+
Today: ${now.toISOString().slice(0, 10)}
|
|
188
|
+
Git: ${branch ? `yes, branch ${branch}` : 'no repository at this folder'}
|
|
189
|
+
Top level of the working directory:
|
|
190
|
+
${listing || '(empty)'}
|
|
191
|
+
</environment>`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function systemPrompt(cwd) {
|
|
195
|
+
const project = cwd ? loadContext(cwd) : null;
|
|
196
|
+
const base = `${SYSTEM_PROMPT}${environmentBlock(cwd)}`;
|
|
197
|
+
if (!project) return base;
|
|
198
|
+
return `${base}
|
|
199
|
+
|
|
200
|
+
The project you are working in ships a ${CONTEXT_FILE} with notes for you. Follow it where
|
|
201
|
+
it applies, and prefer what it says over your own assumptions about this codebase.
|
|
202
|
+
|
|
203
|
+
<${CONTEXT_FILE}>
|
|
204
|
+
${project}
|
|
205
|
+
</${CONTEXT_FILE}>`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Every earlier image is sent again with every request, and the gateway accepts at most
|
|
210
|
+
* 10 MB per request — three or four screenshots into a conversation, every turn failed.
|
|
211
|
+
* The newest images are kept up to IMAGE_BUDGET bytes of base64; older ones are replaced
|
|
212
|
+
* by a line saying one was there. The history itself is not changed.
|
|
213
|
+
*/
|
|
214
|
+
/**
|
|
215
|
+
* Make a history well-formed after any interruption: every assistant tool_use has a
|
|
216
|
+
* tool_result in the next message. A run stopped by Esc, a failed permission prompt or
|
|
217
|
+
* a gateway error partway through could leave a tool call with no result — and both
|
|
218
|
+
* provider formats reject that, so every later request in the session failed. Missing
|
|
219
|
+
* results are filled in as "not run"; an assistant turn that ends the history with tool
|
|
220
|
+
* calls gets its results appended.
|
|
221
|
+
*/
|
|
222
|
+
export function repairToolPairs(messages) {
|
|
223
|
+
for (let i = 0; i < messages.length; i++) {
|
|
224
|
+
const m = messages[i];
|
|
225
|
+
if (m?.role !== 'assistant' || !Array.isArray(m.content)) continue;
|
|
226
|
+
const ids = m.content.filter((b) => b?.type === 'tool_use').map((b) => b.id);
|
|
227
|
+
if (!ids.length) continue;
|
|
228
|
+
const next = messages[i + 1];
|
|
229
|
+
const have = new Set(next?.role === 'user' && Array.isArray(next.content)
|
|
230
|
+
? next.content.filter((b) => b?.type === 'tool_result').map((b) => b.tool_use_id) : []);
|
|
231
|
+
const missing = ids.filter((id) => !have.has(id));
|
|
232
|
+
if (!missing.length) continue;
|
|
233
|
+
const filled = missing.map((id) => result(id, 'Not run: the task was interrupted before this step.', true));
|
|
234
|
+
if (next?.role === 'user') {
|
|
235
|
+
const content = Array.isArray(next.content) ? next.content : [{ type: 'text', text: String(next.content ?? '') }];
|
|
236
|
+
const results = content.filter((b) => b?.type === 'tool_result');
|
|
237
|
+
const rest = content.filter((b) => b?.type !== 'tool_result');
|
|
238
|
+
messages[i + 1] = { ...next, content: [...results, ...filled, ...rest] };
|
|
239
|
+
} else {
|
|
240
|
+
messages.splice(i + 1, 0, { role: 'user', content: filled });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return messages;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Keeping the conversation small ────────────────────────────────────────────
|
|
247
|
+
//
|
|
248
|
+
// Every request re-sends the whole conversation, and in real use that re-send — cached,
|
|
249
|
+
// but not free — was ~75% of what a session cost: 109 requests averaged 46k tokens of
|
|
250
|
+
// context each, most of it tool output from many steps ago (a file read at step 3 is
|
|
251
|
+
// still paid for at step 60). This is the documented "clear old tool results" strategy
|
|
252
|
+
// (Anthropic's context editing, clear_tool_uses; Claude Code's micro-compaction): once
|
|
253
|
+
// the conversation passes TRIM_AT, the outputs of all but the KEEP_RECENT latest tool
|
|
254
|
+
// calls are replaced with a line saying they were cleared and can be re-run. It clears
|
|
255
|
+
// at least TRIM_AT_LEAST at a time, because each clearing breaks the prompt cache once
|
|
256
|
+
// — clearing a little on every step would cost more than it saves. Small results (a
|
|
257
|
+
// "file written", an exit code) are kept: they carry meaning and cost nothing.
|
|
258
|
+
// 100k was the documented default for 200k-window models. Here the window is up to 1M, so
|
|
259
|
+
// that ceiling was rarely near: production requests averaged 53k tokens and two thirds of
|
|
260
|
+
// all input tokens came from requests over 60k. At 40k a clearing (one cache break, ~17
|
|
261
|
+
// credits on glm-5.3-flash) pays for itself within about ten later requests.
|
|
262
|
+
export const TRIM_AT = 40_000; // estimated tokens of conversation
|
|
263
|
+
export const KEEP_RECENT = 6; // latest tool results always kept whole
|
|
264
|
+
export const TRIM_AT_LEAST = 12_000; // estimated tokens a clearing must free
|
|
265
|
+
const TRIM_MIN_CHARS = 1_500; // results shorter than this are never cleared
|
|
266
|
+
const CLEARED = '[Output cleared';
|
|
267
|
+
|
|
268
|
+
const blockChars = (b) => {
|
|
269
|
+
if (b?.type === 'text') return (b.text || '').length;
|
|
270
|
+
if (b?.type === 'tool_use') return JSON.stringify(b.input || {}).length;
|
|
271
|
+
if (b?.type === 'tool_result') return typeof b.content === 'string' ? b.content.length : JSON.stringify(b.content || '').length;
|
|
272
|
+
return 0;
|
|
273
|
+
};
|
|
274
|
+
export function conversationTokens(messages) {
|
|
275
|
+
let chars = 0;
|
|
276
|
+
for (const m of messages || []) chars += typeof m?.content === 'string' ? m.content.length : (Array.isArray(m?.content) ? m.content.reduce((n, b) => n + blockChars(b), 0) : 0);
|
|
277
|
+
return Math.ceil(chars / 4);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Clear old tool outputs in place when the conversation is large. Returns how many
|
|
282
|
+
* estimated tokens were freed (0 when nothing was worth clearing).
|
|
283
|
+
*/
|
|
284
|
+
export function clearOldToolResults(messages, { at = TRIM_AT, keep = KEEP_RECENT, atLeast = TRIM_AT_LEAST } = {}) {
|
|
285
|
+
if (conversationTokens(messages) < at) return 0;
|
|
286
|
+
const names = new Map();
|
|
287
|
+
for (const m of messages) {
|
|
288
|
+
if (m?.role === 'assistant' && Array.isArray(m.content)) for (const b of m.content) if (b?.type === 'tool_use') names.set(b.id, b.name);
|
|
289
|
+
}
|
|
290
|
+
const results = [];
|
|
291
|
+
for (const m of messages) {
|
|
292
|
+
if (m?.role !== 'user' || !Array.isArray(m.content)) continue;
|
|
293
|
+
m.content.forEach((b, i) => { if (b?.type === 'tool_result') results.push({ m, i, b }); });
|
|
294
|
+
}
|
|
295
|
+
const candidates = results.slice(0, Math.max(0, results.length - keep)).filter(({ b }) => {
|
|
296
|
+
const text = typeof b.content === 'string' ? b.content : '';
|
|
297
|
+
return text.length >= TRIM_MIN_CHARS && !text.startsWith(CLEARED);
|
|
298
|
+
});
|
|
299
|
+
const freed = Math.ceil(candidates.reduce((n, { b }) => n + blockChars(b), 0) / 4);
|
|
300
|
+
if (freed < atLeast) return 0;
|
|
301
|
+
for (const { m, i, b } of candidates) {
|
|
302
|
+
const chars = blockChars(b);
|
|
303
|
+
const name = names.get(b.tool_use_id) || 'this tool';
|
|
304
|
+
m.content[i] = { ...b, content: `${CLEARED}: ${name} returned ${chars.toLocaleString()} characters here, removed to keep the conversation small. Run it again if you need it.]` };
|
|
305
|
+
}
|
|
306
|
+
return freed;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export const IMAGE_BUDGET = 6_000_000;
|
|
310
|
+
export function withinImageBudget(messages, budget = IMAGE_BUDGET) {
|
|
311
|
+
let used = 0;
|
|
312
|
+
let changed = false;
|
|
313
|
+
const out = messages.slice();
|
|
314
|
+
for (let i = out.length - 1; i >= 0; i--) {
|
|
315
|
+
const m = out[i];
|
|
316
|
+
if (!Array.isArray(m?.content) || !m.content.some((b) => b?.type === 'image')) continue;
|
|
317
|
+
// Walk this message's blocks newest-last, so within a message the later images win.
|
|
318
|
+
const kept = new Array(m.content.length);
|
|
319
|
+
for (let j = m.content.length - 1; j >= 0; j--) {
|
|
320
|
+
const b = m.content[j];
|
|
321
|
+
if (b?.type !== 'image') { kept[j] = b; continue; }
|
|
322
|
+
const size = String(b.source?.data || '').length;
|
|
323
|
+
if (used + size <= budget) { used += size; kept[j] = b; continue; }
|
|
324
|
+
kept[j] = { type: 'text', text: '[An image was attached here earlier; it is no longer included, to keep the request small.]' };
|
|
325
|
+
changed = true;
|
|
326
|
+
}
|
|
327
|
+
out[i] = { ...m, content: kept };
|
|
328
|
+
}
|
|
329
|
+
return changed ? out : messages;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** One streamed request. Returns { content, stopReason, usage }. */
|
|
333
|
+
export async function streamTurn({ client, messages, model, system, tools, onText, onToolStart, signal, maxTokens = MAX_OUTPUT_TOKENS }) {
|
|
334
|
+
const res = await client.messages({
|
|
335
|
+
model,
|
|
336
|
+
max_tokens: maxTokens,
|
|
337
|
+
system: system || SYSTEM_PROMPT,
|
|
338
|
+
tools,
|
|
339
|
+
messages: withinImageBudget(messages),
|
|
340
|
+
stream: true,
|
|
341
|
+
}, { signal });
|
|
342
|
+
|
|
343
|
+
if (!res.ok) throw await fromResponse(res);
|
|
344
|
+
const requestId = res.headers?.get?.('x-request-id') || '';
|
|
345
|
+
|
|
346
|
+
// Blocks are keyed by the index the stream gives them, because they interleave.
|
|
347
|
+
const blocks = new Map();
|
|
348
|
+
let stopReason = null;
|
|
349
|
+
// Whether the stream ended the way a finished message ends. A stream that just stops
|
|
350
|
+
// — no message_stop, no stop reason — was cut, and used to be taken as a complete,
|
|
351
|
+
// silent answer: the "it stops midway for no reason" bug.
|
|
352
|
+
let sawStop = false;
|
|
353
|
+
// `credits` is what the gateway counted against the plan for this request — real cost,
|
|
354
|
+
// the one unit the plan, the dashboard and this CLI all show.
|
|
355
|
+
const usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, credits: 0 };
|
|
356
|
+
// Usage can arrive at the START of a stream (Anthropic-format providers) or only at
|
|
357
|
+
// the END, in message_delta (OpenAI-format providers, translated by the gateway —
|
|
358
|
+
// their usage exists only once generation finishes). Reading input from message_start
|
|
359
|
+
// alone recorded every OpenAI-format turn as 0 input tokens.
|
|
360
|
+
const takeUsage = (u) => {
|
|
361
|
+
if (!u) return;
|
|
362
|
+
for (const k of ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens', 'credits']) {
|
|
363
|
+
if (u[k] != null && Number.isFinite(Number(u[k]))) usage[k] = Number(u[k]);
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
let buffer = '';
|
|
367
|
+
// A stream that never ends must die here, not in GC hell: the buffer below grows
|
|
368
|
+
// with every chunk and each arrival re-splits all of it, so a runaway provider
|
|
369
|
+
// (twelve silent minutes and counting, seen in the wild) grows memory
|
|
370
|
+
// quadratically until V8 falls over near 4 GB. Past the cap the turn ends as a
|
|
371
|
+
// cut (continued from what arrived) or, with nothing received, as a retryable
|
|
372
|
+
// timeout — never as a crash.
|
|
373
|
+
const MAX_STREAM_BUFFER = 8 * 1024 * 1024;
|
|
374
|
+
// A failure reported INSIDE the stream, after a 200. Recorded here and thrown after
|
|
375
|
+
// the parse below: handle() runs inside a try whose catch exists to skip keepalives,
|
|
376
|
+
// so throwing from it would be swallowed — which is how a mid-stream error used to
|
|
377
|
+
// end an answer halfway with no explanation at all.
|
|
378
|
+
let streamError = null;
|
|
379
|
+
|
|
380
|
+
const handle = (event) => {
|
|
381
|
+
switch (event.type) {
|
|
382
|
+
case 'error':
|
|
383
|
+
streamError = fromGatewayBody(200, event, { requestId });
|
|
384
|
+
break;
|
|
385
|
+
case 'message_start':
|
|
386
|
+
takeUsage(event.message?.usage);
|
|
387
|
+
break;
|
|
388
|
+
case 'content_block_start': {
|
|
389
|
+
const b = event.content_block || {};
|
|
390
|
+
blocks.set(event.index, b.type === 'tool_use'
|
|
391
|
+
? { type: 'tool_use', id: b.id, name: b.name, json: '' }
|
|
392
|
+
: { type: 'text', text: '' });
|
|
393
|
+
if (b.type === 'tool_use') onToolStart?.(b.name);
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
case 'content_block_delta': {
|
|
397
|
+
const b = blocks.get(event.index);
|
|
398
|
+
if (!b) break;
|
|
399
|
+
const d = event.delta || {};
|
|
400
|
+
if (d.type === 'text_delta' && typeof d.text === 'string') {
|
|
401
|
+
b.text += d.text;
|
|
402
|
+
onText?.(d.text);
|
|
403
|
+
} else if (d.type === 'input_json_delta' && typeof d.partial_json === 'string') {
|
|
404
|
+
b.json += d.partial_json;
|
|
405
|
+
}
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
case 'message_delta':
|
|
409
|
+
if (event.delta?.stop_reason) stopReason = event.delta.stop_reason;
|
|
410
|
+
takeUsage(event.usage);
|
|
411
|
+
break;
|
|
412
|
+
case 'message_stop':
|
|
413
|
+
sawStop = true;
|
|
414
|
+
break;
|
|
415
|
+
default:
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
// fetch() yields Uint8Array, NOT a Node Buffer. `uint8.toString('utf8')` ignores the
|
|
421
|
+
// argument and returns "101,118,101,…" — comma-joined byte values — so the whole
|
|
422
|
+
// stream parses to nothing and the agent appears to answer with silence.
|
|
423
|
+
//
|
|
424
|
+
// TextDecoder with stream:true is also the only correct choice for a different reason:
|
|
425
|
+
// a multi-byte character can be split across two chunks, and decoding each chunk
|
|
426
|
+
// independently would corrupt it. The decoder holds the partial bytes until complete.
|
|
427
|
+
const decoder = new TextDecoder();
|
|
428
|
+
|
|
429
|
+
let cut = false;
|
|
430
|
+
try {
|
|
431
|
+
for await (const chunk of res.body) {
|
|
432
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
433
|
+
if (buffer.length > MAX_STREAM_BUFFER) {
|
|
434
|
+
// Breaking the loop releases the reader (the socket is cancelled), so the
|
|
435
|
+
// runaway stops arriving too — not just stops being kept.
|
|
436
|
+
if (!blocks.size) throw new JaitusError({ code: 'timeout', status: 504, message: 'Jaitus took too long to respond. Try again.' });
|
|
437
|
+
cut = true;
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
const frames = buffer.split('\n\n');
|
|
441
|
+
buffer = frames.pop() || '';
|
|
442
|
+
for (const frame of frames) {
|
|
443
|
+
for (const line of frame.split('\n')) {
|
|
444
|
+
if (!line.startsWith('data:')) continue;
|
|
445
|
+
const raw = line.slice(5).trim();
|
|
446
|
+
if (!raw || raw === '[DONE]') continue;
|
|
447
|
+
try { handle(JSON.parse(raw)); } catch { /* keepalive or partial */ }
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (streamError) break;
|
|
451
|
+
}
|
|
452
|
+
} catch (e) {
|
|
453
|
+
// Esc is the only abort that reads as an interruption. Any OTHER AbortError —
|
|
454
|
+
// the request timeout in api.js firing mid-body, a dropped socket — is a `timeout`
|
|
455
|
+
// JaitusError so the agent loop backs off and retries it (TRANSIENT_CODES) instead
|
|
456
|
+
// of reporting "Interrupted." for something the user never stopped.
|
|
457
|
+
if (signal?.aborted) throw e;
|
|
458
|
+
// The connection dropped (or went silent) mid-answer. What arrived is kept and
|
|
459
|
+
// continued from; with nothing received yet, the caller retries the request.
|
|
460
|
+
if (!blocks.size) {
|
|
461
|
+
if (e?.name === 'AbortError') throw new JaitusError({ code: 'timeout', status: 504, message: 'Jaitus took too long to respond. Try again.' });
|
|
462
|
+
throw e;
|
|
463
|
+
}
|
|
464
|
+
cut = true;
|
|
465
|
+
}
|
|
466
|
+
if (streamError) {
|
|
467
|
+
// The provider dropped out after part of the answer had arrived. What arrived is
|
|
468
|
+
// kept and continued from, exactly as when OUR connection drops — it used to be
|
|
469
|
+
// thrown away with a "Something went wrong" and the task stopped.
|
|
470
|
+
if (blocks.size && TRANSIENT_CODES.has(streamError.code)) cut = true;
|
|
471
|
+
else throw streamError;
|
|
472
|
+
}
|
|
473
|
+
if (cut || (!sawStop && !stopReason)) stopReason = 'cut';
|
|
474
|
+
|
|
475
|
+
const content = [...blocks.entries()]
|
|
476
|
+
.sort((a, b) => a[0] - b[0])
|
|
477
|
+
.map(([, b]) => {
|
|
478
|
+
if (b.type !== 'tool_use') return { type: 'text', text: b.text };
|
|
479
|
+
let input = {};
|
|
480
|
+
// The fragments only form valid JSON once joined. An empty string is a
|
|
481
|
+
// no-argument call, which is legitimate.
|
|
482
|
+
try { input = b.json.trim() ? JSON.parse(b.json) : {}; } catch { input = { __parse_error: b.json }; }
|
|
483
|
+
return { type: 'tool_use', id: b.id, name: b.name, input };
|
|
484
|
+
})
|
|
485
|
+
.filter((b) => b.type !== 'text' || b.text.length);
|
|
486
|
+
|
|
487
|
+
return { content, stopReason, usage };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Run one user request to completion.
|
|
492
|
+
* `messages` is mutated so the caller keeps the conversation across turns.
|
|
493
|
+
*/
|
|
494
|
+
export async function runAgent({
|
|
495
|
+
client, messages, model, cwd, permissions, signal, tui = null,
|
|
496
|
+
extraSystem = '', maxIterations = MAX_ITERATIONS, silent = false,
|
|
497
|
+
onText = null, onEvent = null, systemOverride = null, journal = null,
|
|
498
|
+
subagents = true, onContext = null, trimContext = true,
|
|
499
|
+
}) {
|
|
500
|
+
// Loop detection. A call is identified by its tool, its input, and how many files had
|
|
501
|
+
// been changed when it was made — so edit → test → edit → test is progress (the count
|
|
502
|
+
// moves), while reading the same file over and over is not.
|
|
503
|
+
let changes = 0;
|
|
504
|
+
const repeats = new Map();
|
|
505
|
+
// input = fresh input, cached = prompt-cache reads (billed far cheaper), and
|
|
506
|
+
// context = the real size of the last prompt sent, as the provider counted it.
|
|
507
|
+
const totals = { input: 0, cached: 0, output: 0, tools: 0, context: 0, credits: 0 };
|
|
508
|
+
// Read once per request, not per turn: an agent that rewrites JAITUS.md mid-task
|
|
509
|
+
// should not change the rules it is being judged by halfway through.
|
|
510
|
+
// The model is told when it is in plan mode — it used to find out only by having its
|
|
511
|
+
// writes refused, after it had already tried them.
|
|
512
|
+
const planNote = permissions?.mode === 'plan'
|
|
513
|
+
? '\n\nPLAN MODE is on: you can read, list, search and fetch, but nothing you write and no command you run will be carried out. Investigate, then answer with a concrete plan (files, changes, order, risks). The user switches modes when they want it done.'
|
|
514
|
+
: '';
|
|
515
|
+
const system = `${systemOverride || systemPrompt(cwd)}${extraSystem}${planNote}`;
|
|
516
|
+
const mcp = await discoverMcpTools(cwd).catch(() => []);
|
|
517
|
+
// Subagents are offered only to the top-level agent. A subagent that can spawn
|
|
518
|
+
// subagents is an unbounded fan-out on a metered gateway, and the depth that would
|
|
519
|
+
// actually help — one — is the depth that is safe.
|
|
520
|
+
const subagent = subagents ? makeSubagentTool({ client, model, cwd, signal, tui, permissions }) : null;
|
|
521
|
+
const availableTools = [
|
|
522
|
+
...toolSchemas(),
|
|
523
|
+
...(subagent ? [{ name: subagent.name, description: subagent.description, input_schema: subagent.input_schema }] : []),
|
|
524
|
+
...mcp.map((tool) => ({ name: tool.name, description: tool.description, input_schema: tool.input_schema })),
|
|
525
|
+
];
|
|
526
|
+
const toolMap = {
|
|
527
|
+
...byName,
|
|
528
|
+
...(subagent ? { [subagent.name]: subagent } : {}),
|
|
529
|
+
...Object.fromEntries(mcp.map((tool) => [tool.name, tool])),
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
let continuations = 0;
|
|
533
|
+
let networkRetries = 0;
|
|
534
|
+
let busyRetries = 0;
|
|
535
|
+
let planNoticeShown = false;
|
|
536
|
+
// True while the previous reply is being continued: its text is still on screen as
|
|
537
|
+
// one live message, and the continuation must extend it rather than start another.
|
|
538
|
+
let continuing = false;
|
|
539
|
+
|
|
540
|
+
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
|
541
|
+
// Old tool outputs are cleared once the conversation is large (see TRIM_AT) — in
|
|
542
|
+
// place, so the saved session shrinks too and the cache stays warm afterwards.
|
|
543
|
+
if (trimContext) {
|
|
544
|
+
const freed = clearOldToolResults(messages);
|
|
545
|
+
if (freed && tui?.notice) tui.notice(`Cleared old tool output (~${Math.round(freed / 1000)}k tokens) to keep each request small.`, 'info');
|
|
546
|
+
}
|
|
547
|
+
let streamed = false;
|
|
548
|
+
const spin = tui ? { stop: () => {}, update: () => {} } : (silent ? { stop: () => {}, update: () => {} } : spinner(glyph('Working…', 'Working...')));
|
|
549
|
+
if (tui) tui.setStatus(`${c.brand(glyph('•', '*'))} ${c.brand(glyph('Working…', 'Working...'))}`);
|
|
550
|
+
// Codex-style assistant cell: the • leads the turn the way › leads the
|
|
551
|
+
// user's. In scrollback the marker lands before the first streamed chunk.
|
|
552
|
+
let turnVoiced = false;
|
|
553
|
+
|
|
554
|
+
let turn;
|
|
555
|
+
try {
|
|
556
|
+
turn = await streamTurn({
|
|
557
|
+
client,
|
|
558
|
+
messages,
|
|
559
|
+
model,
|
|
560
|
+
system,
|
|
561
|
+
tools: availableTools,
|
|
562
|
+
signal,
|
|
563
|
+
onText: (text) => {
|
|
564
|
+
onText?.(text);
|
|
565
|
+
if (!streamed) {
|
|
566
|
+
spin.stop(); streamed = true;
|
|
567
|
+
if (tui) tui.clearStatus();
|
|
568
|
+
else if (!silent && !onText) { out(''); write(`${c.dim(glyph('•', '*'))} `); }
|
|
569
|
+
}
|
|
570
|
+
if (tui) {
|
|
571
|
+
// The transcript entry draws its own speaker mark now, so the loop no
|
|
572
|
+
// longer prepends one — two marks on the same line was the tell that the
|
|
573
|
+
// renderer and the loop each thought they owned the lead glyph.
|
|
574
|
+
turnVoiced = true;
|
|
575
|
+
tui.printStream(text);
|
|
576
|
+
} else if (!silent && !onText) write(text);
|
|
577
|
+
},
|
|
578
|
+
onToolStart: () => spin.update(glyph('Working…', 'Working...')),
|
|
579
|
+
});
|
|
580
|
+
} catch (e) {
|
|
581
|
+
spin.stop();
|
|
582
|
+
// Esc during streaming: stop mid-sentence, keep what landed, don't treat it
|
|
583
|
+
// as an error. The partial text is already on screen.
|
|
584
|
+
// A network failure before anything arrived: try again, briefly, before reporting.
|
|
585
|
+
// Anything the gateway itself refused (a limit, a sign-in problem) has a status and
|
|
586
|
+
// is reported at once — retrying it would only repeat the refusal.
|
|
587
|
+
if (!(signal?.aborted || e?.name === 'AbortError') && !e?.status && networkRetries < NETWORK_RETRIES) {
|
|
588
|
+
networkRetries += 1;
|
|
589
|
+
await new Promise((r) => setTimeout(r, 800 * networkRetries));
|
|
590
|
+
iteration -= 1;
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
// Busy, rate-limited for a moment, unavailable, timed out: the gateway says to
|
|
594
|
+
// back off and retry (it sends Retry-After), and nothing used to — one provider 429
|
|
595
|
+
// ended a task halfway. A limit (plan, free tier, a daily cap hours away) or a
|
|
596
|
+
// sign-in problem is never retried.
|
|
597
|
+
const wait = retryDelay(e, busyRetries);
|
|
598
|
+
if (!(signal?.aborted || e?.name === 'AbortError') && wait != null && busyRetries < BUSY_RETRIES) {
|
|
599
|
+
busyRetries += 1;
|
|
600
|
+
const secs = Math.max(1, Math.round(wait / 1000));
|
|
601
|
+
const what = e?.code === 'busy' || e?.code === 'rate_limited' ? 'Jaitus is busy' : (e?.title || 'Jaitus did not answer');
|
|
602
|
+
if (tui?.setStatus) tui.setStatus(`${c.yellow('!')} ${c.dim(`${what} — trying again in ${secs}s (${busyRetries} of ${BUSY_RETRIES})`)}`);
|
|
603
|
+
const ok = await sleepUnlessAborted(wait, signal);
|
|
604
|
+
tui?.clearStatus?.();
|
|
605
|
+
if (!ok) return { ...totals, stopReason: 'interrupted' };
|
|
606
|
+
iteration -= 1;
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
if (signal?.aborted || e?.name === 'AbortError') {
|
|
610
|
+
if (tui) tui.flushStream();
|
|
611
|
+
if (streamed && !silent) out('');
|
|
612
|
+
if (tui?.notice) tui.notice('Interrupted.', 'warn');
|
|
613
|
+
else if (tui) tui.print(c.yellow(` ${glyph('⏹', '[x]')} Interrupted.`));
|
|
614
|
+
else if (!silent) out(c.yellow(` ${glyph('⏹', '[x]')} Interrupted.`));
|
|
615
|
+
return { ...totals, stopReason: 'interrupted' };
|
|
616
|
+
}
|
|
617
|
+
// Out of retries: say what the status page knows (back-at time, known problem).
|
|
618
|
+
// Asked of the same service the request went to; a client with no address (a test
|
|
619
|
+
// double) asks nothing.
|
|
620
|
+
if (client?.baseUrl) await explainFailure(e, { baseUrl: client.baseUrl }).catch(() => {});
|
|
621
|
+
throw e;
|
|
622
|
+
}
|
|
623
|
+
spin.stop();
|
|
624
|
+
networkRetries = 0;
|
|
625
|
+
busyRetries = 0;
|
|
626
|
+
// A reply the provider's content filter stopped is cut short; say so rather than
|
|
627
|
+
// letting half an answer pass for the whole one.
|
|
628
|
+
if (turn.stopReason === 'refusal') {
|
|
629
|
+
const why = 'The answer was stopped partway by a content filter. Rephrasing the request usually gets past it.';
|
|
630
|
+
if (tui?.notice) tui.notice(why, 'warn');
|
|
631
|
+
else if (!silent) out(c.yellow(` ${why}`));
|
|
632
|
+
}
|
|
633
|
+
const incomplete = turn.stopReason === 'max_tokens' || turn.stopReason === 'cut';
|
|
634
|
+
// A tool call cut off halfway has JSON that is not whole; it is dropped and the model
|
|
635
|
+
// is asked to make it again.
|
|
636
|
+
if (incomplete) turn.content = turn.content.filter((b) => b.type !== 'tool_use' || b.input?.__parse_error === undefined);
|
|
637
|
+
const willContinue = incomplete && continuations < MAX_CONTINUATIONS && !turn.content.some((b) => b.type === 'tool_use');
|
|
638
|
+
if (!willContinue) {
|
|
639
|
+
if (tui) tui.flushStream();
|
|
640
|
+
if (streamed || continuing) { if (tui) tui.printStream('\n'); else if (!silent && !onText) out(''); }
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
totals.input += turn.usage.input_tokens || 0;
|
|
644
|
+
totals.cached += (turn.usage.cache_read_input_tokens || 0) + (turn.usage.cache_creation_input_tokens || 0);
|
|
645
|
+
totals.output += turn.usage.output_tokens || 0;
|
|
646
|
+
totals.credits += Number(turn.usage.credits) || 0;
|
|
647
|
+
const promptSize = (turn.usage.input_tokens || 0) + (turn.usage.cache_read_input_tokens || 0) + (turn.usage.cache_creation_input_tokens || 0);
|
|
648
|
+
if (promptSize) {
|
|
649
|
+
totals.context = promptSize + (turn.usage.output_tokens || 0);
|
|
650
|
+
// The header's context meter moves after every step. It used to be told only when
|
|
651
|
+
// the whole run returned, so through a long task — and after one stopped with
|
|
652
|
+
// Esc or an error — it sat at 0.
|
|
653
|
+
try { onContext?.(totals.context); } catch { /* the meter never breaks a turn */ }
|
|
654
|
+
}
|
|
655
|
+
// The work bar's live count. It was declared and drawn but never fed, so it never
|
|
656
|
+
// appeared; now it moves after every step of the turn.
|
|
657
|
+
tui?.setTurnTokens?.(totals.input + totals.cached + totals.output, totals.credits);
|
|
658
|
+
|
|
659
|
+
if (willContinue) {
|
|
660
|
+
continuations += 1;
|
|
661
|
+
continuing = true;
|
|
662
|
+
const text = turn.content.filter((b) => b.type === 'text');
|
|
663
|
+
if (text.length) messages.push({ role: 'assistant', content: text });
|
|
664
|
+
messages.push({ role: 'user', content: [{ type: 'text', text: CONTINUE_NOTE }] });
|
|
665
|
+
onEvent?.({ type: 'continued', reason: turn.stopReason, count: continuations });
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
continuing = false;
|
|
669
|
+
if (incomplete && !turn.content.some((b) => b.type === 'tool_use')) {
|
|
670
|
+
// Still unfinished after every continuation: say so, rather than let a partial
|
|
671
|
+
// answer pass as a whole one.
|
|
672
|
+
const why = 'The reply was still unfinished after several continuations. Ask for the rest, or narrow the request.';
|
|
673
|
+
if (tui?.notice) tui.notice(why, 'warn');
|
|
674
|
+
else if (!silent) out(c.yellow(` ${why}`));
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Append the assistant turn verbatim — tool_use blocks and all.
|
|
678
|
+
messages.push({ role: 'assistant', content: turn.content });
|
|
679
|
+
onEvent?.({ type: 'assistant_turn', content: turn.content, usage: turn.usage, iteration });
|
|
680
|
+
|
|
681
|
+
const calls = turn.content.filter((b) => b.type === 'tool_use');
|
|
682
|
+
|
|
683
|
+
// Plan mode: read-only by decree. The turn's text still lands (the model can think
|
|
684
|
+
// out loud), but nothing on disk or in a shell is allowed to happen.
|
|
685
|
+
// web_fetch is not a write: in plan mode it goes to its own prompt instead of ending the turn.
|
|
686
|
+
// Plan mode: a write or command gets a "not run — plan instead" result, the reads in
|
|
687
|
+
// the same batch still run, and the turn — with the plan the model just wrote — stays
|
|
688
|
+
// in the history. It used to pop the whole turn, so "ok, do it" found no plan to do.
|
|
689
|
+
const planBlocked = permissions.mode === 'plan'
|
|
690
|
+
? new Set(calls.filter((call) => toolMap[call.name]?.needsPermission && !toolMap[call.name]?.network).map((call) => call.id))
|
|
691
|
+
: new Set();
|
|
692
|
+
if (planBlocked.size && !planNoticeShown) {
|
|
693
|
+
planNoticeShown = true;
|
|
694
|
+
const text = 'Plan mode — writes and commands are not run; the agent can still read. Shift+Tab (or /code) to let it act.';
|
|
695
|
+
if (tui?.notice) tui.notice(text, 'warn');
|
|
696
|
+
else if (!silent) out(c.yellow(` ${glyph('⏸', '[II]')} ${text}`));
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Esc pressed while the model was mid-turn: stop before any more tools run.
|
|
700
|
+
if (signal?.aborted) {
|
|
701
|
+
if (!silent) out(c.yellow(` ${glyph('⏹', '[x]')} Interrupted.`));
|
|
702
|
+
return { ...totals, stopReason: 'interrupted' };
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (!calls.length) { tui?.bellIfAway?.(); return { ...totals, stopReason: turn.stopReason }; }
|
|
706
|
+
|
|
707
|
+
// The same call, same input, nothing changed since: STUCK_REPEATS times is a loop.
|
|
708
|
+
// Checked before running, so the repeat that proves it is not paid for again.
|
|
709
|
+
const stuckOn = calls.find((call) => {
|
|
710
|
+
// Polling is supposed to repeat: background_read's own description says "poll
|
|
711
|
+
// this", and four polls of a starting dev server are not a loop.
|
|
712
|
+
if (POLLING_TOOLS.has(call.name)) return false;
|
|
713
|
+
const key = `${changes}\u0000${call.name}\u0000${JSON.stringify(call.input ?? null)}`;
|
|
714
|
+
const n = (repeats.get(key) || 0) + 1;
|
|
715
|
+
repeats.set(key, n);
|
|
716
|
+
return n >= STUCK_REPEATS;
|
|
717
|
+
});
|
|
718
|
+
if (stuckOn) {
|
|
719
|
+
// Every tool_use still gets a result — the model is told why these did not run, so
|
|
720
|
+
// after "continue" it does not simply make the same call a fifth time.
|
|
721
|
+
messages.push({ role: 'user', content: calls.map((call) => result(call.id,
|
|
722
|
+
`Not run: this exact call was already made ${STUCK_REPEATS - 1} times with no file changed since, and the run was stopped as stuck. Do something different.`, true)) });
|
|
723
|
+
const label = toolMap[stuckOn.name]?.summarise ? toolMap[stuckOn.name].summarise(stuckOn.input) : stuckOn.name;
|
|
724
|
+
const why = `Stopped: it made the same ${stuckOn.name} call (${String(label).slice(0, 60)}) ${STUCK_REPEATS} times without changing anything, so it looks stuck. Tell it what to do differently, or say "continue".`;
|
|
725
|
+
if (tui?.notice) tui.notice(why, 'warn');
|
|
726
|
+
else if (!silent) out(c.yellow(`\n${why}`));
|
|
727
|
+
tui?.bellIfAway?.();
|
|
728
|
+
return { ...totals, stopReason: 'stuck' };
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Every call gets a result, whatever happened.
|
|
732
|
+
const results = [];
|
|
733
|
+
for (const call of calls) {
|
|
734
|
+
const tool = toolMap[call.name];
|
|
735
|
+
totals.tools += 1;
|
|
736
|
+
|
|
737
|
+
// Esc during a batch stops the REST of it. The check used to run once before the
|
|
738
|
+
// batch, so after Esc the remaining calls still prompted and ran.
|
|
739
|
+
if (signal?.aborted) {
|
|
740
|
+
results.push(result(call.id, 'Not run: the user stopped the task.', true));
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
if (planBlocked.has(call.id)) {
|
|
744
|
+
results.push(result(call.id, 'Not run: plan mode is on, so nothing is written and no command runs. Finish investigating with read-only tools, then present your plan as your answer — the user will switch modes when they want it carried out.', true));
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
if (!tool) {
|
|
748
|
+
results.push(result(call.id, `No such tool: ${call.name}`, true));
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
if (call.input?.__parse_error !== undefined) {
|
|
752
|
+
results.push(result(call.id, 'Your tool arguments were not valid JSON. Send them again.', true));
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const label = tool.summarise ? tool.summarise(call.input) : call.name;
|
|
757
|
+
const activityId = tui?.recordActivity?.({ name: call.name, label, status: 'waiting' });
|
|
758
|
+
// One row per call, mutated in place through waiting → running → ok/failed. The
|
|
759
|
+
// previous surface printed a fresh line per state change, so a session with
|
|
760
|
+
// twenty tool calls scrolled sixty lines of near-duplicates past the answer.
|
|
761
|
+
const row = tui?.tool?.({ name: call.name, label, status: 'waiting', file: call.input?.path });
|
|
762
|
+
|
|
763
|
+
try {
|
|
764
|
+
await permissions.request(tool, call.input, { cwd, tui, signal });
|
|
765
|
+
} catch (e) {
|
|
766
|
+
if (e instanceof PermissionDenied) {
|
|
767
|
+
const declined = ` ${c.red(glyph('✗', 'x'))} ${c.dim('declined')}`;
|
|
768
|
+
if (row) row.update({ status: 'denied', error: e.comment ? `declined — "${e.comment}"` : 'declined' });
|
|
769
|
+
else if (tui) tui.print(declined);
|
|
770
|
+
else if (!silent) out(declined);
|
|
771
|
+
tui?.recordActivity?.({ id: activityId, name: call.name, label, status: 'failed', detail: 'Permission declined by user.' });
|
|
772
|
+
// Told to the model, not just the user: it needs to know to stop trying and
|
|
773
|
+
// propose something else instead. A decision comment rides along verbatim.
|
|
774
|
+
// A run that could not ask (one-shot, a script) says so — "the user declined"
|
|
775
|
+
// was untrue there, and hid the hint about --yes from everyone.
|
|
776
|
+
if (e.headless) totals.denied = (totals.denied || 0) + 1;
|
|
777
|
+
const note = e.headless
|
|
778
|
+
? `Not permitted in this run: ${e.message} Do not retry it; finish what you can without it and say what was left undone.`
|
|
779
|
+
: e.comment
|
|
780
|
+
? `The user declined this action with this instruction, which overrides your plan: "${e.comment}". Do not retry the declined action; follow the instruction instead.`
|
|
781
|
+
: `The user declined this action. Do not retry it; ask what they would prefer.`;
|
|
782
|
+
results.push(result(call.id, note, true));
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
throw e;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
tui?.recordActivity?.({ id: activityId, name: call.name, label, status: 'running' });
|
|
789
|
+
row?.update({ status: 'running' });
|
|
790
|
+
const spin2 = tui ? { stop: () => {} } : (silent ? { stop: () => {} } : spinner(`${call.name}: ${label}`.slice(0, 100)));
|
|
791
|
+
const toolStarted = Date.now();
|
|
792
|
+
// Live elapsed ticker on the status row while a tool runs (1s cadence,
|
|
793
|
+
// silent when animations are off — progress you can watch, not noise).
|
|
794
|
+
let elapsedTimer = null;
|
|
795
|
+
const stopElapsed = () => { if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null; } };
|
|
796
|
+
if (tui && tui.animationsOn?.()) {
|
|
797
|
+
const toolStatus = () => `${c.brand(glyph('•', '*'))} ${call.name}: ${c.dim(String(label).slice(0, 60))} ${c.dim(`· ${((Date.now() - toolStarted) / 1000).toFixed(0)}s`)}`;
|
|
798
|
+
tui.setStatus(toolStatus());
|
|
799
|
+
elapsedTimer = setInterval(() => tui.setStatus(toolStatus()), 1000);
|
|
800
|
+
if (elapsedTimer.unref) elapsedTimer.unref();
|
|
801
|
+
} else if (tui) {
|
|
802
|
+
tui.setStatus(`${c.brand(glyph('•', '*'))} ${call.name}: ${c.dim(String(label).slice(0, 70))}`);
|
|
803
|
+
}
|
|
804
|
+
try {
|
|
805
|
+
// Rewind safety net: before the agent overwrites or creates a file, remember
|
|
806
|
+
// what was there. write_file/edit_file take their targets from the input;
|
|
807
|
+
// run_command's target is unknowable, so its calls snapshot the whole
|
|
808
|
+
// tracked set instead (cheap: only files already touched this session).
|
|
809
|
+
if (journal && ['write_file', 'edit_file'].includes(call.name) && call.input?.path) {
|
|
810
|
+
try { journal.snapshotFile(path.resolve(cwd, String(call.input.path))); } catch { /* path never existed */ }
|
|
811
|
+
} else if (journal && call.name === 'run_command') {
|
|
812
|
+
journal.snapshotTracked(cwd);
|
|
813
|
+
}
|
|
814
|
+
const output = await tool.run(call.input, { cwd, signal });
|
|
815
|
+
spin2.stop(); stopElapsed();
|
|
816
|
+
const took = ((Date.now() - toolStarted) / 1000).toFixed(1);
|
|
817
|
+
// opencode tool icons: $ shell, ← edits, → reads, ✱ search,
|
|
818
|
+
// % fetch, ◈ websearch, ◉ subagents, ⚙ everything else. Done
|
|
819
|
+
// rows rest muted; failures stay red.
|
|
820
|
+
const done = ` ${toolMark(call.name)} ${c.dim(String(label).slice(0, 100))} ${c.dim(`· ${took}s`)}`;
|
|
821
|
+
if (row) {
|
|
822
|
+
tui.clearStatus();
|
|
823
|
+
// The whole output travels with the row (capped), so a click can open it.
|
|
824
|
+
row.update({ status: 'ok', took, preview: previewFor(call.name, output), full: String(output ?? '').slice(0, 200_000) });
|
|
825
|
+
} else if (tui) { tui.clearStatus(); tui.print(done, 'tool'); } else if (!silent) out(done);
|
|
826
|
+
tui?.recordActivity?.({ id: activityId, name: call.name, label, status: 'ok', detail: String(output).slice(0, 2000) });
|
|
827
|
+
onEvent?.({ type: 'tool_result', name: call.name, input: call.input, ok: true, output });
|
|
828
|
+
results.push(result(call.id, output, false));
|
|
829
|
+
// A changed file is progress: calls made after it are not repeats of those before.
|
|
830
|
+
if (['write_file', 'edit_file'].includes(call.name)) changes += 1;
|
|
831
|
+
// read_image: the pixels ride along as an image block in the tool-result
|
|
832
|
+
// message, so the model actually sees the picture instead of a caption of it.
|
|
833
|
+
// read_image: the pixels ride along in this message, after the results. When no
|
|
834
|
+
// image can be attached the call FAILED and says why — it used to report success
|
|
835
|
+
// either way, and the model then described a picture it never saw.
|
|
836
|
+
if (call.name === 'read_image') {
|
|
837
|
+
let block = null;
|
|
838
|
+
let why = '';
|
|
839
|
+
try { block = await readImageForModel(cwd, call.input?.path); } catch (e) { why = e.message; }
|
|
840
|
+
results[results.length - 1] = block
|
|
841
|
+
? { ...result(call.id, 'The image is attached to this message, after the tool results.', false), __images: [block] }
|
|
842
|
+
: result(call.id, why || 'The image could not be attached.', true);
|
|
843
|
+
}
|
|
844
|
+
// todo_write: the checklist is for the human — checkbox cells with progress.
|
|
845
|
+
if (call.name === 'todo_write' && Array.isArray(call.input?.todos)) {
|
|
846
|
+
if (tui?.todo) tui.todo(call.input.todos);
|
|
847
|
+
else if (!silent) {
|
|
848
|
+
const icon = { pending: c.dim(glyph('☐', '[ ]')), in_progress: c.accent(glyph('◐', '[>]')), completed: c.accent(glyph('☑', '[x]')) };
|
|
849
|
+
for (const t of call.input.todos.slice(0, 12)) {
|
|
850
|
+
out(` ${(icon[t?.status] || icon.pending)} ${t?.status === 'completed' ? c.dim(String(t?.task || '').slice(0, 90)) : String(t?.task || '').slice(0, 90)}`);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// A write or an edit shows what it actually did. The journal snapshotted the
|
|
856
|
+
// file a few lines above, so the "before" side is already in memory — and a
|
|
857
|
+
// claim like "Edited src/index.js" is exactly the kind of thing that turns out
|
|
858
|
+
// to have changed the wrong line.
|
|
859
|
+
if (tui?.diff && journal && ['write_file', 'edit_file'].includes(call.name) && call.input?.path) {
|
|
860
|
+
try {
|
|
861
|
+
const absolute = path.resolve(cwd, String(call.input.path));
|
|
862
|
+
const before = journal.latestSnapshot(absolute);
|
|
863
|
+
if (before !== null) {
|
|
864
|
+
const { readFileSync } = await import('node:fs');
|
|
865
|
+
const patch = unifiedDiff(before, readFileSync(absolute, 'utf8'), { file: call.input.path });
|
|
866
|
+
if (patch) tui.diff(String(call.input.path), patch);
|
|
867
|
+
}
|
|
868
|
+
} catch { /* a missing diff never blocks the turn */ }
|
|
869
|
+
}
|
|
870
|
+
} catch (e) {
|
|
871
|
+
spin2.stop(); stopElapsed();
|
|
872
|
+
const msg = e instanceof ToolError ? e.message : `${e.message}`;
|
|
873
|
+
const took = ((Date.now() - toolStarted) / 1000).toFixed(1);
|
|
874
|
+
const bad = ` ${c.red(glyph('✗', 'x'))} ${c.dim(String(label).slice(0, 80))} ${c.dim(`· ${took}s`)} — ${msg.slice(0, 120)}`;
|
|
875
|
+
if (row) {
|
|
876
|
+
tui.clearStatus();
|
|
877
|
+
row.update({ status: 'failed', took, error: msg.split('\n')[0].slice(0, 200), preview: msg.includes('\n') ? msg : '', full: msg.slice(0, 200_000) });
|
|
878
|
+
} else if (tui) { tui.clearStatus(); tui.print(bad, 'tool'); } else if (!silent) out(bad);
|
|
879
|
+
tui?.recordActivity?.({ id: activityId, name: call.name, label, status: 'failed', detail: msg.slice(0, 2000) });
|
|
880
|
+
onEvent?.({ type: 'tool_result', name: call.name, input: call.input, ok: false, error: msg });
|
|
881
|
+
results.push(result(call.id, msg, true));
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// Every tool_result first, then any images, in the same user message. Anthropic's
|
|
886
|
+
// format requires the results to lead the message; an image between two results
|
|
887
|
+
// (read_image followed by another call in one batch) was a rejected request.
|
|
888
|
+
const images = [];
|
|
889
|
+
for (const r of results) {
|
|
890
|
+
for (const img of r.__images || []) images.push(img);
|
|
891
|
+
delete r.__images;
|
|
892
|
+
}
|
|
893
|
+
messages.push({ role: 'user', content: [...results, ...images] });
|
|
894
|
+
if (signal?.aborted) {
|
|
895
|
+
if (!silent) out(c.yellow(` ${glyph('⏹', '[x]')} Interrupted.`));
|
|
896
|
+
return { ...totals, stopReason: 'interrupted' };
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
if (!silent) out(c.yellow(`\nStopped after ${maxIterations} steps — the safety limit for one request. Say "continue" to keep going.`));
|
|
901
|
+
tui?.bellIfAway?.();
|
|
902
|
+
return { ...totals, stopReason: 'max_iterations' };
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* A read-only subagent, as a tool the model can call.
|
|
907
|
+
*
|
|
908
|
+
* The point is context, not concurrency. "Find every place that reads the session file"
|
|
909
|
+
* costs forty tool results, and forty tool results spent in the main conversation are
|
|
910
|
+
* forty tool results the main conversation is still carrying three turns later. A
|
|
911
|
+
* subagent spends them in its own window and returns the paragraph that mattered.
|
|
912
|
+
*
|
|
913
|
+
* Read-only by construction: it is handed a permissions object in plan mode, so every
|
|
914
|
+
* write and every shell command is refused no matter what the parent's mode is. A
|
|
915
|
+
* subagent the user never sees must not be able to change the disk.
|
|
916
|
+
*/
|
|
917
|
+
function makeSubagentTool({ client, model, cwd, signal, tui, permissions = null }) {
|
|
918
|
+
return {
|
|
919
|
+
name: 'task',
|
|
920
|
+
needsPermission: false,
|
|
921
|
+
description: 'Delegate a read-only investigation to a separate agent with its own context window. '
|
|
922
|
+
+ 'Use it when answering needs a lot of searching and reading whose intermediate output you do not need to keep — '
|
|
923
|
+
+ '"find every caller of X", "work out how Y is wired", "check whether Z is covered by tests". '
|
|
924
|
+
+ 'The subagent can read, list, search and fetch, but cannot write files or run commands. '
|
|
925
|
+
+ 'Give it a self-contained brief: it cannot see this conversation. It returns its findings as text.',
|
|
926
|
+
input_schema: {
|
|
927
|
+
type: 'object',
|
|
928
|
+
properties: {
|
|
929
|
+
description: { type: 'string', description: 'Three to five words naming the task, for the activity row.' },
|
|
930
|
+
prompt: { type: 'string', description: 'The full self-contained brief, including what to report back.' },
|
|
931
|
+
},
|
|
932
|
+
required: ['description', 'prompt'],
|
|
933
|
+
},
|
|
934
|
+
summarise: (input) => String(input.description || 'subagent'),
|
|
935
|
+
async run(input, { cwd: runCwd }) {
|
|
936
|
+
const { createPermissions } = await import('./permissions.js');
|
|
937
|
+
const messages = [{ role: 'user', content: String(input.prompt || '') }];
|
|
938
|
+
let answer = '';
|
|
939
|
+
const result = await runAgent({
|
|
940
|
+
client,
|
|
941
|
+
messages,
|
|
942
|
+
model,
|
|
943
|
+
cwd: runCwd || cwd,
|
|
944
|
+
permissions: permissions?.fork ? permissions.fork({ mode: 'plan', interactive: false }) : createPermissions({ mode: 'plan', interactive: false }),
|
|
945
|
+
signal,
|
|
946
|
+
tui: null,
|
|
947
|
+
silent: true,
|
|
948
|
+
subagents: false,
|
|
949
|
+
// Read-only research across a codebase is many small reads; 20 cut it off
|
|
950
|
+
// mid-search. The stuck detector is what catches a loop.
|
|
951
|
+
maxIterations: 100,
|
|
952
|
+
extraSystem: '\n\nYou are a subagent. You cannot ask questions and nobody will read your intermediate '
|
|
953
|
+
+ 'steps — only your final message. End with the findings themselves, with exact file paths and line '
|
|
954
|
+
+ 'numbers where they apply, not a description of how you looked.',
|
|
955
|
+
onText: (text) => { answer += text; },
|
|
956
|
+
});
|
|
957
|
+
const trimmed = answer.trim();
|
|
958
|
+
if (!trimmed) return `The subagent finished (${result.stopReason}) without reporting anything.`;
|
|
959
|
+
return `${trimmed}\n\n[subagent: ${result.tools} tool calls, ${result.input + result.output} tokens]`;
|
|
960
|
+
},
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* What of a tool's output is worth putting on screen.
|
|
966
|
+
*
|
|
967
|
+
* Not everything: a `read_file` the agent asked for is the agent's business, and
|
|
968
|
+
* echoing 400 lines of it buries the reasoning that follows. A command's output, a
|
|
969
|
+
* search's hits and a fetch's first lines are the ones a human reads over the agent's
|
|
970
|
+
* shoulder — so those get a preview and the rest get a row.
|
|
971
|
+
*/
|
|
972
|
+
function previewFor(name, output) {
|
|
973
|
+
const text = String(output ?? '');
|
|
974
|
+
if (!text.trim()) return '';
|
|
975
|
+
switch (name) {
|
|
976
|
+
case 'run_command':
|
|
977
|
+
case 'background_read':
|
|
978
|
+
return text;
|
|
979
|
+
case 'search':
|
|
980
|
+
case 'list_files':
|
|
981
|
+
case 'web_search':
|
|
982
|
+
return text;
|
|
983
|
+
case 'web_fetch':
|
|
984
|
+
return text.split('\n').slice(0, 8).join('\n');
|
|
985
|
+
case 'read_file':
|
|
986
|
+
case 'read_image':
|
|
987
|
+
case 'write_file':
|
|
988
|
+
case 'edit_file':
|
|
989
|
+
case 'todo_write':
|
|
990
|
+
return '';
|
|
991
|
+
default:
|
|
992
|
+
return text.length > 400 ? '' : text;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function result(id, content, isError) {
|
|
997
|
+
return { type: 'tool_result', tool_use_id: id, content: String(content), is_error: isError };
|
|
998
|
+
}
|