diff --git a/.gitignore b/.gitignore index 8b51d95..d42fd3e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,11 @@ wheels/ # Chainlit file storage .files/ .codex +.langgraph_api/ # Local RAG index .rag/ + +# Local test/browser artifacts +.pytest_cache/ +.playwright-mcp/ diff --git a/.langgraph_api/.langgraph_checkpoint.1.pckl b/.langgraph_api/.langgraph_checkpoint.1.pckl deleted file mode 100644 index 5a1fbb4..0000000 Binary files a/.langgraph_api/.langgraph_checkpoint.1.pckl and /dev/null differ diff --git a/.langgraph_api/.langgraph_checkpoint.2.pckl b/.langgraph_api/.langgraph_checkpoint.2.pckl deleted file mode 100644 index 73e3b49..0000000 Binary files a/.langgraph_api/.langgraph_checkpoint.2.pckl and /dev/null differ diff --git a/.langgraph_api/.langgraph_checkpoint.3.pckl b/.langgraph_api/.langgraph_checkpoint.3.pckl deleted file mode 100644 index 9ca463f..0000000 Binary files a/.langgraph_api/.langgraph_checkpoint.3.pckl and /dev/null differ diff --git a/.langgraph_api/.langgraph_ops.pckl b/.langgraph_api/.langgraph_ops.pckl deleted file mode 100644 index 954d78f..0000000 Binary files a/.langgraph_api/.langgraph_ops.pckl and /dev/null differ diff --git a/.langgraph_api/.langgraph_retry_counter.pckl b/.langgraph_api/.langgraph_retry_counter.pckl deleted file mode 100644 index 0a47446..0000000 Binary files a/.langgraph_api/.langgraph_retry_counter.pckl and /dev/null differ diff --git a/.langgraph_api/store.pckl b/.langgraph_api/store.pckl deleted file mode 100644 index 0a47446..0000000 Binary files a/.langgraph_api/store.pckl and /dev/null differ diff --git a/.langgraph_api/store.vectors.pckl b/.langgraph_api/store.vectors.pckl deleted file mode 100644 index 0a47446..0000000 Binary files a/.langgraph_api/store.vectors.pckl and /dev/null differ diff --git a/deepagent.toml b/deepagent.toml index b171e89..e0e1332 100644 --- a/deepagent.toml +++ b/deepagent.toml @@ -1,22 +1,22 @@ -#[model] -#provider = "ollama" -#base_url = "http://127.0.0.1:11434" -#temperature = 0.3 -#repeat_penalty = 1.1 +[model] +provider = "ollama" +base_url = "http://10.0.1.152:11434" +temperature = 0.3 +repeat_penalty = 1.1 -#name = "gemma4:26b" +name = "qwen3.6:35b-a3b" #models = ["gpt-oss:20b", "gemma4:26b", "qwen3.6:27b"] -#reasoning_effort = "medium" +reasoning_effort = "medium" # For LM Studio or another OpenAI-compatible server, switch to: -[model] -provider = "openai_compatible" -base_url = "http://10.0.1.152:1234/v1" -temperature = 0 -name = "qwen3.6-35b-a3b-mtp" -reasoning_effort = "medium" -api_key = "optional" +#[model] +#provider = "openai_compatible" +#base_url = "http://10.0.1.152:1234/v1" +#temperature = 0 +#name = "qwen3.6-35b-a3b-mtp" +#reasoning_effort = "medium" +#api_key = "optional" @@ -24,23 +24,31 @@ api_key = "optional" tool_name_prefix = false stateful = true -[mcp.servers.repo] +#[mcp.servers.repo] +#transport = "stdio" +#command = "npx" +#args = ["-y", "@modelcontextprotocol/server-filesystem@2025.8.21", "."] +#cwd = "." + +[mcp.servers.playwright] transport = "stdio" command = "npx" -args = ["-y", "@modelcontextprotocol/server-filesystem@2025.8.21", "."] +args = ["-y", "@playwright/mcp@latest", "--headless"] +# Set true to run Playwright MCP without a visible browser window. +#headless = true cwd = "." [agent] recursion_limit = 200 skills = ["skills"] -mcp_servers = ["repo"] +mcp_servers = [ "playwright"] summarization_middleware_enabled = true # Optional token thresholds used when the installed LangChain middleware supports them. summarization_trigger_tokens = 150000 summarization_keep_tokens = 10000 [rag] -enabled = true +enabled = false persist_directory = ".rag" include_globs = ["README.md", "chainlit.md", "prompts/**/*.md", "skills/**/*.md"] exclude_globs = ["AGENTS.md", "AGENT.md"] @@ -55,23 +63,23 @@ provider = "auto" # base_url = "http://127.0.0.1:11434" # Async subagent backed by the local LangGraph Agent Protocol server. -[[async_subagents]] -name = "async-researcher" -description = "Runs longer research jobs in the background, such as reading documentation or running code analysis tools." -graph_id = "async-researcher" -url = "http://127.0.0.1:2024" +#[[async_subagents]] +#name = "async-researcher" +#description = "Runs longer research jobs in the background, such as reading documentation or running code analysis tools." +#graph_id = "async-researcher" +#url = "http://127.0.0.1:2024" # If you run only the co-deployed langgraph.json setup, omit url for ASGI transport. # In Chainlit, set url here or use CHAINLIT_ASYNC_SUBAGENT_URL for HTTP transport. #url = "https://researcher-deployment.langsmith.dev" #headers = { Authorization = "Bearer ${RESEARCHER_TOKEN}" } -[[subagents]] -name = "repo-researcher" -description = "Researches the current repository, explains architecture, and identifies the right files to change." -system_prompt_file = "prompts/repo-researcher.md" -skills = ["skills"] -mcp_servers = ["repo"] +#[[subagents]] +#name = "repo-researcher" +#description = "Researches the current repository, explains architecture, and identifies the right files to change." +#system_prompt_file = "prompts/repo-researcher.md" +#skills = ["skills"] +#mcp_servers = ["repo"] [chainlit] # Set false to hide model selection in chat settings and Modes. @@ -82,7 +90,9 @@ reasoning_mode_enabled = true startup_status_enabled = false chronological_ui_enabled = false commands = [ - { name = "ask-researcher", description = "Delegate to repo-researcher.", target = "subagent", value = "repo-researcher", template = "{input}" }, - { name = "repo-readme", description = "Run an MCP tool directly.", target = "mcp_tool", value = "repo_read_file", mcp_server = "repo", template = "{\"path\":\"README.md\"}" }, + # { name = "ask-researcher", description = "Delegate to repo-researcher.", target = "subagent", value = "repo-researcher", template = "{input}" }, + + # { name = "repo-readme", description = "Run an MCP tool directly.", target = "mcp_tool", value = "repo_read_file", mcp_server = "repo", template = "{\"path\":\"README.md\"}" }, + { name = "summarize", description = "Apply a prompt template.", target = "prompt", value = "Summarize the input", template = "Summarize this:\n{input}" } ] diff --git a/deepagent_runtime.py b/deepagent_runtime.py index 3ccf463..6e7a965 100644 --- a/deepagent_runtime.py +++ b/deepagent_runtime.py @@ -68,6 +68,7 @@ DEFAULT_RECURSION_LIMIT = 100 PROJECT_ROOT = Path(__file__).resolve().parent DEEPAGENT_ARTIFACTS_DIRECTORY = Path(".files/deepagent") +LARGE_TOOL_RESULTS_PREFIX = "/large_tool_results" AGENTS_MD_FILENAME = "AGENTS.md" logger = logging.getLogger(__name__) OPENAI_CHAT_COMPLETIONS_PATH_SUFFIX = "/chat/completions" @@ -522,6 +523,98 @@ def deepagent_artifacts_route_prefix(project_root: Path | None = None) -> str: return f"{deepagent_artifacts_root(project_root).as_posix().rstrip('/')}/" +def large_tool_result_path_to_local( + path_value: str, + project_root: Path | None = None, +) -> str: + """Convert a large tool result path into a local artifact path. + + Args: + path_value: The path value to convert. + project_root: Project root used to resolve local paths. + + Returns: + The local artifact path, or the original value when it is not a large + tool result path. + """ + normalized = path_value.strip().replace("\\", "/") + if normalized != LARGE_TOOL_RESULTS_PREFIX and not normalized.startswith( + f"{LARGE_TOOL_RESULTS_PREFIX}/" + ): + return path_value + + relative = PurePosixPath(normalized.removeprefix("/")) + if ".." in relative.parts: + return path_value + + return str((deepagent_artifacts_root(project_root) / Path(*relative.parts)).resolve()) + + +def state_file_text_content(file_data: Any) -> str | None: + """Return text content from a DeepAgents state file entry. + + Args: + file_data: State-backed file data. + + Returns: + Text content when the state entry is text-like, otherwise None. + """ + if not isinstance(file_data, dict): + return None + + encoding = file_data.get("encoding") + if encoding not in {None, "utf-8"}: + return None + + content = file_data.get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + return "\n".join(str(line) for line in content) + return None + + +def materialize_state_large_tool_result( + path_value: str, + state: Any, + project_root: Path, +) -> str: + """Materialize a state-backed large tool result into the local artifacts dir. + + Args: + path_value: The requested large tool result path. + state: Current agent state, potentially including state-backed files. + project_root: Project root used to resolve local paths. + + Returns: + The local artifact path for large tool result paths, otherwise the + original path. + """ + local_path_value = large_tool_result_path_to_local(path_value, project_root) + if local_path_value == path_value: + return path_value + + local_path = Path(local_path_value) + local_path.parent.mkdir(parents=True, exist_ok=True) + if local_path.exists(): + return local_path_value + + if not isinstance(state, dict): + return local_path_value + + files = state.get("files") + if not isinstance(files, dict): + return local_path_value + + normalized = path_value.strip().replace("\\", "/") + content = state_file_text_content(files.get(normalized)) + if content is None: + return local_path_value + + local_path.write_text(content, encoding="utf-8") + return local_path_value + + def summarize_tool_exception(exc: Exception, *, limit: int = 400) -> str: """Summarize tool exception. @@ -554,31 +647,49 @@ def summarize_tool_exception(exc: Exception, *, limit: int = 400) -> str: } -def _map_workspace_tool_path_value(value: Any, project_root: Path) -> Any: +def _map_workspace_tool_path_value( + value: Any, + project_root: Path, + state: Any = None, +) -> Any: """Map one virtual workspace path value to a local path. Args: value: Value to normalize, convert, or serialize. project_root: Project root used to resolve local paths. + state: Current agent state used to materialize state-backed artifacts. Returns: The mapped value. """ if isinstance(value, str): - return virtual_workspace_path_to_local(value, project_root) + return materialize_state_large_tool_result( + virtual_workspace_path_to_local(value, project_root), + state, + project_root, + ) if isinstance(value, list): - return [_map_workspace_tool_path_value(item, project_root) for item in value] + return [ + _map_workspace_tool_path_value(item, project_root, state) for item in value + ] if isinstance(value, tuple): - return tuple(_map_workspace_tool_path_value(item, project_root) for item in value) + return tuple( + _map_workspace_tool_path_value(item, project_root, state) for item in value + ) return value -def map_workspace_paths_in_tool_args(args: Any, project_root: Path | None = None) -> Any: +def map_workspace_paths_in_tool_args( + args: Any, + project_root: Path | None = None, + state: Any = None, +) -> Any: """Map workspace paths in tool args. Args: args: Parsed command-line arguments. project_root: Project root used to resolve local paths. + state: Current agent state used to materialize state-backed artifacts. Returns: The mapped value. @@ -590,7 +701,7 @@ def map_workspace_paths_in_tool_args(args: Any, project_root: Path | None = None mapped = dict(args) for key, value in args.items(): if str(key).lower() in WORKSPACE_PATH_TOOL_ARG_KEYS: - mapped[key] = _map_workspace_tool_path_value(value, root) + mapped[key] = _map_workspace_tool_path_value(value, root, state) return mapped @@ -612,7 +723,11 @@ def _map_workspace_path_args(self, request: ToolCallRequest) -> None: request: The request value. """ args = request.tool_call.get("args") - mapped_args = map_workspace_paths_in_tool_args(args, self.project_root) + mapped_args = map_workspace_paths_in_tool_args( + args, + self.project_root, + request.state, + ) if mapped_args is not args: request.tool_call["args"] = mapped_args @@ -2805,6 +2920,7 @@ async def get_agent( ) for subagent in self.config.extensions.async_subagents ) + backend = build_deepagent_backend(project_root=self.project_root) agent = create_deep_agent( model=model, tools=main_tools or None, @@ -2817,7 +2933,7 @@ async def get_agent( rag_enabled=rag_tool_enabled, ), middleware=middleware, - backend=self._build_backend, + backend=backend, store=self.store, checkpointer=self.checkpointer, skills=list(self.config.extensions.skills) or None, diff --git a/tests/test_deepagent_runtime_rag.py b/tests/test_deepagent_runtime_rag.py index 37c5cf9..a808490 100644 --- a/tests/test_deepagent_runtime_rag.py +++ b/tests/test_deepagent_runtime_rag.py @@ -9,6 +9,7 @@ from langchain.agents.middleware.types import ToolCallRequest from langchain_core.messages import AIMessageChunk, ToolMessage +from deepagents.backends import CompositeBackend from langgraph.checkpoint.memory import MemorySaver from langgraph.store.memory import InMemoryStore import pytest @@ -542,6 +543,83 @@ def handler(updated_request: ToolCallRequest) -> ToolMessage: assert result.status == "success" +def test_tool_execution_middleware_maps_large_tool_result_paths(tmp_path: Path) -> None: + """Verify that large tool result paths map to project-local artifacts.""" + middleware = ToolExecutionResilienceMiddleware(project_root=tmp_path) + request = ToolCallRequest( + tool_call={ + "id": "call-1", + "name": "read_file", + "args": {"path": "/large_tool_results/tool-call-1"}, + "type": "tool_call", + }, + tool=SimpleNamespace(name="read_file"), + state={}, + runtime=SimpleNamespace(), + ) + + def handler(updated_request: ToolCallRequest) -> ToolMessage: + """Capture tool-call arguments for middleware tests.""" + assert updated_request.tool_call["args"] == { + "path": str( + tmp_path / ".files/deepagent/large_tool_results/tool-call-1" + ) + } + return ToolMessage( + content="ok", + name="read_file", + tool_call_id="call-1", + status="success", + ) + + result = middleware.wrap_tool_call(request, handler) + + assert isinstance(result, ToolMessage) + assert result.status == "success" + + +def test_tool_execution_middleware_materializes_state_large_tool_results( + tmp_path: Path, +) -> None: + """Verify that state-backed large tool results are materialized for MCP tools.""" + middleware = ToolExecutionResilienceMiddleware(project_root=tmp_path) + request = ToolCallRequest( + tool_call={ + "id": "call-1", + "name": "read_file", + "args": {"path": "/large_tool_results/tool-call-1"}, + "type": "tool_call", + }, + tool=SimpleNamespace(name="read_file"), + state={ + "files": { + "/large_tool_results/tool-call-1": { + "content": "tool output", + "encoding": "utf-8", + } + } + }, + runtime=SimpleNamespace(), + ) + local_path = tmp_path / ".files/deepagent/large_tool_results/tool-call-1" + + def handler(updated_request: ToolCallRequest) -> ToolMessage: + """Capture tool-call arguments and materialized file content.""" + assert updated_request.tool_call["args"] == {"path": str(local_path)} + assert local_path.read_text(encoding="utf-8") == "tool output" + return ToolMessage( + content="ok", + name="read_file", + tool_call_id="call-1", + status="success", + ) + + result = middleware.wrap_tool_call(request, handler) + + assert isinstance(result, ToolMessage) + assert result.status == "success" + + def test_agent_runtime_initialize_runs_rag_startup_check( tmp_path: Path, monkeypatch, @@ -666,7 +744,7 @@ def search( monkeypatch.setattr(deepagent_runtime, "create_deep_agent", fake_create_deep_agent) - runtime = AgentRuntime(make_runtime_config(tmp_path)) + runtime = AgentRuntime(make_runtime_config(tmp_path), project_root=tmp_path) runtime._store = InMemoryStore() runtime._checkpointer = MemorySaver() runtime._rag_service = ReadyRAG() @@ -677,6 +755,9 @@ def search( assert "search_workspace_knowledge" in tool_names middleware = captured["kwargs"]["middleware"] assert any(isinstance(item, ToolExecutionResilienceMiddleware) for item in middleware) + backend = captured["kwargs"]["backend"] + assert isinstance(backend, CompositeBackend) + assert backend.artifacts_root == str(tmp_path / ".files/deepagent") def test_get_agent_includes_summarization_middleware_when_enabled(