Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 78 additions & 1 deletion agent-flow/agent_flow/backends/claude_code.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
from __future__ import annotations

import hashlib
import os
import shutil
import subprocess
import sys
import tempfile
from contextlib import asynccontextmanager
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _pkg_version
Expand Down Expand Up @@ -445,6 +449,73 @@ async def handler(arguments, definition=definition):
return result


# The Agent SDK passes the system prompt to the ``claude`` binary as a single
# argv string, and Linux caps one argv string at 128 KiB (MAX_ARG_STRLEN), so
# a long prompt kills the CLI at exec with ``[Errno 7] Argument list too
# long``. Above the threshold the full prompt is written to a file under the
# working directory and the argv prompt becomes a short pointer telling the
# model to read it first. The pointer is deliberately plain: no SDK feature
# is involved.
_DEFAULT_SYSTEM_PROMPT_FILE_THRESHOLD = 64 * 1024


def _system_prompt_file_threshold() -> int:
raw = os.environ.get("AGENT_FLOW_SYSTEM_PROMPT_FILE_THRESHOLD")
if raw is None:
return _DEFAULT_SYSTEM_PROMPT_FILE_THRESHOLD
try:
return int(raw)
except ValueError:
print(
f"[agent-flow] ignoring invalid AGENT_FLOW_SYSTEM_PROMPT_FILE_THRESHOLD={raw!r}; "
f"falling back to {_DEFAULT_SYSTEM_PROMPT_FILE_THRESHOLD}",
file=sys.stderr,
flush=True,
)
return _DEFAULT_SYSTEM_PROMPT_FILE_THRESHOLD


SYSTEM_PROMPT_FILE_THRESHOLD = _system_prompt_file_threshold()


def spill_system_prompt(system_prompt: str, cwd: Path, threshold: int | None = None) -> str:
"""Return the prompt to pass on argv; spill the full text to a file if large.

Files are content-addressed (``.agent-flow/system-prompt-<sha12>.md``) so
every role/session with the same prompt shares one file and a changed
prompt never overwrites a file a live session may still be reading.
"""
limit = SYSTEM_PROMPT_FILE_THRESHOLD if threshold is None else threshold
data = system_prompt.encode("utf-8")
if len(data) <= limit:
return system_prompt
digest = hashlib.sha256(data).hexdigest()[:12]
target_dir = Path(cwd) / ".agent-flow"
target_dir.mkdir(parents=True, exist_ok=True)
target = target_dir / f"system-prompt-{digest}.md"
if not target.exists():
# Use a unique temp file per writer so concurrent sessions spilling
# the same prompt can't truncate or unlink each other's in-progress
# file before os.replace runs.
fd, tmp_name = tempfile.mkstemp(dir=target_dir, prefix=target.name, suffix=".tmp")
try:
with os.fdopen(fd, "wb") as fh:
fh.write(data)
os.replace(tmp_name, target)
except BaseException:
Path(tmp_name).unlink(missing_ok=True)
raise
kb = (len(data) + 1023) // 1024
return (
"Your full system instructions could not be passed inline and were written to "
f"this file: {target}\n\n"
f"Before doing anything else, read that file IN FULL with the Read tool ({kb} KB; "
"if a single read stops short of the end, continue with offset until you have read "
"the last line) and follow it as your system prompt for the whole session. "
"Everything it says applies unchanged; the human's task and all rules are in it."
)


class ClaudeCodeBackend(Backend):
def __init__(
self,
Expand Down Expand Up @@ -489,6 +560,9 @@ async def create_client(
tools=_sdk_tools(tools),
)

work_dir = cwd or Path.cwd()
system_prompt = spill_system_prompt(system_prompt, work_dir)

options = ClaudeAgentOptions(
tools=ToolsPreset(type="preset", preset="claude_code"),
system_prompt=SystemPromptPreset(
Expand All @@ -499,14 +573,17 @@ async def create_client(
mcp_servers=mcp_servers,
model=model,
effort=self._reasoning_effort,
cwd=cwd or Path.cwd(),
cwd=work_dir,
sandbox={"enabled": False},
permission_mode="bypassPermissions",
hooks=hooks,
disallowed_tools=[
*(disallowed_tools or []),
*(f"Skill({name})" for name in self._disabled_skills),
],
# Forward the CLI's stderr so startup failures are diagnosable
# instead of vanishing with the subprocess.
stderr=lambda line: print(f"[claude-cli stderr] {line}", file=sys.stderr, flush=True),
)

async with ClaudeSDKClient(options=options) as sdk_client:
Expand Down
24 changes: 24 additions & 0 deletions agent-flow/tests/test_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -1113,3 +1113,27 @@ def test_cli_version_returns_empty_on_nonzero_exit(self, monkeypatch):
lambda *a, **k: SimpleNamespace(returncode=1, stdout="", stderr="boom"),
)
assert codex_mod._codex_cli_version() == ""


class TestSystemPromptSpill:
def test_short_prompt_passes_through_unchanged(self, tmp_path):
assert cc_mod.spill_system_prompt("hello", tmp_path, threshold=1024) == "hello"
assert not (tmp_path / ".agent-flow").exists()

def test_long_prompt_is_written_to_a_file_and_replaced_by_a_pointer(self, tmp_path):
prompt = "x" * 5000
pointer = cc_mod.spill_system_prompt(prompt, tmp_path, threshold=1024)
files = list((tmp_path / ".agent-flow").glob("system-prompt-*.md"))
assert len(files) == 1
assert files[0].read_text() == prompt
assert str(files[0]) in pointer
assert "Read tool" in pointer
assert len(pointer.encode()) < 1024

def test_spill_files_are_content_addressed(self, tmp_path):
prompt = "x" * 5000
cc_mod.spill_system_prompt(prompt, tmp_path, threshold=1024)
# Same content -> same file, no duplicate; different content -> new file.
cc_mod.spill_system_prompt(prompt, tmp_path, threshold=1024)
cc_mod.spill_system_prompt(prompt + "y", tmp_path, threshold=1024)
assert len(list((tmp_path / ".agent-flow").glob("system-prompt-*.md"))) == 2
Loading