Gateway activity storage¶
Editions: OSS, Cloud, Enterprise. Unless stated otherwise, everything on this page ships in OSS.
Every model call that goes through the Preloop gateway inside a runtime
session is stored as one row in the runtime_session_activity table. The
row holds usage, cost and timing, plus the conversation content that the
session views show.
A model call made by a flow run is stored instead as a model_gateway_call
row in flow_execution_log, the execution's log, with the same payload. The
flow execution page builds its Transcript tab from these rows. Everything
below about what a row stores applies to both tables.
What one row stores¶
A model call sends the whole conversation so far. Older releases stored all of it on every row, so call 200 of a session repeated calls 1 to 199, and the table grew with the square of session length. Long agent sessions wrote several hundred kilobytes per model call.
Rows are now stored slim:
- The messages new to this call: what the caller added after the previous response (usually the latest tool result or user message), and the response. The last user message is always kept because the session views title a call by it. Older messages are already on the session's earlier rows, and the console stitches the conversation back together by message position.
- The request parameters, including tool definitions, as before.
- Tool calls and results that belong to this call.
- Usage, tokens, cost, model, outcome and timing, unchanged.
When a call is larger than the cap¶
MODEL_GATEWAY_ACTIVITY_MAX_STORED_BYTES (default 8388608, 8 MB) is a guard
against runaway payloads, not a size budget. A row already holds only what
the call added, so a normal call is stored whole, untruncated. Only a call
whose stored form would exceed the cap is shortened. Short of the final
fallback described below, Preloop never removes:
- the model response: its text, the tool calls it made, the finish reason and usage,
- the messages this call added (usually tool results and file contents),
- the last user message.
It shortens in this order and stops as soon as the row fits:
- Tool definitions are reduced to their names.
tool_activityis dropped. It is derived from the request and response, which stay.- The
toolsentries are dropped (also derived), and long text in the request outside its new messages, such as the system prompt, is cut in the middle. - Long text in the new messages is cut in the middle, longest first, so
the largest tool outputs go first. The first and last
MODEL_GATEWAY_ACTIVITY_TRUNCATE_KEEP_CHARScharacters (default 2048) are kept, with a marker in between:[... truncated 12345 chars ...]. - Only then is long response text cut, middle out, keeping four times as much at the start (8192 characters by default).
Tool-call arguments and tool-call inputs are never cut, because a cut argument is no longer valid JSON.
A cap far below the size of the call can need more: text is cut harder (down
to 64 characters at each end), then only the newest new message and the last
user message are kept, then the response text is cut harder. If even that
does not fit (a cap smaller than the bare structure of the call), the
contents of request and response are replaced with
{"omitted_for_size": true}, the preview is removed and the marker records
content_omitted_for_size. The keys stay so a reader can tell the content
was removed, not absent. With the default cap this only happens for a
single call larger than 8 MB whose tool arguments alone exceed the cap.
The session views show a Truncated badge on every message or tool result that was cut.
body_compaction on the row records exactly what happened:
| Field | Meaning |
|---|---|
original_bytes, stored_bytes |
Size of the call before and after |
request_history_start |
Index of the first new message; earlier ones were replayed history |
tool_definitions_omitted |
Tool definitions were reduced to names |
keys_omitted |
Derived keys that were dropped (tool_activity, tools) |
request_context_shortened, messages_shortened, response_shortened |
strings cut, chars_removed, and the characters kept (keep_chars or head_chars) |
messages_shortened_further, delta_messages_elided, response_shortened_further |
The last-resort steps above, when they ran |
content_omitted_for_size |
The final fallback ran: request and response contents replaced with {"omitted_for_size": true} |
Why the cap is large¶
Because a row keeps only the call's own new messages and response, its size no longer grows with the length of the session. On a synthetic workload of 2,000 long-session calls (a median of 148 replayed messages, up to 1,200, original calls averaging about 700 KB), the stored row is 107 KB at the median and 171 KB at p95 before any cap. The 8 MB default therefore leaves normal calls alone and only stops a single runaway turn, such as a tool that returns a whole build log or a binary file.
Rows live in Postgres, so there is still a limit. Storing arbitrarily large single turns with no limit at all would need an object storage backend for call bodies, which Preloop does not have today.
Rows stored by earlier releases¶
Rows written while the default cap was 32768 can be missing content: their
body_compaction shows keys_omitted containing request and response,
or strings_capped_at (text cut to that many characters from the start).
That content was never written, and the maintenance pass does not touch rows
that already carry body_compaction, so it cannot be restored. Token counts,
cost and timing on those rows are complete.
The live view of a running session is not affected: live events still carry the full call.
Compacting rows written by older releases¶
Rows written before this change keep their full history until they are compacted. A maintenance pass does this in the background:
- It runs hourly from the sync scheduler (task
maintain_gateway_activity) whileGATEWAY_ACTIVITY_COMPACTION_ENABLEDis true (the default). - Each pass rewrites at most
GATEWAY_ACTIVITY_MAINTENANCE_MAX_BATCHESbatches (default 50) ofGATEWAY_ACTIVITY_MAINTENANCE_BATCH_SIZErows (default 200). Each batch is its own transaction, so stopping a pass loses nothing; the next pass continues with the rows still left. Each worker remembers how far it got, so once the backlog is drained an hourly pass only looks at rows it has not seen. After a worker restart the first pass walks the table once more and finds nothing to rewrite. - It is not part of a schema migration, so it never holds up an upgrade.
- Sessions under legal hold are skipped.
To drain a backlog faster, or to see what a pass would do, run it by hand in an API or sync container:
# Count what would change without writing anything
python -m preloop.services.gateway_activity_maintenance --dry-run
# Rewrite up to 500 batches now
python -m preloop.services.gateway_activity_maintenance --max-batches 500
Each batch is logged with the number of rows rewritten and the timestamp it
reached. The command prints a JSON summary with compacted_rows,
bytes_before, bytes_after and exhausted (true when no rows were left).
Run it again until exhausted is true.
Compaction frees space inside the table for new rows. The table file on disk
does not shrink until Postgres rewrites it (VACUUM FULL or pg_repack),
which you schedule in your own maintenance window.
Flow execution logs¶
Flow log rows written by older releases are compacted by the same pass when
FLOW_LOG_GATEWAY_COMPACTION_ENABLED=true. It is off by default because it
removes replayed history from existing rows; new rows are stored slim
whatever this setting says. It uses the same batch settings, skips
executions under legal hold, and leaves every other log type (agent log
lines, tool calls, status updates) untouched.
# See what compacting flow logs would do, without writing anything
python -m preloop.services.gateway_activity_maintenance --flow-logs --no-compaction --dry-run
# Compact up to 500 batches of flow log rows now
python -m preloop.services.gateway_activity_maintenance --flow-logs --no-compaction --max-batches 500
The summary reports flow log rows under flow_log_compaction. Body
retention below does not apply to flow logs.
Retention of conversation content¶
With GATEWAY_ACTIVITY_BODY_RETENTION_ENABLED=true, the same hourly pass also
removes conversation content from every gateway call row older than the
account's plan history window, whatever its size. Usage, tokens, cost and timing stay, so reports over
old periods still add up. Accounts with unlimited history are skipped, and
so are sessions under legal hold. This is off by default because removed
content cannot be restored. Run it by hand with --retention.
Deleting whole sessions and their records is a separate job, the retention
purge (RETENTION_PURGE_ENABLED, periods under Settings > Records).
Stored payload size¶
Each row records the size of its metadata in metadata_bytes, set whenever
the row is written. Readers that page gateway payloads under a byte budget use
it instead of serialising every value to measure it. Rows written before the
column existed are filled in by the same hourly pass
(GATEWAY_ACTIVITY_SIZE_BACKFILL_ENABLED, on by default), one bounded UPDATE
per batch; until then, readers measure those rows the old way. Skip it in a
manual run with --no-size-backfill.
Settings¶
| Setting | Default | Effect |
|---|---|---|
MODEL_GATEWAY_ACTIVITY_MAX_STORED_BYTES |
8388608 |
Guard against runaway gateway call rows (bytes); 0 disables the cap |
MODEL_GATEWAY_ACTIVITY_TRUNCATE_KEEP_CHARS |
2048 |
Characters kept at each end of text cut to fit the cap |
GATEWAY_ACTIVITY_COMPACTION_ENABLED |
true |
Hourly compaction of rows from older releases |
FLOW_LOG_GATEWAY_COMPACTION_ENABLED |
false |
Hourly compaction of flow log model call rows from older releases |
GATEWAY_ACTIVITY_BODY_RETENTION_ENABLED |
false |
Hourly removal of content older than the plan history window |
GATEWAY_ACTIVITY_SIZE_BACKFILL_ENABLED |
true |
Hourly fill of metadata_bytes on rows from older releases |
GATEWAY_ACTIVITY_MAINTENANCE_BATCH_SIZE |
200 |
Rows per transaction |
GATEWAY_ACTIVITY_MAINTENANCE_MAX_BATCHES |
50 |
Batches per pass |