Skip to content

fix(providers): Anthropic reload and parallel results, local probe retry - #124

Open
adriannoes wants to merge 6 commits into
developmentfrom
fix/providers-anthropic-local
Open

adriannoes wants to merge 6 commits into
developmentfrom
fix/providers-anthropic-local

Conversation

@adriannoes

Copy link
Copy Markdown
Collaborator

Summary

Consolidates three cursor high-severity provider fixes as operator-authored commits:

Testing

  • make test_reload — passed
  • make test_anthropic — passed
  • make test_local_provider — passed
  • CI=true make test — passed
  • make static — passed

Supersedes #106, #107 and #110. #118 (model-id gateway mapping) also touches src/providers/anthropic.c/provider.h; it will be rebased on top of this PR as a follow-up.

Dashboard and SIGHUP reloads updated OpenAI and the local provider
but left Anthropic on the config captured at init. The default
provider then kept sending the previous model and max_tokens.

Supersedes #106.
Claude often returns several tool_use blocks in one assistant turn. Each
result was posted as its own user message, which the Messages API rejects,
so the model never saw the tool output after the tools had already run.

Supersedes #107.
Init marks llama-server unreachable once. The default chain puts local
last, so router recovery never clears that flag and offline chat stays
dead until restart. The next chat probes again before refusing.

Supersedes #110.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Three small, independent provider fixes that supersede #106, #107, #110: Anthropic now follows live config reload (try_config_reload -> provider_anthropic_set_live_config, same guard as OpenAI), parallel tool results are folded into one user message, and the local provider re-probes on chat after a startup miss. The Anthropic message builder is also split into short, checkable helpers. I read the diff and surrounding code; I could not build or run the tests here (no libcurl headers in this image), so test status is from reading only.

Must Fix

None.

Should Fix

  • src/providers/local.c:188-195: the re-probe runs on every chat while s_local_unavailable is set, synchronously, and agent_run holds agent_lock at that point. A down server costs up to two curl probes per turn (/v1/models, then /health; LOCAL_PROBE_CONNECT_SEC 2s, LOCAL_PROBE_TIMEOUT_SEC 3s each). When local is the last backend in the chain and everything before it failed, that is up to a few seconds of added latency per message, plus a blocked gateway/channel for the duration. Consider a minimum interval between retries (for example one probe per 10-30s, using a static time_t like the router's g_recovery_last_wall). Test code can reset it in local_provider_test_reset.
  • CHANGELOG.md (Unreleased, Fixed): no entries for the three fixes. Other consolidation PRs in this tree add them.
  • docs/LOCAL_INFERENCE.md:21: still says that if the server is unreachable at startup the provider marks itself unavailable. Add that the next request probes again.
  • tests/test_local_provider.c:82: local_provider_test_set_skip_probe(1) makes local_probe_endpoint return 1 unconditionally, so the test proves the flag clears, but not that local_chat actually calls provider_local_endpoint_reachable against the configured endpoint. Fine as a first cut; a loopback listener (or a probe hook) would cover the real path. The unchanged test_init_unreachable_... does cover the still-down case.
  • tests/test_anthropic.c:238: the grouping test only covers two results at the end of the list. Add: a single tool result (no regression of the old path), results followed by a plain user message (the loop must stop at the first message without tool_use_id), and two assistant/tool rounds in a row (no merging across the assistant message). Those are the boundary conditions of append_tool_result_message.

Nice to Have

  • src/providers/anthropic.c:333: if tc->arguments is non-empty but not valid JSON, cJSON_Parse returns NULL and the tool_use block is sent without input, which the API rejects with a 400 that will repeat every turn for that session. Now that this is its own function it is cheap to fall back to {} (or fail closed). Pre-existing behavior, moved as-is.
  • src/providers/anthropic.c:481: anthropic_live_config_for_test is compiled into production and declared unconditionally in provider.h, unlike the other _for_test hooks. I see why (test_reload links the non-test anthropic.o), but a short comment in provider.h saying so would stop someone from "fixing" it with an #ifdef.
  • tests/test_reload.c:260: the new test is moved to the front of main with no comment, and it does not cover the no-key case (provider_anthropic_set_live_config must stay a no-op when init failed). One assertion would lock the s_anth_api_key guard.
  • The cJSON_AddItemToObject(msg, "role", cJSON_CreateString(...)) calls in append_assistant_tool_content and append_anthropic_messages still ignore allocation failure (missing role would produce an invalid request). Same pattern as before; append_one_tool_result already uses cJSON_AddStringToObject with checks, so the rest could follow.

Positive Highlights

  • The tool-result fix is correct against the Messages API: all tool_result blocks for one assistant turn go into a single user message, and the old per-result messages (a 400 for parallel tool calls) are gone. Ownership on error paths is clean: msg is attached to msg_arr before the loop, so a mid-loop failure does not leak.
  • Splitting the 50-line inline loop in build_and_send into append_one_tool_result, append_tool_result_message, append_assistant_tool_content, and append_anthropic_messages makes the code reviewable, and the anthropic_messages_json_for_test hook lets the test assert on JSON structure instead of strstr. Failure on OOM now returns an error instead of silently dropping a block.
  • The comment in local_chat explains why router recovery cannot clear the flag (it only probes backends before the active index, and local is last in the default chain). I checked provider_router_periodic_recovery_tick, and that reasoning holds.
Open in Web View Automation 

Sent by Cursor Automation: Code review

Comment thread src/providers/local.c
/* Init probes once, often before llama-server has loaded the model.
* Default fallback order puts local last, so router recovery (which
* only probes backends before the active index) never clears the flag. */
if (s_local_unavailable) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This probe is synchronous and repeats on every chat while the server is down (models probe, then health probe, up to ~2s connect / 3s total each). It runs under agent_lock. Please add a minimum retry interval so a dead llama-server does not add seconds to every turn.

Comment thread src/providers/anthropic.c
@@ -265,6 +265,111 @@ static int do_request(const char *body, provider_response_t *response)
return ret;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The loop stops at the first following message without tool_use_id, which is right. Add a test for results followed by a plain user message and for a single result, so this boundary is locked.

@adriannoes adriannoes changed the title fix(providers): Anthropic reload and parallel results, local probe retry (supersedes #106, #107, #110) fix(providers): Anthropic reload and parallel results, local probe retry Oct 16, 2026
@adriannoes adriannoes self-assigned this Oct 16, 2026
agent_run holds agent_lock while local_chat probes synchronously, so a
down llama-server cost up to two curl probes (about 5 s) on every turn,
blocking the gateway and channels for the duration. While the startup
miss flag is set, re-probe at most once per LOCAL_REPROBE_MIN_SEC (15 s),
tracked with a wall-clock timestamp in the router's recovery-timer style;
local_provider_test_reset() clears it so tests control the window.
The grouping test only covered two results at the end of the message
list, so regressions at the loop edges of append_tool_result_message
would pass unnoticed. Add the boundary cases: a single tool result still
yields one user message (old path unregressed), results followed by a
plain user message stop grouping at the first message without a
tool_use_id, and two assistant/tool rounds in a row keep each round's
result message separate.
LOCAL_INFERENCE.md still ended the unreachable-startup story at the
provider marking itself unavailable, with no word on recovery; state
that the next request re-probes, at most once every 15 seconds. Add the
CHANGELOG entries for the three fixes this PR consolidates (live config
reload reaching Anthropic, parallel tool results in one user message,
local re-probe after a startup miss) so the Unreleased section reflects
the tree.
@adriannoes

Copy link
Copy Markdown
Collaborator Author

Review triage — every item mapped:

Must Fix — none raised.

Should Fix

  • src/providers/local.c:188-195 re-probe runs on every chat while unavailable — Fixed in ccbfa38 (fix(providers): throttle the local re-probe while unavailable). While s_local_unavailable is set, chat re-probes at most once per LOCAL_REPROBE_MIN_SEC (15 s), tracked by a static time_t in the router's g_recovery_last_wall style; local_provider_test_reset() clears it. TDD: a second chat inside the window does not probe; after the hook resets the throttle it probes again.
  • CHANGELOG.md Unreleased/Fixed entries missing — Fixed in fa52090 (docs(providers): document local re-probe throttle and consolidated fixes). Bullets added for the three consolidated fixes.
  • docs/LOCAL_INFERENCE.md:21 still ends the story at "marks itself unavailable" — Fixed in fa52090. Now states the provider re-probes on the next request, at most once every 15 seconds.
  • tests/test_local_provider.c:82 loopback listener / probe hook proving local_chat probes the configured endpoint — Not changed: test-infrastructure addition; accepted as a first cut. The skip_probe flag covers the flag-clear path and the unchanged test_init_unreachable_* covers the still-down path. Follow-up.
  • tests/test_anthropic.c:238 grouping boundary cases — Fixed in 77d2bfc (test(anthropic): cover tool-result grouping boundaries). Single tool result still yields one user message; results followed by a plain user message stop grouping at the first message without tool_use_id; two consecutive assistant/tool rounds keep their result messages separate.

Nice to Have — none changed

  • src/providers/anthropic.c {} fallback when tool arguments are non-empty but invalid JSON — Not changed: pre-existing behavior moved as-is in this PR; incremental hardening beyond this PR's smallest patch. Follow-up.
  • One-line comment in provider.h explaining why anthropic_live_config_for_test ships unconditioned — Not changed: comment-only hardening outside the smallest patch. Follow-up.
  • No-key assertion in test_reload (live config must stay a no-op when init failed) — Not changed: incremental test hardening outside this PR's scope. Follow-up.
  • cJSON_AddItemToObject(..., "role", ...) allocation-failure checks in the message builders — Not changed: same pre-existing pattern; append_one_tool_result already checks its adds. Follow-up.

This branch has not been deployed

No deployments
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