From 94adff7d183fcfdc63f35189c8bb0d61ee5b2b18 Mon Sep 17 00:00:00 2001 From: mohamed zwanski Date: Thu, 22 Oct 2026 03:35:05 +0100 Subject: [PATCH] Fix #8: Pass chat-session system prompts via --system-prompt-file Windows limits command lines to ~8191 characters, so passing the create-spec/expand-project skills as a --system-prompt argument fails with "The command line is too long." and surfaces in the UI as "Failed to initialize Claude: Command failed with exit code 1". Spec, expand and assistant chat sessions now write their system prompt to a unique, gitignored .autoforge/.system_prompt...md file and pass it as system_prompt={"type": "file", ...}, which the SDK forwards as --system-prompt-file. The file is removed when the session closes. This also replaces the previous workaround of overwriting the project's CLAUDE.md with the spec/assistant prompt, which clobbered any real project CLAUDE.md the coding agent relies on. Bumps claude-agent-sdk to >=0.1.51 (first release with SystemPromptFile). Co-Authored-By: Claude Opus 5.5 --- autoforge_paths.py | 11 +++++ requirements-prod.txt | 2 +- requirements.txt | 2 +- server/services/assistant_chat_session.py | 19 ++++---- server/services/chat_constants.py | 29 ++++++++++++ server/services/expand_chat_session.py | 12 ++++- server/services/spec_chat_session.py | 17 ++++--- test_system_prompt_file.py | 54 +++++++++++++++++++++++ 8 files changed, 128 insertions(+), 18 deletions(-) create mode 100644 test_system_prompt_file.py diff --git a/autoforge_paths.py b/autoforge_paths.py index e720dfb47..96be57289 100644 --- a/autoforge_paths.py +++ b/autoforge_paths.py @@ -43,6 +43,7 @@ .claude_settings.json .claude_assistant_settings.json .claude_settings.expand.*.json +.system_prompt.*.md .progress_cache .migration_version """ @@ -178,6 +179,16 @@ def get_expand_settings_path(project_dir: Path, uuid_hex: str) -> Path: return project_dir / ".autoforge" / f".claude_settings.expand.{uuid_hex}.json" +def get_system_prompt_path(project_dir: Path, session_kind: str, uuid_hex: str) -> Path: + """Return the path for an ephemeral chat-session system prompt file. + + The system prompt is passed to the Claude CLI via ``--system-prompt-file`` + instead of on the command line, which on Windows is limited to ~8191 + characters. These files are short-lived and always stored in ``.autoforge/``. + """ + return project_dir / ".autoforge" / f".system_prompt.{session_kind}.{uuid_hex}.md" + + # --------------------------------------------------------------------------- # Lock-file safety check # --------------------------------------------------------------------------- diff --git a/requirements-prod.txt b/requirements-prod.txt index 12d5d327c..ba32fc7ae 100644 --- a/requirements-prod.txt +++ b/requirements-prod.txt @@ -1,6 +1,6 @@ # Production runtime dependencies only # For development, use requirements.txt (includes ruff, mypy, pytest) -claude-agent-sdk>=0.1.39,<0.2.0 +claude-agent-sdk>=0.1.51,<0.2.0 python-dotenv>=1.0.0 sqlalchemy>=2.0.0 fastapi>=0.115.0 diff --git a/requirements.txt b/requirements.txt index 23c9afdbd..1dec460ba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -claude-agent-sdk>=0.1.39,<0.2.0 +claude-agent-sdk>=0.1.51,<0.2.0 python-dotenv>=1.0.0 sqlalchemy>=2.0.0 fastapi>=0.115.0 diff --git a/server/services/assistant_chat_session.py b/server/services/assistant_chat_session.py index 1a16606d5..a0149a0f6 100755 --- a/server/services/assistant_chat_session.py +++ b/server/services/assistant_chat_session.py @@ -29,7 +29,9 @@ ROOT_DIR, check_rate_limit_error, format_client_init_error, + remove_system_prompt_file, safe_receive_response, + write_system_prompt_file, ) # Load environment variables from .env file if present @@ -208,6 +210,7 @@ def __init__(self, project_name: str, project_dir: Path, conversation_id: Option self._client_entered: bool = False self.created_at = datetime.now() self._history_loaded: bool = False # Track if we've loaded history for resumed conversations + self._system_prompt_file: Optional[Path] = None async def close(self) -> None: """Clean up resources and close the Claude client.""" @@ -219,6 +222,8 @@ async def close(self) -> None: finally: self._client_entered = False self.client = None + remove_system_prompt_file(self._system_prompt_file) + self._system_prompt_file = None async def start(self) -> AsyncGenerator[dict, None]: """ @@ -279,12 +284,10 @@ async def start(self) -> AsyncGenerator[dict, None]: # Get system prompt with project context system_prompt = get_system_prompt(self.project_name, self.project_dir) - # Write system prompt to CLAUDE.md file to avoid Windows command line length limit - # The SDK will read this via setting_sources=["project"] - claude_md_path = self.project_dir / "CLAUDE.md" - with open(claude_md_path, "w", encoding="utf-8") as f: - f.write(system_prompt) - logger.info(f"Wrote assistant system prompt to {claude_md_path}") + # Pass the system prompt via a file (--system-prompt-file) rather than on the + # command line, which on Windows is limited to ~8191 characters (issue #8) + remove_system_prompt_file(self._system_prompt_file) + self._system_prompt_file = write_system_prompt_file(self.project_dir, "assistant", system_prompt) # Use system Claude CLI system_cli = shutil.which("claude") @@ -304,8 +307,8 @@ async def start(self) -> AsyncGenerator[dict, None]: model=model, effort=effort, # type: ignore[arg-type] # SDK 0.1.61 Literal omits "xhigh" cli_path=system_cli, - # System prompt loaded from CLAUDE.md via setting_sources - # This avoids Windows command line length limit (~8191 chars) + system_prompt={"type": "file", "path": str(self._system_prompt_file)}, + # Load the project's own CLAUDE.md as additional context setting_sources=["project"], allowed_tools=[*READONLY_BUILTIN_TOOLS, *ASSISTANT_FEATURE_TOOLS], disallowed_tools=DISALLOWED_ASSISTANT_TOOLS, diff --git a/server/services/chat_constants.py b/server/services/chat_constants.py index 68e70e96f..0e25170ba 100644 --- a/server/services/chat_constants.py +++ b/server/services/chat_constants.py @@ -11,6 +11,7 @@ import logging import sys +import uuid from pathlib import Path from typing import Any, AsyncGenerator @@ -96,6 +97,34 @@ def format_client_init_error(exc: Exception) -> str: return f"Failed to initialize Claude: {exc}" +def write_system_prompt_file(project_dir: Path, session_kind: str, system_prompt: str) -> Path: + """Write a chat session's system prompt to a unique file under ``.autoforge/``. + + The SDK passes a plain-string ``system_prompt`` to the Claude CLI as a + command-line argument, which fails on Windows with "The command line is + too long." once the prompt exceeds ~8191 characters. Pass the returned + path as ``system_prompt={"type": "file", "path": ...}`` instead so the CLI + reads it via ``--system-prompt-file``. Callers should remove the file + with :func:`remove_system_prompt_file` when the session closes. + """ + from autoforge_paths import ensure_autoforge_dir, get_system_prompt_path + + ensure_autoforge_dir(project_dir) + path = get_system_prompt_path(project_dir, session_kind, uuid.uuid4().hex) + path.write_text(system_prompt, encoding="utf-8") + return path.resolve() + + +def remove_system_prompt_file(path: Path | None) -> None: + """Delete a system prompt file created by :func:`write_system_prompt_file`.""" + if path is None: + return + try: + path.unlink(missing_ok=True) + except OSError as e: + logger.warning(f"Error removing system prompt file {path}: {e}") + + async def safe_receive_response(client: Any, log: logging.Logger) -> AsyncGenerator: """Wrap ``client.receive_response()`` to skip ``MessageParseError``. diff --git a/server/services/expand_chat_session.py b/server/services/expand_chat_session.py index 66edb94e1..62e1092e4 100644 --- a/server/services/expand_chat_session.py +++ b/server/services/expand_chat_session.py @@ -29,7 +29,9 @@ check_rate_limit_error, format_client_init_error, make_multimodal_message, + remove_system_prompt_file, safe_receive_response, + write_system_prompt_file, ) # Load environment variables from .env file if present @@ -74,6 +76,7 @@ def __init__(self, project_name: str, project_dir: Path): self.features_created: int = 0 self.created_feature_ids: list[int] = [] self._settings_file: Optional[Path] = None + self._system_prompt_file: Optional[Path] = None self._query_lock = asyncio.Lock() async def close(self) -> None: @@ -93,6 +96,8 @@ async def close(self) -> None: self._settings_file.unlink() except Exception as e: logger.warning(f"Error removing settings file: {e}") + remove_system_prompt_file(self._system_prompt_file) + self._system_prompt_file = None async def start(self) -> AsyncGenerator[dict, None]: """ @@ -162,6 +167,11 @@ async def start(self) -> AsyncGenerator[dict, None]: project_path = str(self.project_dir.resolve()) system_prompt = skill_content.replace("$ARGUMENTS", project_path) + # Pass the system prompt via a file (--system-prompt-file) rather than on the + # command line, which on Windows is limited to ~8191 characters (issue #8) + remove_system_prompt_file(self._system_prompt_file) + self._system_prompt_file = write_system_prompt_file(self.project_dir, "expand", system_prompt) + # Build environment overrides for API configuration from registry import DEFAULT_MODEL, get_effective_sdk_env, get_effort_setting sdk_env = get_effective_sdk_env() @@ -189,7 +199,7 @@ async def start(self) -> AsyncGenerator[dict, None]: model=model, effort=effort, # type: ignore[arg-type] # SDK 0.1.61 Literal omits "xhigh" cli_path=system_cli, - system_prompt=system_prompt, + system_prompt={"type": "file", "path": str(self._system_prompt_file)}, allowed_tools=[ "Read", "Glob", diff --git a/server/services/spec_chat_session.py b/server/services/spec_chat_session.py index 0788b13ac..5e3f1ca1b 100644 --- a/server/services/spec_chat_session.py +++ b/server/services/spec_chat_session.py @@ -26,7 +26,9 @@ check_rate_limit_error, format_client_init_error, make_multimodal_message, + remove_system_prompt_file, safe_receive_response, + write_system_prompt_file, ) # Load environment variables from .env file if present @@ -64,6 +66,7 @@ def __init__(self, project_name: str, project_dir: Path): self.created_at = datetime.now() self._conversation_id: Optional[str] = None self._client_entered: bool = False # Track if context manager is active + self._system_prompt_file: Optional[Path] = None async def close(self) -> None: """Clean up resources and close the Claude client.""" @@ -75,6 +78,8 @@ async def close(self) -> None: finally: self._client_entered = False self.client = None + remove_system_prompt_file(self._system_prompt_file) + self._system_prompt_file = None async def start(self) -> AsyncGenerator[dict, None]: """ @@ -135,12 +140,10 @@ async def start(self) -> AsyncGenerator[dict, None]: project_path = str(self.project_dir.resolve()) system_prompt = skill_content.replace("$ARGUMENTS", project_path) - # Write system prompt to CLAUDE.md file to avoid Windows command line length limit - # The SDK will read this via setting_sources=["project"] - claude_md_path = self.project_dir / "CLAUDE.md" - with open(claude_md_path, "w", encoding="utf-8") as f: - f.write(system_prompt) - logger.info(f"Wrote system prompt to {claude_md_path}") + # Pass the system prompt via a file (--system-prompt-file) rather than on the + # command line, which on Windows is limited to ~8191 characters (issue #8) + remove_system_prompt_file(self._system_prompt_file) + self._system_prompt_file = write_system_prompt_file(self.project_dir, "spec", system_prompt) # Create Claude SDK client with limited tools for spec creation # Use Opus for best quality spec generation @@ -161,7 +164,7 @@ async def start(self) -> AsyncGenerator[dict, None]: model=model, effort=effort, # type: ignore[arg-type] # SDK 0.1.61 Literal omits "xhigh" cli_path=system_cli, - # System prompt loaded from CLAUDE.md via setting_sources + system_prompt={"type": "file", "path": str(self._system_prompt_file)}, # Include "user" for global skills and subagents from ~/.claude/ setting_sources=["project", "user"], allowed_tools=[ diff --git a/test_system_prompt_file.py b/test_system_prompt_file.py new file mode 100644 index 000000000..7ca062663 --- /dev/null +++ b/test_system_prompt_file.py @@ -0,0 +1,54 @@ +""" +Tests for passing chat-session system prompts via file (issue #8). + +Long system prompts must not be passed on the command line, because Windows +limits command lines to ~8191 characters ("The command line is too long."). +""" + +from claude_agent_sdk import ClaudeAgentOptions +from claude_agent_sdk._internal.transport.subprocess_cli import SubprocessCLITransport + +from server.services.chat_constants import remove_system_prompt_file, write_system_prompt_file + + +def test_write_and_remove_system_prompt_file(tmp_path): + prompt = "x" * 20000 + " ünïcode" + path = write_system_prompt_file(tmp_path, "spec", prompt) + + assert path.exists() + assert path.parent == (tmp_path / ".autoforge").resolve() + assert path.read_text(encoding="utf-8") == prompt + # Runtime file is gitignored + assert ".system_prompt.*.md" in (tmp_path / ".autoforge" / ".gitignore").read_text(encoding="utf-8") + # Does not touch the project's CLAUDE.md + assert not (tmp_path / "CLAUDE.md").exists() + + remove_system_prompt_file(path) + assert not path.exists() + # Idempotent and tolerant of None + remove_system_prompt_file(path) + remove_system_prompt_file(None) + + +def test_unique_file_per_session(tmp_path): + a = write_system_prompt_file(tmp_path, "expand", "a") + b = write_system_prompt_file(tmp_path, "expand", "b") + assert a != b + assert a.read_text(encoding="utf-8") == "a" + assert b.read_text(encoding="utf-8") == "b" + + +def test_prompt_not_on_command_line(tmp_path): + prompt = "LONG_PROMPT_MARKER " + "y" * 20000 + path = write_system_prompt_file(tmp_path, "spec", prompt) + options = ClaudeAgentOptions( + system_prompt={"type": "file", "path": str(path)}, + cli_path="claude", + cwd=str(tmp_path), + ) + cmd = SubprocessCLITransport(prompt="", options=options)._build_command() + + assert "--system-prompt-file" in cmd + assert cmd[cmd.index("--system-prompt-file") + 1] == str(path) + assert not any("LONG_PROMPT_MARKER" in arg for arg in cmd) + assert len(" ".join(cmd)) < 8191