Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 41 additions & 4 deletions devagent/agent/flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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.

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
36 changes: 26 additions & 10 deletions devagent/agent/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down
61 changes: 31 additions & 30 deletions devagent/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand All @@ -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"
Expand All @@ -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 ---
Expand Down Expand Up @@ -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),
Expand All @@ -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]")
Expand Down
45 changes: 36 additions & 9 deletions devagent/codeprism/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion devagent/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading