From c05f2386cc3f8c5ac6278b4af3443003db7286ee Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 11:33:24 +0000 Subject: [PATCH] fix(agent): keep new turns when the session transcript hits its byte cap Compaction runs only after max_context_messages, so a few long replies filled the 128 KiB JSON cap and the save was refused. Later turns were dropped while the old transcript stayed in place. Evict the oldest messages until this exchange fits, and still refuse a truncated JSON write. Co-authored-by: esadrianno --- CHANGELOG.md | 3 +++ src/core/agent.c | 38 +++++++++++++++++++++++++++++++++++++- tests/test_agent.c | 24 +++++++++++++++++------- 3 files changed, 57 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0252119..526d440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha ## [Unreleased] +### Fixed +- Session saves that exceed the 128 KiB transcript cap drop the oldest messages instead of skipping the write. Compaction only runs once the message count passes `max_context_messages`, so a few long turns used to fill the byte cap and every later exchange was forgotten while the old transcript stayed in place. + ## [1.0.1] - 2026-10-18 ### Fixed diff --git a/src/core/agent.c b/src/core/agent.c index a3df259..942d73f 100644 --- a/src/core/agent.c +++ b/src/core/agent.c @@ -515,6 +515,35 @@ static void free_tool_calls_copy(const provider_tool_call_t *copy, size_t n) free(owned); } +/** Print @p arr, dropping oldest messages until the text fits in @p out_size. + * Compaction is count-based, so a few long turns fill the byte cap while the + * message count is still under max_context_messages. Refusing the save then + * drops every later exchange and the session never shrinks. The newest + * @p keep_tail messages (this turn) stay. Caller frees the returned cJSON string. + */ +static char *session_array_print_fitting(cJSON *arr, size_t out_size, int keep_tail, + const char *session_id) +{ + char *printed; + int dropped = 0; + + if (!arr || out_size == 0 || keep_tail < 1) + return NULL; + printed = cJSON_PrintUnformatted(arr); + while (printed && strlen(printed) >= out_size && cJSON_GetArraySize(arr) > keep_tail) { + cJSON_DeleteItemFromArray(arr, 0); + cJSON_free(printed); + printed = cJSON_PrintUnformatted(arr); + dropped++; + } + if (dropped > 0) { + fprintf(stderr, + "agent: dropped %d oldest session message(s) to fit cap session_id=%s\n", + dropped, session_id ? session_id : ""); + } + return printed; +} + /** Append user+assistant exchange to session JSON. Invalid or empty existing becomes []. */ static int append_exchange_to_session_json(const char *existing_json, const char *user_message, const char *assistant_content, char *out_buf, size_t out_size, const char *session_id) @@ -527,6 +556,7 @@ static int append_exchange_to_session_json(const char *existing_json, const char arr = NULL; } } + int added = 0; if (!arr) arr = cJSON_CreateArray(); if (!arr) return -1; cJSON *user_obj = cJSON_CreateObject(); @@ -534,14 +564,20 @@ static int append_exchange_to_session_json(const char *existing_json, const char cJSON_AddItemToObject(user_obj, "role", cJSON_CreateString("user")); cJSON_AddItemToObject(user_obj, "content", cJSON_CreateString(user_message ? user_message : "")); cJSON_AddItemToArray(arr, user_obj); + added++; } cJSON *asst_obj = cJSON_CreateObject(); if (asst_obj) { cJSON_AddItemToObject(asst_obj, "role", cJSON_CreateString("assistant")); cJSON_AddItemToObject(asst_obj, "content", cJSON_CreateString(assistant_content ? assistant_content : "")); cJSON_AddItemToArray(arr, asst_obj); + added++; + } + if (added == 0) { + cJSON_Delete(arr); + return -1; } - char *printed = cJSON_PrintUnformatted(arr); + char *printed = session_array_print_fitting(arr, out_size, added, session_id); cJSON_Delete(arr); return copy_printed_session_json(out_buf, out_size, printed, session_id); } diff --git a/tests/test_agent.c b/tests/test_agent.c index 8be45d8..1d026c2 100644 --- a/tests/test_agent.c +++ b/tests/test_agent.c @@ -2046,10 +2046,11 @@ static const provider_t keep_history_provider = { }; /** - * Concrete trigger: near-cap session JSON + fat assistant reply. - * Before the fix, append truncated mid-JSON, session_save persisted corrupt - * payload, and the next agent_run parse wiped history. After the fix, overflow - * refuses to save and prior history remains parseable. + * Concrete trigger: near-cap session JSON (count still under max_context_messages) + * plus a fat assistant reply. A truncated save must not replace the row with + * invalid JSON. Dropping the new turn instead leaves the byte cap stuck, so + * every later turn is forgotten. The save stays valid JSON, keeps the newer + * stored turn, and persists this exchange by evicting the oldest message. */ static int test_session_overflow_does_not_corrupt_history(void) { @@ -2058,6 +2059,7 @@ static int test_session_overflow_does_not_corrupt_history(void) const char *db_path = "build/test_agent_overflow.db"; const char *config_path = "build/test_agent_overflow.toml"; const char *session_id = "cli:overflow"; + const char *oldest = "OLDEST_TURN_SHOULD_DROP"; const char *marker = "UNIQUE_HISTORY_MARKER_xyz"; /* Two large messages keep msg_count under max_context so compaction does not shrink first. */ enum { PAD_A = 62 * 1024, PAD_B = 62 * 1024, LOAD_CAP = 130 * 1024 }; @@ -2079,12 +2081,12 @@ static int test_session_overflow_does_not_corrupt_history(void) pad_a[PAD_A] = '\0'; memset(pad_b, 'B', PAD_B); pad_b[PAD_B] = '\0'; - need = strlen(marker) + PAD_A + PAD_B + 128; + need = strlen(oldest) + strlen(marker) + PAD_A + PAD_B + 128; session_json = malloc(need); if (!session_json) goto cleanup; off = (size_t)snprintf(session_json, need, - "[{\"role\":\"user\",\"content\":\"%s%s\"},{\"role\":\"assistant\",\"content\":\"%s\"}]", - marker, pad_a, pad_b); + "[{\"role\":\"user\",\"content\":\"%s%s\"},{\"role\":\"assistant\",\"content\":\"%s%s\"}]", + oldest, pad_a, marker, pad_b); if (off == 0 || off >= need) goto cleanup; if (session_save(session_id, session_json) != 0) goto cleanup; @@ -2120,6 +2122,14 @@ static int test_session_overflow_does_not_corrupt_history(void) } cJSON_Delete(parsed); if (strstr(loaded, marker) == NULL) goto cleanup; + if (strstr(loaded, "push over the limit") == NULL) { + fprintf(stderr, "FAIL: tests/test_agent.c: overflow dropped the new turn\n"); + goto cleanup; + } + if (strstr(loaded, oldest) != NULL) { + fprintf(stderr, "FAIL: tests/test_agent.c: overflow kept the oldest turn and cannot fit\n"); + goto cleanup; + } /* Next turn must still see prior history (not wiped to empty []). */ spy_roles_clear();