diff --git a/api/oss/src/core/sessions/records/streaming.py b/api/oss/src/core/sessions/records/streaming.py index d32b87a1dd..722d31c129 100644 --- a/api/oss/src/core/sessions/records/streaming.py +++ b/api/oss/src/core/sessions/records/streaming.py @@ -12,6 +12,7 @@ from oss.src.core.sessions.records.dtos import SessionRecordEvent from oss.src.dbs.redis.shared.engine import get_streams_engine +from oss.src.utils.env import env from oss.src.utils.logging import get_module_logger log = get_module_logger(__name__) @@ -21,6 +22,8 @@ # Truncate attributes at ingest to avoid storing unbounded record bodies. MAX_ATTRIBUTES_BYTES = 64 * 1024 # 64 KB per record +_TRUNCATION_MARKER = "…[truncated]" + def _orjson_default(obj): if AsyncpgUUID is not None and isinstance(obj, AsyncpgUUID): @@ -28,6 +31,52 @@ def _orjson_default(obj): raise TypeError(f"Type is not JSON serializable: {type(obj)}") +def _truncate_attributes(attributes, budget: int, original_bytes: int): + """Shrink an oversized record body to fit `budget` while PRESERVING structure: small fields + (``type``/``id``/``name``…) stay intact and only the largest string values are trimmed, each + marked, so server-side history reconstruction still gets the event shape + partial content. + Falls back to a minimal discriminator-only shape when non-string bloat can't be trimmed.""" + if original_bytes <= budget: + return attributes + if not isinstance(attributes, dict): + return {"_truncated": True, "_original_bytes": original_bytes} + + result = dict(attributes) + trimmed: list[str] = [] + # Trim the largest string field repeatedly until the serialized body fits (or none remain). + for _ in range(len(attributes)): + size = len(dumps(result, default=_orjson_default)) + if size <= budget: + break + str_fields = [ + (k, v) + for k, v in result.items() + if isinstance(v, str) and not k.startswith("_") + ] + if not str_fields: + break + key, value = max(str_fields, key=lambda kv: len(kv[1])) + overflow = size - budget + keep = max( + 0, len(value) - overflow - 128 + ) # margin for the marker + json overhead + result[key] = value[:keep] + _TRUNCATION_MARKER + if key not in trimmed: + trimmed.append(key) + + if len(dumps(result, default=_orjson_default)) > budget: + # Non-string bloat remains → keep only the discriminator fields reconstruction needs. + return { + "type": attributes.get("type"), + "id": attributes.get("id"), + "_truncated": True, + "_original_bytes": original_bytes, + } + + result["_truncated"] = {"fields": trimmed, "original_bytes": original_bytes} + return result + + def _get_redis(): engine = get_streams_engine() return engine.get_redis() if engine else None @@ -72,8 +121,19 @@ async def publish_record( session_id=str(record_event.session_id), original_bytes=len(raw_attributes), ) + # Smart truncation keeps the event shape + partial content so records stay + # reconstructable; legacy path drops the whole body. Flag-gated (additive). + new_attributes = ( + _truncate_attributes( + record_event.attributes, + MAX_ATTRIBUTES_BYTES, + len(raw_attributes), + ) + if env.agenta.sessions.records.smart_truncation + else {"_truncated": True} + ) truncated_event = record_event.model_copy( - update={"attributes": {"_truncated": True}} + update={"attributes": new_attributes} ) message = { diff --git a/api/oss/src/utils/env.py b/api/oss/src/utils/env.py index 3a8aca3320..beef7e70e6 100644 --- a/api/oss/src/utils/env.py +++ b/api/oss/src/utils/env.py @@ -486,6 +486,32 @@ def _validate_mode(self) -> "RedactionConfig": return self +# --------------------------------------------------------------------------- +# agenta.sessions +# --------------------------------------------------------------------------- + + +class SessionsRecordsConfig(BaseModel): + """Durable session-record ingest tuning (server-side history reconstruction).""" + + # When a record body exceeds the cap, preserve its structure + partial content (trim only + # the large field values) instead of replacing the whole body with {"_truncated": True}. + # Off = legacy whole-body drop. On makes reconstruction from records higher-fidelity. + smart_truncation: bool = ( + os.getenv("AGENTA_RECORDS_SMART_TRUNCATION") or "false" + ).lower() in _TRUTHY + + model_config = ConfigDict(extra="ignore") + + +class SessionsConfig(BaseModel): + """Agenta sessions sub-namespace.""" + + records: SessionsRecordsConfig = SessionsRecordsConfig() + + model_config = ConfigDict(extra="ignore") + + # --------------------------------------------------------------------------- # agenta — top-level Agenta core config. # --------------------------------------------------------------------------- diff --git a/api/oss/tests/pytest/unit/sessions/test_records_truncation.py b/api/oss/tests/pytest/unit/sessions/test_records_truncation.py new file mode 100644 index 0000000000..f183c77798 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_records_truncation.py @@ -0,0 +1,73 @@ +"""Smart truncation of oversized record bodies (`_truncate_attributes`). + +The legacy path replaces an over-cap body with `{"_truncated": True}`, losing the event's +type/id and all content. Smart truncation preserves the event shape + partial content so the +record log stays reconstructable server-side. These pin that contract. +""" + +from orjson import dumps + +from oss.src.core.sessions.records.streaming import ( + MAX_ATTRIBUTES_BYTES, + _TRUNCATION_MARKER, + _truncate_attributes, +) + + +def _size(obj) -> int: + return len(dumps(obj)) + + +def test_under_budget_returns_unchanged(): + attrs = {"type": "message", "text": "hi"} + out = _truncate_attributes(attrs, MAX_ATTRIBUTES_BYTES, _size(attrs)) + assert out is attrs # untouched, no _truncated marker + + +def test_large_string_field_is_trimmed_but_structure_preserved(): + big = "x" * (MAX_ATTRIBUTES_BYTES * 2) + attrs = {"type": "tool_result", "id": "call-1", "output": big} + out = _truncate_attributes(attrs, MAX_ATTRIBUTES_BYTES, _size(attrs)) + + # Discriminator fields survive (unlike the legacy whole-body drop). + assert out["type"] == "tool_result" + assert out["id"] == "call-1" + # The big field is trimmed + marked, and the whole body now fits the cap. + assert out["output"].endswith(_TRUNCATION_MARKER) + assert len(out["output"]) < len(big) + assert _size(out) <= MAX_ATTRIBUTES_BYTES + # Metadata records what was trimmed. + assert out["_truncated"]["fields"] == ["output"] + assert out["_truncated"]["original_bytes"] == _size(attrs) + + +def test_trims_the_largest_of_several_string_fields(): + attrs = { + "type": "message", + "small": "ok", + "text": "y" * (MAX_ATTRIBUTES_BYTES * 2), + } + out = _truncate_attributes(attrs, MAX_ATTRIBUTES_BYTES, _size(attrs)) + assert out["small"] == "ok" # small field untouched + assert out["text"].endswith(_TRUNCATION_MARKER) + assert _size(out) <= MAX_ATTRIBUTES_BYTES + + +def test_non_string_bloat_falls_back_to_discriminator_only(): + # A huge nested structure with no single big string leaf can't be string-trimmed. + attrs = { + "type": "tool_call", + "id": "call-9", + "input": {str(i): i for i in range(MAX_ATTRIBUTES_BYTES)}, + } + out = _truncate_attributes(attrs, MAX_ATTRIBUTES_BYTES, _size(attrs)) + assert out["type"] == "tool_call" + assert out["id"] == "call-9" + assert out["_truncated"] is True + assert _size(out) <= MAX_ATTRIBUTES_BYTES + + +def test_non_dict_attributes_fall_back(): + huge = "z" * (MAX_ATTRIBUTES_BYTES * 2) + out = _truncate_attributes(huge, MAX_ATTRIBUTES_BYTES, _size(huge)) + assert out == {"_truncated": True, "_original_bytes": _size(huge)} diff --git a/docs/design/sessions-last-message-only/design.md b/docs/design/sessions-last-message-only/design.md new file mode 100644 index 0000000000..081a380a7f --- /dev/null +++ b/docs/design/sessions-last-message-only/design.md @@ -0,0 +1,55 @@ +# Last-message-only sends + server-side history reconstruction + +**Branch:** `feat/sessions-last-message-only` (off `feat/sessions-storage-rework` / #5436) +**Motivation:** AGE-3970 (trace-drawer OOM on long turns) + device-independent continuity. +Shrink request/trace payloads by sending only the newest user message per turn and +reconstructing prior conversation server-side from durable session **records**. + +## Decisions (locked) +- **Reconstruction lives in the runner (TS)** — at the `buildTurnText`/`priorMessages` seam, + reusing the runner's records client + session plumbing; a TS↔TS port of the FE + `transcriptToMessages.ts` folding. (Shared infra with JP/Mahmoud — keep additive/flagged.) +- **Harden records durability FIRST**, before the FE trusts reconstruction — so reconstructed + history is authoritative, not lossy. + +## Current architecture (verified FE / SDK / runner / api) +Two continuity paths already exist in the runner: +- **Warm** (pool hit / same harness authored last turn): harness native `resumeSession()` + supplies history; `sendLastMessageOnly` is ALREADY true (`runtime-contracts.ts:168`). +- **Cold** (evicted / different harness / cross-device): runner rebuilds the full transcript + from inbound `request.messages` (`transcript.ts:buildTurnText`/`priorMessages`; selected at + `run-turn.ts:134`). + +The FE always sends full history (`agentRequest.ts:401`) because it can't predict warm/cold. +Records are **write-only** from the run's perspective: the runner persists every event +per-turn (`persist.ts` → `POST /sessions/records/ingest`) but never reads them back for model +context. Records ARE sufficient to reconstruct full ordered history (roles, text, reasoning, +tool_call/tool_result, approvals) — each carries the whole ACP `AgentEvent`. + +## Hard constraint: HITL cold-replay +Resume is message-driven, not interaction-driven. The runner binds tool_result→tool_call by +scanning `request.messages` (`responder.ts:413,470`) and scans full history for approval +verdicts (`responder.ts:358`). Durable `session_interactions` rows exist but aren't read to +rebuild history. ⇒ Dropping history on a cold resume breaks approvals/client-tools/elicitation +until the runner reconstructs matched tool_call/tool_result pairs from records. **The BE +reconstructor + durable records are the prerequisite for the FE change.** + +## Phases +- **Phase 1 — records durability** (enabler): make `message` + `tool_result` (and `tool_call`, + `interaction_request`) record persistence reliable end-to-end — reduce the runner's + fire-and-forget drop (ack/retry-to-DLQ) and fix the 64 KB attribute cap + (`records/streaming.py`) for tool bodies (raise + spill). Order records by + `session_turns.turn_index` then `record_index`. +- **Phase 2 — runner reconstructor**: new `services/runner/src/sessions/` module folds + records→`ChatMessage[]` (inverse of `buildPersistingEmitter`); generalize + `sendLastMessageOnly` to fire whenever `session_id` yields reconstructable history; feed + `buildTurnText`/`priorMessages` + otel `run.start`. Behind a flag; FE unchanged. +- **Phase 3 — FE last-message-only**: `buildAgentRequest` sends only the trailing user message + when the session is server-known with records; falls back to full history otherwise. Trace/ + request payloads shrink → AGE-3970 mitigated. + +## Open risks tracked +- Runner is ephemeral: guaranteeing delivery may mean blocking the run on a persist ack + (latency) or a durable outbox — Phase 1 decides the mechanism. +- Interaction-row verdict as a backstop source (if a settled answer must survive without the + last message) — evaluate in Phase 2. diff --git a/hosting/docker-compose/ee/docker-compose.dev.yml b/hosting/docker-compose/ee/docker-compose.dev.yml index b26c27e0cf..29ed893d43 100644 --- a/hosting/docker-compose/ee/docker-compose.dev.yml +++ b/hosting/docker-compose/ee/docker-compose.dev.yml @@ -424,6 +424,12 @@ services: AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS: ${AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS:-} AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM: ${AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM:-} AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} + # This service has no env_file (see above), so the session flags must be listed + # here or they can never be set. AGENTA_SESSIONS_RECONSTRUCT must be enabled + # together with the web NEXT_PUBLIC_SESSIONS_LAST_MESSAGE_ONLY, or a turn that + # carries only its last message loses the conversation. + AGENTA_SESSIONS_RECONSTRUCT: ${AGENTA_SESSIONS_RECONSTRUCT:-} + AGENTA_RECORDS_DURABLE: ${AGENTA_RECORDS_DURABLE:-} # === STORAGE ============================================== # volumes: - ../../../services/runner/src:/app/src diff --git a/hosting/docker-compose/ee/docker-compose.gh.yml b/hosting/docker-compose/ee/docker-compose.gh.yml index 2e71f84034..a27df46a02 100644 --- a/hosting/docker-compose/ee/docker-compose.gh.yml +++ b/hosting/docker-compose/ee/docker-compose.gh.yml @@ -295,6 +295,12 @@ services: AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS: ${AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS:-} AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM: ${AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM:-} AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} + # This service has no env_file (see above), so the session flags must be listed + # here or they can never be set. AGENTA_SESSIONS_RECONSTRUCT must be enabled + # together with the web NEXT_PUBLIC_SESSIONS_LAST_MESSAGE_ONLY, or a turn that + # carries only its last message loses the conversation. + AGENTA_SESSIONS_RECONSTRUCT: ${AGENTA_SESSIONS_RECONSTRUCT:-} + AGENTA_RECORDS_DURABLE: ${AGENTA_RECORDS_DURABLE:-} # === SUBSCRIPTION MOUNTS (opt-in) ========================= # # Use your own harness subscription for LOCAL runs instead of a managed API # key. Mount the login READ-WRITE: the harness runs directly out of the mount and diff --git a/hosting/docker-compose/ee/env.ee.dev.example b/hosting/docker-compose/ee/env.ee.dev.example index 0782fe2c74..94bea8b7b0 100644 --- a/hosting/docker-compose/ee/env.ee.dev.example +++ b/hosting/docker-compose/ee/env.ee.dev.example @@ -111,6 +111,18 @@ AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER=local # 5 min per tool call. # AGENTA_RUNNER_TOOL_CALL_TIMEOUT_MS=300000 +# --- Session history: last-message-only turns (all four default off) --- +# The client sends only the newest user message and the runner rebuilds the earlier turns +# from the durable session record log, which keeps requests and traces small on a long +# conversation. The first two must be flipped together: with the web flag on and the runner +# flag off, a turn arrives with no history at all. +# AGENTA_SESSIONS_RECONSTRUCT=true +# NEXT_PUBLIC_SESSIONS_LAST_MESSAGE_ONLY=true +# Records become the only copy of the conversation, so make writing them durable and keep +# the structure of a record whose body exceeds the API size cap. +# AGENTA_RECORDS_DURABLE=true +# AGENTA_RECORDS_SMART_TRUNCATION=true + # ================================================================== # # Agenta - API # ================================================================== # diff --git a/hosting/docker-compose/ee/env.ee.gh.example b/hosting/docker-compose/ee/env.ee.gh.example index a8de6f8618..3cef8caad3 100644 --- a/hosting/docker-compose/ee/env.ee.gh.example +++ b/hosting/docker-compose/ee/env.ee.gh.example @@ -113,6 +113,18 @@ AGENTA_RUNNER_TOKEN=replace-me # 5 min per tool call. # AGENTA_RUNNER_TOOL_CALL_TIMEOUT_MS=300000 +# --- Session history: last-message-only turns (all four default off) --- +# The client sends only the newest user message and the runner rebuilds the earlier turns +# from the durable session record log, which keeps requests and traces small on a long +# conversation. The first two must be flipped together: with the web flag on and the runner +# flag off, a turn arrives with no history at all. +# AGENTA_SESSIONS_RECONSTRUCT=true +# NEXT_PUBLIC_SESSIONS_LAST_MESSAGE_ONLY=true +# Records become the only copy of the conversation, so make writing them durable and keep +# the structure of a record whose body exceeds the API size cap. +# AGENTA_RECORDS_DURABLE=true +# AGENTA_RECORDS_SMART_TRUNCATION=true + # ================================================================== # # Agenta - API # ================================================================== # diff --git a/hosting/docker-compose/oss/docker-compose.dev.yml b/hosting/docker-compose/oss/docker-compose.dev.yml index 604d62bfa1..b178dfe07d 100644 --- a/hosting/docker-compose/oss/docker-compose.dev.yml +++ b/hosting/docker-compose/oss/docker-compose.dev.yml @@ -410,6 +410,12 @@ services: AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS: ${AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS:-} AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM: ${AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM:-} AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} + # This service has no env_file (see above), so the session flags must be listed + # here or they can never be set. AGENTA_SESSIONS_RECONSTRUCT must be enabled + # together with the web NEXT_PUBLIC_SESSIONS_LAST_MESSAGE_ONLY, or a turn that + # carries only its last message loses the conversation. + AGENTA_SESSIONS_RECONSTRUCT: ${AGENTA_SESSIONS_RECONSTRUCT:-} + AGENTA_RECORDS_DURABLE: ${AGENTA_RECORDS_DURABLE:-} # === STORAGE ============================================== # volumes: - ../../../services/runner/src:/app/src diff --git a/hosting/docker-compose/oss/docker-compose.gh.yml b/hosting/docker-compose/oss/docker-compose.gh.yml index a9777adfa9..09f9f0e802 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.yml @@ -312,6 +312,12 @@ services: AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS: ${AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS:-} AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM: ${AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM:-} AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} + # This service has no env_file (see above), so the session flags must be listed + # here or they can never be set. AGENTA_SESSIONS_RECONSTRUCT must be enabled + # together with the web NEXT_PUBLIC_SESSIONS_LAST_MESSAGE_ONLY, or a turn that + # carries only its last message loses the conversation. + AGENTA_SESSIONS_RECONSTRUCT: ${AGENTA_SESSIONS_RECONSTRUCT:-} + AGENTA_RECORDS_DURABLE: ${AGENTA_RECORDS_DURABLE:-} # === SUBSCRIPTION MOUNTS (opt-in) ========================= # # Use your own harness subscription for LOCAL runs instead of a managed API # key. Mount the login READ-WRITE: the harness runs directly out of the mount and diff --git a/hosting/docker-compose/oss/env.oss.dev.example b/hosting/docker-compose/oss/env.oss.dev.example index b75eb975e1..37e70a43c1 100644 --- a/hosting/docker-compose/oss/env.oss.dev.example +++ b/hosting/docker-compose/oss/env.oss.dev.example @@ -111,6 +111,18 @@ AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER=local # 5 min per tool call. # AGENTA_RUNNER_TOOL_CALL_TIMEOUT_MS=300000 +# --- Session history: last-message-only turns (all four default off) --- +# The client sends only the newest user message and the runner rebuilds the earlier turns +# from the durable session record log, which keeps requests and traces small on a long +# conversation. The first two must be flipped together: with the web flag on and the runner +# flag off, a turn arrives with no history at all. +# AGENTA_SESSIONS_RECONSTRUCT=true +# NEXT_PUBLIC_SESSIONS_LAST_MESSAGE_ONLY=true +# Records become the only copy of the conversation, so make writing them durable and keep +# the structure of a record whose body exceeds the API size cap. +# AGENTA_RECORDS_DURABLE=true +# AGENTA_RECORDS_SMART_TRUNCATION=true + # ================================================================== # # Agenta - API # ================================================================== # diff --git a/hosting/docker-compose/oss/env.oss.gh.example b/hosting/docker-compose/oss/env.oss.gh.example index 7cf53d9c10..dfa65636ea 100644 --- a/hosting/docker-compose/oss/env.oss.gh.example +++ b/hosting/docker-compose/oss/env.oss.gh.example @@ -113,6 +113,18 @@ AGENTA_RUNNER_TOKEN=replace-me # 5 min per tool call. # AGENTA_RUNNER_TOOL_CALL_TIMEOUT_MS=300000 +# --- Session history: last-message-only turns (all four default off) --- +# The client sends only the newest user message and the runner rebuilds the earlier turns +# from the durable session record log, which keeps requests and traces small on a long +# conversation. The first two must be flipped together: with the web flag on and the runner +# flag off, a turn arrives with no history at all. +# AGENTA_SESSIONS_RECONSTRUCT=true +# NEXT_PUBLIC_SESSIONS_LAST_MESSAGE_ONLY=true +# Records become the only copy of the conversation, so make writing them durable and keep +# the structure of a record whose body exceeds the API size cap. +# AGENTA_RECORDS_DURABLE=true +# AGENTA_RECORDS_SMART_TRUNCATION=true + # ================================================================== # # Agenta - API # ================================================================== # diff --git a/services/runner/src/engines/sandbox_agent/reconstruct-history.ts b/services/runner/src/engines/sandbox_agent/reconstruct-history.ts new file mode 100644 index 0000000000..1a3e0a4766 --- /dev/null +++ b/services/runner/src/engines/sandbox_agent/reconstruct-history.ts @@ -0,0 +1,50 @@ +/** + * Seam that lets the runner rebuild prior conversation from the durable record log instead of + * trusting a full inbound history — the server side of "client sends only the last message". + * + * Flag-gated (`AGENTA_SESSIONS_RECONSTRUCT`) and a strict no-op until BOTH the flag is on AND the + * client actually sent a minimal history: when the client still sends the whole conversation + * (`messages.length > 1`), reconstruction is skipped and behaviour is unchanged. Best-effort — any + * miss (no session, no records, fetch failure) leaves the inbound history untouched. + */ + +import type { AgentRunRequest } from "../../protocol.ts"; +import { fetchSessionRecords } from "../../sessions/records-query.ts"; +import { reconstructMessages } from "../../sessions/reconstruct.ts"; + +function reconstructEnabled(): boolean { + return ( + String(process.env.AGENTA_SESSIONS_RECONSTRUCT ?? "").toLowerCase() === "true" + ); +} + +/** + * Returns a request whose `messages` are `[...reconstructed prior turns, ...inbound]` when + * reconstruction applies, else `null` to keep the inbound history as-is. + * + * MUST be called before the current turn's user message is persisted, so the record log holds + * only prior turns (no duplication of the incoming prompt). + */ +export async function reconstructHistoryIfNeeded( + request: AgentRunRequest, + sessionId: string | undefined, + auth: () => string, + log?: (msg: string) => void, +): Promise { + if (!reconstructEnabled() || !sessionId) return null; + const inbound = request.messages ?? []; + // The client already sent the conversation — nothing to rebuild. + if (inbound.length > 1) return null; + + const records = await fetchSessionRecords(sessionId, auth); + if (!records || records.length === 0) return null; + + const reconstructed = reconstructMessages(records); + if (reconstructed.length === 0) return null; + + log?.( + `[reconstruct] session=${sessionId} records=${records.length} ` + + `priorMessages=${reconstructed.length} inbound=${inbound.length}`, + ); + return { ...request, messages: [...reconstructed, ...inbound] }; +} diff --git a/services/runner/src/engines/sandbox_agent/run-turn.ts b/services/runner/src/engines/sandbox_agent/run-turn.ts index bbae6d372b..fa6875385d 100644 --- a/services/runner/src/engines/sandbox_agent/run-turn.ts +++ b/services/runner/src/engines/sandbox_agent/run-turn.ts @@ -75,7 +75,8 @@ import { nextTurnIndex, sessionContinuityStore, } from "./session-continuity.ts"; -import { priorMessages } from "./transcript.ts"; +import { reconstructHistoryIfNeeded } from "./reconstruct-history.ts"; +import { buildTurnText, priorMessages } from "./transcript.ts"; import { resolveRunUsage } from "./usage.ts"; /** @@ -144,9 +145,28 @@ export async function runTurn( }); try { + // Server-side history reconstruction (flag-gated, no-op by default): when the client sent a + // minimal history, rebuild prior turns from the durable record log so a cold turn still has + // full context. Runs before the current user turn is persisted, so records hold only prior + // turns. Reassigns `request` so every downstream reader (turnText, priorMessages, responder, + // otel) sees the same reconstructed history. + const reconstructed = await reconstructHistoryIfNeeded( + request, + sessionId, + () => runCredential(request), + logger, + ); + if (reconstructed) request = reconstructed; + const promptText = resolvePromptText(request); - // Cold: replay the full transcript (plan.turnText). Continuation or loaded: send only new text. - const turnText = sendLastMessageOnly(opts) ? promptText : plan.turnText; + // Cold: replay the full transcript. Continuation or loaded: send only new text. When history + // was rebuilt from records, recompute the transcript from it — the prebuilt plan.turnText + // predates the reconstruction. + const turnText = sendLastMessageOnly(opts) + ? promptText + : reconstructed + ? buildTurnText(request, logger) + : plan.turnText; const run = (deps.createOtel ?? createSandboxAgentOtel)({ harness: plan.harness, diff --git a/services/runner/src/sessions/persist.ts b/services/runner/src/sessions/persist.ts index 1006de5227..8feae4396b 100644 --- a/services/runner/src/sessions/persist.ts +++ b/services/runner/src/sessions/persist.ts @@ -14,7 +14,10 @@ * - The run is never blocked on persistence mid-stream (chain is fire-and-forget). * - The run DOES drain before teardown so the last event is not lost to the race. * - A persist failure is logged and swallowed; the SDK's in-memory replay store is the - * backstop. Three retries with linear backoff before the event is dropped. + * backstop. Three retries with linear backoff before the event is dropped. With + * `AGENTA_RECORDS_DURABLE=true` the retry is stronger (more attempts, exponential backoff) + * and a drop is COUNTED per session (see `takePersistFailures`), so the turn-end drain can + * tell whether the durable history is complete enough to reconstruct model context from. * - `record_source` marks who authored the record: "agent" for engine-emitted events, * "user" for the inbound user turn persisted at run start. */ @@ -26,6 +29,22 @@ import { stableRecordId } from "./record-id.ts"; const INGEST_MAX_RETRIES = 3; const INGEST_RETRY_BASE_MS = 100; +// Durable mode (Phase 1, flag-gated): more attempts with exponential backoff before a drop. +// 6 attempts ≈ 100+200+400+800+1600ms of backoff (~3.1s) — bounded per event so a real outage +// can't hang the turn-end drain indefinitely. +const DURABLE_INGEST_MAX_RETRIES = 6; + +/** Durable-records upgrades (stronger retry + drop counting) are opt-in and read at call time + * so the flag can be toggled per test. Off → the fire-and-forget legacy path, unchanged. */ +function durableRecordsEnabled(): boolean { + return String(process.env.AGENTA_RECORDS_DURABLE ?? "").toLowerCase() === "true"; +} + +/** Attempts before a durable-mode drop; env-overridable for ops tuning (and fast tests). */ +function durableMaxRetries(): number { + const n = Number(process.env.AGENTA_RECORDS_INGEST_MAX_RETRIES); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : DURABLE_INGEST_MAX_RETRIES; +} function log(msg: string): void { process.stderr.write(`[sessions/persist] ${msg}\n`); @@ -34,6 +53,10 @@ function log(msg: string): void { /** Map session_id → tail of the per-session persist chain. */ const persistChains = new Map>(); +/** Map session_id → count of records that exhausted retries (dropped). Only ever populated in + * durable mode; read + cleared at the turn-end drain via `takePersistFailures`. */ +const persistFailures = new Map(); + /** Send one event to the ingest endpoint with bounded retry. Authenticates AS the invoke * caller (the run credential); project scope is resolved server-side, so none is sent. */ async function postEvent( @@ -47,8 +70,10 @@ async function postEvent( spanId?: string, ): Promise { const url = `${apiBase()}/sessions/records/ingest`; + const durable = durableRecordsEnabled(); + const maxRetries = durable ? durableMaxRetries() : INGEST_MAX_RETRIES; let lastErr: unknown; - for (let attempt = 1; attempt <= INGEST_MAX_RETRIES; attempt++) { + for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const res = await fetch(url, { method: "POST", @@ -79,11 +104,22 @@ async function postEvent( return; } catch (err) { lastErr = err; - await new Promise((r) => setTimeout(r, INGEST_RETRY_BASE_MS * attempt)); + if (attempt < maxRetries) { + // Durable: exponential (100·2^n, capped by the attempt count). Legacy: linear. + const backoff = durable + ? INGEST_RETRY_BASE_MS * 2 ** (attempt - 1) + : INGEST_RETRY_BASE_MS * attempt; + await new Promise((r) => setTimeout(r, backoff)); + } } } + // Exhausted retries → the record is lost. In durable mode, count it so the turn-end drain + // knows the session's history is incomplete (a reconstruction/​fallback signal for later). + if (durable) { + persistFailures.set(sessionId, (persistFailures.get(sessionId) ?? 0) + 1); + } log( - `DROPPED session=${sessionId} idx=${eventIndex} type=${event.type} after ${INGEST_MAX_RETRIES} retries: ${String(lastErr instanceof Error ? lastErr.message : lastErr).slice(0, 120)}`, + `DROPPED session=${sessionId} idx=${eventIndex} type=${event.type} after ${maxRetries} retries: ${String(lastErr instanceof Error ? lastErr.message : lastErr).slice(0, 120)}`, ); } @@ -136,6 +172,18 @@ export async function drainPersist(sessionId: string): Promise { } } +/** + * Read and clear the count of records that were dropped (exhausted retries) for a session. + * Only ever non-zero when `AGENTA_RECORDS_DURABLE=true`. Call at the turn-end drain to learn + * whether the session's durable history is complete — a zero count means the record log fully + * captured the turn and is safe to reconstruct model context from. + */ +export function takePersistFailures(sessionId: string): number { + const n = persistFailures.get(sessionId) ?? 0; + persistFailures.delete(sessionId); + return n; +} + /** * A tool call streams as many `tool_call` events with a growing partial-args snapshot for * one id. Idle window after which an open, un-closed tool call is flushed as-is — the diff --git a/services/runner/src/sessions/reconstruct.ts b/services/runner/src/sessions/reconstruct.ts new file mode 100644 index 0000000000..d38a4287a5 --- /dev/null +++ b/services/runner/src/sessions/reconstruct.ts @@ -0,0 +1,117 @@ +/** + * Reconstruct a conversation's `ChatMessage[]` from the durable session record log — the + * server-side inverse of `buildPersistingEmitter`'s coalescing. This is what lets the client + * send only the newest user message: the runner rebuilds prior turns from records instead of + * trusting a full inbound history. + * + * The fold is chronological (records already arrive ordered by ingest time, then per-turn + * `record_index`) and keyed on `record_source`: a "user" record flushes the assistant turn in + * progress and starts a user turn; "agent" records accumulate into the current assistant turn as + * ACP content blocks. The output matches the vercel adapter's `ChatMessage`/`ContentBlock` shape + * exactly (`sdks/python/agenta/sdk/agents/adapters/vercel/messages.py`), so `buildTurnText`, + * `priorMessages`, and the responder's tool_call↔tool_result binding consume it unchanged. + * + * Completeness: records store the coalesced text/tool events, so reconstruction covers text, + * tool calls, and tool results (incl. still-parked calls, so a HITL answer arriving on the last + * message binds to its reconstructed tool_call). Reasoning, usage, and one-way UI events are not + * conversation context and are dropped. User attachments are NOT in the durable log (only the + * prompt text is persisted), so a reconstructed user turn is text-only — a known v1 gap. + */ + +import type { AgentEvent, ChatMessage, ContentBlock } from "../protocol.ts"; + +/** One durable record row as `POST /sessions/records/query` returns it. `attributes` is the + * coalesced `AgentEvent`; `record_source` is the author ("user" | "agent"). */ +export interface SessionRecordRow { + record_source?: string | null; + record_type?: string | null; + attributes?: unknown; + turn_id?: string | null; + record_index?: number | null; + created_at?: string | null; +} + +function eventOf(row: SessionRecordRow): AgentEvent | null { + const attrs = row?.attributes; + if (!attrs || typeof attrs !== "object") return null; + if (typeof (attrs as { type?: unknown }).type !== "string") return null; + return attrs as AgentEvent; +} + +/** One agent `AgentEvent` → an assistant content block, or null when it carries no conversation + * context (reasoning/usage/done/data/file/interaction lifecycle). `callNames` carries tool names + * forward from the call so a later result (which stores only the id) can label itself. */ +function eventToBlock( + event: AgentEvent, + callNames: Map, +): ContentBlock | null { + switch (event.type) { + case "message": + return event.text ? { type: "text", text: event.text } : null; + case "tool_call": { + if (event.id && event.name) callNames.set(event.id, event.name); + return { + type: "tool_call", + toolCallId: event.id, + toolName: event.name, + input: event.input, + }; + } + case "tool_result": + return { + type: "tool_result", + toolCallId: event.id, + toolName: event.id ? callNames.get(event.id) : undefined, + output: event.output ?? event.data, + isError: event.isError, + }; + case "error": + return { type: "text", text: `[error: ${event.message}]` }; + default: + return null; + } +} + +/** Collapse an assistant turn's blocks to a `ChatMessage` — an all-text turn becomes a plain + * string (mirrors the vercel adapter), otherwise the block array is kept. */ +function finalizeAssistant(blocks: ContentBlock[]): ChatMessage { + if (blocks.every((b) => b.type === "text")) { + return { role: "assistant", content: blocks.map((b) => b.text ?? "").join("") }; + } + return { role: "assistant", content: blocks }; +} + +/** + * Fold ordered session records into the conversation's `ChatMessage[]`. Pure; no I/O. Records + * MUST be in conversation order (the query endpoint returns them by `created_at`, then + * `record_index`) — this fold preserves that order and does not re-sort. + */ +export function reconstructMessages( + records: readonly SessionRecordRow[], +): ChatMessage[] { + const messages: ChatMessage[] = []; + const callNames = new Map(); + let assistant: ContentBlock[] | null = null; + + const flushAssistant = (): void => { + if (assistant && assistant.length) messages.push(finalizeAssistant(assistant)); + assistant = null; + }; + + for (const row of records) { + const event = eventOf(row); + if (!event) continue; + + if (row.record_source === "user") { + flushAssistant(); + const text = event.type === "message" ? (event.text ?? "") : ""; + messages.push({ role: "user", content: text }); + continue; + } + + const block = eventToBlock(event, callNames); + if (block) (assistant ??= []).push(block); + } + flushAssistant(); + return messages; +} diff --git a/services/runner/src/sessions/records-query.ts b/services/runner/src/sessions/records-query.ts new file mode 100644 index 0000000000..9bc2468507 --- /dev/null +++ b/services/runner/src/sessions/records-query.ts @@ -0,0 +1,43 @@ +/** + * Read side of the session record log: fetch a session's durable records so the runner can + * reconstruct prior conversation server-side (see `reconstruct.ts`). Mirrors `persist.ts`'s + * ingest client — same apiBase + run-credential auth, project scope resolved server-side. + */ + +import { apiBase } from "../apiBase.ts"; +import type { SessionRecordRow } from "./reconstruct.ts"; + +function log(msg: string): void { + process.stderr.write(`[sessions/records-query] ${msg}\n`); +} + +/** + * Fetch a session's durable record log, ordered for reconstruction (the endpoint returns records + * by ingest time, then per-turn `record_index`). Returns `null` on failure so the caller can fall + * back to the inbound history rather than run with an empty context. + */ +export async function fetchSessionRecords( + sessionId: string, + auth: () => string, +): Promise { + const url = `${apiBase()}/sessions/records/query`; + try { + const res = await fetch(url, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: auth(), + }, + body: JSON.stringify({ session_id: sessionId }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = (await res.json()) as { records?: SessionRecordRow[] }; + return Array.isArray(body?.records) ? body.records : []; + } catch (err) { + const detail = String( + err instanceof Error ? err.message : err, + ).slice(0, 120); + log(`query FAILED session=${sessionId}: ${detail}`); + return null; + } +} diff --git a/services/runner/tests/unit/session-persist.test.ts b/services/runner/tests/unit/session-persist.test.ts index 449f021b90..2def0261fd 100644 --- a/services/runner/tests/unit/session-persist.test.ts +++ b/services/runner/tests/unit/session-persist.test.ts @@ -25,12 +25,13 @@ vi.stubGlobal("fetch", async (_url: string, init?: RequestInit) => { return new Response(JSON.stringify({ ok: true }), { status: 200 }); }); -const { buildPersistingEmitter, drainPersist } = +const { buildPersistingEmitter, drainPersist, takePersistFailures } = await import("../../src/sessions/persist.ts"); beforeEach(() => { postedBodies.length = 0; fetchFailCount = 0; + vi.unstubAllEnvs(); }); describe("buildPersistingEmitter", () => { @@ -466,6 +467,45 @@ describe("buildPersistingEmitter API contract", () => { }); }); +describe("durable records (AGENTA_RECORDS_DURABLE)", () => { + it("legacy (flag off): a permanent failure is dropped and NOT counted", async () => { + fetchFailCount = 99; // never succeeds + const { emit, flush } = buildPersistingEmitter("sess-legacy", () => "t"); + emit({ type: "message", text: "x" }); + await flush(); + + assert.equal(postedBodies.length, 0); // dropped + assert.equal(takePersistFailures("sess-legacy"), 0); // counting is off + }); + + it("durable: a permanent failure is dropped AND counted for the session", async () => { + vi.stubEnv("AGENTA_RECORDS_DURABLE", "true"); + vi.stubEnv("AGENTA_RECORDS_INGEST_MAX_RETRIES", "2"); // keep the test fast + fetchFailCount = 99; + const { emit, flush } = buildPersistingEmitter("sess-durable", () => "t"); + emit({ type: "message", text: "x" }); + emit({ type: "done" }); + await flush(); + + assert.equal(postedBodies.length, 0); // both records dropped + assert.equal(takePersistFailures("sess-durable"), 2); + // take clears the count. + assert.equal(takePersistFailures("sess-durable"), 0); + }); + + it("durable: a transient failure recovers within the retry budget (no drop counted)", async () => { + vi.stubEnv("AGENTA_RECORDS_DURABLE", "true"); + vi.stubEnv("AGENTA_RECORDS_INGEST_MAX_RETRIES", "5"); + fetchFailCount = 2; // fails twice, then the 3rd attempt lands + const { emit, flush } = buildPersistingEmitter("sess-recover", () => "t"); + emit({ type: "message", text: "x" }); + await flush(); + + assert.equal(postedBodies.length, 1); // landed + assert.equal(takePersistFailures("sess-recover"), 0); + }); +}); + describe("drainPersist", () => { it("resolves immediately when no events are pending", async () => { await assert.doesNotReject(() => drainPersist("no-session")); diff --git a/services/runner/tests/unit/session-reconstruct-history.test.ts b/services/runner/tests/unit/session-reconstruct-history.test.ts new file mode 100644 index 0000000000..08c877037e --- /dev/null +++ b/services/runner/tests/unit/session-reconstruct-history.test.ts @@ -0,0 +1,91 @@ +/** + * Unit tests for the reconstruction seam (reconstruct-history.ts). + * + * The safety contract: a strict no-op until BOTH the flag is on AND the client sent a minimal + * history. Anything else leaves the inbound history untouched (returns null). + */ +import { describe, it, beforeEach, vi } from "vitest"; +import assert from "node:assert/strict"; + +let fetchCalls = 0; +let recordsToReturn: unknown[] = []; +let fetchShouldFail = false; + +vi.stubGlobal("fetch", async () => { + fetchCalls++; + if (fetchShouldFail) return new Response("err", { status: 500 }); + return new Response(JSON.stringify({ records: recordsToReturn }), { status: 200 }); +}); + +const { reconstructHistoryIfNeeded } = await import( + "../../src/engines/sandbox_agent/reconstruct-history.ts" +); + +const auth = () => "Secret t"; +const userTurn = { role: "user", content: "hi again" }; + +beforeEach(() => { + fetchCalls = 0; + recordsToReturn = []; + fetchShouldFail = false; + vi.unstubAllEnvs(); +}); + +describe("reconstructHistoryIfNeeded", () => { + it("no-op when the flag is off (never even queries)", async () => { + const req = { messages: [userTurn] } as never; + const out = await reconstructHistoryIfNeeded(req, "sess-1", auth); + assert.equal(out, null); + assert.equal(fetchCalls, 0); + }); + + it("no-op when the client already sent a full history", async () => { + vi.stubEnv("AGENTA_SESSIONS_RECONSTRUCT", "true"); + const req = { messages: [userTurn, userTurn] } as never; // length > 1 + const out = await reconstructHistoryIfNeeded(req, "sess-1", auth); + assert.equal(out, null); + assert.equal(fetchCalls, 0); + }); + + it("no-op when there is no session id", async () => { + vi.stubEnv("AGENTA_SESSIONS_RECONSTRUCT", "true"); + const req = { messages: [userTurn] } as never; + const out = await reconstructHistoryIfNeeded(req, undefined, auth); + assert.equal(out, null); + assert.equal(fetchCalls, 0); + }); + + it("no-op when the record log is empty", async () => { + vi.stubEnv("AGENTA_SESSIONS_RECONSTRUCT", "true"); + recordsToReturn = []; + const req = { messages: [userTurn] } as never; + const out = await reconstructHistoryIfNeeded(req, "sess-1", auth); + assert.equal(out, null); + }); + + it("no-op (falls back) when the records fetch fails", async () => { + vi.stubEnv("AGENTA_SESSIONS_RECONSTRUCT", "true"); + fetchShouldFail = true; + const req = { messages: [userTurn] } as never; + const out = await reconstructHistoryIfNeeded(req, "sess-1", auth); + assert.equal(out, null); + }); + + it("prepends reconstructed prior turns to the inbound message when enabled", async () => { + vi.stubEnv("AGENTA_SESSIONS_RECONSTRUCT", "true"); + recordsToReturn = [ + { record_source: "user", attributes: { type: "message", text: "q1" } }, + { record_source: "agent", attributes: { type: "message", text: "a1" } }, + ]; + const req = { messages: [userTurn], harness: "pi" } as never; + const out = await reconstructHistoryIfNeeded(req, "sess-1", auth); + assert.ok(out); + assert.deepEqual(out!.messages, [ + { role: "user", content: "q1" }, + { role: "assistant", content: "a1" }, + userTurn, + ]); + // Other request fields are preserved. + assert.equal((out as { harness?: string }).harness, "pi"); + }); +}); diff --git a/services/runner/tests/unit/session-reconstruct.test.ts b/services/runner/tests/unit/session-reconstruct.test.ts new file mode 100644 index 0000000000..570bdd8e96 --- /dev/null +++ b/services/runner/tests/unit/session-reconstruct.test.ts @@ -0,0 +1,130 @@ +/** + * Unit tests for server-side history reconstruction (sessions/reconstruct.ts). + * + * Verifies the record-log fold produces the same ChatMessage/ContentBlock shape the vercel + * adapter emits, so buildTurnText / priorMessages / the responder binding consume it unchanged. + */ +import { describe, it } from "vitest"; +import assert from "node:assert/strict"; + +import type { ContentBlock } from "../../src/protocol.ts"; +import { reconstructMessages } from "../../src/sessions/reconstruct.ts"; +import type { SessionRecordRow } from "../../src/sessions/reconstruct.ts"; + +function rec( + source: "user" | "agent", + attributes: unknown, + extra: Partial = {}, +): SessionRecordRow { + return { record_source: source, attributes, ...extra }; +} + +describe("reconstructMessages", () => { + it("folds a simple user→assistant text exchange", () => { + const out = reconstructMessages([ + rec("user", { type: "message", text: "hi" }), + rec("agent", { type: "message", text: "hello there" }), + ]); + assert.deepEqual(out, [ + { role: "user", content: "hi" }, + { role: "assistant", content: "hello there" }, + ]); + }); + + it("keeps turns in order across multiple exchanges", () => { + const out = reconstructMessages([ + rec("user", { type: "message", text: "q1" }), + rec("agent", { type: "message", text: "a1" }), + rec("user", { type: "message", text: "q2" }), + rec("agent", { type: "message", text: "a2" }), + ]); + assert.deepEqual( + out.map((m) => [m.role, m.content]), + [ + ["user", "q1"], + ["assistant", "a1"], + ["user", "q2"], + ["assistant", "a2"], + ], + ); + }); + + it("pairs a tool_call with its tool_result and carries the tool name forward", () => { + const out = reconstructMessages([ + rec("user", { type: "message", text: "search" }), + rec("agent", { type: "message", text: "let me look" }), + rec("agent", { type: "tool_call", id: "c1", name: "web_search", input: { q: "x" } }), + rec("agent", { type: "tool_result", id: "c1", output: "found" }), + rec("agent", { type: "message", text: "done" }), + ]); + assert.equal(out.length, 2); + const assistant = out[1]; + assert.equal(assistant.role, "assistant"); + assert.ok(Array.isArray(assistant.content)); + const blocks = assistant.content as ContentBlock[]; + assert.deepEqual( + blocks.map((b) => b.type), + ["text", "tool_call", "tool_result", "text"], + ); + // The call block carries id + name + input for the responder's coldReplay binding. + assert.equal(blocks[1].toolCallId, "c1"); + assert.equal(blocks[1].toolName, "web_search"); + assert.deepEqual(blocks[1].input, { q: "x" }); + // The result block inherits the tool name from its matching call. + assert.equal(blocks[2].toolCallId, "c1"); + assert.equal(blocks[2].toolName, "web_search"); + assert.equal(blocks[2].output, "found"); + }); + + it("keeps a still-parked tool_call (no result yet) so a later HITL answer can bind", () => { + const out = reconstructMessages([ + rec("user", { type: "message", text: "delete it" }), + rec("agent", { type: "tool_call", id: "gate1", name: "delete_file", input: { p: "/x" } }), + // gate paused — no tool_result recorded for gate1 + ]); + const blocks = out[1].content as ContentBlock[]; + assert.equal(blocks.length, 1); + assert.equal(blocks[0].type, "tool_call"); + assert.equal(blocks[0].toolCallId, "gate1"); + }); + + it("drops reasoning / usage / done / interaction lifecycle events", () => { + const out = reconstructMessages([ + rec("user", { type: "message", text: "hi" }), + rec("agent", { type: "thought", text: "thinking..." }), + rec("agent", { type: "message", text: "answer" }), + rec("agent", { type: "usage", input: 10, output: 5 }), + rec("agent", { type: "interaction_request", id: "i1", kind: "user_approval" }), + rec("agent", { type: "done", stopReason: "end_turn" }), + ]); + assert.deepEqual(out, [ + { role: "user", content: "hi" }, + { role: "assistant", content: "answer" }, + ]); + }); + + it("renders an error event as assistant text", () => { + const out = reconstructMessages([ + rec("user", { type: "message", text: "go" }), + rec("agent", { type: "error", message: "boom" }), + ]); + assert.deepEqual(out[1], { role: "assistant", content: "[error: boom]" }); + }); + + it("ignores malformed / typeless attribute rows", () => { + const out = reconstructMessages([ + rec("user", { type: "message", text: "hi" }), + rec("agent", null), + rec("agent", { noType: true }), + rec("agent", { type: "message", text: "ok" }), + ]); + assert.deepEqual(out, [ + { role: "user", content: "hi" }, + { role: "assistant", content: "ok" }, + ]); + }); + + it("returns an empty history for no records", () => { + assert.deepEqual(reconstructMessages([]), []); + }); +}); diff --git a/web/packages/agenta-playground/src/state/execution/agentRequest.ts b/web/packages/agenta-playground/src/state/execution/agentRequest.ts index 67d0e31ae1..a4600e9642 100644 --- a/web/packages/agenta-playground/src/state/execution/agentRequest.ts +++ b/web/packages/agenta-playground/src/state/execution/agentRequest.ts @@ -30,6 +30,7 @@ import { workflowMolecule, type AgentTemplate, } from "@agenta/entities/workflow" +import {isSessionsLastMessageOnlyEnabled} from "@agenta/shared/api" import {projectIdAtom} from "@agenta/shared/state" import {getDefaultStore} from "jotai" @@ -400,13 +401,24 @@ export async function buildAgentRequest( // Strip answer-less assistant turns so a "no response" turn can't poison the next request. const history = messages.filter(hasAnswer) + // Last-message-only (flag-gated; MUST pair with the backend's AGENTA_SESSIONS_RECONSTRUCT). + // On a fresh user turn, send just the trailing user message and let the runner rebuild prior + // turns from the durable record log — smaller request + trace payloads. A HITL resume, whose + // trailing turn carries the settled answer (not a user turn), keeps the full history so the + // answer still binds to its tool call. + const lastMessage = history[history.length - 1] as {role?: unknown} | undefined + const outboundMessages = + isSessionsLastMessageOnlyEnabled() && opts.sessionId && lastMessage?.role === "user" + ? [lastMessage] + : history + return { invocationUrl: url, headers, requestBody: { session_id: opts.sessionId, references, - data: {inputs: {messages: history}, parameters}, + data: {inputs: {messages: outboundMessages}, parameters}, }, } } diff --git a/web/packages/agenta-playground/tests/unit/agentRequest.test.ts b/web/packages/agenta-playground/tests/unit/agentRequest.test.ts index 596975e814..a717ed7a20 100644 --- a/web/packages/agenta-playground/tests/unit/agentRequest.test.ts +++ b/web/packages/agenta-playground/tests/unit/agentRequest.test.ts @@ -13,7 +13,7 @@ * headers + project come from the real `executionHeadersAtom` / `projectIdAtom`. */ import {createStore, type PrimitiveAtom} from "jotai" -import {describe, expect, it, beforeEach, vi} from "vitest" +import {describe, expect, it, beforeEach, afterEach, vi} from "vitest" vi.mock("@agenta/entities/workflow", async (importOriginal) => { const actual = (await importOriginal()) as any @@ -177,6 +177,48 @@ describe("buildAgentRequest", () => { expect(await buildAgentRequest("e", [], {sessionId: "s1", store})).toBeNull() }) + describe("last-message-only (NEXT_PUBLIC_SESSIONS_LAST_MESSAGE_ONLY)", () => { + const u1 = {role: "user", parts: [{type: "text", text: "q1"}]} + const a1 = {role: "assistant", parts: [{type: "text", text: "a1"}]} + const u2 = {role: "user", parts: [{type: "text", text: "q2"}]} + + // Runtime override path (getEnv checks globalThis.__env before the build-time snapshot). + const enableFlag = () => { + ;(globalThis as any).__env = {NEXT_PUBLIC_SESSIONS_LAST_MESSAGE_ONLY: "true"} + } + afterEach(() => { + delete (globalThis as any).__env + }) + + const outMessages = async (msgs: unknown[], sessionId = "s1") => { + seed(store, "e", {}) + const req = await buildAgentRequest("e", msgs, {sessionId, store}) + return (req!.requestBody.data as any).inputs.messages + } + + it("sends only the trailing user message when enabled", async () => { + enableFlag() + expect(await outMessages([u1, a1, u2])).toEqual([u2]) + }) + + it("sends the full history by default (flag off)", async () => { + const out = await outMessages([u1, a1, u2]) + expect(out).toEqual([u1, a1, u2]) + }) + + it("keeps the full history on a resume (trailing assistant) even when enabled", async () => { + enableFlag() + const out = await outMessages([u1, a1]) + expect(out.length).toBeGreaterThan(1) + expect(out[out.length - 1].role).toBe("assistant") + }) + + it("sends the full history when there is no session id", async () => { + enableFlag() + expect(await outMessages([u1, a1, u2], "")).toEqual([u1, a1, u2]) + }) + }) + it("nests messages under data.inputs + draft-aware parameters under data, with session_id", async () => { seed(store, "e", {config: {temperature: 0.9, prompt: {x: 1}}}) const req = await buildAgentRequest("e", [{role: "user"}], {sessionId: "s1", store}) diff --git a/web/packages/agenta-shared/src/api/env.ts b/web/packages/agenta-shared/src/api/env.ts index 2690758dec..2114853b70 100644 --- a/web/packages/agenta-shared/src/api/env.ts +++ b/web/packages/agenta-shared/src/api/env.ts @@ -39,6 +39,7 @@ export const processEnv = { NEXT_PUBLIC_AGENTA_SANDBOX_LOCAL_ENABLED: process.env.NEXT_PUBLIC_AGENTA_SANDBOX_LOCAL_ENABLED, NEXT_PUBLIC_AGENTA_ENABLED_SANDBOX_PROVIDERS: process.env.NEXT_PUBLIC_AGENTA_ENABLED_SANDBOX_PROVIDERS, + NEXT_PUBLIC_SESSIONS_LAST_MESSAGE_ONLY: process.env.NEXT_PUBLIC_SESSIONS_LAST_MESSAGE_ONLY, } /** @@ -84,6 +85,17 @@ export const isSandboxLocalEnabled = (): boolean => { return SANDBOX_LOCAL_TRUTHY.has(raw.trim().toLowerCase()) } +/** + * Send only the trailing user message per agent turn and let the runner rebuild prior history + * from the durable record log. Default OFF — enable ONLY where the backend runs with + * `AGENTA_SESSIONS_RECONSTRUCT=true` (they must be flipped together), or a cold turn loses its + * context. + */ +export const isSessionsLastMessageOnlyEnabled = (): boolean => { + const raw = getEnv("NEXT_PUBLIC_SESSIONS_LAST_MESSAGE_ONLY") || "false" + return SANDBOX_LOCAL_TRUTHY.has(raw.trim().toLowerCase()) +} + /** The sandbox providers this deployment enabled, normalized to lowercase ids. Unset/empty * falls back to `["local"]` so the picker never hides every option. */ export const getEnabledSandboxProviders = (): string[] => { diff --git a/web/packages/agenta-shared/src/api/index.ts b/web/packages/agenta-shared/src/api/index.ts index ea8aff5e0a..2f39eb8531 100644 --- a/web/packages/agenta-shared/src/api/index.ts +++ b/web/packages/agenta-shared/src/api/index.ts @@ -7,6 +7,7 @@ export { getAgentaApiUrl, getAgentaWebUrl, isSandboxLocalEnabled, + isSessionsLastMessageOnlyEnabled, getEnabledSandboxProviders, processEnv, } from "./env"