fix(agent): keep project-root cwd when the default workspace is filled in

Job tools still receive the default alias. That fill-in is marked so cwd
resolution can ignore it and keep the single-workspace project root at the
SDK boundary.

Co-authored-by: celanwang <lumworn@gmail.com>
This commit is contained in:
Cursor Agent 2026-11-02 12:00:06 +00:00
parent ffda20c451
commit f2c5b16ae3
No known key found for this signature in database
4 changed files with 82 additions and 7 deletions

View file

@ -70,17 +70,20 @@ class BaseAgentWrapper(BaseComponent):
return workspaces[alias].workspace_path
def _call_cwd(self, kwargs: dict[str, Any]) -> Path:
"""Working directory for this call: explicit cwd config wins, then the bound workspace.
"""Working directory for this call.
A relative configured cwd resolves against the bound workspace;
an absolute path keeps its explicit meaning.
An explicit cwd wins. A relative one resolves against the bound
workspace; an absolute path keeps its explicit meaning. Otherwise an
explicitly selected workspace selects that workspace root. The default
alias injected for job tools is not an explicit selection, so a
single-workspace call keeps the project root.
"""
if self._cwd:
cwd = Path(self._cwd)
if cwd.is_absolute():
return cwd
return self._call_workspace_path(kwargs) / cwd
if kwargs.get("workspace"):
if kwargs.get("workspace") and kwargs.get("_workspace_explicit", True):
return self._call_workspace_path(kwargs)
return self.cwd
@ -240,10 +243,18 @@ class BaseAgentWrapper(BaseComponent):
return resolved
def _merged_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]:
"""Merge component defaults with call-time kwargs; call-time values win."""
"""Merge component defaults with call-time kwargs; call-time values win.
An omitted workspace becomes the default alias so job tools keep an
explicit binding. ``_workspace_explicit`` records whether the caller or
the component config selected that alias; cwd resolution uses it to
keep the historical project root when the alias was only filled in.
"""
merged = {**self.kwargs, **kwargs}
if (workspaces := _context_workspaces(self.app_context)) and not merged.get("workspace"):
explicit_workspace = bool(merged.get("workspace"))
if (workspaces := _context_workspaces(self.app_context)) and not explicit_workspace:
merged["workspace"] = next(iter(workspaces))
merged["_workspace_explicit"] = explicit_workspace
if "output_schema" in merged:
merged["output_schema"] = self._normalize_output_schema(merged["output_schema"])
return merged

View file

@ -387,7 +387,11 @@ async def test_agentscope_compact_session_forces_and_persists_compression(tmp_pa
await wrapper.compact_session("session-1")
assert observed["build"] == (None, {"resume": "session-1", "workspace": "default"})
build_inputs, build_kwargs = observed["build"]
assert build_inputs is None
# Default-alias fill-in is marked so cwd resolution can ignore it.
assert build_kwargs.pop("_workspace_explicit") is False
assert build_kwargs == {"resume": "session-1", "workspace": "default"}
assert observed["config"].trigger_ratio == 1e-9
assert observed["state"] is FakeAgent.state

View file

@ -14,6 +14,7 @@ import pytest
from openai_codex.generated.v2_all import TokenUsageBreakdown
from pydantic import BaseModel
from reme.components import ApplicationContext
from reme.components.agent_wrapper.codex_agent_wrapper import CodexAgentWrapper
from reme.components.job import BackgroundJob
from reme.components.outbound_proxy import FixedHttpOutboundProxy
@ -721,6 +722,63 @@ async def test_reply_normalizes_schema_and_reuses_persistent_client(tmp_path, mo
assert close_count == 1
@pytest.mark.asyncio
async def test_reply_keeps_project_root_cwd_without_explicit_workspace(tmp_path, monkeypatch):
"""A single-workspace reply delivers the project root to the SDK.
The default alias is still injected for job tools. Naming that alias, or
choosing another workspace, selects that workspace directory instead.
"""
project = tmp_path / "project"
workspace = project / ".reme"
context = ApplicationContext(workspace_dir=str(workspace))
wrapper = CodexAgentWrapper(
app_context=context,
project_path="..",
auth_mode="oauth",
release_threads=False,
)
observed = {"thread": [], "turn": []}
class FakeThread:
id = "thread-1"
async def run(self, _inputs, **kwargs):
observed["turn"].append(kwargs["cwd"])
return _TurnResult(final_response="ok")
class FakeCodex:
def __init__(self, _config):
pass
async def account(self):
return SimpleNamespace(account=SimpleNamespace())
async def close(self):
return None
async def thread_start(self, **kwargs):
observed["thread"].append(kwargs["cwd"])
return FakeThread()
monkeypatch.setattr("reme.components.agent_wrapper.codex_agent_wrapper.AsyncCodex", FakeCodex)
merged = wrapper._merged_kwargs({}) # pylint: disable=protected-access
assert merged["workspace"] == "default"
assert wrapper._resolve_injected_job_kwargs(merged)["workspace"] == "default" # pylint: disable=protected-access
await wrapper.reply("hello")
await wrapper.reply("again", workspace="default")
await wrapper.close()
project_cwd = str(wrapper.project_path)
workspace_cwd = str(context.workspaces["default"].workspace_path)
assert wrapper.cwd == wrapper.project_path
assert project_cwd != workspace_cwd
assert observed["thread"] == [project_cwd, workspace_cwd]
assert observed["turn"] == [project_cwd, workspace_cwd]
@pytest.mark.asyncio
@pytest.mark.parametrize("schema", [{}, _StructuredModel])
async def test_reply_stream_rejects_output_schema(tmp_path, schema):

View file

@ -89,6 +89,8 @@ def test_call_cwd_resolves_relative_against_bound_workspace(tmp_path):
default_wrapper = CodexAgentWrapper(app_context=context)
assert default_wrapper._call_cwd({"workspace": "beta"}) == tmp_path / "beta"
assert default_wrapper._call_cwd({}) != tmp_path / "beta" # project path, not a workspace
# Filling in the default alias for job tools does not switch a relative cwd.
assert wrapper._call_cwd(wrapper._merged_kwargs({})) == tmp_path / "alpha" / "relative" / "sub"
def test_dream_integration_lock_is_workspace_scoped(tmp_path):