diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eb8354..6c56bf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## Unreleased + +- Fix: the `claude-cli` backend no longer stalls on an infinite chunk bisection under newer Claude Code CLIs. The extraction schema was delivered via `--system-prompt` with only the raw file dump in the user turn, on the assumption that a replacement system prompt is the model's sole authority. Claude Code >= ~2.1 (verified on 2.1.197) does not honour that: it still layers in the local coding-agent context (CLAUDE.md/AGENTS.md in cwd, skills, MCP) and, given a user turn that is just a file with no request, replies conversationally ("I see the file, but there's no actual request attached — what would you like me to do with it?"). That prose parses to zero nodes/edges, so `_response_is_hollow` flagged it as truncation and the adaptive-retry path bisected the chunk indefinitely (`94 → 47 → 23 → …`), never converging and never writing `graph.json`. The full extraction schema plus an explicit imperative now ride in the user turn and `--system-prompt` is dropped, so the CLI emits the JSON object directly; the `` prompt-injection guardrails are carried verbatim and unchanged. Other `_call_claude_cli` behaviour (model override, `--add-dir` image handling, timeout, token accounting) is untouched. + ## 0.9.5 (2026-07-02) - Feat: the MCP server can serve many projects from one process via an optional `project_path` on every tool (#1594, thanks @joanfgarcia). Omit it and nothing changes — the server answers against the graph it was started with. Pass an absolute `project_path` and that call is routed to `//graph.json` instead, with its own mtime+size hot-reload, so one stdio/HTTP server backs a whole workspace of repos. Graphs load lazily and cache per resolved path; a missing/corrupt project graph is a tool error, not a process exit, and the server starts even when its default graph is absent. Backward-compatible and additive. diff --git a/graphify/llm.py b/graphify/llm.py index c0d2efa..940540b 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -1140,14 +1140,23 @@ def _call_claude_cli(user_message: str, max_tokens: int = 8192, *, deep_mode: bo "https://claude.ai/code and run `claude` once to authenticate." ) - # Use --system-prompt (replaces) instead of --append-system-prompt (adds - # to Claude Code's default coding-agent prompt). The default prompt - # pushes the model towards markdown + prose explanations, which conflict - # with the "raw JSON only" extraction instruction and cause ~30-50% of - # responses to come back wrapped in ```json fences or prefixed with a - # preamble — both of which fail the strict json.loads in _parse_llm_json. - # Replacing the default prompt eliminates the conflict at the source. - # Side benefit: cache-creation tokens per call drop ~19% in practice. + # Deliver the extraction instructions in the USER turn rather than via + # --system-prompt. Newer Claude Code CLIs (>= ~2.1) do not treat a + # --system-prompt as the sole authority: they still layer in the local + # coding-agent context (CLAUDE.md/AGENTS.md in cwd, skills, MCP) and, when + # the user turn is only a raw file dump with no request, reply + # conversationally ("I see the file, but there's no actual request + # attached — what would you like me to do with it?"). That prose parses to + # zero nodes/edges, so _response_is_hollow flags it as truncation and the + # adaptive-retry path bisects the chunk indefinitely, never converging and + # never writing graph.json (verified against Claude Code 2.1.197). + # + # Putting the full extraction schema plus an explicit imperative in the + # user turn — and dropping --system-prompt — makes the CLI emit the JSON + # object directly. The guardrails in _extraction_system + # still apply because the schema text is carried verbatim; only its + # delivery channel changes. + # # When images are present, append the Read-the-paths instruction and # allowlist each containing directory so the CLI's Read tool can open them. add_dir_args: list[str] = [] @@ -1160,12 +1169,19 @@ def _call_claude_cli(user_message: str, max_tokens: int = 8192, *, deep_mode: bo seen_dirs.add(d) add_dir_args.extend(["--add-dir", d]) + combined_message = ( + _extraction_system(deep=deep_mode) + + "\n\n---\n" + + "Now extract the knowledge graph from the following source file(s) " + + "and output ONLY the JSON object described above. No prose, no " + + "preamble, no markdown fences.\n\n" + + user_message + ) cli_args = [ claude_cmd, "-p", "--output-format", "json", "--no-session-persistence", *add_dir_args, - "--system-prompt", _extraction_system(deep=deep_mode), ] # claude-cli defaults to Opus, which is overkill for the structured-JSON # extraction graphify performs. GRAPHIFY_CLAUDE_CLI_MODEL=haiku (or @@ -1177,7 +1193,7 @@ def _call_claude_cli(user_message: str, max_tokens: int = 8192, *, deep_mode: bo cli_args.extend(["--model", cli_model]) proc = subprocess.run( cli_args, - input=user_message, + input=combined_message, capture_output=True, text=True, encoding="utf-8", # Force UTF-8 — prevents UnicodeEncodeError on Windows cp1252 diff --git a/tests/test_claude_cli_backend.py b/tests/test_claude_cli_backend.py index 29bebf8..c695fb6 100644 --- a/tests/test_claude_cli_backend.py +++ b/tests/test_claude_cli_backend.py @@ -117,6 +117,43 @@ def test_no_session_persistence_flag_in_subprocess(fake_claude): assert "--no-session-persistence" in call_args +# ---------- extraction instructions delivered in the user turn ---------- +# Newer Claude Code CLIs (>= ~2.1) do not honour a --system-prompt that asks +# for raw JSON: they keep their coding-agent context and reply conversationally +# to a bare file dump, which parses to zero nodes and gets bisected forever. +# The instructions must ride in the user turn instead. See the fix for the +# "hollow response" / infinite-bisection failure on Claude Code 2.1.x. + + +def test_no_system_prompt_flag_in_subprocess(fake_claude): + """--system-prompt must NOT be used: the CLI ignores its 'raw JSON only' + directive and replies with prose, breaking extraction.""" + llm._call_claude_cli("dummy source", max_tokens=8192) + argv = fake_claude.call_args.args[0] + assert "--system-prompt" not in argv + + +def test_extraction_instructions_ride_in_user_turn(fake_claude): + """The full extraction schema, an explicit imperative, and the source must + all be delivered via stdin (the user turn).""" + llm._call_claude_cli("UNIQUE_SOURCE_MARKER", max_tokens=8192) + sent = fake_claude.call_args.kwargs["input"] + # schema text from _extraction_system + assert "graphify semantic extraction agent" in sent + # explicit imperative appended before the source + assert "output ONLY the JSON object" in sent + # the caller's source payload is preserved + assert "UNIQUE_SOURCE_MARKER" in sent + + +def test_user_turn_preserves_untrusted_source_guardrails(fake_claude): + """The guardrails from _extraction_system must survive + the move into the user turn (prompt-injection defence is unchanged).""" + llm._call_claude_cli("dummy", max_tokens=8192) + sent = fake_claude.call_args.kwargs["input"] + assert "untrusted_source" in sent + + # ---------- Windows path resolution (#1072) ---------- diff --git a/tests/test_llm_parser.py b/tests/test_llm_parser.py index c643807..974e8d1 100644 --- a/tests/test_llm_parser.py +++ b/tests/test_llm_parser.py @@ -3,7 +3,7 @@ subprocess argv shape introduced in the hollow-response fix. These tests cover: - The four parser failure modes described in PR #1062 -- The switch from --append-system-prompt to --system-prompt +- Extraction instructions delivered in the user turn (Claude Code >= 2.1) - The GRAPHIFY_CLAUDE_CLI_MODEL env-var passthrough """ from __future__ import annotations @@ -101,21 +101,30 @@ def _make_envelope(result_obj: dict) -> str: @patch("shutil.which", return_value="/usr/local/bin/claude") @patch("subprocess.run") -def test_uses_system_prompt_not_append(mock_run, _which): - """The hollow-response root cause was --append-system-prompt - layering graphify's extraction prompt on top of Claude Code's - default interactive-agent prompt. The fix switches to - --system-prompt (replace) to eliminate the conflict.""" +def test_instructions_ride_in_user_turn_not_system_prompt(mock_run, _which): + """Extraction instructions must be delivered in the user turn, not via + --system-prompt. + + History: the original hollow-response cause was --append-system-prompt + layering graphify's prompt on top of Claude Code's default agent prompt; + the first fix switched to --system-prompt (replace). But newer Claude Code + CLIs (>= ~2.1) don't treat --system-prompt as the sole authority — they + keep the coding-agent context and reply conversationally to a bare file + dump, which parses to zero nodes and gets bisected forever. The instructions + now ride in the user turn (stdin) and neither system-prompt flag is used.""" mock_run.return_value.returncode = 0 mock_run.return_value.stdout = _make_envelope({"nodes": [], "edges": [], "hyperedges": []}) mock_run.return_value.stderr = "" llm._call_claude_cli("payload") argv = mock_run.call_args.args[0] - assert "--system-prompt" in argv, f"--system-prompt missing from argv: {argv}" - assert "--append-system-prompt" not in argv, ( - "--append-system-prompt should have been replaced — it's the root " - "cause of the hollow-response loop" + assert "--system-prompt" not in argv, ( + f"--system-prompt is ignored by Claude Code >= 2.1; argv: {argv}" ) + assert "--append-system-prompt" not in argv + sent = mock_run.call_args.kwargs["input"] + assert "graphify semantic extraction agent" in sent + assert "output ONLY the JSON object" in sent + assert "payload" in sent @patch("shutil.which", return_value="/usr/local/bin/claude")