-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathloop.ts
More file actions
5355 lines (5200 loc) · 246 KB
/
Copy pathloop.ts
File metadata and controls
5355 lines (5200 loc) · 246 KB
1
2
3
4
5
6
7
8
9
10
11
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
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
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
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
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
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
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
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
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* HeadlessSession — the Phase 1 orchestration loop.
*
* Message flow:
*
* [system prompt, user task] → LLM → assistant message (maybe with
* tool_calls) → for each call: parse → execute via ToolExecutor → append
* `tool` role message → repeat until a termination condition.
*
* Termination:
* SUCCESS — the model emits an `attempt_completion` tool call (its
* `args.result` is the final answer), or (pragmatic fallback used when
* attempt_completion is absent or the model just answers) an assistant
* text reply with no tool_calls. A non-empty text-only reply is treated
* as the final answer.
* BOUNDED FAILURE — `maxIterations` reached, or `consecutiveErrorLimit`
* consecutive mistakes (tool errors / parse errors / consecutive identical
* repeated calls / empty replies) exceeded. Also any LLM call error or
* timeout aborts the session with an error.
*
* History management: Phase 1 sliding-window truncation PLUS Phase 3 token-
* budget-aware condensation (see src/engine/condense.ts). The system message
* and the first user message are always kept; when the history exceeds
* `windowSize` messages, the oldest non-system, non-first-user messages are
* dropped before each request — UNLESS the last request's real prompt-token
* count crossed the condensation threshold, in which case the oldest turns
* are first summarized into one compact synthetic message instead of being
* dropped outright (see `maybeCondenseHistory`).
*
* The loop is non-interactive by construction: no approval prompts, no
* webview. `ask_followup_question` calls escalate instead of failing
* immediately: the executor writes a `.harness.needs-decision` marker and
* blocks (budget clock paused) for a configurable timeout waiting for
* `.harness.decision-answer` — a human or the orchestrator can answer via
* `scripts/headlesscode-answer.sh`. Only once that timeout elapses does it
* fall back to the original "harness is non-interactive" error (see
* src/tools/executor.ts).
*/
import * as fsp from "node:fs/promises"
import * as path from "node:path"
import { execFile } from "node:child_process"
import { setTimeout as sleep } from "node:timers/promises"
import { promisify } from "node:util"
import { randomUUID } from "node:crypto"
import { BudgetExceededError, BudgetTracker, type SessionBudget } from "../budget/budget.js"
import { loadPricingTable, mergeLivePrice, type ModelPrice, type PricingTable } from "../budget/cost.js"
import { DEFAULT_MODEL, isRetryableOpenRouterError } from "../llm/openrouter.js"
import { DECISION_PROXY_ANSWER_PREFIX } from "../decision-proxy/proxy.js"
import {
createHeadlessExecutor,
escalateDecision,
NEEDS_DECISION_FILENAME,
readLimitFromEnv,
ToolExecutor,
} from "../tools/executor.js"
import type { PermissionsConfig } from "../permissions/config.js"
import { buildRollingSummary, extractSessionSummary } from "../memory/summarizer.js"
import type { MemoryStore, RecallResult } from "../memory/types.js"
import { createCheckpointService, type CheckpointService } from "../checkpoints/service.js"
import { recordSessionUsage, removeLiveUsage, writeLiveUsage } from "./usage.js"
import { EventFeed, EVENT_TRUNCATE_CHARS, truncateField } from "./events.js"
import { writeSessionReport } from "./reports.js"
import { Logger } from "./logger.js"
import { extractEmbeddedToolCall, parseToolCalls } from "./parser.js"
import {
appendBrowserActionTool,
appendCodeIntelEditTools,
appendCodeIntelTools,
appendDescribeImageTool,
appendRunTestsTool,
appendSetIndentationTool,
buildLeanSystemPrompt,
buildSystemPrompt,
isLeanSystemPromptEnabled,
loadCustomModes,
modeHasEditGroup,
patchEditFileToolForLocalModels,
selectToolsForMode,
} from "./prompt.js"
import type {
AuxLlmUsage,
ChatMessage,
ChatTool,
LlmClient,
LlmResponse,
ParsedToolCall,
SessionResult,
SessionBudgetUsage,
ToolContext,
ToolResult,
} from "./types.js"
import type { ModeConfig } from "../vendor/zoo-code/types/index.js"
import {
buildCondensedMessage,
computeCondensePlan,
computeEvictCount,
condenseOldestTurns,
DEFAULT_CONDENSE_EARLY_FIRE_FRACTION,
DEFAULT_CONDENSE_MAX_TOKENS,
DEFAULT_CONDENSE_THRESHOLD_FRACTION,
DEFAULT_CONTEXT_WINDOW_TOKENS,
estimateMessageChars,
MAX_CONDENSE_INPUT_CHARS,
maybeCondense,
MIN_CONDENSE_TAIL_GROWTH,
skipOrphanedToolMessages,
} from "./condense.js"
import { writeHandoffSummary } from "./handoff.js"
import {
isLazyToolCatalogEnabled,
LIST_TOOLS_NAME,
REQUEST_TOOL_NAME,
renderToolIndex,
splitCoreAndLazyTools,
} from "./lazy-tools.js"
import { runLocalExplorePhase, type LocalExploreOptions, type LocalExploreResult } from "./local-explore.js"
import { hasCodebaseIndex } from "../index-util.js"
// Recursive task decomposition (`new_task`): child mode resolution uses the
// SAME lookup the top-level session does (custom modes from .roomodes first,
// then the vendored built-ins — see src/engine/prompt.ts).
import { getModeBySlug, modes } from "../vendor/zoo-code/src/shared/modes.js"
/** Default max pause duration for dashboard-initiated pauses, ms (matches watch.ts's stall guard). */
export const DEFAULT_MAX_PAUSE_MS = 2 * 60 * 60 * 1000
/** Default pause marker poll interval, ms (same cadence as decision escalation). */
export const DEFAULT_PAUSE_POLL_INTERVAL_MS = 5_000
/** Marker basenames, relative to the workspace root (see src/tools/executor.ts's marker idiom). */
const PAUSE_REQUESTED_FILENAME = ".harness.pause-requested"
const PAUSED_FILENAME = ".harness.paused"
/**
* Mid-session message injection (live chat-UI control): written by the
* dashboard's POST /api/session/:id/message as JSON `{ text, injectedAt }`,
* consumed + deleted by checkInjectedMessage (see plans/live-message-injection-for-chat-uis.md).
*/
export const INJECT_MESSAGE_FILENAME = ".harness.inject-message"
/**
* 2026-08-08: raised from 50 — real rounds against non-trivial issues
* routinely needed MORE than 50 (exploration alone regularly ate 30-45 of
* them before the first edit; see the read-only-equivalent execute_command
* guardrail extension for the biggest driver), so 50 was hitting the cap
* and forcing a continuation restart (see src/engine/handoff.ts) far more
* often than it should have. 250 gives a real multi-file task room to
* finish in ONE session instead of routinely needing 2-4 continuation
* cycles — each iteration is cheap (prompt caching + condensation keep
* later iterations from ballooning in cost), so the main cost of a higher
* cap is wall-clock time on a task that was never going to finish anyway,
* not spend.
*/
export const DEFAULT_MAX_ITERATIONS = 250
export const DEFAULT_CONSECUTIVE_ERROR_LIMIT = 3
/**
* Recursive task decomposition (`new_task`): hard cap on recursion depth.
* Depth 0 is the root session; a session at depth `max` may NOT delegate
* further (its new_task call is refused as a normal tool error). Default 2 =
* root (0) → child (1) → grandchild (2); the grandchild is the deepest level
* that can run. Chosen over 3 because every level also halves the child's
* iteration budget (see DEFAULT_CHILD_ITERATION_FRACTION), so a 2-deep tree
* is already bounded to ~1.75× the parent's own iteration cap while still
* giving a genuinely two-level decomposition — and the shared budget is the
* real cost governor regardless of depth. See plans/recursive-orchestrator-mode.md.
*/
export const DEFAULT_MAX_RECURSION_DEPTH = 2
/**
* Recursive task decomposition (`new_task`): a child session's default
* maxIterations is this fraction of the parent's REMAINING iterations (the
* parent still needs iterations after the child returns to process the
* result and finish). 0.5 gives the child half and keeps half for the parent;
* each further level halves again, so a depth-2 grandchild gets 0.25 of the
* root's remaining budget. Overridable via --child-iteration-fraction /
* $HEADLESSCODE_CHILD_ITERATION_FRACTION; the model cannot request more
* through the strict vendored schema (mode/message/todos only).
*/
export const DEFAULT_CHILD_ITERATION_FRACTION = 0.5
/**
* Recursive task decomposition (`new_task`): floor on a child's maxIterations
* so a parent with only a couple of iterations left doesn't spawn a crippled
* 1-iteration child (real sub-steps need a few tool calls minimum). Never
* exceeds the parent's remaining iterations — the child is always capped at
* what the parent itself has left.
*/
export const DEFAULT_MIN_CHILD_ITERATIONS = 3
/**
* switch_mode (plans/switch-mode-headless.md): hard cap on how many times a
* single session may change its OWN active mode in place. Mirrors new_task's
* recursion-depth cap (same rationale: bound worst-case cost/thrash, not just
* infinite loops). Default 5: a realistic Architect→Code→Architect→Code
* handoff pipeline fits comfortably, while anything beyond ~5 pivots in one
* session is almost certainly a model thrashing between modes instead of
* working — and every switch costs either a human approval round-trip or
* (with auto-approve on) an unvetted permission expansion, so the cap stays
* far below anything a legitimate workflow needs. Overridable via
* --max-mode-switches / $HEADLESSCODE_MAX_MODE_SWITCHES.
*/
export const DEFAULT_MAX_MODE_SWITCHES = 5
/**
* 2026-08-01: raised from 40. `windowSize` is a MESSAGE count, not a token
* count, but the models this harness targets (e.g. deepseek/deepseek-v4-flash
* on its official endpoint, pinned in src/llm/openrouter.ts) have context
* windows over 1M tokens — a 40-message cap was evicting messages hundreds
* of thousands of tokens before the model's real context limit. Confirmed
* live: a real session re-read the same handful of files 14-16 times each
* because truncation kept dropping their earlier `read_file` results out of
* view, burning iterations and cost on redundant reads instead of ever
* reaching a write. 300 messages covers a task's full exploration phase for
* realistic tasks without approaching the real context ceiling; caching
* keeps the cost of carrying more history low (see src/budget/cost.ts).
*/
export const DEFAULT_WINDOW_SIZE = 300
/**
* 2026-08-01: raised from 120_000. A real session's iteration 16 call alone
* took ~59s and generated 8,000+ output tokens (deepseek/deepseek-v4-flash
* is a reasoning model — long generations are normal, not a hang); iteration
* 19 then hit exactly the old 120s ceiling and failed with an opaque "no
* choices[0].message" error. 300s gives a heavy reasoning turn real room
* without masking an actually-hung request as something else.
*/
export const DEFAULT_LLM_TIMEOUT_MS = 300_000
/**
* Default cap on the main per-iteration call's OUTPUT tokens, applied when
* the caller doesn't set `maxTokens` explicitly (there is no CLI flag/env
* for it today — the config object is the plumbing). Chosen at 32k: 4x the
* heaviest generation observed in a real session (8,000+ tokens on one
* iteration — see the DEFAULT_LLM_TIMEOUT_MS comment above; reasoning models
* emit long tool-call turns by design), so a legitimate multi-file write
* turn never truncates mid-tool-call, while still bounding the worst-case
* cost of a runaway generation. Overridable by passing `maxTokens` in the
* session config.
*/
export const DEFAULT_MAX_TOKENS = 32_768
/**
* Blind tree-walking guardrail (speed workstream, P1.6): after this many
* CONSECUTIVE iterations whose every tool call is a plain read/exploration
* tool (list_files / read_file / search_files) with no codebase_search,
* code-intel, or edit mixed in, the loop injects a one-line user-role nudge
* pointing at codebase_search. Chosen at 8: the whole point is to catch a
* model settling into a blind tree-walk BEFORE it has burned a dozen+ calls
* (which is where the real sessions' exploration ballooning started),
* while staying comfortably above a legitimate 2-5 call local recon burst —
* the nudge must never fire for normal short reads. It is a SOFT nudge
* (reversible, matches the empty-reply pattern): a single productive call
* resets the streak, and it never fires when the workspace has no
* codebase-search index (blind walking is then the only option). Overridable
* via $HEADLESSCODE_READ_ONLY_NUDGE_THRESHOLD.
*/
export const DEFAULT_READ_ONLY_NUDGE_THRESHOLD = 8
/**
* Hard cap on consecutive all-read-only iterations (env
* $HEADLESSCODE_READ_ONLY_STALL_LIMIT). A session doing this many pure
* read/exploration iterations with NO edit, test run, or completion is stuck —
* live round w3 (issue #103, 2026-08-16) burned ~200 grep iterations this way,
* substituting manual verification for the one run_tests call it had planned.
* Terminate it like a consecutive-mistakes bounded failure instead of letting
* it eat the whole iteration budget. Any non-read-only call resets the streak,
* so a legitimately read-heavy task only trips this if it never produces
* anything for a very long time.
*/
export const DEFAULT_READ_ONLY_STALL_LIMIT = 75
/** Tool names that count as "blind tree-walking" for the guardrail above. */
const READ_ONLY_TOOL_NAMES = new Set(["list_files", "read_file", "search_files"])
/**
* Identical-consecutive-call guardrail: a local model was repeatedly
* observed (across several separate sessions/nights — apply_diff repeating
* the exact same failing diff 4x despite a corrective error message,
* ask_followup_question asking the identical generic question 4x in a row,
* list_files calling the exact same path back-to-back tens of times) making
* the SAME tool call with the SAME arguments turn after turn, with no
* different action in between. This is distinct from the read-only-stall
* guardrail above: it fires for ANY tool (not just reads), fires on a
* SUCCESS streak just as readily as an error streak (list_files kept
* succeeding every time in the reproduction that motivated this), and its
* threshold is deliberately tight — a genuine workflow (edit, verify,
* edit again, verify again) always has a DIFFERENT call between two
* verification calls, so this only trips on true immediate repetition, not
* legitimate re-checking. One soft nudge, then a hard stop shortly after —
* unlike the read-only stall limit's 75, there is no legitimate reason for
* this specific pattern to run long before intervening.
*/
export const DEFAULT_IDENTICAL_CALL_NUDGE_THRESHOLD = 2
export const DEFAULT_IDENTICAL_CALL_STALL_LIMIT = 4
/**
* Sampling-level companion to the text nudge above (see
* LlmRequest.repeatPenalty's doc comment): once the streak reaches the
* nudge threshold, the RETRY request itself carries this repeat_penalty
* instead of whatever the backend's own default/persisted value is. Chosen
* as a clear step up from this harness's raw-mode default of 1.15
* (the local daemon's generation-execution support) without being so high
* it degrades otherwise-fluent output — not yet tuned against a live trial,
* treat as a starting point. Ignored entirely by non-Ollama clients
* (OpenRouter has no equivalent per-request knob).
*/
export const DEFAULT_IDENTICAL_CALL_REPEAT_PENALTY_BOOST = 1.3
/**
* How many iterations a tool stays excluded PAST the streak that
* triggered it (see excludedToolCooldowns' doc comment in runIterations).
* Verified live 2026-08-20: without a cooldown, exclusion is a one-shot
* deterrent — the model returns to the same denied tool on its very next
* real action and re-triggers exclusion 2 turns later, an indefinite
* oscillation (48+ cycles observed with no cooldown). Not yet tuned
* against a live trial with the cooldown active — treat as a starting
* point, same as the repeat_penalty boost value above.
*/
export const DEFAULT_IDENTICAL_CALL_TOOL_COOLDOWN_TURNS = 4
/**
* Repeated-tool-failure guardrail (issue #146): the varied-args sibling of
* the identical-call guardrail above. identicalCallStreak only fires when
* retries repeat byte-identical arguments — it does nothing when a model
* keeps retrying the SAME tool with DIFFERENT arguments every time, never
* pausing to re-diagnose why each attempt failed the same way. Live
* round 7 of the 2026-08-21 full-cycle demo: `edit_file` failed 17 times in
* a row against `src/orchestrator/state.ts`, each attempt with a different
* old_string/new_string, before the session ran out of budget —
* identicalCallStreak never trips in that shape. MUST stay strictly below
* DEFAULT_CONSECUTIVE_ERROR_LIMIT (3): the generic consecutive-mistake
* check runs INSIDE the same per-call loop this guardrail's own tracking
* runs after, and returns immediately once it trips — live-verified
* 2026-08-21 (scripts/eval-suite/scenario-146-repeated-tool-failure.sh
* against the real code-daemon): with this threshold also at 3, the
* generic check fired first on the 3rd failure and ended the session
* before this guardrail's post-turn block ever ran, so the specific nudge
* never had a chance to redirect the model. At 2, the nudge is injected
* after the 2nd failure — visible in the request that produces the 3rd
* attempt — giving the model one genuinely-informed try before the
* generic hard stop would otherwise end the session on an uninformed one.
*/
export const DEFAULT_TOOL_FAILURE_NUDGE_THRESHOLD = 2
/**
* Artifact-gate rejection guardrail (issue #152): requireArtifactMinCitations
* and requireArtifactSections check PRESENCE (a citation-shaped regex match
* count, a required substring) — they cannot and do not check that a
* citation actually backs the specific claim it was required to back, or
* that a required section actually contains what was asked for. Live
* evidence 2026-08-21 (issue #152's own transcript): a session rejected 3
* times in a row for missing citations/sections patched in EXACTLY the
* missing surface feature each time (a citation-shaped string, a required
* heading) with ZERO read_file calls between rejections — the retry
* nudge's own "you already have enough, don't read more" guidance
* (deliberately anti-context-bloat, see the requireArtifactMinCitations
* rejection message below) is exactly what let this slide: the model
* never went back to verify the patch was actually true. This guardrail
* detects that specific shape — N consecutive artifact-gate rejections
* with no real read_file call in between — and escalates to an explicit
* warning naming the pattern, rather than repeating the same
* easily-satisfied-by-patching instruction. Same threshold as
* DEFAULT_TOOL_FAILURE_NUDGE_THRESHOLD for consistency; no evidence yet
* that a different value is warranted.
*/
export const DEFAULT_ARTIFACT_REJECTION_NUDGE_THRESHOLD = 2
/** The injected nudge (user-role, seen by the model on its next turn), indexed workspaces. */
const READ_ONLY_NUDGE_MESSAGE =
"You've made several read/exploration calls without using `codebase_search`. If the workspace has an index (it lives in the central project store — see `~/.local/share/headlesscode/projects/`), try `codebase_search` for targeted semantic search instead of blind tree-walking."
/** The injected nudge for unindexed workspaces — no index to point at, so lead with progress. */
const READ_ONLY_PROGRESS_NUDGE_MESSAGE =
"You've made several read/exploration calls without progress. If the next step in your todo list is verification, `run_tests` answers it in one shot — prefer running the project's test suite over more manual greps/reads. Take a progress action now (edit, test run, or completion) instead of further read-only exploration."
/**
* The local daemon's own synthetic fallback text (its
* `fallback_response_for_empty_result`) when the underlying generation
* produced no real content — a diagnostic string for the wire response, NOT
* model-authored text. Same failure family as the narrated-tool-call fix
* (see extractEmbeddedToolCall's doc comment and the c274ddc commit it
* references): if persisted into history verbatim, the model is fed its
* own (fake) prior "reply" on the next turn, and if that turn ALSO
* produces nothing, the two-message pair (this + the standard nudge) gets
* evicted and re-added by truncateHistory in exactly matching batches
* every cycle — the rendered prompt becomes byte-for-byte IDENTICAL
* turn after turn, a stable, self-reinforcing trap. Verified live
* 2026-08-20: diffed the daemon's own rendered prompt across three
* consecutive requests during exactly this pattern — byte-identical.
* Matched by exact string rather than any heuristic since it's a fixed,
* known constant on the daemon side, not model-generated text that could
* coincidentally resemble it.
*/
const DAEMON_EMPTY_REPLY_FALLBACK_TEXTS = [
"The model produced an empty reply for this request. No changes were applied.",
"The model attempted a tool-based response but did not produce a final reply. No changes were applied.",
]
/**
* Extract the first not-done line from a todo checklist (`[ ]` pending or
* `[-]` in-progress — never `[x]` done), for identicalCallNudgeMessage.
* Best-effort: any list shape it doesn't recognize just yields undefined,
* which the nudge message handles by omitting the concrete suggestion
* rather than guessing.
*/
function firstPendingTodoLine(todos: string | undefined): string | undefined {
if (!todos) {
return undefined
}
for (const line of todos.split("\n")) {
const trimmed = line.trim()
if (trimmed.startsWith("[ ]") || trimmed.startsWith("[-]")) {
return trimmed
}
}
return undefined
}
/**
* The injected nudge when the SAME call repeats back-to-back — see
* DEFAULT_IDENTICAL_CALL_NUDGE_THRESHOLD. Verified live 2026-08-21: a
* generic version of this message ("try something different") did not
* change the model's next action across several trials — it called
* list_files a 3rd and 4th time anyway, right past the nudge. When a todo
* list exists, naming the concrete next pending step explicitly is a much
* more specific, harder-to-ignore instruction than "do something else."
*/
function identicalCallNudgeMessage(signature: string, streak: number, nextTodo: string | undefined): string {
const concreteNextStep = nextTodo
? ` Your own todo list's next unfinished step is: ${nextTodo} — do THAT now, not another ${signature} call.`
: ""
return (
`You have called ${signature} ${streak} times in a row with IDENTICAL arguments. Repeating the exact same call ` +
`will not produce a different result. The information from your earlier call is still visible above in this ` +
`conversation — re-read it there instead of calling again.${concreteNextStep} If you are stuck on a ` +
`persistent error, address that error specifically instead of resubmitting the same call or looking around ` +
`the workspace again.`
)
}
/**
* The injected nudge when the SAME TOOL NAME fails repeatedly with VARIED
* arguments (issue #146) — see DEFAULT_TOOL_FAILURE_NUDGE_THRESHOLD's doc
* comment for why this is distinct from identicalCallNudgeMessage above.
* Directive, not generic: names the exact re-diagnosis step (re-read the
* CURRENT real content, quote the exact text to match) rather than a vague
* "try something different" — the same lesson identicalCallNudgeMessage's
* own doc comment already draws from a prior failed generic-nudge attempt.
*
* `readFileAvailable`/`retryToolAvailable` (issue #153): live-verified
* 2026-08-21 (scripts/eval-suite/scenario-146-repeated-tool-failure.sh,
* two separate live runs) that this nudge's own advice — "use read_file"
* and "retry `toolName`" — can each independently collide with the
* SEPARATE identical-call guardrail's tool-exclusion cooldown
* (excludedToolCooldowns in runIterations): first observed with
* `read_file` on cooldown (fixed below), then on a second live run with
* `toolName` itself (e.g. `edit_file`) on cooldown from its own identical
* repeat. A model told to do something it cannot currently do has no good
* move — in both observed trials it fabricated a fake `<tool_call>` text
* block for the unavailable tool rather than a real one, which the harness
* correctly refuses to execute (see the excludedThisTurn check around
* `extractEmbeddedToolCall`'s call site) but still counts as a mistake.
* This message checks BOTH tools independently and adapts to whichever
* combination is actually true, rather than assuming either is available.
*
* Known remaining gap, not fixed here: `excludedToolCooldowns` is only
* populated at the START of the NEXT iteration's request prep (reading
* identicalCallStreak's value as of the end of THIS one) — so if
* identicalCallStreak and this guardrail's own streak both cross their
* threshold on the exact same call, this nudge (fired in that same turn)
* still sees the about-to-be-excluded tool as available one turn early.
* Narrow (requires both guardrails tripping simultaneously) and not
* reproduced in the 2026-08-21 live re-verification after this fix — left
* as a documented limitation rather than adding cross-guardrail lookahead.
*/
function toolFailureNudgeMessage(
toolName: string,
streak: number,
targetLabel: string | undefined,
readFileAvailable: boolean,
retryToolAvailable: boolean,
): string {
const targetHint = targetLabel ? ` on '${targetLabel}'` : ""
const base =
`Your last ${streak} attempts to use ${toolName}${targetHint} all failed, even though the arguments were ` +
`different each time. Varying the arguments and hoping is not working. `
if (readFileAvailable && retryToolAvailable) {
return (
base +
`Before trying again: use read_file to see the file's CURRENT exact content around your target, quote the ` +
`exact text you intend to match, and only THEN retry ${toolName} — do not just adjust the arguments again ` +
`without first confirming what the file actually contains right now.`
)
}
if (!readFileAvailable && retryToolAvailable) {
return (
base +
`read_file is temporarily unavailable right now (cooldown from a recent repeat) — instead, carefully ` +
`re-derive the exact current content from what you already read earlier in this conversation, quote the ` +
`exact text you intend to match, and only THEN retry ${toolName} — do not just adjust the arguments ` +
`again without first confirming what the file actually contains right now.`
)
}
if (readFileAvailable && !retryToolAvailable) {
return (
base +
`${toolName} is temporarily unavailable right now (cooldown from a recent repeat), so retrying it this ` +
`turn will not work no matter what arguments you use. Use read_file now to see the file's CURRENT exact ` +
`content and carefully work out the exact text you'll need to match — you'll be able to retry ${toolName} ` +
`again in a few turns, and having the exact match ready will make that attempt count.`
)
}
return (
base +
`Both read_file and ${toolName} are temporarily unavailable right now (cooldown from recent repeats). Do not ` +
`fabricate a call to either — call a genuinely different tool, or give a plain text status update, and wait ` +
`for them to become available again before retrying.`
)
}
/**
* Artifact-gate rejection guardrail (issue #152) — see
* DEFAULT_ARTIFACT_REJECTION_NUDGE_THRESHOLD's doc comment for the real
* failure shape this addresses.
*/
function artifactRejectionNudgeMessage(streak: number, relativePath: string): string {
return (
`Your last ${streak} attempts to complete this task were rejected for the same reason, and each time you ` +
`edited '${relativePath}' without making any read_file call in between. Adding a citation-shaped string or a ` +
`section heading does NOT satisfy this requirement unless it reflects something real you actually verified — ` +
`a citation with a real line number but a wrong description of what's there, or a section heading with ` +
`placeholder content, is worse than not having it at all. Before your next edit: use read_file on the real ` +
`source file(s) you intend to cite, confirm exactly what's at that line, and only then write content that ` +
`genuinely reflects it. Do not just patch in the missing surface feature again.`
)
}
/**
* Leading command names treated as read-only-equivalent by
* isReadOnlyEquivalentShellCommand — every one of these inspects the
* workspace without changing it. Deliberately an ALLOW-list (default "not
* read-only" for anything unrecognized) rather than a deny-list of mutating
* commands: a shell one-liner is too open-ended to safely enumerate every
* way it could mutate something, so the safe default is to under-count
* (miss a real read-only command occasionally) rather than over-count (wrongly
* treat a mutation as exploration and never nudge). Low stakes either way —
* this only feeds a SOFT, reversible nudge, never a block.
*/
const READ_ONLY_SHELL_LEADING_COMMANDS = new Set([
"grep",
"egrep",
"fgrep",
"rg",
"ag",
"find",
"ls",
"cat",
"head",
"tail",
"wc",
"tree",
"pwd",
"echo",
"printf",
"stat",
"file",
"du",
"basename",
"dirname",
"realpath",
// A no-op — common as a `git status || true`-style fallback to suppress a
// read-only command's non-zero exit (e.g. `git log` in a fresh repo).
"true",
])
/** git/docker subcommands that only inspect state (vs. `git commit`, `docker up`, ...). */
const READ_ONLY_GIT_SUBCOMMANDS = new Set(["log", "diff", "status", "show", "blame", "branch", "rev-parse", "remote"])
const READ_ONLY_DOCKER_SUBCOMMANDS = new Set(["ps", "images", "logs", "inspect"])
/**
* Measured on a live round (issue: harness slowness investigation, 2026-08-08):
* `execute_command` — NOT `read_file`/`list_files` — was the dominant
* exploration tool by a wide margin (e.g. 37-48 execute_command calls per
* session vs. 0-1 codebase_search calls), almost entirely ad hoc
* grep/ls/sed/find one-liners doing the exact job `codebase_search` exists to
* shortcut. The blind-tree-walking guardrail above never saw any of this
* because `execute_command` wasn't in READ_ONLY_TOOL_NAMES — a long streak of
* pure shell-grep exploration could run the entire session without ever
* tripping the nudge. This classifier closes that gap: a `sed -n`/`cat`/`git
* log`/`docker ps`-style command reads exactly like `read_file`/`list_files`
* for this guardrail's purposes and should count the same way. Compound
* commands (`&&`/`;`/`|`/newline-joined) count only if EVERY segment is
* read-only — one mutating segment (a `git commit`, `docker compose up`, a
* bare `>` redirect, `sed -i`) marks the whole command as NOT read-only.
*/
export function isReadOnlyEquivalentShellCommand(command: string): boolean {
// `\|\|` MUST be tried before the bare `\|` alternative below it, or a
// `cmd || true` fallback splits into a stray `true` segment (not on the
// allow-list) and the whole command wrongly reads as mutating.
const segments = command.split(/\|\||&&|;|\||\n/).map((s) => s.trim())
if (segments.length === 0 || segments.every((s) => s === "")) {
return false
}
for (const segment of segments) {
if (segment === "") {
continue
}
// A bare `>`/`>>` writes a file; `2>&1`/`>&2` (fd-to-fd redirects) don't.
if (/(?<!\d)>>?(?!&)/.test(segment)) {
return false
}
const words = segment.split(/\s+/).filter(Boolean)
const head = words[0]
if (head === "cd") {
continue // a leading `cd x &&` is just a working-dir change, not itself an inspection
}
if (head === "sed") {
// `sed -n '...'` (print-only) is read-only; anything with `-i` is not.
if (words.includes("-i") || words.some((w) => w.startsWith("-i"))) {
return false
}
continue
}
if (head === "git") {
if (READ_ONLY_GIT_SUBCOMMANDS.has(words[1] ?? "")) {
continue
}
return false
}
if (head === "docker") {
// `docker compose ps/logs` vs. `docker compose up/down/build`.
const sub = words[1] === "compose" ? words[2] : words[1]
if (READ_ONLY_DOCKER_SUBCOMMANDS.has(sub ?? "")) {
continue
}
return false
}
if (READ_ONLY_SHELL_LEADING_COMMANDS.has(head ?? "")) {
continue
}
return false
}
return true
}
/**
* (S3) Tool names that are safe to run CONCURRENTLY with their siblings in a
* single turn: plain read/exploration tools with no shared mutable state.
* `execute_command` is additionally gated per-call by
* isReadOnlyEquivalentShellCommand (see isParallelReadOnlyCall).
*/
const PARALLEL_READ_ONLY_TOOL_NAMES = new Set([
"read_file",
"list_files",
"search_files",
// TS code-intelligence reads (src/codeintel/) — read-only, executor-side.
"outline",
"go_to_definition",
"find_references",
"import_graph",
])
/**
* (S3) Whether a call may run concurrently with the turn's other read-only
* calls. Only pure reads qualify: the tools above, plus an execute_command
* whose shell command is read-only-equivalent. Everything else — any
* edit-capable call, switch_mode / ask_followup_question / new_task (the
* single decision-marker protocol), or anything ambiguous — is serial.
* A read_file whose path collides with a file an edit call in this turn
* modifies is ALSO serial: the model composed that read against the file's
* state at its position in the turn, so submission order must be preserved
* (a read-then-edit must see pre-edit content; an edit-then-read must see
* post-edit content).
*/
function isParallelReadOnlyCall(
workspaceRoot: string,
call: { name: string; args: Record<string, unknown> },
editedPaths: ReadonlySet<string>,
): boolean {
if (call.name === "execute_command") {
return typeof call.args.command === "string" && isReadOnlyEquivalentShellCommand(call.args.command)
}
if (!PARALLEL_READ_ONLY_TOOL_NAMES.has(call.name)) {
return false
}
if (call.name === "read_file") {
const raw = typeof call.args.path === "string" && call.args.path.trim() !== "" ? call.args.path : undefined
if (raw !== undefined) {
try {
if (editedPaths.has(path.resolve(workspaceRoot, raw))) {
return false
}
} catch {
// Unresolvable path: fall through — a parallel read is harmless.
}
}
}
return true
}
/**
* Same-file multi-edit diagnosis (see the tool-execution loop): when the
* model submits several edit calls to the SAME file in ONE turn, the later
* calls' SEARCH text was typically composed against the file's PRE-first-edit
* content — so they fail with a "diff didn't match" error the model has to
* reason its way out of. The loop tracks which paths earlier calls in the
* batch already modified and appends this diagnosis to any later failure on
* the same path, turning the generic mismatch into a pointer at the fix.
*/
const SAME_FILE_BATCH_DIAGNOSIS =
"\n\n<diagnosis>this file was already edited by an earlier tool call in this same turn — your SEARCH text may have been composed against the file's content BEFORE that edit. Re-read the file to get its current state, or next time include both changes as separate SEARCH/REPLACE blocks in ONE apply_diff call.</diagnosis>"
/** Edit tools whose args name a target file (for the same-file batch diagnosis). */
const BATCH_EDIT_TOOL_NAMES = new Set(["apply_diff", "search_replace", "edit_file", "write_to_file", "set_indentation"])
/**
* Best-effort extraction of the file path an edit tool call targets, for the
* same-file batch diagnosis. `apply_diff`/`write_to_file` use `path`;
* `search_replace`/`edit_file` use `file_path`. Resolved against the
* workspace root so two spellings of the same path collide. Not authoritative
* (the executor's own safeTarget is) — a mis-extraction only skips a
* diagnosis, never blocks anything.
*/
/**
* (T1) Strip reasoning from every assistant history message except the most
* recent one. Native DeepSeek only needs reasoning echoed on the
* IMMEDIATELY-PRECEDING assistant message in an active tool-call chain — older
* echoes are pure token cost on every subsequent request (the whole history is
* re-sent). Tool and user messages are never touched. Mutates `messages` in
* place: the caller pushes the new assistant message FIRST, so "most recent"
* is the message that must keep its reasoning for the next request.
*/
export function stripSupersededReasoning(messages: ChatMessage[]): void {
let lastAssistant = -1
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "assistant") {
lastAssistant = i
break
}
}
for (let i = 0; i < messages.length; i++) {
if (i !== lastAssistant && messages[i].role === "assistant" && messages[i].reasoning !== undefined) {
messages[i].reasoning = undefined
}
}
}
/**
* Best-effort absolute path for ANY tool call's `path`/`file_path` arg —
* factored out of editToolTargetPath (below) so the repeated-tool-failure
* guardrail (issue #146) can recognize a `read_file` call as re-diagnosis
* of the SAME target a different, currently-failing tool is acting on
* (read_file isn't in BATCH_EDIT_TOOL_NAMES, so editToolTargetPath alone
* never resolves it).
*/
function toolCallPathArg(workspaceRoot: string, call: { args: Record<string, unknown> }): string | undefined {
const raw =
typeof call.args.path === "string" && call.args.path.trim() !== ""
? call.args.path
: typeof call.args.file_path === "string" && call.args.file_path.trim() !== ""
? call.args.file_path
: undefined
if (raw === undefined) {
return undefined
}
try {
return path.resolve(workspaceRoot, raw)
} catch {
return undefined
}
}
function editToolTargetPath(workspaceRoot: string, call: { name: string; args: Record<string, unknown> }): string | undefined {
if (!BATCH_EDIT_TOOL_NAMES.has(call.name)) {
return undefined
}
return toolCallPathArg(workspaceRoot, call)
}
/**
* A real file:line-shaped citation — e.g. `src/engine/loop.ts:123` or
* `src/tools/executor.ts:45-67`. Used by requireArtifactMinCitations to
* detect whether a research doc contains grounded evidence, not just
* unverified prose. Deliberately permissive on the file-extension part
* (any non-slash, non-colon run) so it matches citations across
* languages, not just TypeScript.
*/
const CITATION_PATTERN = /\b[\w.-]+(?:\/[\w.-]+)+\.\w+:\d+(?:-\d+)?\b/g
/**
* Verifies a CITATION_PATTERN match's file:line actually exists on disk —
* closes PART of issue #152's gap (a citation-shaped string previously
* counted toward requireArtifactMinCitations even when the cited file
* doesn't exist, or the line number is past the file's real length).
* Does NOT verify the citation's CLAIM matches the real content at that
* location — that needs semantic understanding no deterministic check
* can provide (confirmed live 2026-08-21: a real-line-number-but-
* fabricated-claim citation slipped past both this kind of mechanical
* count AND an LLM judge given only document text — see
* scripts/eval-suite/verify-citations.mjs, the human/Claude-facing tool
* that surfaces the real content for that judgment call instead). This
* only catches a citation pointing at something that doesn't exist at
* all — a real, if partial, improvement: previously ANY citation-shaped
* text counted, even a wholly invented file path.
*/
async function countVerifiedCitations(content: string, workspaceRoot: string): Promise<number> {
const matches = content.match(CITATION_PATTERN) ?? []
let verified = 0
for (const citation of matches) {
const parsed = /^(.+):(\d+)(?:-(\d+))?$/.exec(citation)
if (!parsed) {
continue
}
const [, filePart, startStr, endStr] = parsed
const start = Number(startStr)
const end = endStr ? Number(endStr) : start
try {
const fileContent = await fsp.readFile(path.resolve(workspaceRoot, filePart), "utf-8")
const lineCount = fileContent.split("\n").length
if (start >= 1 && end <= lineCount) {
verified++
}
} catch {
// File doesn't exist, unreadable, or outside the workspace — not
// a real citation regardless of how plausible the text looks.
}
}
return verified
}
/**
* requireArtifactPathPattern's real check (see HeadlessSessionConfig's doc
* comment) — a single-directory glob relative to workspaceRoot, e.g.
* `"plans/research/*.md"`. Checks the REAL filesystem (not tool-call
* bookkeeping): at least one non-empty file in the target directory whose
* name matches the glob. Deliberately one directory level only — this
* exists to verify one specific expected deliverable, not to implement a
* general recursive glob engine. Returns the matched file's citation count
* (via CITATION_PATTERN) so the caller can also enforce
* requireArtifactMinCitations without a second filesystem pass.
*/
async function matchingArtifactFileStatus(
workspaceRoot: string,
pattern: string,
): Promise<{ found: boolean; citationCount: number; relativePath?: string; content?: string }> {
const lastSlash = pattern.lastIndexOf("/")
const dirPart = lastSlash === -1 ? "." : pattern.slice(0, lastSlash)
const namePattern = lastSlash === -1 ? pattern : pattern.slice(lastSlash + 1)
const dirAbs = path.resolve(workspaceRoot, dirPart)
let entries: string[]
try {
entries = await fsp.readdir(dirAbs)
} catch {
return { found: false, citationCount: 0 }
}
for (const entry of entries) {
if (!path.matchesGlob(entry, namePattern)) {
continue
}
try {
const filePath = path.join(dirAbs, entry)
const stat = await fsp.stat(filePath)
if (stat.isFile() && stat.size > 0) {
const content = await fsp.readFile(filePath, "utf-8")
const citationCount = await countVerifiedCitations(content, workspaceRoot)
return { found: true, citationCount, relativePath: path.relative(workspaceRoot, filePath), content }
}
} catch {
// Race with a concurrent delete/rename — treat as not-yet-matched.
}
}
return { found: false, citationCount: 0 }
}
/**
* Commit-before-finishing guardrail: the corrective note pushed as the
* attempt_completion tool RESULT (keeping the assistant-with-tool_calls →
* tool-result adjacency contract) when a worker tries to finish with
* uncommitted tracked changes. One nudge per session — a retried
* attempt_completion is always accepted, since a model may legitimately
* finish with nothing to commit or a deliberate decision to leave work
* uncommitted.
*/
const COMMIT_NUDGE_CONTENT =
"[System: attempt_completion not accepted — the workspace has uncommitted changes: either an existing tracked file was modified, or a new file YOU wrote this session is still untracked. Real, working changes must be committed via `git add` + `git commit` BEFORE finishing: one commit per logical change, with a descriptive message matching this repo's normal style (run `git log --oneline` for examples) — `git add` a brand-new file too, it does not commit itself. Commit your changes, then call attempt_completion again. If you genuinely have a reason to leave changes uncommitted (e.g. a read-only investigation), call attempt_completion again as-is and it will be accepted.]"
const execFileP = promisify(execFile)
/**
* True when the workspace's git repo has uncommitted TRACKED-file changes
* (modified/staged/deleted/renamed — NOT `??` untracked files in general,
* which may be pre-existing scratch work the model shouldn't be forced to
* commit) OR an untracked file that THIS session itself wrote via an edit
* tool (*sessionWrittenPaths* — see the field doc on HeadlessSession for
* why the general untracked-file exemption has a blind spot for a
* session's own new-file deliverables, e.g. "write tests for X" almost
* always produces a brand-new, still-untracked file). False when git fails
* (no repo) or the status is clean. The commit-before-finishing
* guardrail's structural backstop — independent of whether the prompt
* instruction alone is followed.
*/
async function hasUncommittedTrackedChanges(
workspaceRoot: string,
sessionWrittenPaths: ReadonlySet<string>,
): Promise<boolean> {
try {
const { stdout } = await execFileP("git", ["status", "--short"], {
cwd: workspaceRoot,
timeout: 15_000,
})
for (const line of stdout.split("\n")) {
if (line.trim() === "") {
continue
}
// `?? path` = untracked only; every other status ( M, M , A, D,
// R, AM, …) is a tracked-file change.
if (!line.startsWith("??")) {
return true
}
const untrackedPath = line.slice(3).trim()
let resolved: string
try {
resolved = path.resolve(workspaceRoot, untrackedPath)
} catch {
continue
}
if (sessionWrittenPaths.has(resolved)) {
return true
}
}
return false
} catch {
return false // not a git repo or git missing — nothing to enforce
}
}
export interface HeadlessSessionConfig {
workspaceRoot: string
/**
* Explicit session id override (default: a fresh randomUUID). The
* dashboard's session-launch endpoint generates the id first and passes
* it through (via --session-id) so the browser can open the session's
* live event view immediately, before the first event exists.
*/
sessionId?: string
/**
* Recursive task decomposition (`new_task`): the id of the session that
* spawned this one (absent on root sessions). Stamped onto every event
* this session emits and used in checkpoint lineage tags, so a child
* feed/checkpoint trail is self-describing.
*/
parentSessionId?: string
/**
* Recursive task decomposition (`new_task`): this session's recursion
* depth, 0 = root (default). Bumped by the parent's new_task handler;
* a session at `recursionDepth >= maxRecursionDepth` refuses further
* delegation as a normal tool error.
*/
recursionDepth?: number
/**
* Recursive task decomposition (`new_task`): hard cap on how deep a
* single root session may delegate (default DEFAULT_MAX_RECURSION_DEPTH
* = 2; also settable via --max-recursion-depth). A normal tool error,
* never a crash, when a child would exceed it.
*/
maxRecursionDepth?: number
/**
* Recursive task decomposition (`new_task`): default maxIterations for a
* child session as a fraction of the parent's remaining iterations
* (default DEFAULT_CHILD_ITERATION_FRACTION = 0.5; also settable via
* --child-iteration-fraction). See the constant's comment for the
* reasoning.
*/
childIterationFraction?: number
/**
* Recursive task decomposition (`new_task`): floor on a child's default
* maxIterations (default DEFAULT_MIN_CHILD_ITERATIONS = 3) so a parent
* with only a couple of iterations left doesn't spawn a crippled child.
* Never exceeds the parent's remaining iterations.
*/
minChildIterations?: number
/**
* switch_mode (plans/switch-mode-headless.md): OPT-IN auto-approval of
* mode switches (default false — OFF). When set, a switch_mode call
* performs the mode change immediately with no human/orchestrator
* approval escalation (still logged + evented, just not gated). OFF by
* default because the approval gate is the actual security boundary:
* a deliberately restricted mode (e.g. architect, read+md-only) must not
* be able to silently grant itself a broader mode's edit permissions.
* Also settable via --auto-approve-mode-switch /
* $HEADLESSCODE_AUTO_APPROVE_MODE_SWITCH.
*/
autoApproveModeSwitch?: boolean
/**
* switch_mode: hard cap on total in-place mode switches per session
* (default DEFAULT_MAX_MODE_SWITCHES = 5; also settable via
* --max-mode-switches / $HEADLESSCODE_MAX_MODE_SWITCHES). Exceeding it is
* a normal tool error (never a crash), mirroring new_task's recursion
* depth cap.
*/
maxModeSwitches?: number
/** Mode slug, default 'code' (built-in or from .roomodes). */
mode?: string
/** Model id; default env OPENROUTER_MODEL or 'deepseek/deepseek-v4-flash-0731'. */
model?: string
taskText: string
maxIterations?: number
consecutiveErrorLimit?: number
/** Skip prompt building and use this exact system prompt. */
systemPromptOverride?: string
/** The LLM client (inject a fake in tests; OpenRouterClient in prod). */
llmClient: LlmClient
/** Optional executor override (default: createHeadlessExecutor(root)). */
executor?: ToolExecutor
/** Optional preloaded custom modes (default: load from .roomodes). */
customModes?: ModeConfig[]
/** Optional explicit tool list override (default: selectToolsForMode). */