From f8b6ccce72cfc4a01a8cc9da39147d107dde05ce Mon Sep 17 00:00:00 2001 From: Jessica Mulein Date: Sat, 6 Jun 2026 19:22:46 -0700 Subject: [PATCH] fix(core): implement workspace grounding and exploration abort guards Ground implement/resume turns with lib/test snapshots and flutter test verification; abort ls/repetition/read-range dead ends; default heavy keep_alive to -1 so router models stay loaded between agent LLM calls. Pin cecli dev-integration (ReadRange int marker coerce). Co-authored-by: Cursor --- bright_vision_core/agent_turn.py | 432 +++++++++++++++++- bright_vision_core/http_api.py | 11 +- bright_vision_core/implement_workspace.py | 225 +++++++++ bright_vision_core/model_router.py | 15 +- bright_vision_core/model_router_apply.py | 7 +- bright_vision_core/session.py | 364 +++++++++++++-- bright_vision_core/spec_focus.py | 23 +- bright_vision_core/spec_steering.py | 16 + cecli | 2 +- docs/ROADMAP.md | 3 +- docs/TROUBLESHOOTING.md | 4 +- e2e/helpers/llmEnv.ts | 2 +- src/App.tsx | 12 +- .../settings/ModelRouterSection.tsx | 19 + src/components/todos/TodoPanel.tsx | 32 +- src/hooks/useVisionSession.ts | 36 +- src/ipc/modelRouterLlm.ts | 11 +- src/ipc/visionApi.ts | 8 + src/theme/modelRouterPrefs.test.ts | 33 ++ src/theme/modelRouterPrefs.ts | 33 +- src/todos/formatContext.test.ts | 37 ++ src/todos/formatContext.ts | 48 +- tests/core/test_agent_turn.py | 281 +++++++++++- tests/core/test_implement_workspace.py | 52 +++ tests/core/test_model_router.py | 13 + tests/core/test_session_io_events.py | 16 + tests/core/test_spec_focus.py | 6 + 27 files changed, 1677 insertions(+), 64 deletions(-) create mode 100644 bright_vision_core/implement_workspace.py create mode 100644 tests/core/test_implement_workspace.py diff --git a/bright_vision_core/agent_turn.py b/bright_vision_core/agent_turn.py index a74e498..f7a5c0d 100644 --- a/bright_vision_core/agent_turn.py +++ b/bright_vision_core/agent_turn.py @@ -13,8 +13,20 @@ "agent_continue_after_shell": True, "agent_continue_after_token_limit": True, "agent_continue_after_stall": True, + "implement_continue_after_edit_failure": True, } +_EDIT_TOOL_ERROR_MARKERS = ( + "Error in EditText", + "Error in ContextManager", + "No edits were successfully applied", +) + +EDIT_FAILURE_ABORT_THRESHOLD = 3 +READRANGE_FAILURE_ABORT_THRESHOLD = 2 +LS_EXPLORATION_ABORT_THRESHOLD = 4 +AGENT_EXPLORATION_EMPTY_ABORT_ROUNDS = 4 + _PROSE_SHELL_FENCE = re.compile( r"```(?:bash|sh|shell|zsh|fish)\s*\n.+?```", re.IGNORECASE | re.DOTALL, @@ -27,6 +39,21 @@ _TOKEN_STATS = re.compile(r"^\d+k\s+[↑↓]", re.UNICODE) +# Cecli usage footer: ``14k ↑ 54 ↓ 306k ↑↓`` (input ↑ output ↓ cumulative ↑↓). +_TOKEN_USAGE_LINE = re.compile( + r"^([\d.]+)k\s+↑\s+([\d.]+k?)\s+↓(?:\s+([\d.]+)k\s+↑↓)?$", + re.UNICODE, +) + +# Long /agent loops that process this many tokens in one turn rarely recover in-place. +AGENT_CONTEXT_DEAD_END_CUMULATIVE = 180_000 +AGENT_CONTEXT_DEAD_END_LLM_ROUNDS = 15 +AGENT_CONTEXT_DEAD_END_INPUT_FUDGE = 0.7 +AGENT_CONTEXT_PRESSURE_CUMULATIVE = 200_000 +AGENT_CONTEXT_ABORT_CUMULATIVE = 220_000 + +_READRANGE_FIRST_EDIT_ERROR = "Please call `ReadRange` first" + _SAFE_SHELL_PREFIX = re.compile( r"^(find|ls|tree|pwd|cat|head|tail|wc|file|rg|grep|git\s+(status|log|branch|diff|show)|" r"cargo\s+(metadata|tree|locate-project))\b", @@ -85,6 +112,166 @@ def empty_local_llm_response_in_events(events: list[dict] | tuple) -> bool: return False +def _parse_k_token_count(raw: str) -> int: + text = (raw or "").strip() + if not text: + return 0 + if text.endswith("k"): + return int(float(text[:-1]) * 1000) + return int(float(text)) + + +def parse_token_usage_stat(text: str) -> dict[str, int] | None: + """Parse cecli ``Nk ↑ Mk ↓ …`` usage footer from tool_output.""" + line = (text or "").strip() + match = _TOKEN_USAGE_LINE.match(line) + if not match: + return None + payload: dict[str, int] = { + "input": _parse_k_token_count(f"{match.group(1)}k"), + "output": _parse_k_token_count(match.group(2)), + } + if match.group(3): + payload["cumulative"] = _parse_k_token_count(f"{match.group(3)}k") + return payload + + +def token_usage_stats_from_events(events: list[dict] | tuple) -> list[dict[str, int]]: + stats: list[dict[str, int]] = [] + for event in events: + if event.get("type") != "tool_output": + continue + parsed = parse_token_usage_stat(str(event.get("text") or "")) + if parsed: + stats.append(parsed) + return stats + + +def max_cumulative_tokens_from_events(events: list[dict] | tuple) -> int: + stats = token_usage_stats_from_events(events) + return max((s.get("cumulative", 0) for s in stats), default=0) + + +def llm_round_count_from_events(events: list[dict] | tuple) -> int: + return len(token_usage_stats_from_events(events)) + + +def is_readrange_first_edit_error_event(event: dict) -> bool: + if event.get("type") != "tool_error": + return False + return _READRANGE_FIRST_EDIT_ERROR in str(event.get("text") or "") + + +def is_readrange_tool_error_event(event: dict) -> bool: + """ReadRange execute/format failures (not EditText 'call ReadRange first').""" + if event.get("type") != "tool_error": + return False + text = str(event.get("text") or "") + if is_readrange_first_edit_error_event(event): + return False + markers = ( + "Error in ReadRange", + "read_range.py", + "Errors encountered for", + "Tool Output Error: readrange", + "Invalid Tool JSON", + ) + return any(marker in text for marker in markers) + + +def readrange_failure_abort_warning(*, total: int) -> str: + return ( + f"Stopped this turn after {total} ReadRange failure(s). " + "Use string markers `@000` / `000@` for empty files — not line numbers. " + "Retry with **Implement** on one step after **Clear chat**; do not loop on ReadRange." + ) + + +def should_abort_turn_for_readrange_failures( + *, + total_readrange_failures: int, + edit_failure_continuation: bool, +) -> bool: + if edit_failure_continuation: + return False + return total_readrange_failures >= READRANGE_FAILURE_ABORT_THRESHOLD + + +def agent_turn_context_overloaded( + events: list[dict] | tuple, + *, + cumulative_hint: int = 0, +) -> bool: + """True when an /agent turn processed enough tokens that auto-continue likely loops.""" + cumulative = max(max_cumulative_tokens_from_events(events), cumulative_hint) + return cumulative >= AGENT_CONTEXT_ABORT_CUMULATIVE + + +def agent_context_pressure_warning(*, cumulative: int, rounds: int) -> str: + cum_label = f"{cumulative // 1000}k" if cumulative >= 1000 else str(cumulative) + return ( + f"/agent context pressure: ~{cum_label} tokens processed across ~{rounds} model calls. " + "Finish with **Implement** on one numbered step instead of a long /agent loop. " + "Call **ReadRange** before every **EditText**; consider **Clear chat** if edits keep failing." + ) + + +def agent_context_pressure_abort_warning(*, cumulative: int, rounds: int) -> str: + cum_label = f"{cumulative // 1000}k" if cumulative >= 1000 else str(cumulative) + return ( + f"Stopped /agent: ~{cum_label} tokens across ~{rounds} calls and EditText failed " + "(ReadRange required after prior edits). " + "Check git diff, then **Clear chat** and use **Tasks → Implement** on one step — " + "not another /agent resume." + ) + + +def should_abort_agent_for_context_pressure( + *, + cumulative_tokens: int, + edit_error_event: dict | None, + agent_cmd: bool, + agent_continuation: bool, +) -> bool: + if not agent_cmd or agent_continuation: + return False + if cumulative_tokens < AGENT_CONTEXT_ABORT_CUMULATIVE: + return False + return edit_error_event is not None and is_readrange_first_edit_error_event( + edit_error_event + ) + + +def agent_context_dead_end_in_events( + events: list[dict] | tuple, + *, + model_context_tokens: int | None = 262_144, +) -> bool: + """ + True when a long /agent turn likely exhausted workable context (empty Ollama + + many LLM rounds or high cumulative token processing). + """ + cumulative = max(max_cumulative_tokens_from_events(events), 0) + if cumulative >= AGENT_CONTEXT_ABORT_CUMULATIVE: + return True + if not empty_local_llm_response_in_events(events): + return False + stats = token_usage_stats_from_events(events) + if not stats: + return False + rounds = len(stats) + cumulative = max((s.get("cumulative", 0) for s in stats), default=0) + last_input = stats[-1].get("input", 0) + if rounds >= AGENT_CONTEXT_DEAD_END_LLM_ROUNDS: + return True + if cumulative >= AGENT_CONTEXT_DEAD_END_CUMULATIVE: + return True + if model_context_tokens and model_context_tokens > 0: + if last_input >= int(model_context_tokens * AGENT_CONTEXT_DEAD_END_INPUT_FUDGE): + return True + return False + + def extract_prose_shell_commands(assistant_text: str) -> list[str]: """Pull shell lines from markdown fences in assistant prose.""" commands: list[str] = [] @@ -318,6 +505,60 @@ def agent_had_write_tool_in_events(events: list[dict] | tuple) -> bool: return False +def is_ls_tool_output_event(event: dict) -> bool: + if event.get("type") != "tool_output": + return False + return "Tool Call: Local • ls" in str(event.get("text") or "") + + +def ls_call_count_from_events(events: list[dict] | tuple) -> int: + return sum(1 for event in events if is_ls_tool_output_event(event)) + + +def exploration_ls_abort_warning(*, total: int) -> str: + return ( + f"Stopped this turn after {total} ls call(s) with no file edits. " + "If `lib/` is missing, use **ContextManager** to scaffold — do not ls again. " + "**Clear chat** and **Implement** task **1.1** first, then later steps." + ) + + +def should_abort_turn_for_ls_exploration( + *, + total_ls_calls: int, + had_write: bool, + edit_failure_continuation: bool, + agent_continuation: bool = False, +) -> bool: + if edit_failure_continuation or agent_continuation: + return False + if had_write: + return False + return total_ls_calls >= LS_EXPLORATION_ABORT_THRESHOLD + + +def exploration_repetition_abort_warning() -> str: + return ( + "Stopped this turn: repetition guard fired (repeated ls/ReadRange) with no edits. " + "**Clear chat** and **Implement** one prerequisite step (e.g. **1.1** scaffold `lib/`) — " + "not another resume." + ) + + +def should_abort_turn_for_repetition_guard( + *, + coder: object | None, + events: list[dict] | tuple, + edit_failure_continuation: bool, + agent_continuation: bool = False, +) -> bool: + if edit_failure_continuation or agent_continuation: + return False + if agent_had_write_tool_in_events(events): + return False + return repetition_detected_in_coder(coder) + + def repetition_detected_in_coder(coder: object | None) -> bool: """Cecli injects repetition as a synthetic user message in the agent loop.""" if coder is None: @@ -355,18 +596,98 @@ def agent_turn_stalled( return False +def empty_ollama_exploration_exhausted(events: list[dict] | tuple) -> bool: + """Empty Ollama after several tool rounds with no file edits — auto-continue usually loops.""" + if not empty_local_llm_response_in_events(events): + return False + if agent_had_write_tool_in_events(events): + return False + return llm_round_count_from_events(events) >= AGENT_EXPLORATION_EMPTY_ABORT_ROUNDS + + +def empty_ollama_exploration_blocked_warning() -> str: + return ( + "Skipped auto-continue: Ollama returned empty after exploration (ls/ReadRange) " + "with no edits. If `lib/` is missing, **Implement** task **1.1** (scaffold) first. " + "Otherwise set **Settings → Model router → Heavy keep-alive** to **-1**, " + "**Terminal → Local LLM → Start**, then **Implement** one step." + ) + + def should_auto_continue_after_agent_stall( *, had_tool_call: bool, events: list[dict] | tuple, assistant_text: str, coder: object | None = None, + model_context_tokens: int | None = None, ) -> bool: """Auto-continue when /agent explored but stalled (empty Ollama, repetition, no edits).""" del assistant_text # reserved for future prose-only stall heuristics if not AGENT_TURN_FEATURES.get("agent_continue_after_stall"): return False - return agent_turn_stalled(had_tool_call=had_tool_call, events=events, coder=coder) + if agent_context_dead_end_in_events( + events, + model_context_tokens=model_context_tokens or _model_context_tokens(coder), + ): + return False + if agent_turn_context_overloaded(events): + return False + if not agent_turn_stalled(had_tool_call=had_tool_call, events=events, coder=coder): + return False + if empty_ollama_exploration_exhausted(events): + return False + return True + + +def _model_context_tokens(coder: object | None) -> int | None: + if coder is None: + return None + try: + return int(coder.main_model.info.get("max_input_tokens") or 0) or None + except Exception: + return None + + +def agent_context_dead_end_warning( + *, + events: list[dict] | tuple, + auto_continue_attempted: bool, + model_context_tokens: int | None = None, +) -> str: + del model_context_tokens + stats = token_usage_stats_from_events(events) + rounds = len(stats) + cumulative = max((s.get("cumulative", 0) for s in stats), default=0) + if cumulative >= AGENT_CONTEXT_ABORT_CUMULATIVE and not empty_local_llm_response_in_events( + events + ): + cum_label = f"{cumulative // 1000}k" if cumulative >= 1000 else str(cumulative) + lead = ( + f"/agent context overloaded (~{cum_label} tokens across ~{rounds} model calls). " + "Continuing in the same chat will likely loop or edit the wrong files." + ) + recovery = ( + "Check git diff, **Clear chat** (or `/clear`), then **Tasks → Implement** on " + "**one** numbered step. Avoid another /agent resume in this session." + ) + if auto_continue_attempted: + return f"{lead} Auto-continue already ran. {recovery}" + return f"{lead} {recovery}" + cum_label = f"{cumulative // 1000}k" if cumulative >= 1000 else str(cumulative) + lead = ( + f"/agent hit a context dead end after ~{rounds} model calls" + f"{f' ({cum_label} tokens processed this turn)' if cumulative else ''}. " + "The local model returned empty responses — continuing in the same chat will loop." + ) + recovery = ( + "Use **Tasks → Implement** on **one** numbered step (not /agent), or send a narrow " + "message without exploration. If it persists: chat **Clear** (or `/clear`), then " + "**Stop → Start** for a fresh session." + ) + if auto_continue_attempted: + return f"{lead} Auto-continue already ran once. {recovery}" + return f"{lead} BrightVision will auto-continue once; if it still stops, {recovery}" def agent_continue_after_stall_message() -> str: @@ -422,3 +743,112 @@ def vibe_token_limit_recovery_warning() -> str: "This turn hit a model token limit before finishing. " "Send **continue** with a narrower next step, or use **Clear chat** to free context." ) + + +def edit_tool_failures_in_events(events: list[dict] | tuple) -> list[str]: + """EditText/ContextManager tool_error texts from the turn event ring.""" + failures: list[str] = [] + for event in events: + if is_edit_tool_error_event(event): + failures.append(str(event.get("text") or "").strip()) + return failures + + +def is_edit_tool_error_event(event: dict) -> bool: + if event.get("type") != "tool_error": + return False + text = str(event.get("text") or "").strip() + if not text: + return False + if any(marker in text for marker in _EDIT_TOOL_ERROR_MARKERS): + return True + return "EditText" in text or "ContextManager" in text + + +def is_edit_tool_success_event(event: dict) -> bool: + if event.get("type") != "tool_output": + return False + text = str(event.get("text") or "") + return ( + ("Applied " in text and " edits in " in text) + or "Successfully executed EditText" in text + or ("Created '" in text and "editable" in text) + ) + + +def is_read_range_success_event(event: dict) -> bool: + if event.get("type") != "tool_output": + return False + text = str(event.get("text") or "") + return "Retrieved context for" in text or text.strip().startswith("range_") + + +def should_abort_turn_for_edit_failures( + *, + consecutive_edit_failures: int, + total_edit_failures: int, + agent_cmd: bool, + edit_failure_continuation: bool, +) -> bool: + """Stop a runaway implement turn retrying EditText without ReadRange.""" + if agent_cmd or edit_failure_continuation: + return False + if not AGENT_TURN_FEATURES.get("implement_continue_after_edit_failure"): + return False + return ( + consecutive_edit_failures >= EDIT_FAILURE_ABORT_THRESHOLD + or total_edit_failures >= EDIT_FAILURE_ABORT_THRESHOLD + ) + + +def edit_failure_abort_warning(*, consecutive: int, total: int) -> str: + return ( + f"Stopped this turn after {total} EditText failure(s)" + f"{f' ({consecutive} in a row without a successful read/edit)' if consecutive >= EDIT_FAILURE_ABORT_THRESHOLD else ''}. " + "Run **ReadRange** on the target file (`@000`/`000@`), then **EditText** one file only. " + "Do not mark tasks done in UpdateTodoList until edits succeed." + ) + + +def edit_failure_turn_warning( + *, + events: list[dict] | tuple, + edited_files: list[str] | None = None, +) -> str | None: + """User-facing warning when edit tools failed during the turn.""" + if not edit_tool_failures_in_events(events): + return None + if not edited_files: + return ( + "EditText/ContextManager failed and no files were saved this turn. " + "Run **ReadRange** on the target file (`@000`/`000@` for new or empty files), " + "then **EditText** one file per call. " + "Do not mark tasks done in UpdateTodoList until edits succeed." + ) + return ( + "One or more EditText calls failed this turn (see errors above). " + "Run **ReadRange** before editing; one file per EditText call. " + "Do not mark implementation tasks done until the failed edit succeeds." + ) + + +def should_auto_continue_after_edit_failure( + *, + events: list[dict] | tuple, + agent_cmd: bool, + edit_failure_continuation: bool, +) -> bool: + """One-shot auto-continue for implement/spec-focus turns after EditText failure.""" + if not AGENT_TURN_FEATURES.get("implement_continue_after_edit_failure"): + return False + if agent_cmd or edit_failure_continuation: + return False + return bool(edit_tool_failures_in_events(events)) + + +def edit_failure_continue_message() -> str: + return ( + "The last EditText failed. Call **ReadRange** on the target file first " + "(`@000`/`000@` for new or empty files), then **EditText** exactly one file. " + "Do not update UpdateTodoList until the edit succeeds." + ) diff --git a/bright_vision_core/http_api.py b/bright_vision_core/http_api.py index 5f80a65..54825d4 100644 --- a/bright_vision_core/http_api.py +++ b/bright_vision_core/http_api.py @@ -22,7 +22,7 @@ from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request @@ -125,9 +125,16 @@ class ModelRouterRequest(BaseModel): token_fast_max: int = 4_096 token_heavy_min: int = 12_000 keep_alive_fast: int | str = 300 - keep_alive_heavy: int | str = 0 + keep_alive_heavy: int | str = -1 escalate_on_failure: bool = True + @field_validator("keep_alive_heavy", mode="before") + @classmethod + def _normalize_heavy_keep_alive(cls, value: int | str | None) -> int | str: + from bright_vision_core.model_router import normalize_keep_alive_for_tier + + return normalize_keep_alive_for_tier("heavy", -1 if value is None else value) + class CreateSessionRequest(BaseModel): workspace: str = Field(..., description="Absolute path to git workspace root") diff --git a/bright_vision_core/implement_workspace.py b/bright_vision_core/implement_workspace.py new file mode 100644 index 0000000..4353e7f --- /dev/null +++ b/bright_vision_core/implement_workspace.py @@ -0,0 +1,225 @@ +"""Ground spec-focus implement turns in on-disk workspace facts (avoid ls loops).""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path + +from bright_vision_core.spec_steering import workspace_lib_missing +from bright_vision_core.workspace_todos import ChecklistItem + +_PATH_IN_CHECKLIST = re.compile( + r"(?:`((?:lib|test)/[\w./-]+)`|((?:lib|test)/[\w./-]+))", + re.IGNORECASE, +) + +_SNAPSHOT_DIRS = ("lib", "test") +_MAX_LIST_FILES = 24 + + +def list_workspace_test_files(workspace: str | Path, *, limit: int = _MAX_LIST_FILES) -> list[str]: + return _list_tree_files(Path(workspace).resolve(), "test", limit=limit) + + +def _list_tree_files(root: Path, subdir: str, *, limit: int = _MAX_LIST_FILES) -> list[str]: + base = root / subdir + if not base.is_dir(): + return [] + out: list[str] = [] + for path in sorted(base.rglob("*")): + if not path.is_file(): + continue + if path.name.startswith("."): + continue + rel = path.relative_to(root).as_posix() + out.append(rel) + if len(out) >= limit: + break + return out + + +def paths_from_checklist_text(text: str) -> list[str]: + found: list[str] = [] + seen: set[str] = set() + for match in _PATH_IN_CHECKLIST.finditer(text or ""): + raw = (match.group(1) or match.group(2) or "").strip().rstrip("/") + if not raw or raw in seen: + continue + seen.add(raw) + found.append(raw) + return found + + +def deliverable_paths_exist(workspace: str | Path, paths: list[str]) -> bool: + """True when every path is an existing file or non-empty directory.""" + root = Path(workspace).resolve() + if not paths: + return False + for rel in paths: + target = root / rel + if target.is_file(): + continue + if target.is_dir() and any(target.iterdir()): + continue + return False + return True + + +def first_open_checklist_item(checklist: list[ChecklistItem]) -> ChecklistItem | None: + for entry in checklist: + if not entry.done and entry.text.strip(): + return entry + return None + + +def build_workspace_snapshot_lines(workspace: str | Path) -> list[str]: + root = Path(workspace).resolve() + lines = ["## Workspace snapshot (verified on disk — do **not** ls to rediscover)"] + pubspec = root / "pubspec.yaml" + if pubspec.is_file(): + lines.append("- `pubspec.yaml` — present") + else: + lines.append("- `pubspec.yaml` — **missing**") + + for sub in _SNAPSHOT_DIRS: + files = _list_tree_files(root, sub) + if not files: + lines.append(f"- `{sub}/` — **empty or missing**") + continue + preview = ", ".join(f"`{f}`" for f in files[:8]) + extra = f" (+{len(files) - 8} more)" if len(files) > 8 else "" + lines.append(f"- `{sub}/` — {len(files)} file(s): {preview}{extra}") + return lines + + +def build_implement_next_action_lines( + workspace: str | Path, + checklist: list[ChecklistItem], + *, + resume: bool, +) -> list[str]: + lines = ["## Next action (this turn)"] + focus = first_open_checklist_item(checklist) + if focus is None: + lines.append( + "All checklist items are marked done. Run project tests if applicable, " + "then update the task status — **no ls/Grep exploration**." + ) + return lines + + paths = paths_from_checklist_text(focus.text) + lower = focus.text.lower() + on_disk = deliverable_paths_exist(workspace, paths) if paths else False + test_files = _list_tree_files(Path(workspace).resolve(), "test") + + if ("test" in lower or "verify" in lower) and test_files: + target = next((f for f in test_files if "test" in f), test_files[0]) + lines.append( + f"Focus checklist: **{focus.text.strip()}** — test file(s) already on disk." + ) + lines.append( + f"1. **ReadRange** `{target}` with `@000` / `000@` once\n" + f"2. **EditText** only if tests need fixes\n" + f"3. BrightVision runs **`flutter test`** at end of this turn\n" + f"4. Mark checklist item done after tests pass\n" + f"**Do not** call ls, Grep, GitStatus, or repeat ReadRange on the same file." + ) + elif on_disk and ("test" in lower or "verify" in lower): + test_files = _list_tree_files(Path(workspace).resolve(), "test") + target = next((f for f in test_files if "test" in f), test_files[0] if test_files else None) + if target: + lines.append( + f"Focus checklist: **{focus.text.strip()}** — deliverable files already exist." + ) + lines.append( + f"1. **ReadRange** `{target}` with `@000` / `000@` once\n" + f"2. **EditText** only if tests need fixes\n" + f"3. BrightVision will run **`flutter test`** when this turn edits test files\n" + f"4. Mark checklist item done after tests pass\n" + f"**Do not** call ls, Grep, GitStatus, or repeat ReadRange on the same file." + ) + else: + lines.append( + f"Focus: **{focus.text.strip()}** — create tests with **ContextManager** + " + "**ReadRange** + **EditText** (one file). **No ls.**" + ) + elif on_disk: + target = paths[0] if paths else "lib/" + lines.append( + f"Focus checklist: **{focus.text.strip()}** — paths exist on disk (`{target}`)." + ) + lines.append( + f"**ReadRange** the target source file, then **EditText** to finish. **No ls.**" + ) + elif workspace_lib_missing(workspace): + lines.append(f"Focus checklist: **{focus.text.strip()}**") + lines.append( + "**ContextManager** to scaffold `lib/` (and `test/` if needed), then **ReadRange** + " + "**EditText** on one file. **Do not ls** empty directories." + ) + elif resume: + lines.append(f"Focus checklist: **{focus.text.strip()}**") + lines.append( + "Use **ReadRange** + **EditText** on **one file** for this item. " + "**Do not** ls, Grep, or GitStatus — use the workspace snapshot above." + ) + else: + lines.append(f"Focus checklist: **{focus.text.strip()}**") + lines.append( + "Work this item only: **ContextManager** / **ReadRange** / **EditText**. **No ls.**" + ) + return lines + + +def build_implement_workspace_block( + workspace: str | Path, + checklist: list[ChecklistItem] | None, + *, + resume: bool, +) -> str: + """Markdown block injected on implement / resume turns.""" + parts = build_workspace_snapshot_lines(workspace) + if checklist: + parts.append("") + parts.extend(build_implement_next_action_lines(workspace, checklist, resume=resume)) + parts.append("") + parts.append( + "**Hard rule:** Do not batch UpdateTodoList JSON with other tool args. " + "One tool per call. Do not call **ls** when this snapshot is present." + ) + return "\n".join(parts) + + +def edited_dart_test_files(edited_files: list[str]) -> list[str]: + out: list[str] = [] + for raw in edited_files: + rel = raw.replace("\\", "/").lstrip("./") + if rel.startswith("test/") and rel.endswith("_test.dart"): + out.append(rel) + return out + + +def run_flutter_tests(workspace: str | Path, test_paths: list[str]) -> tuple[bool, str]: + """Run ``flutter test`` on specific files; return (passed, combined output).""" + root = Path(workspace).resolve() + if not (root / "pubspec.yaml").is_file(): + return False, "pubspec.yaml missing — cannot run flutter test" + if not test_paths: + return False, "no test paths" + cmd = ["flutter", "test", *test_paths] + try: + proc = subprocess.run( + cmd, + cwd=str(root), + capture_output=True, + text=True, + timeout=300, + ) + except subprocess.TimeoutExpired: + return False, "flutter test timed out after 300s" + except FileNotFoundError: + return False, "flutter not found on PATH" + out = (proc.stdout or "") + (proc.stderr or "") + tail = out.strip()[-4000:] if out.strip() else "(no output)" + return proc.returncode == 0, tail diff --git a/bright_vision_core/model_router.py b/bright_vision_core/model_router.py index 2f04b65..b1f27da 100644 --- a/bright_vision_core/model_router.py +++ b/bright_vision_core/model_router.py @@ -15,6 +15,12 @@ RouteTier = Literal["fast", "heavy"] +# Heavy tier keep_alive=0 unloads Ollama after every LLM call — agent loops get empty responses. +def normalize_keep_alive_for_tier(tier: RouteTier, value: int | str) -> int | str: + if tier == "heavy" and value in (0, "0"): + return -1 + return value + # Per-file context bump for *display* only (routing uses message_tokens). _FILE_TOKEN_PER_FILE = 500 _FILE_TOKEN_CAP = 2_000 @@ -94,9 +100,12 @@ class ModelRouterConfig: token_fast_max: int = 4_096 token_heavy_min: int = 12_000 keep_alive_fast: int | str = 300 - keep_alive_heavy: int | str = 0 + keep_alive_heavy: int | str = -1 escalate_on_failure: bool = True + def __post_init__(self) -> None: + self.keep_alive_heavy = normalize_keep_alive_for_tier("heavy", self.keep_alive_heavy) + @classmethod def from_payload(cls, raw: dict[str, Any] | None) -> ModelRouterConfig | None: if not raw: @@ -142,7 +151,9 @@ def from_payload(cls, raw: dict[str, Any] | None) -> ModelRouterConfig | None: token_fast_max=int(raw.get("token_fast_max") or 4_096), token_heavy_min=int(raw.get("token_heavy_min") or 12_000), keep_alive_fast=raw.get("keep_alive_fast", 300), - keep_alive_heavy=raw.get("keep_alive_heavy", 0), + keep_alive_heavy=normalize_keep_alive_for_tier( + "heavy", raw.get("keep_alive_heavy", -1) + ), escalate_on_failure=bool(raw.get("escalate_on_failure", True)), ) diff --git a/bright_vision_core/model_router_apply.py b/bright_vision_core/model_router_apply.py index 29fc491..fd4c813 100644 --- a/bright_vision_core/model_router_apply.py +++ b/bright_vision_core/model_router_apply.py @@ -4,7 +4,7 @@ from cecli import models -from bright_vision_core.model_router import ModelRouterConfig, RouteDecision +from bright_vision_core.model_router import ModelRouterConfig, RouteDecision, normalize_keep_alive_for_tier def apply_route_to_coder(coder, decision: RouteDecision, router: ModelRouterConfig) -> None: @@ -13,8 +13,9 @@ def apply_route_to_coder(coder, decision: RouteDecision, router: ModelRouterConf new_model = models.Model(decision.model_name, from_model=prev) if new_model.is_ollama(): new_model._ensure_extra_params_dict() - keep_alive = ( - router.keep_alive_fast if decision.tier == "fast" else router.keep_alive_heavy + keep_alive = normalize_keep_alive_for_tier( + decision.tier, + router.keep_alive_fast if decision.tier == "fast" else router.keep_alive_heavy, ) new_model.extra_params["keep_alive"] = keep_alive coder.main_model = new_model diff --git a/bright_vision_core/session.py b/bright_vision_core/session.py index 41ac636..2173d45 100644 --- a/bright_vision_core/session.py +++ b/bright_vision_core/session.py @@ -290,9 +290,12 @@ def create( router_cfg.heavy_model = model_name main_model = models.Model(model_name) - if main_model.is_ollama() and not (router_cfg and router_cfg.enabled): + if main_model.is_ollama(): main_model._ensure_extra_params_dict() - main_model.extra_params.setdefault("keep_alive", -1) + keep_alive = -1 + if router_cfg and router_cfg.enabled: + keep_alive = router_cfg.keep_alive_heavy + main_model.extra_params.setdefault("keep_alive", keep_alive) fnames = [str(Path(f).resolve()) for f in (files or [])] @@ -319,6 +322,8 @@ def create( auto_load=auto_load, auto_save_session_name=auto_save_session_name, ) + max_input = int(main_model.info.get("max_input_tokens") or 0) + compaction_max = int(max_input * 0.65) if max_input > 0 else 65_536 coder = run( Coder.create( main_model=main_model, @@ -333,6 +338,8 @@ def create( commands=commands, use_git=repo is not None, args=headless_args, + enable_context_compaction=True, + context_compaction_max_tokens=compaction_max, ) ) commands.coder = coder @@ -388,6 +395,16 @@ def _emit_model_route( payload["swapped"] = swapped return self.io.emit("model_route", **payload) + def _yield_model_route( + self, + decision: RouteDecision, + *, + escalated: bool = False, + ) -> Iterator[dict[str, Any]]: + """Emit one model_route SSE event (emit queues; do not also yield emit return).""" + self._emit_model_route(decision, escalated=escalated) + yield from self.io.drain_events() + def _route_and_apply( self, user_message: str, @@ -427,6 +444,7 @@ def run_message( force_tier: str | None = None, escalate_from_last: bool = False, agent_continuation: bool = False, + edit_failure_continuation: bool = False, ) -> Iterator[dict[str, Any]]: user_text = maybe_append_roadmap_hint(message, self.coder) focus_requested = spec_focus_requested( @@ -467,18 +485,135 @@ def run_message( assistant_text: list[str] = [] turn_had_tool_activity = False turn_had_tool_call = False + turn_context_state = { + "warned": False, + "cumulative": 0, + "rounds": 0, + "aborted": False, + "readrange_errors": 0, + "ls_calls": 0, + "exploration_aborted": False, + } def _track_tool_activity(event: dict[str, Any]) -> None: nonlocal turn_had_tool_activity, turn_had_tool_call from bright_vision_core.agent_turn import ( + AGENT_CONTEXT_PRESSURE_CUMULATIVE, + agent_context_pressure_abort_warning, + agent_context_pressure_warning, + agent_had_write_tool_in_events, + exploration_ls_abort_warning, + exploration_repetition_abort_warning, is_agent_tool_activity_event, + is_ls_tool_output_event, + is_readrange_first_edit_error_event, + is_readrange_tool_error_event, is_tool_activity_event, + parse_token_usage_stat, + readrange_failure_abort_warning, + should_abort_agent_for_context_pressure, + should_abort_turn_for_ls_exploration, + should_abort_turn_for_readrange_failures, + should_abort_turn_for_repetition_guard, ) if is_agent_tool_activity_event(event): turn_had_tool_call = True if is_tool_activity_event(event): turn_had_tool_activity = True + if turn_context_state["aborted"]: + return + if event.get("type") == "tool_output": + stat = parse_token_usage_stat(str(event.get("text") or "")) + if stat: + turn_context_state["rounds"] += 1 + cumulative = stat.get("cumulative", 0) + if cumulative: + turn_context_state["cumulative"] = max( + turn_context_state["cumulative"], cumulative + ) + if ( + agent_cmd + and not agent_continuation + and not turn_context_state["warned"] + and turn_context_state["cumulative"] + >= AGENT_CONTEXT_PRESSURE_CUMULATIVE + ): + turn_context_state["warned"] = True + self.io.tool_warning( + agent_context_pressure_warning( + cumulative=turn_context_state["cumulative"], + rounds=turn_context_state["rounds"], + ) + ) + if ( + agent_cmd + and not agent_continuation + and event.get("type") == "tool_error" + and is_readrange_first_edit_error_event(event) + and should_abort_agent_for_context_pressure( + cumulative_tokens=turn_context_state["cumulative"], + edit_error_event=event, + agent_cmd=agent_cmd, + agent_continuation=agent_continuation, + ) + ): + turn_context_state["aborted"] = True + turn_context_state["exploration_aborted"] = True + self.io.tool_warning( + agent_context_pressure_abort_warning( + cumulative=turn_context_state["cumulative"], + rounds=turn_context_state["rounds"], + ) + ) + self.interrupt_turn() + if event.get("type") == "tool_error" and is_readrange_tool_error_event(event): + turn_context_state["readrange_errors"] += 1 + if ( + not turn_context_state["aborted"] + and should_abort_turn_for_readrange_failures( + total_readrange_failures=turn_context_state["readrange_errors"], + edit_failure_continuation=edit_failure_continuation, + ) + ): + turn_context_state["aborted"] = True + turn_context_state["exploration_aborted"] = True + self.io.tool_warning( + readrange_failure_abort_warning( + total=turn_context_state["readrange_errors"], + ) + ) + self.interrupt_turn() + if event.get("type") == "tool_output" and is_ls_tool_output_event(event): + turn_context_state["ls_calls"] += 1 + ring = list(getattr(self.io, "debug_event_ring", []) or []) + if ( + not turn_context_state["aborted"] + and should_abort_turn_for_ls_exploration( + total_ls_calls=turn_context_state["ls_calls"], + had_write=agent_had_write_tool_in_events(ring), + edit_failure_continuation=edit_failure_continuation, + agent_continuation=agent_continuation, + ) + ): + turn_context_state["aborted"] = True + turn_context_state["exploration_aborted"] = True + self.io.tool_warning( + exploration_ls_abort_warning(total=turn_context_state["ls_calls"]) + ) + self.interrupt_turn() + if not turn_context_state["aborted"] and is_agent_tool_activity_event(event): + ring = list(getattr(self.io, "debug_event_ring", []) or []) + if should_abort_turn_for_repetition_guard( + coder=self.coder, + events=ring, + edit_failure_continuation=edit_failure_continuation, + agent_continuation=agent_continuation, + ): + turn_context_state["aborted"] = True + turn_context_state["exploration_aborted"] = True + self.io.tool_warning(exploration_repetition_abort_warning()) + self.interrupt_turn() def _run_agent_continuation(continue_message: str, status: str) -> Iterator[dict[str, Any]]: if not agent_cmd or agent_continuation: @@ -496,6 +631,26 @@ def _run_agent_continuation(continue_message: str, status: str) -> Iterator[dict ): yield event + def _run_edit_failure_continuation() -> Iterator[dict[str, Any]]: + if edit_failure_continuation: + return + from bright_vision_core.agent_turn import edit_failure_continue_message + + yield self.io.tool_output( + "EditText failed — auto-continuing once with ReadRange guidance…" + ) + for event in self.run_message( + edit_failure_continue_message(), + preproc=False, + skip_workspace_init=True, + active_todo_id=turn_todo_id, + inject_todo_spec=False, + spec_focus=focus_requested, + force_tier=effective_force_tier, + edit_failure_continuation=True, + ): + yield event + def _maybe_continue_agent_after_shell() -> Iterator[dict[str, Any]]: from bright_vision_core.agent_turn import agent_continue_after_shell_message @@ -520,6 +675,41 @@ def _maybe_continue_agent_after_stall() -> Iterator[dict[str, Any]]: "Continuing /agent after stalled exploration (empty model / repetition)…", ) + def _maybe_verify_implement_tests() -> Iterator[dict[str, Any]]: + from bright_vision_core.implement_workspace import ( + edited_dart_test_files, + first_open_checklist_item, + list_workspace_test_files, + run_flutter_tests, + ) + from bright_vision_core.spec_focus import is_implement_turn_message + + if not is_implement_turn_message(message): + return + edited = _edited_files(self.coder) + tests = edited_dart_test_files(edited) + if not tests and item is not None and item.checklist: + focus = first_open_checklist_item(item.checklist) + if focus and "test" in focus.text.lower(): + tests = [ + f + for f in list_workspace_test_files(self.coder.root) + if f.endswith("_test.dart") + ][:4] + if not tests: + return + ok, output = run_flutter_tests(self.coder.root, tests) + header = "✅ flutter test passed" if ok else "❌ flutter test failed" + yield self.io.tool_output(f"{header} ({', '.join(tests)}):\n{output}") + if ok: + yield self.io.tool_warning( + "Tests passed — mark the checklist item **done** in Tasks if this step is complete." + ) + else: + yield self.io.tool_warning( + "Fix failing tests with **ReadRange** + **EditText** on one file — do not ls or resume." + ) + def _maybe_warn_agent_shell_stop() -> Iterator[dict[str, Any]]: if not agent_cmd or agent_continuation: return @@ -607,11 +797,15 @@ def _finalize_agent_preproc_turn() -> Iterator[dict[str, Any]]: ) yield from _maybe_recover_prose_shell() from bright_vision_core.agent_turn import ( + agent_context_dead_end_in_events, + agent_context_dead_end_warning, agent_stall_recovery_warning, agent_token_limit_recovery_warning, agent_turn_stalled, empty_local_llm_response_in_events, empty_ollama_auto_continue_blocked_warning, + empty_ollama_exploration_blocked_warning, + empty_ollama_exploration_exhausted, is_agent_shell_only_stop, should_auto_continue_after_agent_stall, should_auto_continue_after_shell, @@ -623,7 +817,11 @@ def _finalize_agent_preproc_turn() -> Iterator[dict[str, Any]]: ring = list(getattr(self.io, "debug_event_ring", []) or []) blob = _assistant_text_blob() - if not agent_continuation: + model_ctx = int(self.coder.main_model.info.get("max_input_tokens") or 0) or None + context_dead_end = agent_context_dead_end_in_events( + ring, model_context_tokens=model_ctx + ) + if not agent_continuation and not turn_context_state["exploration_aborted"]: if should_auto_continue_after_shell( had_tool_activity=turn_had_tool_activity, had_tool_call=turn_had_tool_call, @@ -639,6 +837,7 @@ def _finalize_agent_preproc_turn() -> Iterator[dict[str, Any]]: events=ring, assistant_text=blob, coder=self.coder, + model_context_tokens=model_ctx, ): yield from _maybe_continue_agent_after_stall() return @@ -657,24 +856,59 @@ def _finalize_agent_preproc_turn() -> Iterator[dict[str, Any]]: yield self.io.tool_warning( agent_token_limit_recovery_warning(auto_continue_attempted=False) ) + elif empty_ollama_exploration_exhausted(ring): + yield self.io.tool_warning(empty_ollama_exploration_blocked_warning()) + elif turn_context_state.get("exploration_aborted"): + pass # warning already emitted during the turn elif agent_turn_stalled( had_tool_call=turn_had_tool_call, events=ring, coder=self.coder, ): - yield self.io.tool_warning(agent_stall_recovery_warning(auto_continue_attempted=False)) + if context_dead_end: + yield self.io.tool_warning( + agent_context_dead_end_warning( + events=ring, + auto_continue_attempted=False, + model_context_tokens=model_ctx, + ) + ) + else: + yield self.io.tool_warning( + agent_stall_recovery_warning(auto_continue_attempted=False) + ) elif token_limit_exhausted(events=ring, assistant_text=blob): yield self.io.tool_warning( agent_token_limit_recovery_warning(auto_continue_attempted=True) ) + elif turn_context_state.get("exploration_aborted"): + pass elif agent_turn_stalled( had_tool_call=turn_had_tool_call, events=ring, coder=self.coder, ): - yield self.io.tool_warning(agent_stall_recovery_warning(auto_continue_attempted=True)) + if context_dead_end: + yield self.io.tool_warning( + agent_context_dead_end_warning( + events=ring, + auto_continue_attempted=True, + model_context_tokens=model_ctx, + ) + ) + else: + yield self.io.tool_warning( + agent_stall_recovery_warning(auto_continue_attempted=True) + ) yield from _maybe_warn_incomplete_agent() yield from _maybe_warn_agent_shell_stop() + yield from _maybe_verify_implement_tests() + ring = list(getattr(self.io, "debug_event_ring", []) or []) + from bright_vision_core.agent_turn import edit_failure_turn_warning + + msg = edit_failure_turn_warning(events=ring, edited_files=_edited_files(self.coder)) + if msg: + yield self.io.tool_warning(msg) self.sync_agent_todos_with_workspace() yield self.io.emit( "done", @@ -731,9 +965,7 @@ def _restore_agent_preproc_io() -> None: message, intent_message=message, force_tier="heavy" ) if pre_route: - yield self._emit_model_route(pre_route) - for event in self.io.drain_events(): - yield event + yield from self._yield_model_route(pre_route) emit_progress(self.io, label="Vision", message="Running slash commands…") yield from _drain_io_events(self.io) @@ -779,8 +1011,9 @@ async def _preproc_coro(): on_event=_track_tool_activity, ): if isinstance(item, dict): - _track_tool_activity(item) yield item + if turn_context_state["aborted"]: + break else: user_msg = item except TimeoutError as err: @@ -843,6 +1076,17 @@ async def _preproc_coro(): _restore_agent_preproc_io() return + if turn_context_state["aborted"]: + yield from _drain_io_events( + self.io, + mirror_assistant_complete=True, + assistant_text=assistant_text, + on_event=_track_tool_activity, + ) + yield from _finalize_agent_preproc_turn() + _restore_agent_preproc_io() + return + if user_msg is None or agent_cmd: yield from _finalize_agent_preproc_turn() _restore_agent_preproc_io() @@ -857,29 +1101,81 @@ async def _preproc_coro(): user_msg, intent_message=message, force_tier="heavy" ) if route_decision: - yield self._emit_model_route(route_decision, escalated=True) - for event in self.io.drain_events(): - yield event + yield from self._yield_model_route(route_decision, escalated=True) elif self._model_router and self._model_router.enabled: route_decision = self._route_and_apply( user_msg, intent_message=message, force_tier=effective_force_tier ) if route_decision: - yield self._emit_model_route(route_decision) - for event in self.io.drain_events(): - yield event + yield from self._yield_model_route(route_decision) turn_had_tool_error = False turn_tool_error_text = "" + consecutive_edit_failures = 0 + total_edit_failures = 0 + total_readrange_failures = 0 + edit_failure_aborted = False max_attempts = 2 if self._model_router and self._model_router.escalate_on_failure else 1 + + def _yield_turn_event(event: dict[str, Any]) -> Iterator[dict[str, Any]]: + nonlocal turn_had_tool_error, turn_tool_error_text + nonlocal consecutive_edit_failures, total_edit_failures, edit_failure_aborted + nonlocal total_readrange_failures + from bright_vision_core.agent_turn import ( + edit_failure_abort_warning, + is_edit_tool_error_event, + is_edit_tool_success_event, + is_read_range_success_event, + is_readrange_tool_error_event, + readrange_failure_abort_warning, + should_abort_turn_for_edit_failures, + should_abort_turn_for_readrange_failures, + ) + + if event.get("type") == "tool_error": + turn_had_tool_error = True + turn_tool_error_text += str(event.get("text") or "") + if is_edit_tool_error_event(event): + consecutive_edit_failures += 1 + total_edit_failures += 1 + elif is_readrange_tool_error_event(event): + total_readrange_failures += 1 + elif is_edit_tool_success_event(event) or is_read_range_success_event(event): + consecutive_edit_failures = 0 + yield event + if edit_failure_aborted: + return + if should_abort_turn_for_readrange_failures( + total_readrange_failures=total_readrange_failures, + edit_failure_continuation=edit_failure_continuation, + ): + edit_failure_aborted = True + yield self.io.tool_warning( + readrange_failure_abort_warning(total=total_readrange_failures) + ) + self.interrupt_turn() + return + if should_abort_turn_for_edit_failures( + consecutive_edit_failures=consecutive_edit_failures, + total_edit_failures=total_edit_failures, + agent_cmd=agent_cmd, + edit_failure_continuation=edit_failure_continuation, + ): + edit_failure_aborted = True + yield self.io.tool_warning( + edit_failure_abort_warning( + consecutive=consecutive_edit_failures, + total=total_edit_failures, + ) + ) + self.interrupt_turn() + for attempt in range(max_attempts): if attempt > 0 and route_decision: route_decision = self._route_and_apply( user_msg, intent_message=message, force_tier="heavy" ) - yield self._emit_model_route(route_decision, escalated=True) - for event in self.io.drain_events(): - yield event + yield from self._yield_model_route(route_decision, escalated=True) wait_initial, wait_heartbeat = llm_wait_messages(self.coder.main_model) emit_progress(self.io, label="LLM", message=wait_initial) @@ -895,10 +1191,11 @@ async def _preproc_coro(): message=wait_heartbeat, ): for event in self.io.drain_events(): - if event.get("type") == "tool_error": - turn_had_tool_error = True - turn_tool_error_text += str(event.get("text") or "") - yield event + yield from _yield_turn_event(event) + if edit_failure_aborted: + break + if edit_failure_aborted: + break if piece is HEARTBEAT_PULSE: continue if piece: @@ -907,10 +1204,12 @@ async def _preproc_coro(): yield self.io.emit("token", text=piece) for event in self.io.drain_events(): - if event.get("type") == "tool_error": - turn_had_tool_error = True - turn_tool_error_text += str(event.get("text") or "") - yield event + yield from _yield_turn_event(event) + if edit_failure_aborted: + break + + if edit_failure_aborted: + break edited = _edited_files(self.coder) if ( @@ -957,16 +1256,29 @@ async def _preproc_coro(): self.sync_agent_todos_with_workspace() from bright_vision_core.agent_turn import ( + edit_failure_turn_warning, + should_auto_continue_after_edit_failure, token_limit_exhausted, vibe_token_limit_recovery_warning, ) ring = list(getattr(self.io, "debug_event_ring", []) or []) + msg = edit_failure_turn_warning(events=ring, edited_files=edited) + if msg: + yield self.io.tool_warning(msg) + if should_auto_continue_after_edit_failure( + events=ring, + agent_cmd=agent_cmd, + edit_failure_continuation=edit_failure_continuation, + ): + yield from _run_edit_failure_continuation() + return if token_limit_exhausted( events=ring, assistant_text="".join(assistant_text), ): yield self.io.tool_warning(vibe_token_limit_recovery_warning()) + yield from _maybe_verify_implement_tests() yield self.io.emit("done", **_attach_turn_capture(payload)) except BaseException as err: if is_switch_coder_signal(err): diff --git a/bright_vision_core/spec_focus.py b/bright_vision_core/spec_focus.py index 8d9f269..9ba7707 100644 --- a/bright_vision_core/spec_focus.py +++ b/bright_vision_core/spec_focus.py @@ -7,9 +7,11 @@ from bright_vision_core.spec_steering import ( IMPLEMENTATION_TOOL_HINTS, - SPEC_FOCUS_INSTRUCTIONS, + SCAFFOLD_MISSING_HINT, build_spec_focus_preamble, + workspace_lib_missing, ) +from bright_vision_core.implement_workspace import build_implement_workspace_block from bright_vision_core.workspace_todos import ( TodoItem, TodoStore, @@ -63,6 +65,8 @@ def is_implement_turn_message(message: str) -> bool: return True if lower.startswith("work the active task checklist"): return True + if lower.startswith("continue the active task"): + return True return False @@ -102,6 +106,13 @@ def spec_focus_preamble_applies( return bool(focus_requested and item is not None and todo_has_spec_content(item)) +def _is_resume_implement_message(message: str) -> bool: + trimmed = message.strip().lower() + if trimmed.startswith("/agent"): + trimmed = trimmed[6:].lstrip() + return trimmed.startswith("continue the active task") + + def build_user_message_with_spec_context( workspace: str | Path, message: str, @@ -139,6 +150,16 @@ def build_user_message_with_spec_context( blocks = [build_spec_focus_preamble(workspace)] if implement_turn: blocks.append(IMPLEMENTATION_TOOL_HINTS.strip()) + if workspace_lib_missing(workspace): + blocks.append(SCAFFOLD_MISSING_HINT.strip()) + checklist = item.checklist if item is not None else [] + blocks.append( + build_implement_workspace_block( + workspace, + checklist, + resume=_is_resume_implement_message(message), + ) + ) user_text = "\n\n".join(blocks) + "\n\n" + user_text return user_text, preamble, turn_todo_id diff --git a/bright_vision_core/spec_steering.py b/bright_vision_core/spec_steering.py index 1be5c9c..b70ce25 100644 --- a/bright_vision_core/spec_steering.py +++ b/bright_vision_core/spec_steering.py @@ -15,12 +15,28 @@ - Do not mark implementation done until requirements pass EARS lint (WHEN/SHALL, no duplicate REQ ids). """ +SCAFFOLD_MISSING_HINT = """\ +## Workspace state (read before exploring) + +`lib/` does **not** exist yet — scaffolding tasks (e.g. **1.1**, **1.2**) are incomplete. +Do **not** repeat `ls` on `lib/` or `test/`. Use **ContextManager** to create directories/files, +then **ReadRange** + **EditText**. If the active task is **1.3+**, complete **1.1** first. +""" + + +def workspace_lib_missing(workspace: str | Path) -> bool: + return not (Path(workspace).resolve() / "lib").is_dir() + + IMPLEMENTATION_TOOL_HINTS = """\ ## Implementation turn (tools) - **Empty files:** `ReadRange` once with `@000`/`000@`, then **`EditText`** (replace `@000`–`@000`) or **`ContextManager`** create — do not re-read the same empty file. +- **Before EditText:** always **`ReadRange`** the target file in the same turn (required for new files and after ContextManager create). - **Scaffolding:** prefer `ContextManager` + `EditText` over repeated `ls` / `Grep` on known paths. - After a successful read, edit — do not loop on exploration. +- **UpdateTodoList:** mark a task `done: true` only after **EditText** succeeded for that task's deliverable — never on failed edits. +- When EditText errors, read the error, **ReadRange**, retry one file; do not assume success from assistant prose alone. """ diff --git a/cecli b/cecli index f2ad1c7..04441c5 160000 --- a/cecli +++ b/cecli @@ -1 +1 @@ -Subproject commit f2ad1c75cc471f27e9c23bf68d25b1d0d56f8ab3 +Subproject commit 04441c50073353e15bf2f5d72c880d0ad2444845 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7caa9b3..37d3909 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -110,7 +110,7 @@ Log dogfooding bugs as roadmap rows or issues with repro (workspace path, file p | **43** | **Done** | **LLM fixture packs for e2e** — external curated workspace collection via `E2E_FIXTURE_PACK_ROOT` (submodule-friendly), in-repo fallback, plus `scripts/verify-e2e-fixture-pack.sh` (`yarn test:e2e:fixtures`) for structure + optional pin-status preflight. | | **44** | **Done** | **Session debug export** — `GET /sessions/{id}/debug` JSON bundle (messages, tool_calls, duplicate hints, agent todo, EventIO ring); Settings **Session history → Export debug bundle**. **Spec job debug** — `GET …/generate-spec/{job_id}/debug` for background spec generation (headless session events + job metadata); header chip copy/export while running. See [IPC.md](./IPC.md). | | **45** | **Partial** | **BrightVision Remote** — R0 connect + **MVP chat tab** (`apps/remote`: session, SSE send, Stop, status). LAN Settings/QR per [MOBILE_REMOTE.md](./MOBILE_REMOTE.md). **Open:** R1 acceptance dogfood, file add, pause, progress bar, Connect relay (R2). | -| **51** | **Partial** | **Desktop WebKit HTTP** — mutating Vision API via Tauri/reqwest (`vision_api_fetch`, `createCoreHttpClient`); debug export bytes. **Agent guard (UI):** Settings limits, `/pause` `/resume`, header turn chip, plan ETA. **Headless git:** `default_headless_args` includes cecli commit attribution fields (fixes `attribute_author` commit errors). **Agent dead-end:** `/agent` always finalizes through recovery/warnings; **injected-task `/agent`** rebuilds slash preproc when checklist prefix hides the leading `/` (`synthetic_slash_preproc_input`); empty-turn warning; auto-yes on confirms; basename **Add file** dedupe; **token-limit auto-continue** (`agent_turn.py`, `session.py`). **Quit:** `ExitRequested` frees `:8741`. **Open:** shell command allowlist (cecli). See [TROUBLESHOOTING.md](./TROUBLESHOOTING.md). | +| **51** | **Partial** | **Desktop WebKit HTTP** — mutating Vision API via Tauri/reqwest (`vision_api_fetch`, `createCoreHttpClient`); debug export bytes. **Agent guard (UI):** Settings limits, `/pause` `/resume`, header turn chip, plan ETA. **Headless git:** `default_headless_args` includes cecli commit attribution fields (fixes `attribute_author` commit errors). **Agent dead-end:** `/agent` always finalizes through recovery/warnings; **injected-task `/agent`** rebuilds slash preproc when checklist prefix hides the leading `/` (`synthetic_slash_preproc_input`); empty-turn warning; auto-yes on confirms; basename **Add file** dedupe; **token-limit auto-continue**; **exploration abort** (ls/repetition, no auto-continue after abort); **implement workspace snapshot** (`implement_workspace.py`). **Quit:** `ExitRequested` frees `:8741`. **Open:** shell command allowlist (cecli). See [TROUBLESHOOTING.md](./TROUBLESHOOTING.md). | | **52** | **Longer-term** | **Out-of-repo context** — design options in [design/OUT_OF_REPO_CONTEXT.md](./design/OUT_OF_REPO_CONTEXT.md) (#7); no implementation until product picks upload vs attach bridge. | ## Spec-driven development (#18) @@ -129,6 +129,7 @@ Log dogfooding bugs as roadmap rows or issues with repro (workspace path, file p | # | Status | Item | |---|--------|------| | 18a–18e | **Done** | Core/UI todos API, generate/refine, steered steps, reload spec from disk; **auto-import** spec from disk when layers empty (`maybe_import_spec_from_disk`, short spec folder ids); **light task inject** (checklist-only, no placeholder spec sections); **/agent → heavy** routing before preproc | +| **53** | **Partial** | **Implement workspace grounding (2026-06)** — inject on-disk `lib/`/`test/` snapshot + **Next action** on Start/Resume implement (no ls); end-of-turn **`flutter test`** when checklist is test-focused; repetition/ls abort. **Tests:** `test_implement_workspace.py`. **Open:** auto-checklist on pass; pubspec dep repair | ### Kiro / spec parity (from [SPEC_DRIVEN_DEV.md](./SPEC_DRIVEN_DEV.md)) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index f501084..93f3ebb 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -274,12 +274,12 @@ pip install -e . **Also:** Turn runs 10+ minutes with ls/ReadRange/GitStatus, then **Empty response from the local model** and **Repetition Detected** on read tools — chat shows only opening prose and **no auto-recovery**. -**Cause:** Ollama/Qwen often returns `finish_reason=length` with an empty body — not real context exhaustion. Auto-continue then drives a second huge implement pass; the model batches many `EditText` calls (`@000` on a dozen files), triggering cecli repetition guard. Separately, exploration-heavy turns can end when Ollama stalls after read tools with no EditText — previously no recovery ran. +**Cause:** Ollama/Qwen often returns `finish_reason=length` with an empty body — not real context exhaustion. Auto-continue then drives a second huge implement pass; the model batches many `EditText` calls (`@000` on a dozen files), triggering cecli repetition guard. Separately, exploration-heavy turns can end when Ollama stalls after read tools with no EditText — often because **Model router → Heavy keep-alive** was **0** (unload 27B between every agent LLM call). Default is now **-1** (keep loaded); existing saved **0** migrates to **-1** on Settings load. **Current behavior (2026-06):** - Spurious Ollama token limits (~0 output, input ≪ window) **no longer auto-continue**; snackbar explains the stall. -- **Stalled exploration** (agent tools ran + empty Ollama or repetition on ls/ReadRange with no edits) **auto-continues once** with a directive to EditText one file — no more silent 14-minute dead ends. +- **Stalled exploration** with empty Ollama **auto-continues once** only when fewer than four LLM rounds ran with no edits; after that, BrightVision stops with a directive to fix keep-alive and **Implement** one step — avoids looping on empty Ollama. - Token-limit continue prompts scope to **one numbered task** and **one file per EditText**. - Prefer **Implement** on step **1.1** only — not open-ended **Start work** for greenfield scaffolding. diff --git a/e2e/helpers/llmEnv.ts b/e2e/helpers/llmEnv.ts index 7077e68..1207393 100644 --- a/e2e/helpers/llmEnv.ts +++ b/e2e/helpers/llmEnv.ts @@ -149,7 +149,7 @@ export function buildRouterPrefsForStorage(): tokenFastMax: Number(process.env.E2E_ROUTER_TOKEN_FAST_MAX || 4096), tokenHeavyMin: Number(process.env.E2E_ROUTER_TOKEN_HEAVY_MIN || 12000), keepAliveFastSec: 300, - keepAliveHeavySec: 0, + keepAliveHeavySec: -1, escalateOnFailure: true, } } diff --git a/src/App.tsx b/src/App.tsx index cd23a04..28666d6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -62,7 +62,7 @@ import { useVisionSession } from './hooks/useVisionSession' import { useTurnResourcePeak } from './hooks/useTurnResourcePeak' import { usePathCompletion } from './hooks/usePathCompletion' import { filesToUploadParts } from './utils/imageUpload' -import { buildImplementStepMessage, buildStartWorkMessage } from './todos/formatContext' +import { buildImplementStepMessage, buildStartWorkMessage, shouldResumeWork } from './todos/formatContext' import type { ImplementationStep } from './todos/tasksMd' import type { TodoItem } from './todos/types' import { useCommandCatalog } from './hooks/useCommandCatalog' @@ -807,6 +807,7 @@ function AppShell({ }, []) const stallWatchRef = useRef<(type: string, detail?: string) => void>(() => {}) + const releaseInflightAfterTurnRef = useRef<() => void>(() => {}) const handleCoreEvent = useCallback((ev: CoreEventBase) => { if (!isCoreEvent(ev)) return @@ -829,6 +830,7 @@ function AppShell({ agentTurnActiveRef.current = false setAgentTurnLive(false) lastToolSnippetRef.current = '' + releaseInflightAfterTurnRef.current() } process.ingestCoreEvent(ev) if (ev.type === 'done') bumpGitRefresh() @@ -1407,6 +1409,7 @@ function AppShell({ stop, send, cancelSend, + releaseInflightAfterTurn, submitConfirm, addFiles, uploadFiles, @@ -1414,6 +1417,7 @@ function AppShell({ refreshSessionInfo, patchSessionFiles, } = useVisionSession(wrapHandler(handleCoreEvent), { onOutboundMessage }) + releaseInflightAfterTurnRef.current = releaseInflightAfterTurn const isRunningRef = useRef(isRunning) isRunningRef.current = isRunning @@ -2644,8 +2648,12 @@ function AppShell({ }) todoInjectedIdRef.current = null setActiveTab('chat') + const resume = shouldResumeWork(todo) setInputValue(buildStartWorkMessage(todo, todoStore?.todos ?? [])) - setSnackbar({ message: `Active task: ${todo.title}`, severity: 'info' }) + setSnackbar({ + message: resume ? `Resuming: ${todo.title}` : `Active task: ${todo.title}`, + severity: 'info', + }) }, [setActiveTodo, updateTodo, todoStore?.todos] ) diff --git a/src/components/settings/ModelRouterSection.tsx b/src/components/settings/ModelRouterSection.tsx index ba401df..4e2c3a0 100644 --- a/src/components/settings/ModelRouterSection.tsx +++ b/src/components/settings/ModelRouterSection.tsx @@ -88,6 +88,25 @@ export function ModelRouterSection({ )} + + { + const n = parseInt(e.target.value, 10) + onChange({ + ...prefs, + keepAliveHeavySec: Number.isFinite(n) ? (n === 0 ? -1 : n) : -1, + }) + }} + helperText="Use -1 for agent/implement (keeps 27B loaded). 0 unloads between calls and causes empty Ollama responses." + sx={{ flex: 1 }} + inputProps={{ 'data-testid': 'pref-model-router-heavy-keep-alive' }} + /> + (selected ? parseImplementationSteps(tasksMd) : []), [selected?.id, tasksMd] ) + const workTodoDraft = useMemo(() => { + if (!selected) return null + return { + ...selected, + title, + requirements, + design, + tasks_md: tasksMd, + depends_on: dependsOn, + branch, + pr_url: prUrl, + checklist, + } + }, [ + selected, + title, + requirements, + design, + tasksMd, + dependsOn, + branch, + prUrl, + checklist, + ]) + const resumeWork = workTodoDraft ? shouldResumeWork(workTodoDraft) : false const implementBlockedByEars = Boolean(earsLint && !earsLint.ok) @@ -1319,7 +1346,7 @@ export function TodoPanel({