Skip to content

Commit 40cc2de

Browse files
authored
Merge pull request #2 from iamjacobjia/master
fix(agents): isolate repo-local instructions in agent runs
2 parents 1afc32c + c760e91 commit 40cc2de

7 files changed

Lines changed: 130 additions & 8 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ test-results/
1010
.e2e-flaky
1111
dist/
1212
*.egg-info/
13+
.worktrees/

agentflow/agents/claude.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from agentflow.agents.base import AgentAdapter
88
from agentflow.env import merge_env_layers
99
from agentflow.prepared import ExecutionPaths, PreparedExecution
10-
from agentflow.specs import NodeSpec, ToolAccess
10+
from agentflow.specs import NodeSpec, RepoInstructionsMode, ToolAccess
1111

1212

1313
_CLAUDE_READ_ONLY_TOOLS = [
@@ -37,6 +37,7 @@ class ClaudeAdapter(AgentAdapter):
3737
def prepare(self, node: NodeSpec, prompt: str, paths: ExecutionPaths) -> PreparedExecution:
3838
provider = self.provider_config(node.provider, node.agent)
3939
executable = node.executable or "claude"
40+
repo_instructions_ignored = node.repo_instructions_mode == RepoInstructionsMode.IGNORE
4041
command = [
4142
executable,
4243
"-p",
@@ -47,6 +48,8 @@ def prepare(self, node: NodeSpec, prompt: str, paths: ExecutionPaths) -> Prepare
4748
"--permission-mode",
4849
"bypassPermissions",
4950
]
51+
if repo_instructions_ignored:
52+
command.extend(["--bare", "--add-dir", paths.target_workdir])
5053
if node.model:
5154
command.extend(["--model", node.model])
5255
allowed_tools = _CLAUDE_READ_ONLY_TOOLS if node.tools == ToolAccess.READ_ONLY else _CLAUDE_READ_WRITE_TOOLS
@@ -87,10 +90,13 @@ def prepare(self, node: NodeSpec, prompt: str, paths: ExecutionPaths) -> Prepare
8790
if api_key is not None:
8891
env.setdefault("ANTHROPIC_API_KEY", api_key)
8992
command.extend(node.extra_args)
93+
cwd = paths.target_workdir
94+
if repo_instructions_ignored:
95+
cwd = str(Path(paths.target_runtime_dir))
9096
return PreparedExecution(
9197
command=command,
9298
env=env,
93-
cwd=paths.target_workdir,
99+
cwd=cwd,
94100
trace_kind="claude",
95101
runtime_files=runtime_files,
96102
)

agentflow/agents/codex.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from agentflow.agents.base import AgentAdapter
66
from agentflow.env import merge_env_layers
77
from agentflow.prepared import ExecutionPaths, PreparedExecution
8-
from agentflow.specs import NodeSpec, ProviderConfig, ToolAccess
8+
from agentflow.specs import NodeSpec, ProviderConfig, RepoInstructionsMode, ToolAccess
99

1010

1111
class CodexAdapter(AgentAdapter):
@@ -68,6 +68,7 @@ def prepare(self, node: NodeSpec, prompt: str, paths: ExecutionPaths) -> Prepare
6868
provider = self.provider_config(node.provider, node.agent)
6969
executable = node.executable or "codex"
7070
sandbox = "read-only" if node.tools == ToolAccess.READ_ONLY else "workspace-write"
71+
repo_instructions_ignored = node.repo_instructions_mode == RepoInstructionsMode.IGNORE
7172
command = [
7273
executable,
7374
"exec",
@@ -84,18 +85,26 @@ def prepare(self, node: NodeSpec, prompt: str, paths: ExecutionPaths) -> Prepare
8485
command.extend(["--model", node.model])
8586
if provider:
8687
command.extend(["--profile", "agentflow"])
88+
if repo_instructions_ignored:
89+
command.extend(["--disable", "plugins"])
90+
command.extend(["--add-dir", paths.target_workdir])
8791
command.extend(node.extra_args)
8892
command.append(prompt)
8993

9094
env = merge_env_layers(getattr(provider, "env", None), node.env)
9195
runtime_files: dict[str, str] = {}
92-
if provider or node.mcps:
96+
if provider or node.mcps or repo_instructions_ignored:
97+
codex_home = str(Path(paths.target_runtime_dir) / "codex_home")
9398
runtime_files[self.relative_runtime_file("codex_home", "config.toml")] = self._render_config(node, provider)
94-
env["CODEX_HOME"] = str(Path(paths.target_runtime_dir) / "codex_home")
99+
env["CODEX_HOME"] = codex_home
100+
env["HOME"] = codex_home
101+
cwd = paths.target_workdir
102+
if repo_instructions_ignored:
103+
cwd = str(Path(paths.target_runtime_dir))
95104
return PreparedExecution(
96105
command=command,
97106
env=env,
98-
cwd=paths.target_workdir,
107+
cwd=cwd,
99108
trace_kind="codex",
100109
runtime_files=runtime_files,
101110
)

agentflow/agents/kimi.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@
77
from agentflow.agents.base import AgentAdapter
88
from agentflow.env import merge_env_layers
99
from agentflow.prepared import ExecutionPaths, PreparedExecution
10-
from agentflow.specs import NodeSpec
10+
from agentflow.specs import NodeSpec, RepoInstructionsMode
1111

1212

1313
class KimiAdapter(AgentAdapter):
1414
def prepare(self, node: NodeSpec, prompt: str, paths: ExecutionPaths) -> PreparedExecution:
1515
provider = self.provider_config(node.provider, node.agent)
1616
executable = node.executable or "kimi"
17+
repo_instructions_ignored = node.repo_instructions_mode == RepoInstructionsMode.IGNORE
1718
command = [
1819
executable,
1920
"--print",
@@ -23,9 +24,14 @@ def prepare(self, node: NodeSpec, prompt: str, paths: ExecutionPaths) -> Prepare
2324
"-p",
2425
prompt,
2526
]
27+
if repo_instructions_ignored:
28+
empty_skills_dir = Path(paths.target_runtime_dir) / "empty-skills"
29+
command.extend(["--add-dir", paths.target_workdir, "--skills-dir", str(empty_skills_dir)])
2630
if node.model:
2731
command.extend(["--model", node.model])
2832
runtime_files: dict[str, str] = {}
33+
if repo_instructions_ignored:
34+
runtime_files[self.relative_runtime_file("empty-skills", ".gitkeep")] = ""
2935
if node.mcps:
3036
mcp_payload: dict[str, object] = {"mcpServers": {}}
3137
for mcp in node.mcps:
@@ -57,10 +63,13 @@ def prepare(self, node: NodeSpec, prompt: str, paths: ExecutionPaths) -> Prepare
5763
api_key = os.getenv(provider.api_key_env)
5864
if api_key is not None:
5965
env.setdefault("KIMI_API_KEY", api_key)
66+
cwd = paths.target_workdir
67+
if repo_instructions_ignored:
68+
cwd = str(Path(paths.target_runtime_dir))
6069
return PreparedExecution(
6170
command=command,
6271
env=env,
63-
cwd=paths.target_workdir,
72+
cwd=cwd,
6473
trace_kind="kimi",
6574
runtime_files=runtime_files,
6675
)

agentflow/specs.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ class CaptureMode(StrEnum):
4646
TRACE = "trace"
4747

4848

49+
class RepoInstructionsMode(StrEnum):
50+
INHERIT = "inherit"
51+
IGNORE = "ignore"
52+
53+
4954
class PeriodicActuationMode(StrEnum):
5055
NONE = "none"
5156
OUTPUT_JSON = "output_json"
@@ -728,6 +733,7 @@ class NodeSpec(BaseModel):
728733
skills: list[str] = Field(default_factory=list)
729734
target: TargetSpec = Field(default_factory=LocalTarget)
730735
capture: CaptureMode = CaptureMode.FINAL
736+
repo_instructions_mode: RepoInstructionsMode = RepoInstructionsMode.INHERIT
731737
output_key: str | None = None
732738
timeout_seconds: int = Field(default=1800, gt=0)
733739
env: dict[str, str] = Field(default_factory=dict)

docs/pipelines.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ Each node supports:
4646
- `model`: any model string understood by the backend
4747
- `provider`: a string or a structured provider config with `base_url`, `api_key_env`, headers, and env
4848
- `tools`: `read_only` or `read_write`
49+
- `repo_instructions_mode`: `inherit` (default) or `ignore` for agent CLIs that should not absorb repo-local instruction files such as `AGENTS.md`, `CLAUDE.md`, or project skills
4950
- `mcps`: a list of MCP server definitions
5051
- `skills`: a list of local skill paths or names
5152
- `target`: `local`, `container`, `ssh`, `ec2`, or `ecs`
@@ -217,6 +218,8 @@ Runtime numeric settings are validated up front: `concurrency` must be at least
217218

218219
MCP definitions are also validated before launch: `stdio` servers require `command` and reject HTTP-only fields such as `url`, `streamable_http` servers require `url` and reject stdio-only fields such as `command`, and MCP server names must be unique within a node.
219220

221+
`repo_instructions_mode: ignore` is a generic AgentFlow switch with agent-specific implementations. The current adapters use the same high-level pattern: start the agent from an isolated runtime directory, keep the target repo accessible via an explicit allowlist flag such as `--add-dir`, and disable or override repo-local instruction discovery where the underlying CLI supports it. When you enable this mode, write prompts that use absolute paths or explicitly tell the agent to `cd` into the repository before running shell commands.
222+
220223
Built-in provider shorthands:
221224

222225
- `codex`: `openai`

tests/test_agents.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,53 @@ def test_codex_adapter_uses_runtime_codex_home_for_mcp_config(tmp_path):
123123
assert 'command = "npx"' in prepared.runtime_files["codex_home/config.toml"]
124124

125125

126+
def test_codex_adapter_isolates_home_when_runtime_codex_home_is_used(tmp_path):
127+
node = NodeSpec.model_validate(
128+
{
129+
"id": "plan",
130+
"agent": "codex",
131+
"prompt": "Plan",
132+
"provider": {
133+
"name": "openai-pinned",
134+
"base_url": "http://example.test/v1",
135+
"api_key_env": "OPENAI_API_KEY",
136+
"wire_api": "responses",
137+
},
138+
}
139+
)
140+
141+
prepared = CodexAdapter().prepare(node, "Plan", _paths(tmp_path))
142+
143+
expected_home = str(tmp_path / ".runtime" / "codex_home")
144+
assert prepared.env["CODEX_HOME"] == expected_home
145+
assert prepared.env["HOME"] == expected_home
146+
assert prepared.runtime_files.keys() == {"codex_home/config.toml"}
147+
148+
149+
def test_codex_adapter_can_ignore_repo_instructions_with_isolated_runtime_cwd(tmp_path):
150+
node = NodeSpec.model_validate(
151+
{
152+
"id": "plan",
153+
"agent": "codex",
154+
"prompt": "Plan",
155+
"repo_instructions_mode": "ignore",
156+
}
157+
)
158+
159+
prepared = CodexAdapter().prepare(node, "Plan", _paths(tmp_path))
160+
161+
expected_home = str(tmp_path / ".runtime" / "codex_home")
162+
assert prepared.env["CODEX_HOME"] == expected_home
163+
assert prepared.env["HOME"] == expected_home
164+
assert prepared.cwd == str(tmp_path / ".runtime")
165+
assert "--disable" in prepared.command
166+
disable_index = prepared.command.index("--disable")
167+
assert prepared.command[disable_index + 1] == "plugins"
168+
assert "--add-dir" in prepared.command
169+
add_dir_index = prepared.command.index("--add-dir")
170+
assert prepared.command[add_dir_index + 1] == str(tmp_path)
171+
172+
126173
def test_claude_adapter_uses_tools_flag_for_read_only_access(tmp_path):
127174
node = NodeSpec.model_validate(
128175
{
@@ -156,6 +203,25 @@ def test_claude_adapter_uses_tools_flag_for_read_write_access(tmp_path):
156203
assert "Write" in prepared.command[index + 1].split(",")
157204

158205

206+
def test_claude_adapter_can_ignore_repo_instructions_with_bare_runtime_cwd(tmp_path):
207+
node = NodeSpec.model_validate(
208+
{
209+
"id": "review",
210+
"agent": "claude",
211+
"prompt": "Review",
212+
"repo_instructions_mode": "ignore",
213+
}
214+
)
215+
216+
prepared = ClaudeAdapter().prepare(node, "Review", _paths(tmp_path))
217+
218+
assert "--bare" in prepared.command
219+
assert "--add-dir" in prepared.command
220+
add_dir_index = prepared.command.index("--add-dir")
221+
assert prepared.command[add_dir_index + 1] == str(tmp_path)
222+
assert prepared.cwd == str(tmp_path / ".runtime")
223+
224+
159225
def test_claude_adapter_supports_kimi_provider_alias(tmp_path, monkeypatch):
160226
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-kimi-secret")
161227
node = NodeSpec.model_validate(
@@ -225,6 +291,28 @@ def test_kimi_adapter_respects_custom_executable(tmp_path):
225291
assert prepared.command[0] == "/usr/local/bin/kimi"
226292

227293

294+
def test_kimi_adapter_can_ignore_repo_instructions_with_isolated_runtime_cwd(tmp_path):
295+
node = NodeSpec.model_validate(
296+
{
297+
"id": "review",
298+
"agent": "kimi",
299+
"prompt": "Review",
300+
"repo_instructions_mode": "ignore",
301+
}
302+
)
303+
304+
prepared = KimiAdapter().prepare(node, "Review", _paths(tmp_path))
305+
306+
assert "--add-dir" in prepared.command
307+
add_dir_index = prepared.command.index("--add-dir")
308+
assert prepared.command[add_dir_index + 1] == str(tmp_path)
309+
assert "--skills-dir" in prepared.command
310+
skills_dir_index = prepared.command.index("--skills-dir")
311+
assert prepared.command[skills_dir_index + 1] == str(tmp_path / ".runtime" / "empty-skills")
312+
assert prepared.cwd == str(tmp_path / ".runtime")
313+
assert prepared.runtime_files.keys() == {"empty-skills/.gitkeep"}
314+
315+
228316
def test_claude_adapter_prefers_node_env_over_provider_env(tmp_path):
229317
node = NodeSpec.model_validate(
230318
{

0 commit comments

Comments
 (0)