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
99 changes: 89 additions & 10 deletions devagent/agent/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,14 @@
from typing import Any

from devagent.agent.permissions import PermissionManager
from devagent.core.llm import LLMClient
from devagent.core.llm import AgentMessage, LLMClient, ToolCallRequest
from devagent.session.budget import BudgetExceeded, TokenBudget
from devagent.session.history import build_messages, fit_request_context
from devagent.session.history import (
build_iteration_messages,
build_messages, # noqa: F401
build_resume_context,
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 @@ -126,6 +131,43 @@ class ApprovalNeededEvent:

# Tool names that trigger the auto-test repair loop
_WRITE_TOOL_NAMES = {"write_file", "edit_file"}
_READ_TOOL_NAMES = {
"read_file", "grep", "find_files", "list_files", "git_diff", "git_show",
"cp_get_context", "cp_search_symbol", "cp_get_module_summary", "cp_get_callers",
"cp_get_callees", "cp_get_file_map", "cp_get_dependencies", "cp_get_impact",
"cp_get_data_flow",
}
_WRITE_FOLLOWUP_NAMES = _WRITE_TOOL_NAMES | {"run_shell", "git_diff", "git_show"}
_TOOL_KEYWORDS = (
"file", "code", "project", "repo", "read", "inspect", "find", "search",
"test", "run", "fix", "implement", "change", "edit", "write", "create",
"delete", "git", "index", "mcp", "function", "class",
)


def _request_needs_tools(user_message: str) -> bool:
"""Avoid sending tool schemas for clearly conversational requests."""
text = user_message.lower()
return any(keyword in text for keyword in _TOOL_KEYWORDS)


def _tools_for_iteration(registry, all_tools, user_message: str, iteration: int, last_tool_names: list[str]):
"""Return only the tools needed for this iteration."""
if iteration == 1:
return all_tools if _request_needs_tools(user_message) else []

allowed = set(last_tool_names)
if set(last_tool_names) & _WRITE_TOOL_NAMES:
allowed.update(_WRITE_FOLLOWUP_NAMES)
elif set(last_tool_names) & _READ_TOOL_NAMES:
allowed.update(_READ_TOOL_NAMES)

for tool in all_tools:
for name in last_tool_names:
prefix = name.split("_", 1)[0] + "_"
if tool.name.startswith(prefix):
allowed.add(tool.name)
return registry.get_restricted_definitions(sorted(allowed))


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -176,9 +218,19 @@ def run(self, user_message: str) -> Generator[AgentEvent, None, None]:
# Persist user message
self.session_mgr.record_user(self.session_id, user_message)

tools = self.registry.get_definitions()
all_tools = self.registry.get_definitions()
iteration = 0
last_tool_names: list[str] = []
pending_assistant: AgentMessage | None = None
pending_tool_results: list[AgentMessage] = []
execution_state = ""

db_path = getattr(self.session_mgr, "db_path", None)
if not isinstance(db_path, (str, Path)):
db_path = None
resume_context = (
"" if self._bare else build_resume_context(self.session_id, db_path=db_path)
)

# Reload full system prompt with latest memory + session overlay
full_system = self.system_prompt
Expand All @@ -204,13 +256,20 @@ def run(self, user_message: str) -> Generator[AgentEvent, None, None]:
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,
tools = _tools_for_iteration(
self.registry,
all_tools,
user_message,
iteration,
last_tool_names,
)
messages = build_iteration_messages(
current_system,
db_path=db_path,
user_message,
resume_context=resume_context if pending_assistant is None else "",
execution_state=execution_state,
assistant_message=pending_assistant,
tool_results=pending_tool_results,
)
from devagent.session.compressor import _context_window_for
provider = getattr(getattr(current_llm, "cfg", None), "provider", None) or "anthropic"
Expand Down Expand Up @@ -285,6 +344,15 @@ def run(self, user_message: str) -> Generator[AgentEvent, None, None]:

# Execute each tool call and track names for router
last_tool_names = []
pending_assistant = AgentMessage(
role="assistant",
content=response.content,
tool_calls=[
ToolCallRequest(id=tc.id, name=tc.name, args=tc.args)
for tc in response.tool_calls
],
)
pending_tool_results = []
for tc in response.tool_calls:
last_tool_names.append(tc.name)

Expand Down Expand Up @@ -376,8 +444,19 @@ def run(self, user_message: str) -> Generator[AgentEvent, None, None]:
tool_name=tc.name,
content=result,
)
pending_tool_results.append(AgentMessage(
role="tool_result",
content=result[:4_000],
tool_call_id=tc.id,
tool_name=tc.name,
))

execution_state = (
"Latest tool round completed: "
+ ", ".join(message.tool_name for message in pending_tool_results)
)

# Loop back to let the LLM continue
# Loop back with only the latest tool round, not full history.
continue

else:
Expand Down
70 changes: 70 additions & 0 deletions devagent/session/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from __future__ import annotations

import json
import sqlite3

from devagent.core.llm import AgentMessage, ToolCallRequest
from devagent.session import store
Expand Down Expand Up @@ -77,6 +78,75 @@ def build_messages(
return messages


def _clip(text: str, max_chars: int) -> str:
"""Keep a bounded representation while preserving both ends of a result."""
if len(text) <= max_chars:
return text
half = max_chars // 2
return text[:half] + "\n...[truncated]...\n" + text[-half:]


def build_resume_context(
session_id: str,
db_path=None,
max_chars: int = 3_000,
) -> str:
"""Return compact prior-session context for a new user turn.

Full events remain persisted for history and audit, but only a bounded
summary or recent event window is carried into the next LLM request.
"""
try:
summary = store.get_compressed_summary(session_id, db_path=db_path)
if summary:
return _clip(summary, max_chars)
events = store.get_events(session_id, db_path=db_path)[-4:]
except sqlite3.OperationalError:
return ""
if not events:
return ""

lines: list[str] = []
for event in events:
role = event.get("role", "unknown")
content = (event.get("content") or "").strip()
if content:
lines.append(f"[{role}] {_clip(content, 600)}")
return _clip("\n".join(lines), max_chars)


def build_iteration_messages(
system_prompt: str,
user_message: str,
*,
resume_context: str = "",
execution_state: str = "",
assistant_message: AgentMessage | None = None,
tool_results: list[AgentMessage] | None = None,
) -> list[AgentMessage]:
"""Build a bounded request for one ReAct iteration.

The complete session history is deliberately excluded. Only the current
user request, compact state, and the latest tool round are sent.
"""
messages = [AgentMessage(role="system", content=system_prompt)]
if resume_context:
messages.append(AgentMessage(
role="system",
content="## Prior session context (bounded)\n" + _clip(resume_context, 3_000),
))
messages.append(AgentMessage(role="user", content=user_message))
if execution_state:
messages.append(AgentMessage(
role="system",
content="## Current execution state\n" + _clip(execution_state, 3_000),
))
if assistant_message is not None:
messages.append(assistant_message)
messages.extend(tool_results or [])
return messages


def estimate_tokens(messages: list[AgentMessage]) -> int:
"""Estimate message tokens, including tool-call arguments."""
total = 0
Expand Down
35 changes: 35 additions & 0 deletions tests/test_phase8.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,3 +392,38 @@ def test_fit_request_context_counts_tools_and_keeps_newest_messages():
assert fitted[0].role == "system"
assert "latest request" in fitted[-1].content
assert len(fitted) < len(messages)


def test_iteration_messages_do_not_replay_old_history():
from devagent.core.llm import AgentMessage, ToolCallRequest
from devagent.session.history import build_iteration_messages

assistant = AgentMessage(
role="assistant",
tool_calls=[ToolCallRequest(id="1", name="read_file", args={"path": "x.py"})],
)
result = AgentMessage(
role="tool_result",
content="latest result",
tool_call_id="1",
tool_name="read_file",
)
messages = build_iteration_messages(
"system",
"current request",
assistant_message=assistant,
tool_results=[result],
)

contents = [message.content for message in messages]
assert "current request" in contents
assert "latest result" in contents
assert "old conversation" not in contents
assert len(messages) == 4


def test_conversational_requests_do_not_need_tool_schemas():
from devagent.agent.loop import _request_needs_tools

assert not _request_needs_tools("What is 2 + 2?")
assert _request_needs_tools("Read the project README and summarize it.")
Loading