From 139c9cbbe69c512e171b92d8b1421643af30b225 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Fri, 16 Oct 2026 00:10:55 -0300 Subject: [PATCH 1/6] fix(providers): apply live config reload to Anthropic 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. --- src/core/reload.c | 1 + src/providers/anthropic.c | 11 ++++++++++ src/providers/provider.h | 4 ++++ tests/test_reload.c | 44 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+) diff --git a/src/core/reload.c b/src/core/reload.c index 17c64e5..cbad861 100644 --- a/src/core/reload.c +++ b/src/core/reload.c @@ -87,6 +87,7 @@ int try_config_reload(config_t **pcfg) return -1; } provider_router_set_live_config(new_cfg); + provider_anthropic_set_live_config(new_cfg); provider_openai_set_live_config(new_cfg); provider_local_set_live_config(new_cfg); tool_set_config(new_cfg); diff --git a/src/providers/anthropic.c b/src/providers/anthropic.c index 2801427..2e4789f 100644 --- a/src/providers/anthropic.c +++ b/src/providers/anthropic.c @@ -414,6 +414,17 @@ const provider_t *provider_anthropic_get(void) return &anthropic_provider; } +void provider_anthropic_set_live_config(const config_t *cfg) +{ + if (cfg && s_anth_api_key) + s_anth_cfg = cfg; +} + +const config_t *anthropic_live_config_for_test(void) +{ + return s_anth_cfg; +} + #ifdef SHELLCLAW_TEST int anthropic_parse_response_for_test(const char *json, provider_response_t *response) { diff --git a/src/providers/provider.h b/src/providers/provider.h index 6b8fa1a..0040b32 100644 --- a/src/providers/provider.h +++ b/src/providers/provider.h @@ -124,6 +124,10 @@ void provider_stub_b_set_chat_error_message(const char *message); /** Anthropic (Claude) provider. Messages API, tool_use. */ const provider_t *provider_anthropic_get(void); +/** After SIGHUP or dashboard reload: refresh model and max_tokens without re-init. */ +void provider_anthropic_set_live_config(const config_t *cfg); +/** Config pointer Anthropic chat reads. NULL until init. For tests. */ +const config_t *anthropic_live_config_for_test(void); /** OpenAI provider. Chat Completions, tool_calls (function calling). */ const provider_t *provider_openai_get(void); diff --git a/tests/test_reload.c b/tests/test_reload.c index 37e7a75..7c07693 100644 --- a/tests/test_reload.c +++ b/tests/test_reload.c @@ -2,12 +2,15 @@ * @file test_reload.c * @brief Unit tests for SIGHUP config reload and stale config queue. */ +#define _POSIX_C_SOURCE 200809L #include "test_runner.h" #include "core/reload.h" #include "core/bootstrap.h" #include "core/config.h" +#include "providers/provider.h" #include +#include #include static config_t *load_minimal_config(const char *path, const char *model) @@ -212,8 +215,49 @@ static int test_try_config_reload_ignores_stale_caller_pointer(void) return 0; } +static int test_try_config_reload_updates_anthropic_model(void) +{ + char path[128]; + config_t *cfg = NULL; + const provider_t *anth; + const config_t *live; + + ASSERT(test_runner_mkstemp_path("shellclaw_test_reload", path, sizeof(path)) == 0); + cfg = load_config_with_tokens(path, "model-before", 111); + ASSERT(cfg != NULL); + bootstrap_set_config_path(path); + bootstrap_set_cfg(cfg); + ASSERT(setenv("ANTHROPIC_API_KEY", "test-key-not-sent", 1) == 0); + anth = provider_anthropic_get(); + ASSERT(anth != NULL && anth->init != NULL && anth->cleanup != NULL); + ASSERT(anth->init(cfg) == 0); + live = anthropic_live_config_for_test(); + ASSERT(live != NULL); + ASSERT(strcmp(config_agent_model(live), "model-before") == 0); + ASSERT(config_agent_max_tokens(live) == 111); + { + FILE *f = fopen(path, "w"); + ASSERT(f); + fprintf(f, "[agent]\nmodel = \"model-after\"\nmax_tokens = 222\n"); + fclose(f); + } + ASSERT(try_config_reload(&cfg) == 0); + live = anthropic_live_config_for_test(); + ASSERT(live != NULL); + ASSERT(strcmp(config_agent_model(live), "model-after") == 0); + ASSERT(config_agent_max_tokens(live) == 222); + anth->cleanup(); + stale_free_all(); + config_free(cfg); + bootstrap_set_cfg(NULL); + unsetenv("ANTHROPIC_API_KEY"); + remove(path); + return 0; +} + int main(void) { + RUN(test_try_config_reload_updates_anthropic_model()); RUN(test_on_hup_sets_reload_flag()); RUN(test_stale_enqueue_null_is_noop()); RUN(test_stale_enqueue_preserves_old_config()); From ef965e0d6705c7e55a3d668835b0246213b12a01 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Fri, 16 Oct 2026 00:10:55 -0300 Subject: [PATCH 2/6] fix(providers): send parallel Anthropic tool results in one user message 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. --- src/providers/anthropic.c | 180 +++++++++++++++++++++++++++----------- tests/test_anthropic.c | 55 ++++++++++++ 2 files changed, 183 insertions(+), 52 deletions(-) diff --git a/src/providers/anthropic.c b/src/providers/anthropic.c index 2e4789f..998aa87 100644 --- a/src/providers/anthropic.c +++ b/src/providers/anthropic.c @@ -265,6 +265,111 @@ static int do_request(const char *body, provider_response_t *response) return ret; } +static int append_one_tool_result(cJSON *content_arr, const provider_message_t *msg) +{ + cJSON *tr = cJSON_CreateObject(); + if (!tr) + return -1; + if (!cJSON_AddStringToObject(tr, "type", "tool_result") || + !cJSON_AddStringToObject(tr, "tool_use_id", msg->tool_use_id ? msg->tool_use_id : "") || + !cJSON_AddStringToObject(tr, "content", msg->content ? msg->content : "")) { + cJSON_Delete(tr); + return -1; + } + cJSON_AddItemToArray(content_arr, tr); + return 0; +} + +/* One user message must carry every tool_result for that assistant turn. */ +static int append_tool_result_message(cJSON *msg_arr, const provider_message_t *messages, + size_t message_count, size_t *index) +{ + cJSON *msg = cJSON_CreateObject(); + cJSON *content_arr = cJSON_CreateArray(); + if (!msg || !content_arr) { + cJSON_Delete(msg); + cJSON_Delete(content_arr); + return -1; + } + cJSON_AddItemToObject(msg, "role", cJSON_CreateString("user")); + cJSON_AddItemToObject(msg, "content", content_arr); + cJSON_AddItemToArray(msg_arr, msg); + for (;;) { + if (append_one_tool_result(content_arr, &messages[*index]) != 0) + return -1; + if (*index + 1 >= message_count || !messages[*index + 1].tool_use_id) + break; + (*index)++; + } + return 0; +} +static int append_assistant_tool_content(cJSON *msg, const provider_message_t *message) +{ + cJSON *content_arr = cJSON_CreateArray(); + size_t k; + if (!content_arr) + return -1; + if (message->content && message->content[0] != '\0') { + cJSON *text_block = cJSON_CreateObject(); + if (!text_block) { + cJSON_Delete(content_arr); + return -1; + } + cJSON_AddItemToObject(text_block, "type", cJSON_CreateString("text")); + cJSON_AddItemToObject(text_block, "text", cJSON_CreateString(message->content)); + cJSON_AddItemToArray(content_arr, text_block); + } + for (k = 0; k < message->tool_calls_count; k++) { + const provider_tool_call_t *tc = &message->tool_calls[k]; + cJSON *tu = cJSON_CreateObject(); + cJSON *input; + if (!tu) { + cJSON_Delete(content_arr); + return -1; + } + cJSON_AddItemToObject(tu, "type", cJSON_CreateString("tool_use")); + cJSON_AddItemToObject(tu, "id", cJSON_CreateString(tc->id ? tc->id : "")); + cJSON_AddItemToObject(tu, "name", cJSON_CreateString(tc->name ? tc->name : "")); + input = tc->arguments && tc->arguments[0] ? cJSON_Parse(tc->arguments) : cJSON_CreateObject(); + if (input) + cJSON_AddItemToObject(tu, "input", input); + cJSON_AddItemToArray(content_arr, tu); + } + cJSON_AddItemToObject(msg, "content", content_arr); + return 0; +} + +static int append_anthropic_messages(cJSON *msg_arr, const provider_message_t *messages, + size_t message_count) +{ + size_t i; + for (i = 0; i < message_count; i++) { + const char *role = messages[i].role ? messages[i].role : "user"; + cJSON *msg; + if (strcmp(role, "system") == 0) + continue; + if (messages[i].tool_use_id) { + if (append_tool_result_message(msg_arr, messages, message_count, &i) != 0) + return -1; + continue; + } + msg = cJSON_CreateObject(); + if (!msg) + return -1; + cJSON_AddItemToArray(msg_arr, msg); + cJSON_AddItemToObject(msg, "role", cJSON_CreateString(role)); + if (messages[i].tool_calls && messages[i].tool_calls_count > 0 && + strcmp(role, "assistant") == 0) { + if (append_assistant_tool_content(msg, &messages[i]) != 0) + return -1; + } else { + cJSON_AddItemToObject(msg, "content", + cJSON_CreateString(messages[i].content ? messages[i].content : "")); + } + } + return 0; +} + static int build_and_send(const provider_message_t *messages, size_t message_count, const provider_tool_def_t *tools, size_t tool_count, provider_response_t *response) @@ -286,59 +391,12 @@ static int build_and_send(const provider_message_t *messages, size_t message_cou } cJSON *msg_arr = cJSON_CreateArray(); if (!msg_arr) { cJSON_Delete(root); provider_set_error(response, "Out of memory"); return -1; } - for (size_t i = 0; i < message_count; i++) { - const char *role = messages[i].role ? messages[i].role : "user"; - if (strcmp(role, "system") == 0) continue; - cJSON *msg = cJSON_CreateObject(); - if (!msg) { - cJSON_Delete(root); - provider_set_error(response, "Out of memory"); - return -1; - } - cJSON_AddItemToObject(msg, "role", cJSON_CreateString(role)); - if (messages[i].tool_use_id) { - cJSON *content_arr = cJSON_CreateArray(); - if (content_arr) { - cJSON *tr = cJSON_CreateObject(); - if (tr) { - cJSON_AddItemToObject(tr, "type", cJSON_CreateString("tool_result")); - cJSON_AddItemToObject(tr, "tool_use_id", cJSON_CreateString(messages[i].tool_use_id)); - cJSON_AddItemToObject(tr, "content", cJSON_CreateString(messages[i].content ? messages[i].content : "")); - cJSON_AddItemToArray(content_arr, tr); - } - cJSON_AddItemToObject(msg, "content", content_arr); - } else - cJSON_AddItemToObject(msg, "content", cJSON_CreateString("")); - } else if (messages[i].tool_calls && messages[i].tool_calls_count > 0 && strcmp(role, "assistant") == 0) { - cJSON *content_arr = cJSON_CreateArray(); - if (content_arr) { - if (messages[i].content && messages[i].content[0] != '\0') { - cJSON *text_block = cJSON_CreateObject(); - if (text_block) { - cJSON_AddItemToObject(text_block, "type", cJSON_CreateString("text")); - cJSON_AddItemToObject(text_block, "text", cJSON_CreateString(messages[i].content)); - cJSON_AddItemToArray(content_arr, text_block); - } - } - for (size_t k = 0; k < messages[i].tool_calls_count; k++) { - const provider_tool_call_t *tc = &messages[i].tool_calls[k]; - cJSON *tu = cJSON_CreateObject(); - if (!tu) break; - cJSON_AddItemToObject(tu, "type", cJSON_CreateString("tool_use")); - cJSON_AddItemToObject(tu, "id", cJSON_CreateString(tc->id ? tc->id : "")); - cJSON_AddItemToObject(tu, "name", cJSON_CreateString(tc->name ? tc->name : "")); - cJSON *input = tc->arguments && tc->arguments[0] ? cJSON_Parse(tc->arguments) : cJSON_CreateObject(); - if (input) cJSON_AddItemToObject(tu, "input", input); - cJSON_AddItemToArray(content_arr, tu); - } - cJSON_AddItemToObject(msg, "content", content_arr); - } else - cJSON_AddItemToObject(msg, "content", cJSON_CreateString(messages[i].content ? messages[i].content : "")); - } else - cJSON_AddItemToObject(msg, "content", cJSON_CreateString(messages[i].content ? messages[i].content : "")); - cJSON_AddItemToArray(msg_arr, msg); - } cJSON_AddItemToObject(root, "messages", msg_arr); + if (append_anthropic_messages(msg_arr, messages, message_count) != 0) { + cJSON_Delete(root); + provider_set_error(response, "Out of memory"); + return -1; + } if (tool_count > 0 && tools) { cJSON *tools_arr = cJSON_CreateArray(); if (tools_arr) { @@ -431,4 +489,22 @@ int anthropic_parse_response_for_test(const char *json, provider_response_t *res if (!json || !response) return -1; return parse_response_body(json, response); } + +char *anthropic_messages_json_for_test(const provider_message_t *messages, size_t message_count) +{ + cJSON *arr; + char *printed; + if (!messages && message_count > 0) + return NULL; + arr = cJSON_CreateArray(); + if (!arr) + return NULL; + if (append_anthropic_messages(arr, messages, message_count) != 0) { + cJSON_Delete(arr); + return NULL; + } + printed = cJSON_PrintUnformatted(arr); + cJSON_Delete(arr); + return printed; +} #endif diff --git a/tests/test_anthropic.c b/tests/test_anthropic.c index a82b28f..248e560 100644 --- a/tests/test_anthropic.c +++ b/tests/test_anthropic.c @@ -6,6 +6,7 @@ #include "core/config.h" #include "providers/provider.h" +#include "cJSON.h" #include #include #include @@ -15,6 +16,7 @@ #ifdef SHELLCLAW_TEST extern int anthropic_parse_response_for_test(const char *json, provider_response_t *response); +extern char *anthropic_messages_json_for_test(const provider_message_t *messages, size_t message_count); extern void anthropic_test_fail_next_reallocs(int n); #endif @@ -233,6 +235,58 @@ static int test_parse_text_realloc_failure_is_error(void) return 0; } +static int test_parallel_tool_results_share_one_user_message(void) +{ + provider_tool_call_t calls[2] = { + {.id = "toolu_a", .name = "shell", .arguments = "{\"command\":\"pwd\"}"}, + {.id = "toolu_b", .name = "read_file", .arguments = "{\"path\":\"a.txt\"}"}, + }; + provider_message_t messages[4]; + char *json; + cJSON *arr; + cJSON *assistant; + cJSON *user; + cJSON *content; + cJSON *role; + cJSON *block; + + memset(messages, 0, sizeof(messages)); + messages[0].role = "user"; + messages[0].content = "do both"; + messages[1].role = "assistant"; + messages[1].content = ""; + messages[1].tool_calls = calls; + messages[1].tool_calls_count = 2; + messages[2].role = "user"; + messages[2].content = "out-a"; + messages[2].tool_use_id = "toolu_a"; + messages[3].role = "user"; + messages[3].content = "out-b"; + messages[3].tool_use_id = "toolu_b"; + json = anthropic_messages_json_for_test(messages, 4); + ASSERT(json != NULL); + arr = cJSON_Parse(json); + cJSON_free(json); + ASSERT(arr != NULL && cJSON_IsArray(arr)); + ASSERT(cJSON_GetArraySize(arr) == 3); + assistant = cJSON_GetArrayItem(arr, 1); + content = assistant ? cJSON_GetObjectItem(assistant, "content") : NULL; + ASSERT(cJSON_IsArray(content) && cJSON_GetArraySize(content) == 2); + user = cJSON_GetArrayItem(arr, 2); + role = user ? cJSON_GetObjectItem(user, "role") : NULL; + ASSERT(cJSON_IsString(role) && strcmp(role->valuestring, "user") == 0); + content = user ? cJSON_GetObjectItem(user, "content") : NULL; + ASSERT(cJSON_IsArray(content) && cJSON_GetArraySize(content) == 2); + block = cJSON_GetArrayItem(content, 0); + role = block ? cJSON_GetObjectItem(block, "tool_use_id") : NULL; + ASSERT(cJSON_IsString(role) && strcmp(role->valuestring, "toolu_a") == 0); + block = cJSON_GetArrayItem(content, 1); + role = block ? cJSON_GetObjectItem(block, "tool_use_id") : NULL; + ASSERT(cJSON_IsString(role) && strcmp(role->valuestring, "toolu_b") == 0); + cJSON_Delete(arr); + return 0; +} + static int test_parse_tool_realloc_failure_is_error(void) { char body[1024]; @@ -268,6 +322,7 @@ int main(void) RUN(test_parse_null_json_returns_error()); RUN(test_parse_text_past_initial_cap()); RUN(test_parse_six_tool_use_blocks()); + RUN(test_parallel_tool_results_share_one_user_message()); RUN(test_parse_text_realloc_failure_is_error()); RUN(test_parse_tool_realloc_failure_is_error()); #endif From be7e281095c28972d81d999d3149dcb424ce5bd1 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Fri, 16 Oct 2026 00:10:55 -0300 Subject: [PATCH 3/6] fix(providers): retry the local probe after a startup miss 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. --- src/providers/local.c | 9 +++++++ src/providers/provider.h | 2 +- tests/test_local_provider.c | 47 +++++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/providers/local.c b/src/providers/local.c index 5a3f537..2510277 100644 --- a/src/providers/local.c +++ b/src/providers/local.c @@ -1,6 +1,7 @@ /** * @file local.c * @brief OpenAI-compatible Chat Completions client for llama-server; init probe marks unreachable without failing startup. + * A later chat probes again so a server that was down only at startup can still serve. */ #define _POSIX_C_SOURCE 200809L @@ -184,6 +185,14 @@ static int local_chat(const provider_message_t *messages, size_t message_count, provider_set_error(response, "Local provider not initialized"); return -1; } + /* 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) { + const char *endpoint = config_provider_local_endpoint(s_local_cfg); + if (endpoint && endpoint[0] != '\0' && provider_local_endpoint_reachable(endpoint)) + provider_local_recovery_probe_succeeded(); + } if (s_local_unavailable) { provider_set_error(response, "Local inference server unreachable"); return -1; diff --git a/src/providers/provider.h b/src/providers/provider.h index 0040b32..385b200 100644 --- a/src/providers/provider.h +++ b/src/providers/provider.h @@ -138,7 +138,7 @@ void provider_openai_set_live_config(const config_t *cfg); const provider_t *provider_local_get(void); /** After SIGHUP reload: refresh live config reads without re-init (PRD ยง9 Q4). */ void provider_local_set_live_config(const config_t *cfg); -/** Lightweight GET probe via `/v1/models` or `/health`; used by init and router recovery. */ +/** Lightweight GET probe via `/v1/models` or `/health`; used by init, chat retry, and router recovery. */ int provider_local_endpoint_reachable(const char *endpoint); /** After a successful recovery GET probe on local; clears init-time unreachable flag. */ void provider_local_recovery_probe_succeeded(void); diff --git a/tests/test_local_provider.c b/tests/test_local_provider.c index 859f679..aafaf3b 100644 --- a/tests/test_local_provider.c +++ b/tests/test_local_provider.c @@ -75,6 +75,52 @@ static int test_init_unreachable_sets_unavailable_then_chat_errors(void) return 0; } +/** + * Init probe failure must not stick for the process lifetime. llama-server + * often is not listening yet at startup; the next chat has to probe again. + */ +static int test_chat_reprobes_when_server_comes_up(void) +{ + FILE *f = fopen(TMP_CFG, "w"); + config_t *cfg = NULL; + char errbuf[256]; + const provider_t *p; + provider_message_t msg = { .role = "user", .content = "ping", .tool_calls = NULL, + .tool_calls_count = 0, .tool_use_id = NULL }; + provider_response_t response = {0}; + ASSERT(f); + local_provider_test_reset(); + unsetenv("SHELLCLAW_FALLBACK_CHAIN"); + unsetenv("SHELLCLAW_LOCAL_ENDPOINT"); + unsetenv("SHELLCLAW_LOCAL_MODEL"); + fprintf(f, "[agent]\nmodel = \"test-model\"\n"); + fprintf(f, "[providers.local]\n"); + fprintf(f, "endpoint = \"http://127.0.0.1:1/v1/chat/completions\"\nmodel = \"tiny\"\n"); + fclose(f); + ASSERT(config_load(TMP_CFG, &cfg, errbuf, sizeof(errbuf)) == 0); + p = provider_local_get(); + ASSERT(p->init(cfg) == 0); +#ifdef SHELLCLAW_TEST + ASSERT(local_provider_is_unavailable_for_test() != 0); +#endif + local_provider_test_set_skip_probe(1); + local_provider_test_set_http_response( + "{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"back online\"}}]}"); + ASSERT(p->chat(&msg, 1, NULL, 0, &response) == 0); + ASSERT(response.error == 0); + ASSERT(response.content != NULL); + ASSERT(strstr(response.content, "back online") != NULL); +#ifdef SHELLCLAW_TEST + ASSERT(local_provider_is_unavailable_for_test() == 0); +#endif + p->cleanup(); + provider_response_clear(&response); + local_provider_test_reset(); + config_free(cfg); + remove(TMP_CFG); + return 0; +} + static int test_chat_mock_http_happy_path(void) { FILE *f = fopen(TMP_CFG, "w"); @@ -113,6 +159,7 @@ int main(void) RUN(test_local_provider_vtable()); RUN(test_init_requires_config()); RUN(test_init_unreachable_sets_unavailable_then_chat_errors()); + RUN(test_chat_reprobes_when_server_comes_up()); RUN(test_chat_mock_http_happy_path()); curl_global_cleanup(); printf("test_local_provider: all tests passed\n"); From ccbfa3816a1ea3ca39e53348582fd94aa8cfea8b Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sat, 17 Oct 2026 02:27:41 -0300 Subject: [PATCH 4/6] fix(providers): throttle the local re-probe while unavailable 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. --- src/providers/local.c | 19 ++++++++-- tests/test_local_provider.c | 69 +++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/src/providers/local.c b/src/providers/local.c index 2510277..1bcd4aa 100644 --- a/src/providers/local.c +++ b/src/providers/local.c @@ -15,14 +15,17 @@ #include #include #include +#include #define REQUEST_TIMEOUT_SEC 120 #define CONNECT_TIMEOUT_SEC 30 #define LOCAL_PROBE_TIMEOUT_SEC 3L #define LOCAL_PROBE_CONNECT_SEC 2L +#define LOCAL_REPROBE_MIN_SEC 15L static const config_t *s_local_cfg; static int s_local_unavailable; +static time_t s_local_reprobe_last_wall; #ifdef SHELLCLAW_TEST static const char *s_local_test_http_body; static int s_local_test_skip_probe; @@ -190,8 +193,18 @@ static int local_chat(const provider_message_t *messages, size_t message_count, * only probes backends before the active index) never clears the flag. */ if (s_local_unavailable) { const char *endpoint = config_provider_local_endpoint(s_local_cfg); - if (endpoint && endpoint[0] != '\0' && provider_local_endpoint_reachable(endpoint)) - provider_local_recovery_probe_succeeded(); + time_t now = time(NULL); + /* agent_run holds agent_lock while this probe runs synchronously; + * at most one probe per LOCAL_REPROBE_MIN_SEC while unavailable, + * else a down llama-server blocks the gateway/channel every turn. */ + int reprobe_due = s_local_reprobe_last_wall == (time_t)0 || + (unsigned long)(now - s_local_reprobe_last_wall) >= + (unsigned long)LOCAL_REPROBE_MIN_SEC; + if (reprobe_due) { + s_local_reprobe_last_wall = now; + if (endpoint && endpoint[0] != '\0' && provider_local_endpoint_reachable(endpoint)) + provider_local_recovery_probe_succeeded(); + } } if (s_local_unavailable) { provider_set_error(response, "Local inference server unreachable"); @@ -205,6 +218,7 @@ static void local_cleanup(void) { s_local_cfg = NULL; s_local_unavailable = 0; + s_local_reprobe_last_wall = (time_t)0; } #ifdef SHELLCLAW_TEST @@ -218,6 +232,7 @@ void local_provider_test_reset(void) s_local_test_http_body = NULL; s_local_test_skip_probe = 0; s_local_unavailable = 0; + s_local_reprobe_last_wall = (time_t)0; } void local_provider_test_set_http_response(const char *json_body) diff --git a/tests/test_local_provider.c b/tests/test_local_provider.c index aafaf3b..1b4dc13 100644 --- a/tests/test_local_provider.c +++ b/tests/test_local_provider.c @@ -121,6 +121,74 @@ static int test_chat_reprobes_when_server_comes_up(void) return 0; } +/** + * While unavailable, chat re-probes at most once per LOCAL_REPROBE_MIN_SEC: + * agent_run holds agent_lock during chat, so a down llama-server must not + * cost synchronous curl probes on every turn. The second chat inside the + * window must not probe (skip_probe would otherwise recover it through the + * mock); after the test hook resets the throttle it must probe again. + */ +static int test_chat_reprobe_throttled_within_min_interval(void) +{ + FILE *f = fopen(TMP_CFG, "w"); + config_t *cfg = NULL; + char errbuf[256]; + const provider_t *p; + provider_message_t msg = { .role = "user", .content = "ping", .tool_calls = NULL, + .tool_calls_count = 0, .tool_use_id = NULL }; + provider_response_t response = {0}; + ASSERT(f); + local_provider_test_reset(); + unsetenv("SHELLCLAW_FALLBACK_CHAIN"); + unsetenv("SHELLCLAW_LOCAL_ENDPOINT"); + unsetenv("SHELLCLAW_LOCAL_MODEL"); + fprintf(f, "[agent]\nmodel = \"test-model\"\n"); + fprintf(f, "[providers.local]\n"); + fprintf(f, "endpoint = \"http://127.0.0.1:1/v1/chat/completions\"\nmodel = \"tiny\"\n"); + fclose(f); + ASSERT(config_load(TMP_CFG, &cfg, errbuf, sizeof(errbuf)) == 0); + p = provider_local_get(); + ASSERT(p->init(cfg) == 0); +#ifdef SHELLCLAW_TEST + ASSERT(local_provider_is_unavailable_for_test() != 0); +#endif + /* First chat after the startup miss: the probe runs against port 1 + * and fails, arming the throttle window. */ + ASSERT(p->chat(&msg, 1, NULL, 0, &response) == -1); + ASSERT(response.error != 0); + ASSERT(strstr(response.content, "unreachable") != NULL); + provider_response_clear(&response); + /* Second chat inside the window: no probe, so skip_probe cannot clear + * the flag and the mock is not served. */ + local_provider_test_set_skip_probe(1); + local_provider_test_set_http_response( + "{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"back online\"}}]}"); + ASSERT(p->chat(&msg, 1, NULL, 0, &response) == -1); + ASSERT(response.error != 0); + ASSERT(strstr(response.content, "unreachable") != NULL); + provider_response_clear(&response); + /* After the hook resets the throttle and init re-marks unavailable, + * the next chat must probe again and recover through the mock. */ + local_provider_test_reset(); + ASSERT(p->init(cfg) == 0); +#ifdef SHELLCLAW_TEST + ASSERT(local_provider_is_unavailable_for_test() != 0); +#endif + local_provider_test_set_skip_probe(1); + local_provider_test_set_http_response( + "{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"back online\"}}]}"); + ASSERT(p->chat(&msg, 1, NULL, 0, &response) == 0); + ASSERT(response.error == 0); + ASSERT(response.content != NULL); + ASSERT(strstr(response.content, "back online") != NULL); + p->cleanup(); + provider_response_clear(&response); + local_provider_test_reset(); + config_free(cfg); + remove(TMP_CFG); + return 0; +} + static int test_chat_mock_http_happy_path(void) { FILE *f = fopen(TMP_CFG, "w"); @@ -160,6 +228,7 @@ int main(void) RUN(test_init_requires_config()); RUN(test_init_unreachable_sets_unavailable_then_chat_errors()); RUN(test_chat_reprobes_when_server_comes_up()); + RUN(test_chat_reprobe_throttled_within_min_interval()); RUN(test_chat_mock_http_happy_path()); curl_global_cleanup(); printf("test_local_provider: all tests passed\n"); From 77d2bfc67f33e05d7728b1d95e6593e16db8a3e8 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sat, 17 Oct 2026 02:28:37 -0300 Subject: [PATCH 5/6] test(anthropic): cover tool-result grouping boundaries 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. --- tests/test_anthropic.c | 171 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) diff --git a/tests/test_anthropic.c b/tests/test_anthropic.c index 248e560..dba4f50 100644 --- a/tests/test_anthropic.c +++ b/tests/test_anthropic.c @@ -287,6 +287,174 @@ static int test_parallel_tool_results_share_one_user_message(void) return 0; } +/** Old path unregressed: one tool result still yields exactly one user message. */ +static int test_single_tool_result_one_user_message(void) +{ + provider_tool_call_t calls[1] = { + {.id = "toolu_a", .name = "shell", .arguments = "{\"command\":\"pwd\"}"}, + }; + provider_message_t messages[3]; + char *json; + cJSON *arr; + cJSON *user; + cJSON *role; + cJSON *content; + cJSON *block; + + memset(messages, 0, sizeof(messages)); + messages[0].role = "user"; + messages[0].content = "run pwd"; + messages[1].role = "assistant"; + messages[1].content = ""; + messages[1].tool_calls = calls; + messages[1].tool_calls_count = 1; + messages[2].role = "user"; + messages[2].content = "out-a"; + messages[2].tool_use_id = "toolu_a"; + json = anthropic_messages_json_for_test(messages, 3); + ASSERT(json != NULL); + arr = cJSON_Parse(json); + cJSON_free(json); + ASSERT(arr != NULL && cJSON_IsArray(arr)); + ASSERT(cJSON_GetArraySize(arr) == 3); + user = cJSON_GetArrayItem(arr, 2); + role = user ? cJSON_GetObjectItem(user, "role") : NULL; + ASSERT(cJSON_IsString(role) && strcmp(role->valuestring, "user") == 0); + content = user ? cJSON_GetObjectItem(user, "content") : NULL; + ASSERT(cJSON_IsArray(content) && cJSON_GetArraySize(content) == 1); + block = cJSON_GetArrayItem(content, 0); + role = block ? cJSON_GetObjectItem(block, "tool_use_id") : NULL; + ASSERT(cJSON_IsString(role) && strcmp(role->valuestring, "toolu_a") == 0); + cJSON_Delete(arr); + return 0; +} + +/** Grouping must stop at the first message without a tool_use_id. */ +static int test_tool_results_stop_at_plain_user_message(void) +{ + provider_tool_call_t calls[2] = { + {.id = "toolu_a", .name = "shell", .arguments = "{\"command\":\"pwd\"}"}, + {.id = "toolu_b", .name = "read_file", .arguments = "{\"path\":\"a.txt\"}"}, + }; + provider_message_t messages[5]; + char *json; + cJSON *arr; + cJSON *user; + cJSON *role; + cJSON *content; + cJSON *block; + + memset(messages, 0, sizeof(messages)); + messages[0].role = "user"; + messages[0].content = "do both"; + messages[1].role = "assistant"; + messages[1].content = ""; + messages[1].tool_calls = calls; + messages[1].tool_calls_count = 2; + messages[2].role = "user"; + messages[2].content = "out-a"; + messages[2].tool_use_id = "toolu_a"; + messages[3].role = "user"; + messages[3].content = "out-b"; + messages[3].tool_use_id = "toolu_b"; + messages[4].role = "user"; + messages[4].content = "what now?"; + json = anthropic_messages_json_for_test(messages, 5); + ASSERT(json != NULL); + arr = cJSON_Parse(json); + cJSON_free(json); + ASSERT(arr != NULL && cJSON_IsArray(arr)); + ASSERT(cJSON_GetArraySize(arr) == 4); + user = cJSON_GetArrayItem(arr, 2); + role = user ? cJSON_GetObjectItem(user, "role") : NULL; + ASSERT(cJSON_IsString(role) && strcmp(role->valuestring, "user") == 0); + content = user ? cJSON_GetObjectItem(user, "content") : NULL; + ASSERT(cJSON_IsArray(content) && cJSON_GetArraySize(content) == 2); + block = cJSON_GetArrayItem(content, 0); + role = block ? cJSON_GetObjectItem(block, "tool_use_id") : NULL; + ASSERT(cJSON_IsString(role) && strcmp(role->valuestring, "toolu_a") == 0); + block = cJSON_GetArrayItem(content, 1); + role = block ? cJSON_GetObjectItem(block, "tool_use_id") : NULL; + ASSERT(cJSON_IsString(role) && strcmp(role->valuestring, "toolu_b") == 0); + /* The plain user message after the results stays its own message. */ + user = cJSON_GetArrayItem(arr, 3); + role = user ? cJSON_GetObjectItem(user, "role") : NULL; + ASSERT(cJSON_IsString(role) && strcmp(role->valuestring, "user") == 0); + content = user ? cJSON_GetObjectItem(user, "content") : NULL; + ASSERT(cJSON_IsString(content) && strcmp(content->valuestring, "what now?") == 0); + cJSON_Delete(arr); + return 0; +} + +/** Results of a second round must not merge across the assistant message. */ +static int test_tool_results_do_not_merge_across_assistant_rounds(void) +{ + provider_tool_call_t calls_a[1] = { + {.id = "toolu_a", .name = "shell", .arguments = "{\"command\":\"pwd\"}"}, + }; + provider_tool_call_t calls_c[1] = { + {.id = "toolu_c", .name = "read_file", .arguments = "{\"path\":\"b.txt\"}"}, + }; + provider_message_t messages[5]; + char *json; + cJSON *arr; + cJSON *assistant; + cJSON *user; + cJSON *role; + cJSON *content; + cJSON *block; + + memset(messages, 0, sizeof(messages)); + messages[0].role = "user"; + messages[0].content = "start"; + messages[1].role = "assistant"; + messages[1].content = ""; + messages[1].tool_calls = calls_a; + messages[1].tool_calls_count = 1; + messages[2].role = "user"; + messages[2].content = "out-a"; + messages[2].tool_use_id = "toolu_a"; + messages[3].role = "assistant"; + messages[3].content = ""; + messages[3].tool_calls = calls_c; + messages[3].tool_calls_count = 1; + messages[4].role = "user"; + messages[4].content = "out-c"; + messages[4].tool_use_id = "toolu_c"; + json = anthropic_messages_json_for_test(messages, 5); + ASSERT(json != NULL); + arr = cJSON_Parse(json); + cJSON_free(json); + ASSERT(arr != NULL && cJSON_IsArray(arr)); + ASSERT(cJSON_GetArraySize(arr) == 5); + /* Round one result message: exactly its own block. */ + user = cJSON_GetArrayItem(arr, 2); + role = user ? cJSON_GetObjectItem(user, "role") : NULL; + ASSERT(cJSON_IsString(role) && strcmp(role->valuestring, "user") == 0); + content = user ? cJSON_GetObjectItem(user, "content") : NULL; + ASSERT(cJSON_IsArray(content) && cJSON_GetArraySize(content) == 1); + /* The second assistant round keeps its own assistant message. */ + assistant = cJSON_GetArrayItem(arr, 3); + role = assistant ? cJSON_GetObjectItem(assistant, "role") : NULL; + ASSERT(cJSON_IsString(role) && strcmp(role->valuestring, "assistant") == 0); + content = assistant ? cJSON_GetObjectItem(assistant, "content") : NULL; + ASSERT(cJSON_IsArray(content) && cJSON_GetArraySize(content) == 1); + block = cJSON_GetArrayItem(content, 0); + role = block ? cJSON_GetObjectItem(block, "id") : NULL; + ASSERT(cJSON_IsString(role) && strcmp(role->valuestring, "toolu_c") == 0); + /* Round two result lands in its own user message. */ + user = cJSON_GetArrayItem(arr, 4); + role = user ? cJSON_GetObjectItem(user, "role") : NULL; + ASSERT(cJSON_IsString(role) && strcmp(role->valuestring, "user") == 0); + content = user ? cJSON_GetObjectItem(user, "content") : NULL; + ASSERT(cJSON_IsArray(content) && cJSON_GetArraySize(content) == 1); + block = cJSON_GetArrayItem(content, 0); + role = block ? cJSON_GetObjectItem(block, "tool_use_id") : NULL; + ASSERT(cJSON_IsString(role) && strcmp(role->valuestring, "toolu_c") == 0); + cJSON_Delete(arr); + return 0; +} + static int test_parse_tool_realloc_failure_is_error(void) { char body[1024]; @@ -323,6 +491,9 @@ int main(void) RUN(test_parse_text_past_initial_cap()); RUN(test_parse_six_tool_use_blocks()); RUN(test_parallel_tool_results_share_one_user_message()); + RUN(test_single_tool_result_one_user_message()); + RUN(test_tool_results_stop_at_plain_user_message()); + RUN(test_tool_results_do_not_merge_across_assistant_rounds()); RUN(test_parse_text_realloc_failure_is_error()); RUN(test_parse_tool_realloc_failure_is_error()); #endif From fa5209073f1bbb3e38317e1063c8f79d3c44ba74 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sat, 17 Oct 2026 02:29:02 -0300 Subject: [PATCH 6/6] docs(providers): document local re-probe throttle and consolidated fixes 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. --- CHANGELOG.md | 3 +++ docs/LOCAL_INFERENCE.md | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ce4ee7..0ebbd2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha ## [Unreleased] ### Fixed +- Live config reload reaches the Anthropic provider (`provider_anthropic_set_live_config`, guarded like OpenAI), so dashboard/SIGHUP reloads update model and `max_tokens` instead of staying on the config captured at init. +- Parallel tool results are grouped into one `user` message (one `tool_result` block per call, per the Messages API contract), fixing Anthropic 400s on parallel tool calls. +- The local provider re-probes on the next request after an unreachable startup, at most once every 15 seconds, so a down `llama-server` no longer costs synchronous curl probes on every agent turn. - Cron re-offer waits the full timeout when a due id could not be stored in the 16-slot table, and an ack frees that slot so the next due job can be delivered and then backed off. - Landlock device grants stay `/dev/null`, `/dev/zero`, `/dev/urandom`, and `/dev/tty` without `IOCTL_DEV`. A failed `landlock_add_rule` fails the ruleset closed. - Dashboard config save and JSON patch validation use a unique temp plus `fsync`, and the live gateway config pointer is published once under `agent_lock`. diff --git a/docs/LOCAL_INFERENCE.md b/docs/LOCAL_INFERENCE.md index 0c76275..bf3cde9 100644 --- a/docs/LOCAL_INFERENCE.md +++ b/docs/LOCAL_INFERENCE.md @@ -18,7 +18,7 @@ llama-server (127.0.0.1:8080) GGUF model on disk (/var/lib/shellclaw/models/) ``` -At startup, `local.c` probes `GET /health` and `GET /v1/models`. If unreachable, the local provider marks itself unavailable and the router falls through to the next entry in `providers.fallback_chain`. +At startup, `local.c` probes `GET /health` and `GET /v1/models`. If unreachable, the local provider marks itself unavailable and the router falls through to the next entry in `providers.fallback_chain`. After an unreachable startup the provider re-probes on the next request, at most once every 15 seconds, so a `llama-server` that comes up later can still serve. ---