diff --git a/devagent/agent/flows.py b/devagent/agent/flows.py index 9620193..ed06dd6 100644 --- a/devagent/agent/flows.py +++ b/devagent/agent/flows.py @@ -30,7 +30,7 @@ _REPL_COMMANDS = [ "/explain", "/test", "/review", "/commit", "/summarize", "/deep-research", "/help", - "/status", "/context", "/clear", "/rewind", "/permissions", "/goal", "/btw", "/autocompact", + "/status", "/history", "/context", "/clear", "/rewind", "/permissions", "/goal", "/btw", "/autocompact", "/model", "/effort", "/think", "/memory", "/tokens", "/security", "/undo", "/diff", "/exit", "/fork", "/tasks", "/loop", "/rename", "/sessions", "/fast", "/compact", "/recap", "/branch", "/theme", "/batch", "/background", @@ -383,6 +383,7 @@ def _diff_confirm_fn(path: str, diff: str) -> bool: permission_mgr=self._permission_mgr, bare=bare, ) + self._router = router # Phase 15 — expose effort and bare for REPL commands self._bare = bare @@ -449,6 +450,7 @@ def _maybe_compress(self) -> None: session_id=self.session_id, llm=self._llm, session_cfg=self._cfg.session, + db_path=self._mgr.db_path, ) if result: self._console.print( @@ -458,6 +460,27 @@ def _maybe_compress(self) -> None: except Exception: pass # compression is best-effort; never block a turn + def show_history(self, limit: int = 8) -> None: + """Display a bounded resume/history view without changing LLM context.""" + from devagent.session import store + + summary = store.get_compressed_summary(self.session_id, db_path=self._mgr.db_path) + events = self._mgr.get_events(self.session_id) + if summary: + self._console.print("[bold]Earlier session summary[/bold]") + self._console.print(summary) + recent = events[-limit:] + if recent: + self._console.print(f"[bold]Recent messages (last {len(recent)})[/bold]") + for event in recent: + role = event["role"].replace("tool_result", "tool") + content = (event.get("content") or "").strip().replace("\n", " ") + if len(content) > 240: + content = content[:237] + "..." + self._console.print(f"[dim]{role}:[/dim] {content}") + elif not summary: + self._console.print("[dim]No previous messages in this session.[/dim]") + def run_message(self, message: str, quiet: bool = False) -> str: """Drive one user turn; render events. Returns the final LLM text. @@ -666,6 +689,15 @@ def _bg_mention(tid=_mention_task_id, task=_mention_task, cfg=_cfg, render_diff(self._last_diff) continue + if cmd.startswith("/history"): + parts = raw.split(None, 1) + try: + limit = max(1, min(int(parts[1]), 50)) if len(parts) > 1 else 8 + except ValueError: + limit = 8 + self.show_history(limit) + continue + # Phase 23 / Phase 28 — /model: hot-swap provider/model; /model alone shows picker if cmd.startswith("/model"): parts = raw.split(None, 1) @@ -745,6 +777,8 @@ def _get_ollama_models() -> list[str]: self._console.print( f"[dim]Model switched to [bold]{self._cfg.llm.provider}/{spec}[/bold][/dim]" ) + if self._router is not None and len(parts) >= 2: + self._router.set_override(self._cfg.llm) continue # Phase 15 — /effort: change effort level for the rest of the session @@ -908,6 +942,8 @@ def _get_ollama_models() -> list[str]: f"[bold]{self._cfg.llm.provider}/{self._cfg.llm.model}[/bold] " f"(type [bold]/fast off[/bold] to restore)[/dim]" ) + if self._router is not None: + self._router.set_override(self._cfg.llm) continue # Phase 32 — /compact [focus on X]: force-compress session history now @@ -1057,12 +1093,13 @@ def _get_ollama_models() -> list[str]: # Phase 13 — /context: token usage breakdown if cmd == "/context": budget = self._budget + total_tokens = budget.total_used lines = [ - f"Total tokens used this session: {budget.total_tokens():,}", + f"Total tokens used this session: {total_tokens:,}", ] - cap = getattr(budget, "_max_tokens", None) + cap = budget.max_tokens if cap: - pct = int(100 * budget.total_tokens() / cap) + pct = int(100 * total_tokens / cap) lines.append(f"Budget cap: {cap:,} ({pct}% used)") else: lines.append("Budget cap: unlimited") diff --git a/devagent/agent/loop.py b/devagent/agent/loop.py index 599ed85..7414ac8 100644 --- a/devagent/agent/loop.py +++ b/devagent/agent/loop.py @@ -14,12 +14,13 @@ from collections.abc import Generator from dataclasses import dataclass +from pathlib import Path from typing import Any from devagent.agent.permissions import PermissionManager from devagent.core.llm import LLMClient from devagent.session.budget import BudgetExceeded, TokenBudget -from devagent.session.history import build_messages +from devagent.session.history import build_messages, fit_request_context from devagent.session.manager import SessionManager from devagent.session.memory import MemoryBlock from devagent.tools.registry import ToolRegistry @@ -196,8 +197,30 @@ def run(self, user_message: str) -> Generator[AgentEvent, None, None]: if overlay: current_system = full_system + overlay - # Build message history from DB - messages = build_messages(self.session_id, current_system) + # Select LLM for this iteration (router or default) + task = "fallback" + if _HAS_ROUTER and self._router is not None: + current_llm, task = self._router.get_llm_for_iteration(last_tool_names, iteration) + else: + current_llm = self.llm + + db_path = getattr(self.session_mgr, "db_path", None) + if not isinstance(db_path, (str, Path)): + db_path = None + messages = build_messages( + self.session_id, + current_system, + db_path=db_path, + ) + from devagent.session.compressor import _context_window_for + provider = getattr(getattr(current_llm, "cfg", None), "provider", None) or "anthropic" + model = getattr(getattr(current_llm, "cfg", None), "model", None) or "claude-sonnet-4-6" + if not isinstance(provider, str): + provider = "anthropic" + if not isinstance(model, str): + model = "claude-sonnet-4-6" + context_limit = int(_context_window_for(provider, model) * 0.55) + messages, tools = fit_request_context(messages, tools, context_limit) # Budget check try: @@ -206,13 +229,6 @@ def run(self, user_message: str) -> Generator[AgentEvent, None, None]: yield ErrorEvent(str(exc)) return - # Select LLM for this iteration (router or default) - task = "fallback" - if _HAS_ROUTER and self._router is not None: - current_llm, task = self._router.get_llm_for_iteration(last_tool_names, iteration) - else: - current_llm = self.llm - # LLM call try: response = current_llm.complete_with_tools(messages, tools) diff --git a/devagent/cli.py b/devagent/cli.py index 6fffad9..b8826c9 100644 --- a/devagent/cli.py +++ b/devagent/cli.py @@ -677,9 +677,7 @@ def index( from devagent.core.storage import ( clear_project_index, ensure_dirs, - get_changed_files_count, get_chroma_dir, - get_index_status, get_sqlite_path, ) @@ -698,9 +696,10 @@ def index( # --- --status flag --- if status: - idx_status = asyncio.run(get_index_status(sqlite_path)) + from devagent.codeprism.client import CodePrismClient - if not idx_status["exists"]: + codeprism = CodePrismClient(str(project_root)) + if not codeprism.is_indexed: console.print( Panel( "This project has not been indexed yet.\n" @@ -710,31 +709,14 @@ def index( ) ) return - - # Check for changes - changes = asyncio.run(get_changed_files_count(project_root, sqlite_path)) - - table = Table(title="Index Status", border_style="cyan") - table.add_column("Metric", style="bold") - table.add_column("Value", justify="right") - - table.add_row("Indexed files", str(idx_status["total_files"])) - table.add_row("Total chunks", str(idx_status["total_chunks"])) - table.add_row("Last indexed", idx_status["last_indexed"] or "never") - table.add_row("Changed files", str(changes["changed"])) - table.add_row("New files", str(changes["new"])) - table.add_row("Deleted files", str(changes["deleted"])) - - console.print(table) - - total_changes = changes["changed"] + changes["new"] + changes["deleted"] - if total_changes > 0: - console.print( - f"\n[yellow]WARN {total_changes} file(s) changed since last index. " - "Run [bold]devagent index[/bold] to update.[/yellow]" + console.print( + Panel( + "CodePrism index is available for this project.\n" + "Graph-powered tools will be enabled in new sessions.", + title="Index Status", + border_style="green", ) - else: - console.print("\n[green]Index is up to date.[/green]") + ) return # --- --clear flag --- @@ -1632,12 +1614,30 @@ def run( cfg.llm.model = model tools_list = [t.strip() for t in allow_tools.split(",")] if allow_tools else None + selected_resume = resume + if selected_resume is None and sys.stdin.isatty() and sys.stdout.isatty(): + from devagent.session.manager import SessionManager + + sessions = SessionManager().list_by_project(str(project_root), limit=10) + if sessions: + console.print("[bold]Recent sessions[/bold]") + for index, item in enumerate(sessions, 1): + title = item.get("title") or "Untitled session" + console.print(f" [cyan]{index}[/cyan]. {title} ({item['id'][:8]})") + new_index = len(sessions) + 1 + console.print(f" [cyan]{new_index}[/cyan]. Start new session") + choice = console.input("Select a session [default: new]: ").strip() + if choice and choice.isdigit(): + selected = int(choice) + if 1 <= selected <= len(sessions): + selected_resume = sessions[selected - 1]["id"] + try: session = DevAgentSession( cfg, project_root, max_tokens=max_tokens, - resume_id=resume, + resume_id=selected_resume, no_limit=no_limit, plan_mode=plan, allow=list(allow), @@ -1658,8 +1658,9 @@ def run( _handle_error(exc) return - if resume: + if selected_resume: console.print(f"[dim]Resuming session: {session.session_id[:8]}[/dim]") + session.show_history() else: _name_suffix = f" ({name})" if name else "" console.print(f"[dim]New session: {session.session_id[:8]}{_name_suffix}[/dim]") diff --git a/devagent/codeprism/client.py b/devagent/codeprism/client.py index 66a3e44..4e1fcb4 100644 --- a/devagent/codeprism/client.py +++ b/devagent/codeprism/client.py @@ -21,15 +21,33 @@ # Background event loop (started once per process) # --------------------------------------------------------------------------- -_loop: asyncio.AbstractEventLoop = asyncio.new_event_loop() -_thread = threading.Thread(target=_loop.run_forever, daemon=True, name="codeprism-loop") -_thread.start() +_loop: asyncio.AbstractEventLoop | None = None +_thread: threading.Thread | None = None +_loop_lock = threading.Lock() + + +def _get_loop() -> asyncio.AbstractEventLoop: + global _loop, _thread + with _loop_lock: + if _loop is None: + _loop = asyncio.new_event_loop() + _thread = threading.Thread( + target=_loop.run_forever, + daemon=True, + name="codeprism-loop", + ) + _thread.start() + return _loop def _run(coro, timeout: float = 30.0) -> Any: """Submit coroutine to the background loop and block until done.""" - future = asyncio.run_coroutine_threadsafe(coro, _loop) - return future.result(timeout=timeout) + future = asyncio.run_coroutine_threadsafe(coro, _get_loop()) + try: + return future.result(timeout=timeout) + except TimeoutError: + future.cancel() + raise # --------------------------------------------------------------------------- @@ -115,7 +133,7 @@ def __init__(self, project_root: str) -> None: self._session_obj = None self._session_id: str | None = None self._ready = False # True after first _ensure_ready() - self._indexed = False # True if .codeprism/codeprism.db exists + self._indexed = False # True if CodePrism's project database exists # ------------------------------------------------------------------ # Lazy init @@ -125,7 +143,7 @@ def _ensure_ready(self) -> bool: if self._ready: return self._indexed - db_path = self.project_root / ".codeprism" / "codeprism.db" + db_path = self._db_path() if not db_path.exists(): self._ready = True self._indexed = False @@ -134,7 +152,7 @@ def _ensure_ready(self) -> bool: try: from codeprism import CodePrism prism = CodePrism(str(self.project_root)) - _run(prism.initialize(), timeout=60) + _run(prism.initialize(), timeout=10) self._prism = prism self._ready = True self._indexed = True @@ -149,7 +167,16 @@ def _ensure_ready(self) -> bool: @property def is_indexed(self) -> bool: - return self._ensure_ready() + if self._ready: + return self._indexed + return self._db_path().exists() + + def _db_path(self) -> Path: + try: + from codeprism.core.paths import get_db_path + return get_db_path(self.project_root) + except Exception: + return self.project_root / ".codeprism" / "codeprism.db" def _not_indexed(self) -> dict: return { diff --git a/devagent/core/config.py b/devagent/core/config.py index 48b853d..93d7a81 100644 --- a/devagent/core/config.py +++ b/devagent/core/config.py @@ -108,7 +108,7 @@ class SessionConfig(BaseModel): max_sessions: int = 20 # Phase 8 — context auto-compression auto_compress: bool = True - compression_threshold: float = 0.6 # compress when history exceeds this fraction of context window + compression_threshold: float = 0.45 # compress before history dominates the request compression_window_size: int = 20 # keep this many most-recent events verbatim compression_model: str = "cheap" # router tier used for summarisation LLM calls diff --git a/devagent/core/router.py b/devagent/core/router.py index ea214f8..8a22abe 100644 --- a/devagent/core/router.py +++ b/devagent/core/router.py @@ -10,7 +10,7 @@ from __future__ import annotations -from devagent.core.config import DevAgentConfig +from devagent.core.config import DevAgentConfig, LLMConfig from devagent.core.llm import LLMClient # Tool name sets for task detection @@ -29,6 +29,21 @@ class MultiModelRouter: def __init__(self, config: DevAgentConfig) -> None: self._config = config self._cache: dict[str, LLMClient] = {} + self._override: LLMConfig | None = None + + def set_override(self, llm_config: LLMConfig) -> None: + """Use one live model for every routed iteration. + + Interactive model changes must take precedence over static router tiers. + Copy the config so later mutations cannot leave a cached client stale. + """ + self._override = llm_config.model_copy(deep=True) + self._cache.clear() + + def clear_override(self) -> None: + """Return to the configured per-task router tiers.""" + self._override = None + self._cache.clear() def detect_task(self, last_tool_names: list[str], iteration: int) -> str: """Return a task key: planning | coding | reviewing | cheap | fallback.""" @@ -44,6 +59,10 @@ def detect_task(self, last_tool_names: list[str], iteration: int) -> str: return "fallback" def get_llm(self, task: str = "fallback") -> LLMClient: + if self._override is not None: + if "__override__" not in self._cache: + self._cache["__override__"] = LLMClient(self._override) + return self._cache["__override__"] if task not in self._cache: from devagent.core.llm import get_llm_for_task self._cache[task] = get_llm_for_task(self._config, task) diff --git a/devagent/core/storage.py b/devagent/core/storage.py index f4a8e72..00f8d96 100644 --- a/devagent/core/storage.py +++ b/devagent/core/storage.py @@ -8,6 +8,7 @@ import hashlib import os +import sqlite3 from pathlib import Path import platformdirs @@ -98,11 +99,9 @@ def ensure_dirs(project_root: Path) -> None: async def init_db(sqlite_path: Path) -> None: """Create the SQLite tables if they don't exist.""" - import aiosqlite - sqlite_path.parent.mkdir(parents=True, exist_ok=True) - async with aiosqlite.connect(sqlite_path) as db: - await db.execute(""" + with sqlite3.connect(sqlite_path, timeout=5) as db: + db.execute(""" CREATE TABLE IF NOT EXISTS indexed_files ( file_path TEXT PRIMARY KEY, last_modified REAL NOT NULL, @@ -110,13 +109,13 @@ async def init_db(sqlite_path: Path) -> None: indexed_at TEXT NOT NULL ) """) - await db.execute(""" + db.execute(""" CREATE TABLE IF NOT EXISTS project_meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL ) """) - await db.commit() + db.commit() async def get_index_status(sqlite_path: Path) -> dict: @@ -124,20 +123,17 @@ async def get_index_status(sqlite_path: Path) -> dict: Returns dict with keys: total_files, total_chunks, last_indexed, exists. """ - import aiosqlite - if not sqlite_path.is_file(): return {"total_files": 0, "total_chunks": 0, "last_indexed": None, "exists": False} - async with aiosqlite.connect(sqlite_path) as db: - async with db.execute("SELECT COUNT(*), COALESCE(SUM(chunk_count), 0) FROM indexed_files") as cursor: - row = await cursor.fetchone() - total_files = row[0] if row else 0 - total_chunks = row[1] if row else 0 - - async with db.execute("SELECT MAX(indexed_at) FROM indexed_files") as cursor: - row = await cursor.fetchone() - last_indexed = row[0] if row else None + with sqlite3.connect(sqlite_path, timeout=5) as db: + row = db.execute( + "SELECT COUNT(*), COALESCE(SUM(chunk_count), 0) FROM indexed_files" + ).fetchone() + total_files = row[0] if row else 0 + total_chunks = row[1] if row else 0 + row = db.execute("SELECT MAX(indexed_at) FROM indexed_files").fetchone() + last_indexed = row[0] if row else None return { "total_files": total_files, @@ -152,7 +148,6 @@ async def get_changed_files_count(project_root: Path, sqlite_path: Path) -> dict Returns dict with keys: changed, new, deleted. """ - import aiosqlite import pathspec as _pathspec if not sqlite_path.is_file(): @@ -160,10 +155,9 @@ async def get_changed_files_count(project_root: Path, sqlite_path: Path) -> dict # Load indexed files indexed: dict[str, float] = {} - async with aiosqlite.connect(sqlite_path) as db: # noqa: SIM117 - async with db.execute("SELECT file_path, last_modified FROM indexed_files") as cursor: - async for row in cursor: - indexed[row[0]] = row[1] + with sqlite3.connect(sqlite_path, timeout=5) as db: + rows = db.execute("SELECT file_path, last_modified FROM indexed_files").fetchall() + indexed.update({row[0]: row[1] for row in rows}) # Load gitignore spec gitignore_path = project_root / ".gitignore" diff --git a/devagent/mcp/server.py b/devagent/mcp/server.py index 55be719..b9456fd 100644 --- a/devagent/mcp/server.py +++ b/devagent/mcp/server.py @@ -85,7 +85,7 @@ def serve_mcp( github_token: str | None = None try: - from codeprism import CodePrismClient # type: ignore[import] + from devagent.codeprism.client import CodePrismClient codeprism_client = CodePrismClient(project_root) except Exception: pass diff --git a/devagent/session/budget.py b/devagent/session/budget.py index 3e3c309..ef75f13 100644 --- a/devagent/session/budget.py +++ b/devagent/session/budget.py @@ -118,6 +118,11 @@ def output_tokens(self) -> int: def total_used(self) -> int: return self.input_tokens + self.output_tokens + @property + def total_tokens(self) -> int: + """Backward-compatible alias for the aggregate token count.""" + return self.total_used + @property def call_count(self) -> int: return self._call_count diff --git a/devagent/session/compressor.py b/devagent/session/compressor.py index 71a24ca..4fbd4b5 100644 --- a/devagent/session/compressor.py +++ b/devagent/session/compressor.py @@ -68,7 +68,19 @@ class CompressionResult: def _estimate_tokens(text: str) -> int: - return len(text) // 4 + return (len(text) + 3) // 4 + + +def _estimate_event_tokens(events: list[dict[str, Any]]) -> int: + """Estimate the full serialized event payload sent back to the model.""" + total = 0 + for event in events: + total += len(event.get("role", "")) + total += len(event.get("content") or "") + total += len(str(event.get("tool_calls") or [])) + total += len(event.get("tool_name") or "") + total += len(event.get("tool_call_id") or "") + return _estimate_tokens("x" * total) def _context_window_for(provider: str, model: str) -> int: @@ -124,8 +136,7 @@ def maybe_compress( return None # Estimate total history tokens - history_text = _format_events_for_summary(all_events) - history_tokens = _estimate_tokens(history_text) + history_tokens = _estimate_event_tokens(all_events) context_window = _context_window_for(llm.cfg.provider, llm.cfg.model) threshold_tokens = int(context_window * session_cfg.compression_threshold) diff --git a/devagent/session/history.py b/devagent/session/history.py index f2a4c2f..9609e05 100644 --- a/devagent/session/history.py +++ b/devagent/session/history.py @@ -10,6 +10,8 @@ from __future__ import annotations +import json + from devagent.core.llm import AgentMessage, ToolCallRequest from devagent.session import store @@ -76,6 +78,53 @@ def build_messages( def estimate_tokens(messages: list[AgentMessage]) -> int: - """Very rough token estimate: 1 token ~= 4 characters of text.""" - total = sum(len(m.content) for m in messages) - return total // 4 + """Estimate message tokens, including tool-call arguments.""" + total = 0 + for message in messages: + total += len(message.content or "") + total += sum(len(json.dumps(call.args, ensure_ascii=False)) for call in message.tool_calls) + total += len(message.tool_call_id or "") + len(message.tool_name or "") + return (total + 3) // 4 + + +def estimate_request_tokens(messages: list[AgentMessage], tools: list | None = None) -> int: + """Estimate the prompt payload, including serialized tool definitions.""" + total = estimate_tokens(messages) * 4 + for tool in tools or []: + total += len(tool.name) + len(tool.description) + total += len(json.dumps(tool.parameters or {}, ensure_ascii=False)) + return (total + 3) // 4 + + +def fit_request_context( + messages: list[AgentMessage], + tools: list, + max_tokens: int, +) -> tuple[list[AgentMessage], list]: + """Keep the system prompt and newest messages within a request budget. + + Tool definitions are retained because removing them can make tool calls + invalid. Older conversation messages are dropped first; durable history + remains in SQLite and can be compressed for later turns. + """ + if estimate_request_tokens(messages, tools) <= max_tokens: + return messages, tools + + system = messages[:1] + retained: list[AgentMessage] = [] + for message in reversed(messages[1:]): + candidate = system + [message] + list(reversed(retained)) + if estimate_request_tokens(candidate, tools) > max_tokens: + break + retained.append(message) + + retained.reverse() + result = system + retained + if len(result) == 1 and result[0].content: + tool_tokens = estimate_request_tokens([], tools) + allowed_chars = max(0, (max_tokens - tool_tokens) * 4) + result[0] = AgentMessage( + role="system", + content=result[0].content[:allowed_chars], + ) + return result, tools diff --git a/devagent/session/store.py b/devagent/session/store.py index 9e22f4d..f94f5f9 100644 --- a/devagent/session/store.py +++ b/devagent/session/store.py @@ -18,6 +18,8 @@ def _conn(db_path: Path | None = None) -> sqlite3.Connection: + if not isinstance(db_path, (str, Path)): + db_path = None path = db_path or get_sessions_db_path() conn = sqlite3.connect(str(path), timeout=30.0) conn.row_factory = sqlite3.Row @@ -246,6 +248,7 @@ def append_event( tokens_in, tokens_out, now, ), ) + conn.commit() return seq @@ -383,11 +386,14 @@ def delete_memory_key( def get_compressed_summary(session_id: str, db_path: Path | None = None) -> str: """Return the stored compressed summary for this session, or '' if none.""" - with _conn(db_path) as conn: - row = conn.execute( - "SELECT compressed_summary FROM sessions WHERE id = ?", (session_id,) - ).fetchone() - return (row["compressed_summary"] or "") if row else "" + try: + with _conn(db_path) as conn: + row = conn.execute( + "SELECT compressed_summary FROM sessions WHERE id = ?", (session_id,) + ).fetchone() + return (row["compressed_summary"] or "") if row else "" + except Exception: + return "" def save_compressed_summary( diff --git a/tests/test_budget.py b/tests/test_budget.py index 02a4a97..2b567e0 100644 --- a/tests/test_budget.py +++ b/tests/test_budget.py @@ -158,3 +158,9 @@ def test_default_provider_model_args(): b.record(100, 50) # use defaults: provider="ollama", model="unknown" assert b.total_used == 150 assert b.total_cost_usd == 0.0 # ollama default + + +def test_total_tokens_alias_matches_total_used(): + budget = TokenBudget() + budget.record(120, 30) + assert budget.total_tokens == budget.total_used == 150 diff --git a/tests/test_phase5.py b/tests/test_phase5.py index e966601..6594f0c 100644 --- a/tests/test_phase5.py +++ b/tests/test_phase5.py @@ -43,6 +43,28 @@ def test_router_different_tasks_different_clients(): assert cheap_client.cfg.model == "qwen2.5-coder:3b" +def test_router_runtime_override_replaces_cached_task_clients(): + from devagent.core.config import LLMConfig + from devagent.core.router import MultiModelRouter + + router = MultiModelRouter(_make_config()) + router.get_llm("planning") + router.set_override(LLMConfig( + provider="openai", + model="gpt-test", + api_key="new-key", + base_url="https://example.test/v1", + )) + + planning, _ = router.get_llm_for_iteration([], 1) + coding, _ = router.get_llm_for_iteration(["write_file"], 2) + assert planning is coding + assert planning.cfg.provider == "openai" + assert planning.cfg.model == "gpt-test" + assert planning.cfg.api_key == "new-key" + assert planning.cfg.base_url == "https://example.test/v1" + + def test_router_detect_task_iteration_1(): from devagent.core.router import MultiModelRouter cfg = _make_config() diff --git a/tests/test_phase8.py b/tests/test_phase8.py index 6671b4d..2d630f0 100644 --- a/tests/test_phase8.py +++ b/tests/test_phase8.py @@ -50,7 +50,7 @@ def test_session_config_compression_defaults(): from devagent.core.config import SessionConfig cfg = SessionConfig() assert cfg.auto_compress is True - assert cfg.compression_threshold == 0.6 + assert cfg.compression_threshold == 0.45 assert cfg.compression_window_size == 20 assert cfg.compression_model == "cheap" @@ -350,6 +350,12 @@ def test_context_window_lookup_ollama_fallback(): assert result > 0 +def test_default_compression_threshold_limits_history_growth(): + from devagent.core.config import SessionConfig + + assert SessionConfig().compression_threshold == 0.45 + + def test_context_window_lookup_default(): from devagent.session.compressor import _context_window_for result = _context_window_for("unknown-provider", "unknown-model") @@ -367,3 +373,22 @@ def test_session_compress_command_exists(): sig = inspect.signature(session_compress) assert "session_id" in sig.parameters assert "keep" in sig.parameters + + +def test_fit_request_context_counts_tools_and_keeps_newest_messages(): + from devagent.core.llm import AgentMessage, ToolDef + from devagent.session.history import fit_request_context + + messages = [ + AgentMessage(role="system", content="system prompt"), + AgentMessage(role="user", content="old " * 500), + AgentMessage(role="assistant", content="middle " * 500), + AgentMessage(role="user", content="latest request"), + ] + tools = [ToolDef(name="read_file", description="x" * 100, parameters={"type": "object"})] + fitted, fitted_tools = fit_request_context(messages, tools, max_tokens=100) + + assert fitted_tools == tools + assert fitted[0].role == "system" + assert "latest request" in fitted[-1].content + assert len(fitted) < len(messages)