fix(claude-cli): deliver extraction instructions in the user turn

The claude-cli backend passed the extraction schema via --system-prompt with
only the raw file dump in the user turn, assuming 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"). That prose parses to zero nodes/edges, so
_response_is_hollow flags it as truncation and the adaptive-retry path bisects
the chunk indefinitely (94 -> 47 -> 23 -> ...), never converging and never
writing graph.json.

Move the full extraction schema plus an explicit imperative into the user turn
and drop --system-prompt, so the CLI emits the JSON object directly. The
<untrusted_source> prompt-injection guardrails are carried verbatim; model
override, --add-dir image handling, timeout, and token accounting are untouched.
This commit is contained in:
Jeisson
2026-07-02 22:29:30 +01:00
committed by safishamsi
parent 62f49bad73
commit 32ff6d6fb3
4 changed files with 86 additions and 20 deletions
+4
View File
@@ -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 `<untrusted_source>` 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 `<project_path>/<GRAPHIFY_OUT>/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.
+26 -10
View File
@@ -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 <untrusted_source> 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
+37
View File
@@ -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 <untrusted_source> 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) ----------
+19 -10
View File
@@ -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")