diff --git a/src/core/agent.c b/src/core/agent.c index 92bc6f0..504c051 100644 --- a/src/core/agent.c +++ b/src/core/agent.c @@ -82,7 +82,25 @@ static int copy_printed_session_json(char *dst, size_t dst_size, char *printed, static const char SUMMARIZE_SYSTEM[] = "Summarize the following conversation in one short paragraph. Output only the summary, no preamble."; -/** Summarize oldest messages when over max_ctx; replace with one summary + trailing. */ +/** Append one "role: content" line, or roll back if the whole line does not fit. */ +static int summary_source_append(char *buf, size_t cap, size_t *used, + const char *role, const char *content) +{ + int n; + if (!buf || !used || *used >= cap - 1) + return 0; + n = snprintf(buf + *used, cap - *used, "%s: %s\n", + role ? role : "user", content ? content : ""); + if (n < 0 || (size_t)n >= cap - *used) { + buf[*used] = '\0'; + return 0; + } + *used += (size_t)n; + return 1; +} + +/** Summarize oldest messages when over max_ctx; replace with one summary + trailing. + * Messages that do not fit in the summary source stay in the session. */ static int compact_session_via_llm(const char *session_id, char *session_buf, size_t session_buf_size, int msg_count, int max_ctx, const provider_t *provider) { @@ -97,17 +115,27 @@ static int compact_session_via_llm(const char *session_id, char *session_buf, si if (!source_buf) { cJSON_Delete(root); return -1; } source_buf[0] = '\0'; size_t used = 0; - for (int i = 0; i < to_summarize_n && used < SUMMARY_SOURCE_MAX - 1; i++) { + int next_unsummarized = to_summarize_n; + for (int i = 0; i < to_summarize_n; i++) { const cJSON *item = cJSON_GetArrayItem(root, i); - if (!item || !cJSON_IsObject(item)) continue; const char *role = "user"; const char *content = ""; - const cJSON *r = cJSON_GetObjectItem(item, "role"); - const cJSON *c = cJSON_GetObjectItem(item, "content"); + const cJSON *r; + const cJSON *c; + if (!item || !cJSON_IsObject(item)) continue; + r = cJSON_GetObjectItem(item, "role"); + c = cJSON_GetObjectItem(item, "content"); if (cJSON_IsString(r)) role = r->valuestring; if (cJSON_IsString(c)) content = c->valuestring; - int n = snprintf(source_buf + used, SUMMARY_SOURCE_MAX - used, "%s: %s\n", role, content ? content : ""); - if (n > 0 && (size_t)n < SUMMARY_SOURCE_MAX - used) used += (size_t)n; + if (!summary_source_append(source_buf, SUMMARY_SOURCE_MAX, &used, role, content)) { + next_unsummarized = i; + break; + } + } + if (used == 0 || next_unsummarized == 0) { + free(source_buf); + cJSON_Delete(root); + return -1; } provider_message_t summ_msgs[2] = { { .role = "system", .content = SUMMARIZE_SYSTEM, .tool_calls = NULL, .tool_calls_count = 0, .tool_use_id = NULL }, @@ -136,7 +164,8 @@ static int compact_session_via_llm(const char *session_id, char *session_buf, si cJSON_AddItemToArray(new_arr, summ_obj); } int keep = max_ctx - 1; - int start = msg_count - keep; + int start = (next_unsummarized < to_summarize_n) + ? next_unsummarized : (msg_count - keep); if (start < 0) start = 0; for (int i = start; i < msg_count; i++) { const cJSON *item = cJSON_GetArrayItem(root, i); diff --git a/tests/test_agent.c b/tests/test_agent.c index 4b3b7e6..dda846a 100644 --- a/tests/test_agent.c +++ b/tests/test_agent.c @@ -452,6 +452,87 @@ static int test_context_compaction_when_history_exceeds_max(void) return failed; } +/* A message past the summary-source cap must stay in the session. The summarizer + * only receives lines that fit; deleting the rest loses history the model never saw. */ +static int test_compaction_keeps_message_that_does_not_fit_summary(void) +{ + enum { BIG = 65 * 1024, JSON_CAP = 96 * 1024, LOAD_CAP = 128 * 1024 }; + int failed = 1; + config_t *cfg = NULL; + char *session_json = NULL; + char *loaded = NULL; + char *big = NULL; + const char *db_path = "build/test_agent_compact_fit.db"; + const char *config_path = "build/test_agent_compact_fit.toml"; + size_t off; + int i; + char response_buf[4096]; + char errbuf[256]; + FILE *cf; + + memory_cleanup(); + compaction_call_count = 0; + if (memory_init(db_path) != 0) + goto cleanup_early; + session_json = malloc(JSON_CAP); + big = malloc((size_t)BIG + 1); + loaded = malloc(LOAD_CAP); + if (!session_json || !big || !loaded) + goto cleanup; + memset(big, 'A', (size_t)BIG); + memcpy(big + BIG - 16, "BIG_MARKER_END!!", 16); + big[BIG] = '\0'; + off = 0; + off += (size_t)snprintf(session_json + off, JSON_CAP - off, "["); + off += (size_t)snprintf(session_json + off, JSON_CAP - off, + "{\"role\":\"user\",\"content\":\"alpha_small\"}"); + off += (size_t)snprintf(session_json + off, JSON_CAP - off, + ",{\"role\":\"user\",\"content\":\"%s\"}", big); + for (i = 2; i < 6; i++) { + off += (size_t)snprintf(session_json + off, JSON_CAP - off, + ",{\"role\":\"user\",\"content\":\"m%d\"}", i); + } + off += (size_t)snprintf(session_json + off, JSON_CAP - off, "]"); + if (off >= JSON_CAP) + goto cleanup; + if (session_save("cli:compactfit", session_json) != 0) + goto cleanup; + cf = fopen(config_path, "w"); + if (!cf) + goto cleanup; + fprintf(cf, "[agent]\nmodel = \"test\"\nmax_context_messages = 4\n[memory]\npath = \"%s\"\n", + db_path); + fclose(cf); + if (config_load(config_path, &cfg, errbuf, sizeof(errbuf)) != 0) + goto cleanup; + response_buf[0] = '\0'; + if (agent_run(cfg, "cli:compactfit", "hi", &compaction_provider, NULL, 0, + response_buf, sizeof(response_buf)) != 0) + goto cleanup; + if (session_load("cli:compactfit", loaded, LOAD_CAP) != 0) + goto cleanup; + if (strstr(loaded, "BIG_MARKER_END!!") == NULL) { + fprintf(stderr, + "FAIL: compacted session dropped the message that did not fit the summary\n"); + goto cleanup; + } + if (strstr(loaded, "Summary of earlier") == NULL) { + fprintf(stderr, "FAIL: expected a summary of the messages that did fit\n"); + goto cleanup; + } + failed = 0; +cleanup: + config_free(cfg); + free(session_json); + free(big); + free(loaded); +cleanup_early: + remove(config_path); + remove(db_path); + memory_cleanup(); + return failed; +} + static int test_local_offline_note_when_active_is_local(void) { const char *path = "build/test_agent_local_note.toml"; @@ -1186,6 +1267,7 @@ int main(void) RUN(test_react_loop_preserves_prior_tool_results()); RUN(test_session_persisted_after_exchange()); RUN(test_context_compaction_when_history_exceeds_max()); + RUN(test_compaction_keeps_message_that_does_not_fit_summary()); RUN(test_local_offline_note_when_active_is_local()); RUN(test_local_offline_note_skipped_for_non_local()); RUN(test_session_overflow_does_not_corrupt_history());