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({
}
+ startIcon={resumeWork ? : }
onClick={() => {
persistEditor()
onStartWork({
@@ -1334,8 +1361,9 @@ export function TodoPanel({
checklist,
})
}}
+ data-testid={resumeWork ? 'todo-resume-work' : 'todo-start-work'}
>
- Start work
+ {resumeWork ? 'Resume work' : 'Start work'}