Skip to content
Merged
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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha
- Session compaction includes the boundary turn (index `msg_count - max_context_messages`) in the summary window, so the turn at the keep-tail boundary is not deleted without being summarized.
- Session compaction keeps a message that does not fit the 64 KiB summary source, and everything after it, instead of skipping the long line and deleting it anyway.
- Session compaction no longer persists the summarized transcript before the user turn finishes: a failed follow-up provider call leaves the raw stored session unchanged, and when the kept tail already starts with a user turn the summary is folded into that turn so consecutive user roles cannot break the next Anthropic call (supersedes #126).
- Session compaction summarizes a message larger than the summary source as a truncated `[truncated]` stub, so a single oversized line can no longer abort the pass with an empty summary source or keep the session above `max_context_messages` forever.
- Session compaction summarizes a single oversized line as a truncated `[truncated]` stub only against an empty summary source: a later oversized message is deferred whole instead of cut to the sliver the earlier lines left, a pass whose summary would strand it folds the summary into the line so the count keeps shrinking, and the line is finally stubbed against a full 64 KiB source.
- WebChat `ws_send_to` drops a send whose session has no live `wsi` (still returning 0) and `ws_unregister_conn` drops frames still queued for the closing session, so ~64 abandoned replies cannot pin the shared outgoing queue at `MAX_QUEUE` (64) and stall every live client until restart.
- `read_file` reads that do not fit the result buffer now fail with a JSON error instead of returning a clipped prefix, so a later `write_file` cannot persist a silently truncated file.

Expand Down
61 changes: 37 additions & 24 deletions src/core/agent.c
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,10 @@ static int kept_tail_starts_with_user(const cJSON *root, int start)
return cJSON_IsString(role) && role->valuestring && strcmp(role->valuestring, "user") == 0;
}

/** Fold the summary into an existing user turn. A second user message 400s on Anthropic. */
static int fold_summary_into_user(cJSON *item, const char *summary)
/** Fold the summary into the first kept message: a user turn (a second user
* message 400s on Anthropic) or the oversized line a partial pass broke on,
* so the next pass starts on it with an empty source for the full stub. */
static int fold_summary_into_message(cJSON *item, const char *summary)
{
const cJSON *content;
const char *old;
Expand Down Expand Up @@ -245,6 +247,7 @@ static int compact_session_via_llm(const char *session_id, char *session_buf, si
source_buf[0] = '\0';
size_t used = 0;
int next_unsummarized = to_summarize_n;
int fold_into_oversized = 0;
for (int i = 0; i < to_summarize_n; i++) {
const cJSON *item = cJSON_GetArrayItem(root, i);
const char *role = "user";
Expand All @@ -257,23 +260,31 @@ static int compact_session_via_llm(const char *session_id, char *session_buf, si
if (cJSON_IsString(r)) role = r->valuestring;
if (cJSON_IsString(c)) content = c->valuestring;
if (!summary_source_append(source_buf, SUMMARY_SOURCE_MAX, &used, role, content)) {
/* A line that can never fit whole must be stubbed when the
* source is still (mostly) empty: the first line has to be
* stubbed or the source stays empty and compaction returns
* -1 on every turn. Once earlier lines already consumed the
* buffer, a stub would see only the bytes left over and
* destroy the line in the summary, so leave it raw via
* next_unsummarized; the next pass starts it near the top of
* a fresh source and gives it (almost) the whole buffer. A
* line that merely lacks room here is left raw the same way. */
int stubbed = summary_line_never_fits(role, content) &&
used <= SUMMARY_SOURCE_MAX / 2 &&
summary_source_append_truncated(source_buf, SUMMARY_SOURCE_MAX,
&used, role, content);
if (!stubbed) {
next_unsummarized = i;
break;
}
/* Stub a never-fit line only against an empty source. Leftover
* room after earlier lines is a sliver: the stub still counts as
* the whole message, and the unread tail is deleted. Defer so
* this pass summarizes the prefix and the next pass stubs the
* line at the full 64 KiB. A never-fit first line still has to
* be stubbed here, or used==0 aborts compaction and the session
* never shrinks. Lines that fit a fresh source stay raw. */
if (used == 0 &&
summary_line_never_fits(role, content) &&
summary_source_append_truncated(source_buf, SUMMARY_SOURCE_MAX, &used, role, content))
continue;
/* A non-user line directly behind the only line this pass consumed would
* leave the count unchanged: the tail starts on it, a user summary
* is prepended in front of it, and the next pass sees the same
* pair again - the wrapper always counts toward used, so no pass
* ever reaches the only-empty stub and the count only grows until
* SESSION_JSON_MAX stops the saves. That holds for a never-fit
* line and for one that merely fits a fresh source but not the
* wrapped remainder, so any non-user line stopping the pass at
* index 1 folds: the count shrinks now and the next pass handles
* the folded line against a fresh source. */
if (i == 1 && strcmp(role, "user") != 0)
fold_into_oversized = 1;
next_unsummarized = i;
break;
}
}
if (used == 0) {
Expand Down Expand Up @@ -308,10 +319,12 @@ static int compact_session_via_llm(const char *session_id, char *session_buf, si
if (start < 0) start = 0;
/* Tail can start on a user turn (odd max_context_messages, or a cut that
* lands on user). Inserting another user role makes Anthropic reject the
* session on this turn and every later one. */
* session on this turn and every later one. fold_into_oversized breaks on
* that rule for the line that stopped the pass: it is not a user turn, so
* the summary folds into it instead of a prepended wrapper (see the loop). */
merge = kept_tail_starts_with_user(root, start);
if (!new_arr) { cJSON_Delete(root); return -1; }
if (!merge && append_summary_user(new_arr, summary_buf) != 0) {
if (!merge && !fold_into_oversized && append_summary_user(new_arr, summary_buf) != 0) {
cJSON_Delete(new_arr);
cJSON_Delete(root);
return -1;
Expand All @@ -322,8 +335,8 @@ static int compact_session_via_llm(const char *session_id, char *session_buf, si
if (!item) continue;
dup = cJSON_Duplicate(item, 1);
if (!dup) continue;
if (merge && i == start) {
if (fold_summary_into_user(dup, summary_buf) != 0) {
if ((merge || fold_into_oversized) && i == start) {
if (fold_summary_into_message(dup, summary_buf) != 0) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When the fold triggers and the window reaches the folded line, the first message sent to the provider is an assistant turn. Before this PR the tail always began with a user summary. append_anthropic_messages does not guard a leading assistant. Please confirm the API accepts it, or assert the first history role in a test.

cJSON_Delete(dup);
cJSON_Delete(new_arr);
cJSON_Delete(root);
Expand All @@ -333,7 +346,7 @@ static int compact_session_via_llm(const char *session_id, char *session_buf, si
}
cJSON_AddItemToArray(new_arr, dup);
}
if (merge && !folded) {
if ((merge || fold_into_oversized) && !folded) {
cJSON_Delete(new_arr);
cJSON_Delete(root);
return -1;
Expand Down
Loading
Loading