Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion docs/LOCAL_INFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
1 change: 1 addition & 0 deletions src/core/reload.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
191 changes: 139 additions & 52 deletions src/providers/anthropic.c
Original file line number Diff line number Diff line change
Expand Up @@ -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.

}

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)
Expand All @@ -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) {
Expand Down Expand Up @@ -414,10 +472,39 @@ 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)
{
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
24 changes: 24 additions & 0 deletions src/providers/local.c
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -14,14 +15,17 @@
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>

#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;
Expand Down Expand Up @@ -184,6 +188,24 @@ 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) {

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.

const char *endpoint = config_provider_local_endpoint(s_local_cfg);
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");
return -1;
Expand All @@ -196,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
Expand All @@ -209,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)
Expand Down
6 changes: 5 additions & 1 deletion src/providers/provider.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -134,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);
Expand Down
Loading
Loading