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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user