diff --git a/devagent/agent/loop.py b/devagent/agent/loop.py index 7414ac8..314208c 100644 --- a/devagent/agent/loop.py +++ b/devagent/agent/loop.py @@ -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 @@ -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)) # --------------------------------------------------------------------------- @@ -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 @@ -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" @@ -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) @@ -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: diff --git a/devagent/session/history.py b/devagent/session/history.py index 9609e05..325a434 100644 --- a/devagent/session/history.py +++ b/devagent/session/history.py @@ -11,6 +11,7 @@ from __future__ import annotations import json +import sqlite3 from devagent.core.llm import AgentMessage, ToolCallRequest from devagent.session import store @@ -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 diff --git a/tests/test_phase8.py b/tests/test_phase8.py index 2d630f0..4064e88 100644 --- a/tests/test_phase8.py +++ b/tests/test_phase8.py @@ -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.")