Skip to content
Closed
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
36 changes: 26 additions & 10 deletions graphify/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand All @@ -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
Expand All @@ -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
Expand Down
37 changes: 37 additions & 0 deletions tests/test_claude_cli_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) ----------


Expand Down
29 changes: 19 additions & 10 deletions tests/test_llm_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

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
Expand Down Expand Up @@ -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")
Expand Down