Skip to content

fix(coding-agent): deliver project rules to the model on the claude-agent-sdk lane and on every turn - #542

Merged
code-yeongyu merged 4 commits into
code-yeongyu:mainfrom
Yoonkeee:fix/rules-project-instructions-delivery
Jul 31, 2026
Merged

fix(coding-agent): deliver project rules to the model on the claude-agent-sdk lane and on every turn#542
code-yeongyu merged 4 commits into
code-yeongyu:mainfrom
Yoonkeee:fix/rules-project-instructions-delivery

Conversation

@Yoonkeee

@Yoonkeee Yoonkeee commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Project rules discovered by the rules builtin (.omo/rules, .claude/rules, .cursor/rules, .github/instructions) never reach the model on the claude-agent-sdk lane, and reach it only on the first user prompt of a session on every other provider. /rules keeps reporting them as loaded the whole time.

Fixes #541.

Root cause

Two independent defects.

1. The claude-agent-sdk lane discards the composed system prompt. buildClaudeAgentSdkQueryOptions() never sends input.context.systemPrompt; it rebuilds one from the claude_code preset plus append, and append only holds extractAgentsAppend() + extractSkillsAppend(). The ## Project Instructions block the rules builtin appends in before_agent_start has no extractor, so it is dropped. AGENTS.md survives only because it is re-read from disk, which is why the symptom looked like "only one rule source is broken".

2. Static selection was gated on a previous turn's mark. rules/index.ts filtered candidates on !engine.isStaticInjected(rule) and marked every rule immediately after formatting. The host re-emits before_agent_start from the base system prompt on every user prompt (agent-session.ts), and staticDedup clears only on session_start / accepted session_compact. From turn 2 the filter emptied, the handler returned undefined, and the block was gone — on every provider, not just this lane. Nothing surfaced it because other extensions still return a modified prompt.

Why this could not be an extension

The append list is assembled inside buildClaudeAgentSdkQueryOptions() in the builtin provider; no extension hook can reach it. The region also has to be delimited by whoever produces it, which is the rules builtin. Both adaptations are documented in the respective changes.md per the fork contract.

Fix

  • rules/rules/constants.ts, rules/rules/formatter.tsformatStaticBlock wraps its output in a model-facing <project_rules> envelope, and wraps that in opaque region sentinels. Rule paths and bodies are emitted with the four marker literals neutralized.
  • claude-agent-sdk/options.tsextractProjectRulesAppend() locates the region by the sentinels, structurally validates each candidate, skips invalid ones, and fails closed when a candidate has no end sentinel. Wired as the third append entry; extractAgentsAppend and extractSkillsAppend are untouched.
  • rules/index.ts — static selection is no longer gated on isStaticInjected; the marks remain, serving only the dynamic tool_result dedup.

The block deliberately is not located by its ## Project Instructions heading or by a bare <project_rules> tag: rule bodies, context files, and extensions registered after rules (mcp is registered last) can all legitimately contain such text, and any of those shapes silently mislabels or truncates the region. Each shape has a test.

How this was found (evidence)

Measured at the SDK boundary by replacing the @anthropic-ai/claude-agent-sdk package entry with a shim that captures the query() options and returns a canned stream — zero tokens, zero network. Fixture: a temp git repo containing one alwaysApply rule and an AGENTS.md. Same commit (d99936a1), same command, only the production files differ:

observable before after
systemPrompt.append length 18585 19216
## Project Instructions present no yes
rule body canary present no yes
AGENTS.md marker present yes yes
</available_skills> present yes yes

Reproduced with -ne, so it is not caused by a loaded extension.

Discovery was never the problem: driving the engine directly returns the rule with matchReason: "alwaysApply" and builds the static block, which was then thrown away.

For defect 2, emitBeforeAgentStart() called twice with the same base prompt returned the block only on the first call. Driving --mode rpc with two prompts in a single process now shows both turns carrying the region.

Upstream

The rules builtin is vendored from code-yeongyu/pi-rules. The envelope and the per-turn re-emission belong there too so this adaptation can be dropped on the next re-vendor; rules/changes.md records that.

Validation

  • npm run build — exit 0
  • npm run check — exit 0 (working tree clean afterwards)
  • Targeted surface (claude-agent-sdk*, agent-session*, both new suites) — 42 files / 324 tests passed, 0 failed. On the subset that existed before this PR the counts are identical to the pristine baseline at d99936a1 (40 files / 301 tests), so no regressions.
  • New tests: 20 in test/claude-agent-sdk-project-instructions.test.ts (including producer→consumer round-trips through the real formatStaticBlock, marker-quoting rule bodies and paths, decoy marker pairs before and after the region, a dangling sentinel, and fail-closed) and 3 in test/rules-before-agent-start.test.ts (per-turn re-emission, dynamic dedup preserved, native contextFiles exclusion). Every production change was landed against tests that failed first for the right reason.
  • npm test in the deterministic mode CI uses (CI=1, which vitest.config.ts serializes to a single fork) — 1009 test files / 8469 tests passed, 0 failed across all five workspaces (@code-yeongyu/senpi alone: 737 files / 6128 tests). No FAIL, no not ok, no unhandled rejections; the npm error ETARGET lines in that log come from the package-command suite's own negative-path fixtures (nonexistent-package@1.0.0).
  • For completeness: a plain local npm test (default parallel pool) surfaced 4 failures — test/mcp/connection.test.ts, test/suite/hooks-command-runner.test.ts, test/suite/terminal-reload-survival.test.ts, test/suite/regressions/5303-bash-output-truncation.test.ts. All four pass in isolation, and a second run of the same workspace on the same commit produced a disjoint single failure (test/omo-local-update.test.ts) with none of the original four. That is the CPU-oversubscription flakiness vitest.config.ts already documents and is exactly why it serializes under CI. None of those suites touch the code paths in this PR.

Scope

This PR only claims that the static project-rules block reaches the model on the claude-agent-sdk lane and on every turn. The same lane still drops the other before_agent_start system-prompt mutations (hooks, compaction, mcp, todotools, terminal) and project CLAUDE.md, and /rules still reports discovered rather than delivered rules. Those are noted in #541 and left for separate changes.

@code-yeongyu
code-yeongyu merged commit 0782151 into code-yeongyu:main Jul 31, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Project rules never reach the model on claude-agent-sdk, and only on the first prompt elsewhere

2 participants