ADR 0098 — Consistent transport limits and effective compaction window
- Status: Accepted
- Date: 2026-08-19
- Context: fix for
413 request entity too largeon PRs (found through real use), RN-412
Context
The QA/SecOps gate was dying with 413 request entity too large on
legitimate PRs. Comments scattered across the code (apps/engine/config/runtime.exs,
apps/engine/lib/engine/actions/terminal_executor.ex, docker/docker-compose.yml)
attributed the error to the LLM PROVIDER. The investigation confirmed that
attribution was wrong: Engine.Agents.FalhaDeTurno.origem({413, _})
already classified the failure as "codigo" (code), not "modelo"
(model) — the classifier was correct, nobody had just followed the
trail to the end.
The real cause has two independent ends, and both need to close together — fixing only one just postpones the overflow, it doesn't resolve it:
- The api never configured an Express body limit.
NestFactory.createinapps/api/src/main.tsnever touched the JSON parser, so the Express default applied: 100 KB. Phoenix (engine) accepts bodies up to 8 MB. On the heaviest leg of the transport — engine → api, specificallyPOST /internal/sessions/:sessionId/llm-turn, which receives the ENTIRE conversation history on everyToolLoopiteration — the api was the narrowest bottleneck by a factor of 80×, without anyone having decided this: it was an absence of configuration, not a choice. - The engine's context compaction was structurally unreachable
before the overflow.
Engine.Harness.ContextManager.Defaultdecides to compact when the token estimate exceedsthreshold * context_window. Two defects pushed that trigger far past the transport limit:estimate/1only summed thecontentfield of messages.assistantmessages withtoolCalls(the format of a tool call) have emptycontent— the tool call arguments, which ARE real bytes in the HTTP body, counted as ~zero tokens.- The compaction window used only
context_window— 128,000 tokens across the five gate/dev agents (qa_automacao_agent.ex,qa_performance_seguranca_agent.ex,qa_estrategia_agent.ex,appsec_agent.ex,dev_agent_server.ex). Withthreshold: 0.7, that puts compaction at ~350 KB of estimated payload — well past any reasonable transport limit, and worse still when the estimate itself was already undercounting.
With a gate at 60 iterations (the ceiling for execution/gate agents) and three to four 32 KiB tool results (the INDIVIDUAL cap already closed by RN-150), the accumulated body exceeded 100 KB long before compaction would even consider acting.
Decision
Both ends close in the same fix, deliberately together:
apps/api/src/main.tsnow explicitly configures the JSON parser limit (app.useBodyParser('json', { limit })), read fromAPI_JSON_BODY_LIMITwith default10mb— headroom over the 8 MB that Phoenix already accepts, with no redeploy required if the engine's cap changes.Engine.Harness.ContextManager.Defaultgains:estimate/1now countscontentof EVERY message PLUS the JSON serialization oftoolCallsonassistantmessages — the bytes-per-token heuristic is the SAME one the approximate tokenizer uses (Engine.Harness.Tokenizer.bytes_per_token/0, a new public function, to avoid duplicating the constant).- The EFFECTIVE compaction window becomes
min(context_window, transport_ceiling), where the transport ceiling is a NEW config (transport_max_body_bytes, default 8 MiB — the cap of the engine's OWN transport, not replicated across the five agent files, which keep declaringcontext_window: 128_000because that describes the MODEL, not the transport). - The cut between old messages (summarized) and recent ones
(preserved) now respects the
ToolLoop's ITERATION BOUNDARY (group_by_iteration/1) — anassistantmessage withtoolCallsand therole: "tool"messages that answer it always travel together to the same side of the cut. Cutting through the middle would break the provider's tool-use protocol (a tool result with no matching call in the history).
The general rule this ADR records: compaction must trigger BEFORE the body overflows the real HTTP limit, not before the model "forgets" its window. A transport ceiling that only exists implicitly (a library's default) isn't a ceiling — it's a failure waiting for the right payload.
Consequences
- The value of
transport_max_body_bytes(8 MiB) is declared config, not calibrated against real traffic — the same honesty rule the hybrid search (RAG) weights and thesearch_workspacecaps (RN-150) already follow: an adjustable starting point, not a definitive number. - Raising only the api's limit without fixing the engine's estimate (or vice versa) reintroduces the defect in another form: either the body keeps growing unchecked up to a bigger ceiling, or compaction starts acting too early and summarizes context that would still fit in the transport. Both ends were born in the same commit.
dev.awaiting_gateas a dev agent state should get rarer in practice (the gate no longer dies to a 413 on a legitimate payload), but RN-412 also extendsDEV_PENDING_TYPESto hold the session in that state — defense in depth, not a dependency on this ADR closing 100% of the gate-slow/stuck cases for any other reason.