| 94 | */ |
| 95 | maxIterationsOverride?: number; |
| 96 | /** Per-session reasoning effort (set via /effort). undefined = model default. */ |
| 97 | reasoningEffort?: 'low' | 'medium' | 'high'; |
| 98 | } |
| 99 | |
| 100 | /** |
| 101 | * Synthesize tool-result messages for tool_calls that were NEVER executed — e.g. a |
| 102 | * loop detector (read-loop / stuck-loop / error-loop) fired and skipped the batch with |
| 103 | * `continue`. The assistant message carrying those tool_calls was already appended to |
| 104 | * history, so without a matching tool result for each id the conversation is malformed: |
| 105 | * OpenAI-format providers (kimi, DeepSeek, LM Studio, …) reject the next request with |
| 106 | * "an assistant message with 'tool_calls' must be followed by tool messages responding |
| 107 | * to each 'tool_call_id'". (Anthropic tolerates it, which is why it hid until an |
| 108 | * OpenAI-compatible model was used.) Emitting a short skip-marker per id keeps the |
| 109 | * assistant→tool invariant intact so the run can continue. |
| 110 | */ |
| 111 | function skippedToolResults(toolCalls: ToolCall[], reason: string): Message[] { |
| 112 | return toolCalls.map(tc => ({ |
| 113 | role: 'tool' as const, |
| 114 | tool_call_id: tc.id, |
| 115 | name: tc.function.name, |
| 116 | content: `[skipped] ${reason} — this tool call was not executed; do not wait on its result.`, |
| 117 | })); |
| 118 | } |
| 119 | |
| 120 | export class AgentLoop { |
| 121 | private router: ModelRouter; |
| 122 | private registry: ToolRegistry; |
| 123 | private permissions: PermissionEngine; |
| 124 | private config: QodexConfig; |
| 125 | private cwd: string; |
| 126 | /** Working root for tool path-resolution when the user attaches a directory. Sticky across |
| 127 | * turns in a session until a new directory is attached. Falls back to `cwd` when unset. */ |
| 128 | private effectiveCwd?: string; |
| 129 | /** Auto-snapshot service (only set when safety.autoSnapshot is enabled). */ |
| 130 | private snapshotService: SnapshotService | undefined; |
| 131 | /** Active git sandbox for the current run (set by runSandboxed), exposed to tools. */ |
| 132 | private activeSandbox: GitSandbox | null = null; |
| 133 | /** Tracks current turn — used for snapshot retention. */ |
| 134 | private currentTurn = 0; |
| 135 | /** |
| 136 | * Mid-task steering queue. The UI pushes `/btw …` notes here while a run is in |
| 137 | * flight; the run loop drains them at the top of each iteration and injects them |
| 138 | * into the conversation so the model can adjust course without being stopped. |
| 139 | * Single-threaded JS event loop ⇒ no lock needed. |
| 140 | */ |
| 141 | private steerQueue: string[] = []; |
| 142 | /** Session ledger of files the model has demonstrably read (read-before-write gate). */ |
| 143 | private readLedger = new ReadLedger(); |
| 144 | /** Skills already auto-injected this session — never inject the same one twice. */ |
| 145 | private autoInjectedSkills = new Set<string>(); |
| 146 | /** Monotonic union of tool names shipped this session. The relevance gate only ever |
| 147 | * ADDS to this — never drops — so the tools block stays a byte-stable cache prefix |
| 148 | * across turns (a sliding per-turn set would flip and invalidate the prompt cache). */ |
| 149 | private sessionToolNames = new Set<string>(); |
| 150 | /** Where the active model is actually served from ('ollama'/'lmstudio'/'anthropic'/…) and |
| 151 | * its LIVE context window — set each iteration, forwarded on budget_update for the UI. */ |
| 152 | private lastModelSource = ''; |
| 153 | private lastEffectiveCtxWindow = 0; |
nothing calls this directly
no outgoing calls
no test coverage detected