Skip to content

Commit ac200bc

Browse files
committed
feat: introduce agent mode functionality and enhance backend handling
- Added support for `agent_mode` in `ChatRequest` and `build_agent`, allowing for "chat" and "research" modes to control agent behavior and subagent inclusion. - Implemented `_apply_mode_harness_profile` to register provider harness profiles based on the selected agent mode. - Refactored `build_system_prompt` to accommodate workspace rules without exposing host paths. - Enhanced event handling in `should_emit_langgraph_event` to differentiate behavior based on agent mode. - Updated tests to validate new agent mode functionality and ensure proper integration across components. - Improved UI components to reflect changes in agent mode settings and interactions.
1 parent 9d61615 commit ac200bc

32 files changed

Lines changed: 1454 additions & 217 deletions

agent/app/agent.py

Lines changed: 63 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
from __future__ import annotations
44

55
import logging
6-
from typing import Any
6+
import threading
7+
from typing import Any, Literal
78

89
from app.config import get_settings
910
from app.prompts import (
@@ -13,27 +14,38 @@
1314
SYSTEM_PROMPT,
1415
build_system_prompt,
1516
)
17+
from app.workspace_backend import make_workspace_backend_factory
1618

1719
logger = logging.getLogger(__name__)
1820

21+
AgentMode = Literal["chat", "research"]
22+
23+
_HARNESS_LOCK = threading.Lock()
24+
_PROVIDER_PROFILE_KEYS = (
25+
"openai",
26+
"anthropic",
27+
"google",
28+
"google_genai",
29+
"azure_openai",
30+
"bedrock",
31+
)
32+
1933

2034
def build_agent(
2135
session_id: str | None = None,
2236
working_dir: str | None = None,
2337
checkpointer=None,
2438
store=None,
2539
tools: list[Any] | None = None,
26-
include_subagents: bool = True,
40+
include_subagents: bool | None = None,
41+
agent_mode: AgentMode = "chat",
2742
model: Any | None = None,
2843
):
2944
"""Create a DeepAgent graph for the given session context.
3045
31-
``model`` may be a LangChain chat model instance or a provider:model string.
32-
When omitted, falls back to ``settings.agent_model``.
33-
34-
When ``working_dir`` validates, filesystem tools (`ls` / `read_file` / …)
35-
and shell `execute` use that directory as the default root — not an
36-
ephemeral in-memory StateBackend.
46+
``agent_mode="chat"`` (default) disables the ``task`` tool and all sync
47+
subagents, including DeepAgents' auto-injected general-purpose agent.
48+
``agent_mode="research"`` enables researcher/coder/analyst plus GP.
3749
"""
3850
del session_id # reserved for future per-session customization
3951
settings = get_settings()
@@ -50,16 +62,15 @@ def build_agent(
5062
"deepagents is not installed. Install with: pip install -e '.[agent]'"
5163
) from exc
5264

65+
mode = _resolve_agent_mode(agent_mode, include_subagents)
5366
validated = _resolve_working_dir(working_dir)
5467
agent_tools = tools if tools is not None else _safe_get_tools()
55-
subagents = _build_subagents(settings) if include_subagents else []
68+
subagents = _build_subagents(settings) if mode == "research" else []
5669

5770
kwargs: dict[str, Any] = {
5871
"model": model if model is not None else settings.agent_model,
5972
"tools": agent_tools,
60-
"system_prompt": build_system_prompt(
61-
working_dir=str(validated) if validated is not None else None
62-
),
73+
"system_prompt": build_system_prompt(has_workspace=validated is not None),
6374
"subagents": subagents,
6475
"interrupt_on": settings.interrupt_config,
6576
"checkpointer": checkpointer,
@@ -68,25 +79,43 @@ def build_agent(
6879

6980
kwargs["skills"] = [str(settings.skills_dir)]
7081
kwargs["memory"] = [str(settings.memories_dir)]
71-
kwargs["backend"] = _make_backend_factory(validated, LocalShellBackend, StateBackend, CompositeBackend)
72-
return create_deep_agent(**kwargs)
82+
kwargs["backend"] = make_workspace_backend_factory(
83+
validated, LocalShellBackend, StateBackend, CompositeBackend
84+
)
7385

86+
with _HARNESS_LOCK:
87+
_apply_mode_harness_profile(mode)
88+
return create_deep_agent(**kwargs)
7489

75-
def _make_backend_factory(validated, local_shell_cls, state_cls, composite_cls):
76-
"""Bind filesystem + shell to working_dir when present; else StateBackend."""
7790

78-
def factory(rt):
79-
if validated is not None:
80-
# virtual_mode: `/` and relative paths map under working_dir.
81-
# LocalShellBackend also sets shell cwd to working_dir.
82-
return local_shell_cls(
83-
root_dir=str(validated),
84-
virtual_mode=True,
85-
inherit_env=True,
86-
)
87-
return composite_cls(default=state_cls(rt), routes={})
91+
def _resolve_agent_mode(
92+
agent_mode: AgentMode,
93+
include_subagents: bool | None,
94+
) -> AgentMode:
95+
if include_subagents is None:
96+
return "research" if agent_mode == "research" else "chat"
97+
# Backward-compatible override used by older tests/callers.
98+
return "research" if include_subagents else "chat"
99+
100+
101+
def _apply_mode_harness_profile(mode: AgentMode) -> None:
102+
"""Register provider harness profiles so chat mode truly drops ``task``."""
103+
try:
104+
from deepagents import (
105+
GeneralPurposeSubagentProfile,
106+
HarnessProfileConfig,
107+
register_harness_profile,
108+
)
109+
except ImportError:
110+
logger.warning("Harness profile APIs unavailable; cannot disable general-purpose")
111+
return
88112

89-
return factory
113+
enabled = mode == "research"
114+
profile = HarnessProfileConfig(
115+
general_purpose_subagent=GeneralPurposeSubagentProfile(enabled=enabled)
116+
)
117+
for key in _PROVIDER_PROFILE_KEYS:
118+
register_harness_profile(key, profile)
90119

91120

92121
def _resolve_working_dir(working_dir: str | None):
@@ -152,4 +181,10 @@ def _build_subagents(settings) -> list[dict[str, Any]]:
152181

153182

154183
# Re-export for tests that imported SYSTEM_PROMPT via this module historically.
155-
__all__ = ["SYSTEM_PROMPT", "build_agent", "_build_subagents", "_resolve_working_dir"]
184+
__all__ = [
185+
"SYSTEM_PROMPT",
186+
"build_agent",
187+
"_build_subagents",
188+
"_resolve_working_dir",
189+
"_apply_mode_harness_profile",
190+
]

agent/app/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ class ChatRequest(BaseModel):
8080
config: ChatConfig = Field(default_factory=ChatConfig)
8181
session_id: str | None = None
8282
working_dir: str | None = None
83+
# chat = direct tools only; research = DeepAgents task/subagents
84+
agent_mode: str = "chat"
8385

8486
model_config = {"frozen": False, "extra": "ignore"}
8587

agent/app/prompts.py

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,22 +20,27 @@
2020
"""
2121

2222

23-
def build_system_prompt(working_dir: str | None = None) -> str:
24-
"""Assemble the system prompt, including working-directory path rules."""
25-
if not working_dir:
23+
def build_system_prompt(has_workspace: bool = False, working_dir: str | None = None) -> str:
24+
"""Assemble the system prompt, including workspace virtual-path rules.
25+
26+
``working_dir`` is accepted for backward compatibility but is never injected
27+
into the prompt (host absolute paths cause models to concatenate badly).
28+
"""
29+
del working_dir
30+
if not has_workspace:
2631
return SYSTEM_PROMPT
2732

28-
workspace_rules = f"""
33+
workspace_rules = """
2934
3035
## Working directory
31-
- The bound project directory is: `{working_dir}`
36+
- The bound project is mounted at the virtual root `/workspace`.
3237
- Filesystem tools (`ls`, `read_file`, `write_file`, `edit_file`, `glob`, `grep`)
33-
treat `/` as this directory. Prefer virtual paths like `/`, `/src`, `/README.md`.
34-
- Shell `execute` also starts with this directory as its cwd.
35-
- Do NOT invent a `/workspace` prefix and do NOT concatenate `/workspace` with
36-
Windows absolute paths (e.g. `/workspaceD:\\...` is wrong).
37-
- Prefer relative or virtual paths over host absolute paths unless the user
38-
explicitly asks for a path outside the project.
38+
MUST use paths like `/workspace`, `/workspace/src`, `/workspace/README.md`.
39+
- Shell `execute` already starts with the project directory as its cwd; prefer
40+
relative shell paths (e.g. `ls`, `type README.md`) rather than host paths.
41+
- NEVER use Windows/host absolute paths (e.g. `D:\\...`).
42+
- NEVER concatenate `/workspace` with a host path
43+
(e.g. `/workspaceD:\\...` is invalid).
3944
"""
4045
return SYSTEM_PROMPT + workspace_rules
4146

@@ -46,6 +51,7 @@ def build_system_prompt(working_dir: str | None = None) -> str:
4651
- Gather and synthesize information thoroughly.
4752
- Prefer verifiable sources and explicit uncertainty when evidence is weak.
4853
- Use memory search when prior findings may help.
54+
- When filesystem tools are available, use only `/workspace/<relative-path>`.
4955
5056
## Output
5157
- Summarize findings clearly.
@@ -59,6 +65,7 @@ def build_system_prompt(working_dir: str | None = None) -> str:
5965
- Write clean, maintainable code with clear error handling.
6066
- Prefer small, reviewable changes over large rewrites.
6167
- Use workspace filesystem tools carefully when available.
68+
- Filesystem paths must be `/workspace/<relative-path>` only.
6269
6370
## Output
6471
- Provide complete, runnable snippets when asked.

agent/app/routers/agent.py

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import json
66
import logging
7+
import traceback
78
from collections.abc import AsyncGenerator
89
from typing import Any
910

@@ -39,7 +40,11 @@ async def agent_chat(request: ChatRequest) -> ChatResponse:
3940
except HTTPException:
4041
raise
4142
except Exception as exc:
42-
logger.exception("agent chat failed")
43+
logger.exception(
44+
"agent chat failed session_id=%s mode=%s",
45+
request.session_id,
46+
request.agent_mode,
47+
)
4348
raise HTTPException(status_code=500, detail=str(exc)) from exc
4449

4550

@@ -58,43 +63,57 @@ async def agent_stream(request: ChatRequest):
5863

5964

6065
async def _stream_agent(request: ChatRequest) -> AsyncGenerator[str, None]:
66+
open_tools: dict[str, dict[str, Any]] = {}
67+
thinking_acc = ""
68+
emitted_tool_ids: set[str] = set()
6169
try:
6270
agent = _build_request_agent(request)
63-
thinking_acc = ""
64-
emitted_tool_ids: set[str] = set()
6571
async for event in agent.astream_events(
6672
{"messages": _request_to_agent_messages(request)},
6773
config=_thread_config(request),
6874
version="v2",
6975
):
70-
if not should_emit_langgraph_event(event):
76+
if not should_emit_langgraph_event(event, agent_mode=request.agent_mode):
7177
continue
7278
for sse_data in _format_sse_events(
7379
event,
7480
thinking_acc=thinking_acc,
7581
emitted_tool_ids=emitted_tool_ids,
82+
open_tools=open_tools,
7683
):
77-
# Keep cumulative thinking for suffix-delta extraction.
7884
if sse_data.startswith("event: thinking\n"):
7985
data_line = sse_data.split("\n", 2)[1]
8086
payload = json.loads(data_line.removeprefix("data: "))
8187
thinking_acc += str(payload.get("content") or "")
8288
yield sse_data
89+
for frame in _close_open_tools(open_tools, reason="Stream ended without tool_end"):
90+
yield frame
8391
yield _sse("done", {"finished": True})
8492
except Exception as exc:
85-
logger.exception("agent stream failed")
93+
tb = traceback.format_exc()
94+
logger.error(
95+
"agent stream failed session_id=%s mode=%s error=%s\n%s",
96+
request.session_id,
97+
request.agent_mode,
98+
exc,
99+
tb,
100+
)
101+
for frame in _close_open_tools(open_tools, reason=str(exc) or "stream error"):
102+
yield frame
86103
yield _sse("error", {"message": str(exc)})
87104

88105

89106
def _build_request_agent(request: ChatRequest):
90107
"""Assemble agent using the provider binding from the chat request."""
91108
model = resolve_chat_model(request.config)
109+
mode = "research" if request.agent_mode == "research" else "chat"
92110
return build_agent(
93111
session_id=request.session_id,
94112
working_dir=request.working_dir,
95113
checkpointer=get_checkpointer(),
96114
store=get_store(),
97115
model=model,
116+
agent_mode=mode,
98117
)
99118

100119

@@ -104,9 +123,11 @@ def _request_to_agent_messages(request: ChatRequest) -> list[dict[str, str]]:
104123

105124
def _thread_config(request: ChatRequest) -> dict[str, Any]:
106125
settings = get_settings()
126+
# Keep a finite recursion ceiling for research; chat stays leaner.
127+
limit = settings.agent_recursion_limit if request.agent_mode == "research" else 50
107128
config: dict[str, Any] = {
108129
"configurable": {"thread_id": request.session_id or "default"},
109-
"recursion_limit": settings.agent_recursion_limit,
130+
"recursion_limit": limit,
110131
}
111132
if request.config.model:
112133
config["configurable"]["model"] = request.config.model
@@ -118,11 +139,13 @@ def _format_sse_events(
118139
*,
119140
thinking_acc: str = "",
120141
emitted_tool_ids: set[str] | None = None,
142+
open_tools: dict[str, dict[str, Any]] | None = None,
121143
) -> list[str]:
122144
"""Convert a LangGraph event into zero or more SSE payloads."""
123145
kind = event.get("event")
124146
run_id = event.get("run_id") or event.get("id")
125147
tool_ids = emitted_tool_ids if emitted_tool_ids is not None else set()
148+
open_map = open_tools if open_tools is not None else {}
126149

127150
if kind == "on_chat_model_stream":
128151
chunk = event.get("data", {}).get("chunk")
@@ -146,18 +169,47 @@ def _format_sse_events(
146169
return []
147170
if tool_id:
148171
tool_ids.add(tool_id)
172+
open_map[tool_id] = {
173+
"name": payload.get("name") or "",
174+
"server_id": payload.get("server_id"),
175+
}
149176
return [_sse("tool_start", payload)]
150177

151178
if kind == "on_tool_end":
179+
tool_id = str(run_id) if run_id else ""
180+
open_map.pop(tool_id, None)
152181
return [_format_tool_end(event, run_id, error=None)]
153182

154183
if kind == "on_tool_error":
184+
tool_id = str(run_id) if run_id else ""
185+
open_map.pop(tool_id, None)
155186
err = event.get("data", {}).get("error") or event.get("data", {}).get("message")
156187
return [_format_tool_end(event, run_id, error=str(err or "tool error"))]
157188

158189
return []
159190

160191

192+
def _close_open_tools(
193+
open_tools: dict[str, dict[str, Any]],
194+
*,
195+
reason: str,
196+
) -> list[str]:
197+
frames: list[str] = []
198+
for tool_id, meta in list(open_tools.items()):
199+
payload: dict[str, Any] = {
200+
"id": tool_id,
201+
"name": str(meta.get("name") or "unknown"),
202+
"output": "",
203+
"status": "error",
204+
"error": reason,
205+
}
206+
if meta.get("server_id"):
207+
payload["server_id"] = meta["server_id"]
208+
frames.append(_sse("tool_end", payload))
209+
open_tools.pop(tool_id, None)
210+
return frames
211+
212+
161213
def _format_tool_end(event: dict[str, Any], run_id: Any, error: str | None) -> str:
162214
name = str(event.get("name") or "")
163215
output = event.get("data", {}).get("output", "")
@@ -168,7 +220,6 @@ def _format_tool_end(event: dict[str, Any], run_id: Any, error: str | None) -> s
168220
"status": "error" if error else "complete",
169221
}
170222
if name == "mcp_bridge":
171-
# Prefer the expanded tool name from inputs when available.
172223
raw_input = event.get("data", {}).get("input")
173224
if isinstance(raw_input, dict):
174225
payload["name"] = str(

0 commit comments

Comments
 (0)