Drop oldest turn groups when context exceeds budget. Preserves tool_call/tool_result coupling.
(messages: Message[], maxTokens: number)
| 2573 | this.planGateSatisfied = true; // good — the model is planning; let it through |
| 2574 | } else if (tc.function.name.startsWith('artifact_')) { |
| 2575 | // Artifact tools produce a standalone deliverable (a page/component the user keeps), |
| 2576 | // not a refactor of the project codebase — the architecture-plan discipline doesn't |
| 2577 | // apply, and gating them just derails the create→preview→review flow. Let them through. |
| 2578 | } else if (!this.planGateSatisfied && !this.planGateFired) { |
| 2579 | this.planGateFired = true; // fire at most once per run |
| 2580 | logger.info('Pre-flight gate: requiring a plan before the first build action', { tool: tc.function.name }); |
| 2581 | uiEvents.push({ type: 'progress', message: '🧭 Architecture gate: plan first, then build' } as any); |
| 2582 | return { content: PREFLIGHT_MESSAGE, isError: true, uiEvents }; |
| 2583 | } |
| 2584 | } |
| 2585 | |
| 2586 | // ─── Read-before-write gate (stateful tool gating) ─── |
| 2587 | // The system prompt's "Read before write" rule, enforced in code instead of prose. |
| 2588 | // A mutating filesystem tool on an EXISTING file is refused unless the file was |
| 2589 | // successfully read this session — and refused if it changed on disk since the |
| 2590 | // last read (stale knowledge). The refusal is a normal tool-result observation, |
| 2591 | // so the model self-corrects by calling read_file. New-file creation always passes. |
| 2592 | // Off-switch: discipline.readBeforeWrite: false |
| 2593 | if (isGatedMutationTool(tc.function.name) && |
| 2594 | (this.config as any)?.discipline?.readBeforeWrite !== false) { |
| 2595 | for (const p of extractMutationPaths(tc.function.name, args)) { |
| 2596 | const absP = path.isAbsolute(p) ? p : path.resolve(this.effectiveCwd ?? this.cwd, p); |
| 2597 | let st: fsSync.Stats | null = null; |
| 2598 | try { st = fsSync.statSync(absP); } catch { st = null; } |
| 2599 | if (!st || !st.isFile()) continue; // new file (or non-file): creation is allowed |
| 2600 | const verdict = this.readLedger.check(absP, st.mtimeMs); |
| 2601 | if (!verdict.ok) { |
| 2602 | const rel = path.relative(this.effectiveCwd ?? this.cwd, absP) || p; |
| 2603 | logger.info('Read-before-write gate refused mutation', { |
| 2604 | tool: tc.function.name, path: rel, kind: verdict.kind, |
| 2605 | }); |
| 2606 | uiEvents.push({ |
| 2607 | type: 'progress', |
| 2608 | message: `🔒 Read-before-write: ${rel} ${verdict.kind === 'unread' ? 'not read yet' : 'changed since last read'} — asking model to read it first`, |
| 2609 | } as any); |
| 2610 | return { content: buildGateMessage(rel, verdict.kind), isError: true, uiEvents }; |
| 2611 | } |
| 2612 | } |
| 2613 | } |
| 2614 | |
| 2615 | // ─── Auto-snapshot: take ONE snapshot per turn, before the first mutating tool ─── |
| 2616 | // Why per-turn instead of per-tool: a single user request often triggers many |
| 2617 | // edits (read → edit_text × 3 → bash test → edit_text × 2). One snapshot at |
| 2618 | // the start of the work captures the pre-change state. /undo rolls back the |
| 2619 | // whole turn — exactly what the user wants if "make the change" went sideways. |
| 2620 | // |
| 2621 | // This is the safety net Hamed asked for: every mutation has a back button. |
| 2622 | if (this.snapshotService && !this.turnSnapshotTaken) { |
| 2623 | const isMutating = tool && !tool.isReadOnly; |
| 2624 | if (isMutating) { |
| 2625 | try { |
| 2626 | const rec = this.snapshotService.takeSnapshot( |
| 2627 | `turn-${this.currentTurn}: before ${tc.function.name}`, |
| 2628 | this.currentTurn, |
| 2629 | ); |
| 2630 | if (rec) { |
| 2631 | this.turnSnapshotTaken = true; |
| 2632 | logger.info('Auto-snapshot taken before mutating tool', { |
no test coverage detected