Repository navigation
Releases: BlakeHung/acp-bridge
Release list
v0.9.2
Added
- Thinking-mode tool-call recovery — reasoning models (DeepSeek-R1,
Qwen 2.5/3, GLM) frequently emit tool-call JSON inside the content
channel instead of the structuredtool_callsfield. acp-bridge
previously treated such responses as a final text answer, meaning the
agent never dispatched the requested tool and the session stalled.
The engine now sanitizes the reasoning scaffolding
(strip_thinking_blocks) and recovers embedded tool-call JSON —
fenced ```json / ```tool_call blocks, bare balanced objects with
name+arguments/args/parameters/input, or the whole-call
{"function": {…}}shape — reproducing the OpenAI-style tool_call
objects the structured path would have produced. Recovered rounds
continue the normal agentic loop and are logged for debugging.
Eleven new unit tests (thinking_recovery_tests). Project page:
wchung.tw/acp-bridge/.
v0.9.1
Fixed — ACP v2 wire-shape blockers
The 0.9.0 release shipped with three wire-shape violations against the
v2 protocol that a spec-compliant v2 Client (e.g. an early OpenCode v2
preview) would have rejected. All three are addressed in this release:
state_updatediscriminator — the v2 schema requires every
state_updatepayload to have astatefield set to one of
"running" | "idle" | "requires_action". The previous code emitted
"available": false(a non-spec field) and was missingstate. Now
emits{sessionUpdate: "state_update", state: "idle", stopReason?}
per theIdleStateUpdateschema.session/promptv2 response carriesmessageId— the v2
PromptResponseis{required: ["messageId"]}. The previous v1-style
response ({stopReason, status, text}) would have failed schema
validation. acp-bridge now mints a UUID-derived messageId per prompt
and emits the v2 wire shape for v2 Clients. The v1 wire is unchanged.
Legacystatus/text/stopReasonnow live onstate_update
for v2 Clients, exactly as the v2 spec requires.- v2 session lifecycle methods — the v2 baseline includes
session/new | session/list | session/resume | session/close | session/prompt | session/cancel | session/update. acp-bridge
previously only implemented the v1 surface (session/new,
session/end,session/prompt,session/cancel). New:session/close(v2 baseline; shares the implementation with
session/end)session/list(v2 baseline; returns active sessions as
{sessions: [{sessionId, cwd}], nextCursor: null})
session/delete(v2 optional) andsession/resume/
session/loadreturn graceful-32601 not_implemented/
-32001 no_persistencerejections with the stabledata.reason
field.
Fixed — Ollama native protocol bugs
Three pre-existing bugs in the Ollama native code path that had been
documented as fix-plan priorities P0-C. None were wired through the
test suite (tests pointed the harness at 127.0.0.1:1, so Ollama
native was never exercised in CI):
tool.argumentsobject vs string — Ollama native/api/chat
returnsfunction.argumentsas a JSON object; OpenAI-compatible
backends return it as a JSON-encoded string. The previous code called
as_str()on the value and silently fell back to"{}"when it
wasn't a string, which meant every Ollama native tool call ran
with empty arguments. Now handles object / array / string uniformly.options.*sampling fields — Ollama native wants
temperatureandmax_tokens(renamednum_predict) inside an
optionsobject; OpenAI-compatible expects them at the top level.
The previous code only sent top-level fields, so every Ollama
native request silently used the model's defaults for sampling.format_tool_resultOllama field — Ollama native tool messages
use{"role": "tool", "content": …}and ignore (some versions
reject) thetool_call_idfield that acp-bridge included. The new
helper emitstool_call_idonly when the backend is not Ollama
native.
Fixed — sandbox escapes
search_codewalks symlinks — previous code usedpath.is_dir()
/path.is_file(), which follow symlinks. A symlink inside the
sandbox pointing at/etc/passwd(or anywhere outsideworking_dir)
would be read and its contents returned. Now:- Skip entries whose
symlink_metadatareportsfile_type().is_symlink() - Canonicalize the entry and skip it if it does not start with the
canonicalized working dir - Bound recursion depth to
MAX_LIST_DEPTH * 4to prevent
adversarial directory structures
- Skip entries whose
web_fetchredirects bypassLLM_WEB_ALLOWLIST— reqwest's
default redirect policy follows up to 10 hops to any host. A
server on an allowlisted domain could 302 to an internal host and
acp-bridge would happily return the body. Now uses
redirect::Policy::customthat re-validates the next hop's host
againstLLM_WEB_ALLOWLISTon every redirect, with a 5-hop cap.
Changed
docs/scope.mdsynced with current state — the previous version
still said "Not a v2 protocol agent yet" (incorrect as of 0.9.0),
listed the wrong tool set (5 tools instead of the 11 actually
shipped in 0.8.2), and referenced acodex_style.rstest file
that was renamed tominimal_style.rslong ago. Now reflects the
real v1 / v2 dual implementation, the full tool surface, and the
currenttests/clients/layout.CHANGELOG.mdcorrections — 0.9.0 entry over-claimed
Session::protocol_versionis read at emit sites; the field is
stored on each Session but the emit helpers currently use
AppState.protocol_version(one Client per process in practice).
0.8.2 entry under-reported the test count.
Tests
171 tests total (from 168 in 0.9.0). Added in tests/clients/protocol_version.rs:
v2_session_prompt_response_carries_message_id— asserts the v2
PromptResponsecarries the requiredmessageIdand does not
carry the legacy v1stopReason/statusfields.v2_session_close_succeeds_and_v2_session_delete_gracefully_rejects
— confirms the newsession/closerouting and the stable
data.reason: "not_implemented"error onsession/delete.v2_session_list_returns_session_info_with_cwd— asserts
session/listreturns the active sessions in the v2 wire shape.
Hardened existing tests:
v2_emits_state_update_at_end_of_turnnow asserts the
state: "idle"discriminator (the previous version only checked
the discriminator string, which let the bug through).tests/clients/inspector_style.rs::inspector_style_session_*_returns_method_not_found_gracefully
were updated to positive tests — the methods are now implemented
and the previous negative assertions no longer held.
v0.9.0
Negotiate protocolVersion at init time and emit v1 or v2
session/update payloads accordingly. Same code base, dispatch by
version. The implementation adds:
protocol::ProtocolVersionenum (V1,V2,LATEST) and the
Session::protocol_versionfield for defense-in-depth and future
per-session routing.AppState::protocol_version(set byrun_acp_loopfrom the negotiated
value) so every emit helper branches on the same source of truth.
AppState: Cloneis hand-written; sessions are wrapped in
Arc<RwLock<...>>so the clone shares the map.acp::notify_*_for(version, …)dispatchers that route to the v1 or
v2 implementation. The plain (no-suffix)notify_*functions stay
as the v1 shortcut for code paths that have not migrated.acp::notify_state_idle_for()for v2-onlystate_update(IdleState).negotiate_protocol_version(params)inmain.rs— implements the
ACP spec's "pick the highest version we both support" rule.
Clients that omitprotocolVersionfall back to v1 (conservative
default). Clients requesting a version > 2 fall back to v2 with a
warning.- v2-shaped
InitializeResponse: unifiedinfo+capabilities
(role-agnostic), withcapabilities.session.prompt.imageexpressed
as{}(capability marker) when supported. - v2-shaped
session/updatediscriminators:agent_message_chunk,agent_thought_chunk(chunk variants)tool_call_update(instead of v1'stool_call)plan_updatewithplan: { type: "items", planId, entries[] }
- v2 baseline
session/close(alias for v1'ssession/end) and
session/list(returns active sessions as
{sessions: [{sessionId, cwd}], nextCursor: null}). - v2 graceful rejects for
session/delete,session/resume,
session/load:-32601not_implemented/-32001
no_persistencewith stabledata.reason. LlmConfig.build_bodyis now backend-aware: OpenAI-compatible gets
top-leveltemperature/max_tokens; Ollama native gets them
insideoptions(temperature+num_predict). The previous
top-level-only shape meant every Ollama native request silently used
the model's defaults for sampling.
Tests
9 new tests in tests/clients/protocol_version.rs:
v2_initialize_returns_unified_capabilities_shapev2_tool_call_uses_tool_call_update_not_tool_callv2_plan_uses_plan_update_with_plan_idv2_emits_state_update_at_end_of_turnv2_does_not_emit_state_update_for_v1_clientsv1_client_gets_legacy_shapes_unchangedinspector_style_session_list_returns_empty_array_with_no_sessions
(updated from the pre-0.9.0 negative-test now thatsession/list
is implemented)inspector_style_session_close_succeeds_and_variants(updated
similarly)
168 tests passing; cargo fmt --check and
cargo clippy --all-targets -- -D warnings clean.
Migration
- v1 Clients see no behavior change. The plain
notify_*functions
still emit v1, and every emit site inmain.rswas migrated to
use the_for(version, …)helpers that branch on the negotiated
version. - Existing
inspector_style_session_list_returns_method_not_found_gracefully
andinspector_style_session_close_returns_method_not_found_gracefully
tests were renamed and rewritten as positive tests. The original
intent of those tests (probe capability, get clear error) is
preserved —session/closenow returns-32001 UnknownSession
instead of-32601 MethodNotFoundwhen called on a non-existent
session, andsession/listreturns{sessions: []}with no
sessions open.
Co-Authored-By: Claude noreply@anthropic.com
Full Changelog: v0.8.2...v0.9.0
v0.8.2
Added
- New tools — four more built-in tools round out the surface so AI
agents can stay inside acp-bridge instead of falling back to
their own knowledge:edit— surgical string replacement. Replace exactly one
occurrence ofold_textwithnew_textin an existing file.
Refuses to act ifold_textis missing or appears more than once,
so the model has to re-read the file rather than guess.write_file— create or overwrite a file with new content.
Sibling toeditfor whole-file rewrites; rejects..escapes.web_fetch— fetch a URL over HTTP/HTTPS and return the body
as text. HTML is reduced to readable text (scripts/styles
stripped, tags removed, whitespace collapsed). 5 MB body cap,
30 s timeout.
Off by default: requiresLLM_WEB_ALLOWLISTto be set to a
comma-separated list of host suffixes (e.g.
LLM_WEB_ALLOWLIST=docs.rs,crates.io). Empty allowlist blocks
every request. This is an opt-in safety boundary so a
misconfigured sandbox cannot exfiltrate to internal
infrastructure.git_status,git_diff,git_log,git_commit—
read-only and write git operations, all run inside the session
working directory.git_diffaccepts an optionalpathand a
staged: trueflag (--cached).git_logacceptsmax_count
(clamped to 1–200, default 20) and an optionalpathfilter.
git_commitstages the listedpaths(orgit add -uwhen
pathsis omitted) and commits with the supplied message.
acp-bridgenow publishes fourplannotification helpers —
acp::PlanEntry,acp::notify_plan,acp::notify_session_info,
acp::notify_usage,acp::AvailableCommand,
acp::notify_available_commands. Engine hooks fire
available_commands_updateandsession_info_updateimmediately
aftersession/new, andusage_update+session_info_update
aftersession/promptreturns.LlmConfig.context_size— model context window in tokens,
surfaced asusage_update.size. Override viaLLM_MODEL_CONTEXT
env var or[llm].model_contextconfig field (default 32768).PromptResult::usagecarries an estimatedusedtoken count
(chars / 4 across the session history) so theusage_updatehas
a number to ship. Local backends rarely stream stable per-turn
token counts; this is intentionally approximate.acp::kind_for_toolnow classifiesedit,write_file,
web_fetch, and the fourgit_*tools so Clients render the
right icon and affordance.- Classified backend errors —
LlmErrorKind(Unreachable,
RateLimited,ServerBusy,Auth,BadRequest,NotFound,
Timeout,ParseError,Unknown) andLlmError::is_retryable().
chatandstream_chatnow returnResult<_, LlmError>instead
of plain strings. Failed turns emit a structured
error.data.category+error.data.retryablein the JSON-RPC
response so Clients can branch on it (e.g. "auto-retry on
backend_unreachable, show 'check your model name' on
not_found").
Changed
- Wire order on
session/promptandsession/new— the
post-event notifications (available_commands_update,
session_info_update,usage_update) are emitted before the
JSON-RPC response, not after. Clients that buffer the entire
notification stream per turn (most ACP Clients) see the
notifications bound to the right sessionId; Clients that read
strictly one-line-at-a-time still get the response on the line
after the notifications. PromptResultgainserror_class: Option<LlmErrorKind>and
error_retryable: boolso the engine's failure classification
reaches the JSON-RPC response without string-matching.
Tests
- 14 new unit tests (
src/tools.rs): write / edit (unique match,
missing, ambiguous, empty), web_fetch allowlist enforcement, HTML
reduction, git status. - 3 new unit tests (
src/llm.rs):LlmErrorKind::as_strstability,
retryable classification, status-code → kind mapping. - All existing test suites still pass; 159 tests total.
v0.7.8
Breaking Changes
- A2A mode removed —
--a2aHTTP server with Agent Card support is no longer available - Client mode removed —
--clientexternal ACP agent spawning is no longer available - Configuration changes —
[a2a]and[agent]sections in config.toml are no longer supported
Added
- Backend abstraction layer — New
Backendenum (Ollama/OpenAi) encapsulates protocol-specific logic for message formatting, response extraction, and tool-call handling - Simplified architecture — Focused ACP-only adapter with cleaner codebase
- Benchmark mode — Added
--benchflag for performance testing
Removed
- A2A implementation — src/a2a.rs (299 lines) deleted
- Client implementation — src/client.rs (869 lines) deleted
- Client tests — tests/client_test.rs (173 lines) deleted
- Marketing materials — DEMO-AND-MARKETING.md (718 lines) and marketing-drafts.md (176 lines) deleted
- Dependencies — axum and libc crates removed from runtime dependencies (axum kept as dev-dependency for tests)
Changed
- README updates — Removed --a2a HTTP server mention, added --bench example, updated Project status to reflect ACP-only scope
- Configuration system — Simplified to only support LLM configuration
- Help text — Updated to reflect ACP-only positioning (removed --a2a and --client options)
- Project status — Updated to reflect v0.7.8 ACP-only scope
Internal
- Backend-specific logic moved — Protocol quirks moved from engine.rs to llm.rs Backend enum
- Code reduction — 1,649 lines of code removed overall
- Simplified RunMode enum — Now only Acp and Bench modes
Migration Notes
Users relying on A2A mode should migrate to ACP mode with their ACP harness. Users using client mode should configure their harness to spawn acp-bridge directly via stdin/stdout JSON-RPC.
v0.7.7
Fixed
session/promptfinal response was missing the accumulated text —handle_acp_promptpreviously sent the assistant's final text exclusively throughNotification::TextChunkand replied with{"status": "completed"}. Upstream pipelines that consume the final response (or that treatToolDone("llm_chat","completed")as the turn boundary and stop reading further notifications) saw an empty reply even though the chunks had been streamed. The final response now also carriestext: result.textso non-streaming consumers and edge-case race conditions still get the body. Reviewer-flagged by Eren.
v0.7.6
Fixed
<sender_context>metadata in user prompts made models emit empty replies with no tool calls — OpenAB-style harnesses prepend a<sender_context>{…json…}</sender_context>block to the user message. Several local LLMs (observed on Qwen3-Coder via Ollama) interpret the XML wrapper as a directive and stall — the model returns empty content with no tool calls, which surfaces upstream as "the agent doesn't reply" and "the agent doesn't know about brain/KB".engine::strip_sender_contextnow detects the block, removes it from the forwarded user text, and logs the captured inner string at debug level for traceability. Both the ACP (handle_acp_prompt) and A2A (handle_message_send) entry points strip before the empty-prompt guard and before passing tosession_prompt. Four unit tests cover the leading-block case, the no-block passthrough, an unterminated open tag, and the all-metadata edge case. Reviewer-flagged by openab-rukawa.
v0.7.5
Fixed
- Inbound image MIME type was discarded and rewritten as JPEG — both
engine::extract_image_partsand the per-block path insidesession_prompthad only kept the base64 data and hard-codeddata:image/jpeg;base64,…when forwarding to OpenAI-compatible backends. Any ACP/A2A client sending PNG/WebP/GIF content was therefore mislabeled, which can break vision-model decoding or yield undefined multi-modal behaviour. Image extraction now returns a newImageBlock { data, mime_type }, the per-blockmimeTypeis threaded through, andsession_promptemitsdata:<mime>;base64,<data>using the client's declared MIME (withimage/jpegonly as a fallback for clients that omit the field). Reviewer-flagged by Eren. - A2A transport silently dropped image inputs —
handle_message_sendonly extracted text frommessage.partsand always calledengine::session_prompt(..., &[], None), so text+image A2A requests lost their images and image-only A2A requests were rejected as empty.initialize()already advertisesagentCapabilities.promptCapabilities.image: true, so the A2A path now honors it: both text and image parts are extracted, an empty prompt is rejected only when both are absent, and images are forwarded tosession_prompt. Reviewer-flagged by Eren.
v0.7.4
Fixed
bench.rsTOTAL aggregate was dragged down by error fixtures — when a fixture timed out or failed, itswall_mswas still summed into the aggregate even though its completion-token count was absent. The aggregate tok/s now skips error rows. Reviewer-flagged by Mikasa.- OpenAI-mode
tok/swas not labelled as wall-clock-derived — Ollama-native mode computes tok/s fromeval_duration(decode only), while OpenAI-compat mode divides by wall_ms (which includes TTFT and transit). The column header now showstok/s*in OpenAI mode and a footnote explains the difference, so readers don't conclude OpenAI backends are slower than they actually are. Reviewer-flagged by Mikasa.
Changed
bench::Fixturegains anOption<&'static str> system_promptfield — decode-heavy fixtures (explain_concept,summarize) now run without a "concise" system prompt so they produce enough tokens to make the timing meaningful. Other fixtures keep a tight prompt because they're intentionally short. Reviewer-flagged by Mikasa.
Internal
engine.rsuser-message path — removed a dead innerif user_images.is_empty()inside the OpenAI-compat else arm. The outer branch already guarantees the slice is non-empty there; the inner check could never fire. Reviewer-flagged by Eren.
v0.7.3
Important
- Skip 0.7.2 on crates.io — it is the buggy pre-fix code from a cancelled release run. The 0.7.2 git tag and Docker image at
ghcr.io/blakehung/acp-bridge:0.7.2point at the fixed code (commitfc0b9c7), but crates.io permanently locked the version at the earlierb253172snapshot before the cancel landed. Use 0.7.3+ from crates.io. The 0.7.2 entry below still describes the intended contents; 0.7.3 ships those plus the second-round reviewer findings.
Fixed
- NVIDIA product names containing commas were truncated —
parse_nvidia_sminow splits withrsplitn(2, ',')(from the right) instead ofsplitn(2, ','), so names like "NVIDIA GeForce RTX 4090, Ada" parse correctly and the VRAM column lines up. Reviewer-flagged by Mikasa. parse_rocm_smirejected MB-unit VRAM — olderrocm-smiversions emit VRAM in bytes, newer ones emit MB. The old> 100_000_000filter discarded MB values entirely. Now takes the max parseable number on the row and converts only when it looks like bytes. Reviewer-flagged by Mikasa.- AMD Vulkan-only fallback missed cards with unprefixed / upper-case vendor IDs —
scan_sysfs_amdnow normalizes the sysfs vendor string and accepts both0x1002and1002. Reviewer-flagged by Mikasa. - First fixture in
--benchate the cold-start cost —bench::runnow does a discarded warm-upchat()before the first measured fixture to prime model load + cache. Reviewer-flagged by Mikasa and Armin.
Changed
session/load,session/resume,session/set_modenow return-32601— these are not supported (we don't advertiseloadSession, sessions are created withoutmodes), so ACP capability-based negotiation calls for method-not-found rather than the-32001/-32602codes the previous patch used. Message strings still explain the underlying reason. Reviewer-flagged by Armin.