Skip to content

Fix cloud agent constraint loss in long conversations - #579

Open
QuanCheng-QC wants to merge 6 commits into
developfrom
bugfix/cloud-agent-context-window
Open

Fix cloud agent constraint loss in long conversations#579
QuanCheng-QC wants to merge 6 commits into
developfrom
bugfix/cloud-agent-context-window

Conversation

@QuanCheng-QC

Copy link
Copy Markdown
Collaborator

Problem

Cloud agents are stateless per turn and rebuilt context from only the latest
CLOUD_AGENT_MAX_CONTEXT_MESSAGES channel messages (default 10, i.e. ~5 turns
of user+agent messages). In long conversations, constraints the user confirmed
early (e.g. agreed interface fields) fell out of the prompt entirely and the
model re-decided them from scratch — observed as the agent repeatedly
modifying already-confirmed interface fields around turn 15 of a multi-part
task. Three further defects compounded it: noise rows (thinking/status/
todos) were filtered after the SQL LIMIT and consumed window slots; the
triggering message was excluded by blindly dropping the newest row, which
could evict real history under concurrent posts; and messages committed after
the trigger could leak into the request and get answered early.

Changes (workspace/backend/app/services/cloud_agent.py, config.py)

  • Causal trigger boundary. History is limited to events with
    timestamp < trigger (SQL-level), so a message committed after the trigger
    can never leak into this request. Events carry no sub-millisecond ordering,
    so rows sharing the trigger's millisecond are conservatively dropped;
    id-based and positional exclusion remain as fallbacks when no timestamp is
    available.
  • Window holds real conversation. message_type == "chat" allowlist
    (error/queue_cancel/etc. excluded, not just known noise) with paginated
    scanning until the window holds CLOUD_AGENT_MAX_CONTEXT_MESSAGES chat
    messages (default raised 10 → 100, env-overridable). The scan is explicitly
    best-effort under a cap (10 batches) so a busy channel can't degrade into an
    unbounded table scan; hitting the cap logs a warning naming agent and
    channel.
  • Whole-request char budget. New CLOUD_AGENT_MAX_CONTEXT_CHARS (default
    60000, env-overridable) covers system prompt + history + trigger message, so
    the assembled payload has a hard char bound — message count alone is not a
    safe context budget for small/custom models. An overlong trigger is
    truncated with a warning; a system prompt that alone consumes the budget
    raises a clear error which the caller surfaces to the channel instead of
    silently dropping the turn. Chars are a rough token proxy (CJK caveat
    documented in config); exact per-model token budgeting isn't portable since
    custom providers expose neither window sizes nor tokenizers.
  • Image composer. Same budgeting with its own smaller 8000-char budget,
    computed from the actual system prompt and the full wrapped user message.
  • Anthropic API guard. A rolling window can start with an assistant
    message, which the API rejects — leading non-user messages are trimmed in
    _anthropic_chat.

Prompt caching was deliberately excluded from this PR (a rolling window
invalidates the tail prefix once full); it will come as a follow-up with cache
usage observability and rollover tests.

Tests

tests/test_cloud_agent_context.py, 20 tests: trigger-boundary exclusion
(incl. same-millisecond and id-only fallback), early-constraint survival at
turn 30, noise beyond a full over-fetch batch, allowlist, window cap, char
budget (newest-first loading, truncation), final provider payload bounded for
both chat and composer paths (composer pinned to the exact 8000 boundary),
system-prompt-over-budget raising, and the scan-cap warning path.

Full backend suite: the 20 pre-existing failures are identical with and
without this change (verified via stash diff) — no regressions.

Review history: five rounds of code review addressed across commits
6b5eb4b, 0128820, 2af86d6, afeb525, 1b7e7a3.

🤖 Generated with Claude Code

Cloud agents rebuilt context from only the latest 10 channel messages,
so constraints confirmed early in a conversation fell out of the prompt
after about 5 turns and the model would re-decide settled questions.

- raise the CLOUD_AGENT_MAX_CONTEXT_MESSAGES default from 10 to 100
- over-fetch and filter thinking/status/todos rows in Python so noise
  no longer consumes window slots
- exclude the triggering message by event id instead of blindly
  dropping the newest row, which could evict real history under
  concurrent posts
- mark Anthropic system prompt and conversation tail with ephemeral
  cache breakpoints so the resent history bills at cache-read rates
- add tests covering window size, noise filtering, id-based exclusion
  and role mapping
- bound context to events causally prior to the trigger via a
  before_timestamp SQL filter so messages committed after the trigger
  can no longer leak into the request and get answered early, with
  same-millisecond rows conservatively dropped since events carry no
  finer ordering, and the id-only and positional exclusions kept as
  fallbacks when no timestamp is available
- add a character budget (CLOUD_AGENT_MAX_CONTEXT_CHARS, default
  60000, env-overridable) loaded newest-first alongside the message
  count cap, since 100 unbounded message bodies could exceed the
  context of small or custom models where 10 previously fit, and the
  newest message is truncated rather than dropped when it alone
  exceeds the budget, with the image prompt composer using its own
  smaller 8000-char budget
- replace the fixed 3x over-fetch and noise blacklist with paginated
  scanning under a scan cap and a message_type chat allowlist so any
  amount of status/thinking/error/queue_cancel rows can no longer
  evict real conversation from the window
- drop the prompt caching change from this PR per review since the
  rolling window invalidates the tail prefix once full, keeping only
  a trim of leading assistant messages which the Anthropic API rejects
  as the first message
- update the concurrent-message test to assert exclusion and add
  coverage for the same-millisecond boundary, beyond-batch noise,
  the allowlist and the char budget
Second round of review fixes.

- the char budget now covers the assembled request rather than history
  alone. The chat path subtracts the system prompt and trigger message
  from CLOUD_AGENT_MAX_CONTEXT_CHARS before budgeting history, and an
  overlong trigger is truncated with a warning, so the final payload
  char total is hard-bounded. The image composer subtracts its
  instruction and fixed system prompt the same way. Exact per-model
  token budgeting is not portable here since custom providers expose
  neither window sizes nor tokenizers, so chars remain the proxy and
  the config comment documents the CJK variance and advises lowering
  the knob for small models
- the scan cap is now documented as deliberate best-effort truncation
  instead of implying strict semantics, and hitting it logs a warning
  naming the agent and channel so the condition is observable
- add tests for the final provider payload staying within the total
  budget, trigger truncation, and the scan-cap-hit path asserting the
  warning and the documented empty result
Third round of review fixes.

- when the configured system prompt alone reaches the whole-request
  budget, raise a clear ValueError instead of truncating the trigger
  to an empty string and silently dropping the turn. The caller's
  existing error handler turns the exception into a visible failure
  message in the channel. Regression test added
- soften the scan-cap warning to say older history, if any, is
  invisible, since reaching the cap does not prove more rows exist
- compute the image composer's fixed overhead from the actual system
  prompt length instead of a hardcoded 600 so future prompt wording
  changes cannot break the budget bound
Fourth round review fix. The image composer budget subtracted only the
raw instruction, but the sent user message wraps it in fixed text
worth 46 more chars, so a full history could push the request to 8046
chars. Build the complete user_prompt first, budget with its real
length, and reuse the same string when sending. Adds a mock provider
payload test asserting the composer request stays within
_IMAGE_CONTEXT_MAX_CHARS.
Review follow-up. A single 10000-char history message now forces the
builder to truncate to precisely the remaining budget, and the
assertion pins the payload total to exactly the cap — the pre-fix
implementation that missed the 46-char wrapper lands on 8046 and
fails, whereas the previous 3000-char chunks left enough slack for it
to pass.
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
openagents-workspace Ready Ready Preview Jul 30, 2026 10:34am

Request Review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant