Skip to content

feat(acp): implement systemPrompt with protocol version gating#981

Merged
wpfleger96 merged 15 commits into
mainfrom
duncan/system-prompt-session-new
Jun 12, 2026
Merged

feat(acp): implement systemPrompt with protocol version gating#981
wpfleger96 merged 15 commits into
mainfrom
duncan/system-prompt-session-new

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

Implements the systemPrompt field from ACP RFD #1237 with protocol version-based capability detection. Agents declaring protocolVersion: 2 receive their system prompt via the systemPrompt field in session/new — no duplication in user messages. Legacy agents (goose, Claude Code, codex) stay on version 1 and continue receiving [Base]/[System] sections in the user message.

How It Works

The harness parses protocolVersion from the agent's initialize response and gates behavior on it:

Agent version systemPrompt in session/new [Base]/[System] in user message
>= 2 (buzz-agent) ✅ Sent ❌ Suppressed
< 2 (goose/CC/codex) ❌ Not sent ✅ Present

Zero duplication in either path. Zero breaking change for non-buzz-agent harnesses.

Agent side (buzz-agent)

  • Declares protocolVersion: 2 in initialize response
  • SessionNewParams gains optional systemPrompt field (camelCase wire format)
  • When systemPrompt is present: uses it as base content, suppresses DEFAULT_SYSTEM_PROMPT, appends hints last
  • When absent: falls back to DEFAULT_SYSTEM_PROMPT + hints (legacy behavior)
  • Empty/whitespace-only systemPrompt treated as absent
  • Rejects combined prompts exceeding 512KB (MAX_SYSTEM_PROMPT_BYTES) with JSON-RPC error (-32602)

Harness side (buzz-acp)

  • Parses protocolVersion from initialize response, stores on OwnedAgent struct
  • Both init paths (initial startup and respawn) parse and thread the version
  • protocol_version >= 2: delivers systemPrompt in session/new, sets has_system_prompt_support flag
  • protocol_version < 2: skips systemPrompt, format_prompt emits [Base]/[System] in user message
  • FormatPromptArgs provides a clean interface for the conditional emission logic

Backward Compatibility

  • Old harnesses omitting systemPrompt: agent uses its own default (Option field, defaults to None)
  • Harness sending systemPrompt to an agent that doesn't handle it: ignored per JSON-RPC (unknown fields discarded)
  • No capability negotiation flag — protocol version is the gate per the RFD
  • Residual risk: because the gate is purely the protocol version, a future upstream goose (or other harness) that declares protocolVersion: 2 would be treated as systemPrompt-capable and silently lose [Base]/[System] in the legacy user-message paths — inherent to the RFD's version-as-gate design, not a bug in this PR.

Design Decisions

  • Protocol version gating over capability flag: RFD explicitly rejects a separate capability in favor of version detection
  • Additive semantics: client systemPrompt replaces the default but hints are always appended
  • Size limit as implementation detail: 512KB is defensive engineering, not a protocol constraint
  • unwrap_or(1) default: agents not declaring protocolVersion get version 1 (legacy behavior) — safe default

@wpfleger96 wpfleger96 marked this pull request as draft June 11, 2026 18:55
@wpfleger96 wpfleger96 marked this pull request as ready for review June 11, 2026 19:37
@wpfleger96 wpfleger96 changed the title feat(acp): add systemPrompt field to session/new feat(buzz-agent): implement systemPrompt field in session/new Jun 11, 2026
@wpfleger96

Copy link
Copy Markdown
Collaborator Author

Context: ACP RFD for systemPrompt in session/new

This PR implements the systemPrompt field proposed in agentclientprotocol/agent-client-protocol#1237 (RFD: Client System Prompt in session/new).

This serves as a reference implementation we're dogfooding internally. The RFD is pending adoption by the ACP core team — this PR demonstrates the proposal works in practice with additive semantics and no capability negotiation.

wpfleger96 and others added 6 commits June 11, 2026 16:59
Implement the systemPrompt field proposed in RFD PR #1237 across both
buzz-agent and buzz-acp. The agent receives an optional systemPrompt in
session/new params and appends it to its effective system prompt (after
env-default + hints). The harness passes its combined [Base] + [System]
content via this field instead of embedding it in every user message.

Agent side (buzz-agent):
- SessionNewParams gains optional systemPrompt field (camelCase serde)
- session_new appends client prompt to effective_system_prompt with
  \n\n separator; rejects combined prompts exceeding 512KB
- initialize response advertises systemPrompt: true in promptCapabilities

Harness side (buzz-acp):
- AcpClient.initialize() parses promptCapabilities.systemPrompt from
  the agent's init response into a supports_system_prompt bool
- session_new_full/session_new accept system_prompt: Option<&str>;
  field is only included in params when agent advertises support
- create_session_and_apply_model combines base_prompt + system_prompt
  and passes it via session/new when supported
- format_prompt skips [Base] and [System] sections when
  system_prompt_via_session is true (content already in system role)
- Initial-message path skips prepend_base_prompt when agent supports
  systemPrompt (base content already passed via session/new)

Backward compatible: old harnesses omitting systemPrompt work unchanged;
new harness talking to old agent (no capability) falls back to embedding
in user messages.

Co-authored-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Add three tests covering the session_new_full systemPrompt wire behavior:
- Includes systemPrompt in params when agent advertises capability
- Omits systemPrompt when agent does not advertise capability
- Omits systemPrompt when value is None (even with capability)

Uses bash echo-back pattern: the test script echoes the received request
in the response so assertions can inspect the exact JSON sent.

Co-authored-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Strip [Base]/[System] labels from the systemPrompt wire value — the
protocol carries raw content, not harness-specific formatting. Add TODO
explaining why agent_core stays in user messages (resolved per-channel
after session creation). Move MAX_SYSTEM_PROMPT_BYTES to config.rs
alongside other limit constants.

New tests: 512KB size gate rejection (integration), empty-string
systemPrompt deserialization (unit).

Co-authored-by: Will Pfleger <wpfleger@squareup.com>
Signed-off-by: Will Pfleger <wpfleger@squareup.com>
Add request-capturing fake LLM server variant and two integration tests
that prove the full contract end-to-end without any live LLM dependency:

- system_prompt_reaches_llm_system_role: sends systemPrompt via
  session/new, triggers a prompt, inspects the captured LLM request body
  to verify the canary string appears in the system role message with
  correct additive ordering (agent default prompt before client prompt).

- system_prompt_absent_no_canary: same flow without systemPrompt field,
  verifies the canary does NOT appear in the system message while the
  agent's default prompt is still present.

Co-authored-by: Will Pfleger <wpfleger@squareup.com>
Signed-off-by: Will Pfleger <wpfleger@squareup.com>
Co-authored-by: Will Pfleger <wpfleger@squareup.com>
Signed-off-by: Will Pfleger <wpfleger@squareup.com>
…Prompt

The RFD explicitly states 'No separate capability flag is needed' and
recommends protocol-version-based detection. Remove the
supports_system_prompt field and system_prompt_via_session flag that
gated sending systemPrompt on a promptCapabilities boolean.

The harness now always passes systemPrompt in session/new when it has
system content. Agents that support the field use it; others ignore
unknown fields per JSON-RPC. The [Base]/[System] user-message injection
continues unchanged for backward compatibility.

Co-authored-by: Will Pfleger <wpfleger@squareup.com>
Signed-off-by: Will Pfleger <wpfleger@squareup.com>
@wpfleger96 wpfleger96 force-pushed the duncan/system-prompt-session-new branch from 2dcd358 to 44a7fa6 Compare June 11, 2026 20:59
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 3 commits June 11, 2026 18:26
PR 981 correctly routes base_prompt and system_prompt into the LLM
system role via session/new. However the legacy injection path
(format_prompt prepending [Base] and [System] to the user message)
was not removed, causing all system-prompt content to appear twice —
once in the system role and once in the user message.

Remove the [Base] and [System] sections from format_prompt(), remove
prepend_base_prompt() calls from heartbeat and initial_message paths,
and delete the now-unused prepend_base_prompt function and struct
fields. The user message now contains only per-turn dynamic content:
Agent Memory, Context, Thread Context, and Events.

Co-authored-by: Will Pfleger <wpfleger@squareup.com>
Signed-off-by: Will Pfleger <wpfleger@squareup.com>
…ault

When the harness provides a systemPrompt via session/new, use it as the
base content and suppress the DEFAULT_SYSTEM_PROMPT entirely. Hints are
appended last as supplementary reference material.

Previous ordering placed the useless default ("You are buzz-agent...")
first, then hints, then the substantive client_prompt last. This buried
the agent's core identity (base_prompt + persona) under noise.

New behavior:
- systemPrompt present: client_prompt + hints (no default)
- systemPrompt absent: DEFAULT_SYSTEM_PROMPT + hints (legacy fallback)

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Implement protocol version-based capability detection so the harness
only sends systemPrompt in session/new and suppresses [Base]/[System]
from user messages when the agent declares protocol version >= 2.

Legacy agents (goose, claude-code, codex) report version 1 and continue
receiving base_prompt and system_prompt via [Base]/[System] sections in
the user message — no behavior change for them.

Changes:
- buzz-agent: bump PROTOCOL_VERSION to 2
- buzz-acp: parse protocolVersion from initialize response, store on
  OwnedAgent, gate systemPrompt delivery and user-message injection
- queue.rs: restore [Base]/[System] emission gated on
  has_system_prompt_support flag (false for legacy agents)

Also applies Thufir's review fixes:
- Whitespace guard: trim() before is_empty() check on client_prompt
- Arc::from(prompt) instead of Arc::from(prompt.as_str())

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 changed the title feat(buzz-agent): implement systemPrompt field in session/new feat(acp): implement systemPrompt with protocol version gating Jun 12, 2026
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 6 commits June 11, 2026 20:36
Legacy agents (protocol_version < 2) lost their base_prompt on heartbeat
and initial_message code paths after the systemPrompt refactor moved
base_prompt delivery to session/new. Those paths bypass format_prompt,
so legacy agents received no [Base] section.

Additionally, the harness dishonestly sent protocolVersion: 1 in
initialize while buzz-agent unconditionally responded with 2. Now the
harness sends 2 and the agent responds with min(requested, supported)
for honest negotiation.

Changes:
- lib.rs dispatch_heartbeat: gate base_prompt prepend on protocol_version < 2
- pool.rs initial_message: gate base_prompt prepend on protocol_version < 2
- acp.rs initialize: send protocolVersion 2 (what we actually support)
- buzz-agent initialize: respond with min(requested, PROTOCOL_VERSION)
- Update tests to reflect honest negotiation semantics

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The [Base] framing was inlined at three dispatch sites (batch flush, heartbeat, initial message) after prepend_base_prompt was deleted, creating drift risk. Re-extract a single base_section helper so the format is defined in exactly one place, and add a direct unit test covering the trim-and-prepend contract that the regression depended on. Also removes a stray blank line in acp.rs::initialize().

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The round-2 bug shipped because legacy agents silently lost [Base] on the heartbeat and initial-message dispatch paths, and nothing tested that branch. Extract the legacy-prepend gate into prepend_base_for_legacy so both sites share one definition, then add regression tests that fail if the protocol-version gate is flipped or the base_prompt arg is dropped.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The version-as-gate behavior (gating [Base] on protocol_version < 2, and the harness pinning protocolVersion: 2) is intentional but temporary. Document the assumption at both sites so a future genuine upstream-v2 agent silently losing [Base] is a known, revisitable tradeoff rather than a surprise.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…session-new

* origin/main:
  fix(huddle): Pocket TTS quality overhaul — reference parity + cross-message pipelining (#997)
  Add manual ACP session rotation command (#932)
  fix(desktop): heal stale persona_team_dir paths in release builds (#1003)
  ci(docker): publish public ghcr.io/block/buzz image (native multi-arch) (#986)
  fix(buzz-agent): cap tool-result text at 50 KiB with middle elision (#952)
  feat(huddle): sentence-at-a-time voice-mode guidelines for lower TTS latency (#996)
  Shard desktop Playwright CI jobs (#992)
  chore(release): release version 0.3.18 (#995)
  Video Player Improvements  (#993)
  Improve first-run welcome setup (#970)
  fix(release): use legacy updater key secret (#991)

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>

# Conflicts:
#	crates/buzz-acp/src/lib.rs
#	crates/buzz-agent/src/config.rs
…session-new

* origin/main:
  Add relay disconnect UX: friendly errors, reconnect, cached identity (#1004)
  feat(agents): add active turn indicators to Agents Menu (#1005)
  ci: add fork guards to docker, release, and auto-tag workflows (#1007)
  docs(nip-rs): add optional thread read context scheme (#1006)
@wpfleger96 wpfleger96 merged commit f085882 into main Jun 12, 2026
27 checks passed
@wpfleger96 wpfleger96 deleted the duncan/system-prompt-session-new branch June 12, 2026 19:07
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.

1 participant