Files
AutoGPT/autogpt_platform/backend/scripts/download_transcripts.py
Zamil Majdy 24d0c35ed3 fix(backend/copilot): prompt-too-long retry, compaction churn, model-aware compression, and truncated tool call recovery (#12625)
## Why

CoPilot has several context management issues that degrade long
sessions:
1. "Prompt is too long" errors crash the session instead of triggering
retry/compaction
2. Stale thinking blocks bloat transcripts, causing unnecessary
compaction every turn
3. Compression target is hardcoded regardless of model context window
size
4. Truncated tool calls (empty `{}` args from max_tokens) kill the
session instead of guiding the model to self-correct

## What

**Fix 1: Prompt-too-long retry bypass (SENTRY-1207)**
The SDK surfaces "prompt too long" via `AssistantMessage.error` and
`ResultMessage.result` — neither triggered the retry/compaction loop
(only Python exceptions did). Now both paths are intercepted and
re-raised.

**Fix 2: Strip stale thinking blocks before upload**
Thinking/redacted_thinking blocks in non-last assistant entries are
10-50K tokens each but only needed for API signature verification in the
*last* message. Stripping before upload reduces transcript size and
prevents per-turn compaction.

**Fix 3: Model-aware compression target**
`compress_context()` now computes `target_tokens` from the model's
context window (e.g. 140K for Opus 200K) instead of a hardcoded 120K
default. Larger models retain more history; smaller models compress more
aggressively.

**Fix 4: Self-correcting truncated tool calls**
When the model's response exceeds max_tokens, tool call inputs get
silently truncated to `{}`. Previously this tripped a circuit breaker
after 3 attempts. Now the MCP wrapper detects empty args and returns
guidance: "write in chunks with `cat >>`, pass via
`@@agptfile:filename`". The model can self-correct instead of the
session dying.

## How

- **service.py**: `_is_prompt_too_long` checks in both
`AssistantMessage.error` and `ResultMessage` error handlers. Circuit
breaker limit raised from 3→5.
- **transcript.py**: `strip_stale_thinking_blocks()` reverse-scans for
last assistant `message.id`, strips thinking blocks from all others.
Called in `upload_transcript()`.
- **prompt.py**: `get_compression_target(model)` computes
`context_window - 60K overhead`. `compress_context()` uses it when
`target_tokens` is None.
- **tool_adapter.py**: `_truncating` wrapper intercepts empty args on
tools with required params, returns actionable guidance instead of
failing.

## Related

- Fixes SENTRY-1207
- Sessions: `d2f7cba3` (repeated compaction), `08b807d4` (prompt too
long), `130d527c` (truncated tool calls)
- Extends #12413, consolidates #12626

## Test plan

- [x] 6 unit tests for `strip_stale_thinking_blocks`
- [x] 1 integration test for ResultMessage prompt-too-long → compaction
retry
- [x] Pyright clean (0 errors), all pre-commit hooks pass
- [ ] E2E: Load transcripts from affected sessions and verify behavior
2026-04-01 15:10:57 +00:00

298 lines
10 KiB
Python

#!/usr/bin/env python3
"""Download CoPilot transcripts from prod GCS and load into local dev environment.
Usage:
# Step 1: Download from prod GCS (needs MEDIA_GCS_BUCKET_NAME + gcloud auth)
MEDIA_GCS_BUCKET_NAME=<prod-bucket> USER_ID=<user-uuid> \
poetry run python scripts/download_transcripts.py download <session_id> ...
# Step 2: Load downloaded transcripts into local storage + DB
poetry run python scripts/download_transcripts.py load <session_id> ...
# Or do both in one step (if you have GCS access):
MEDIA_GCS_BUCKET_NAME=<prod-bucket> USER_ID=<user-uuid> \
poetry run python scripts/download_transcripts.py both <session_id> ...
The "download" step saves transcripts to transcripts/<session_id>.jsonl.
The "load" step reads those files and:
1. Creates a ChatSession in local DB (or reuses existing)
2. Populates messages from the transcript
3. Stores transcript in local workspace storage
4. Creates metadata so --resume works on the next turn
After "load", you can send a message to the session via the CoPilot UI
and it will use --resume with the loaded transcript.
"""
from __future__ import annotations
import asyncio
import json
import os
import re
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
_SAFE_RE = re.compile(r"[^0-9a-fA-F-]")
TRANSCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "transcripts")
def _sanitize(raw: str) -> str:
cleaned = _SAFE_RE.sub("", raw or "")[:36]
if not cleaned:
raise ValueError(f"Invalid ID: {raw!r}")
return cleaned
def _transcript_path(session_id: str) -> str:
return os.path.join(TRANSCRIPTS_DIR, f"{_sanitize(session_id)}.jsonl")
def _meta_path(session_id: str) -> str:
return os.path.join(TRANSCRIPTS_DIR, f"{_sanitize(session_id)}.meta.json")
# ── Download from GCS ─────────────────────────────────────────────────────
async def cmd_download(session_ids: list[str]) -> None:
"""Download transcripts from prod GCS to transcripts/ directory."""
from backend.copilot.sdk.transcript import download_transcript
user_id = os.environ.get("USER_ID", "")
if not user_id:
print("ERROR: Set USER_ID env var to the session owner's user ID.")
print(" You can find it in Sentry breadcrumbs or the DB.")
sys.exit(1)
bucket = os.environ.get("MEDIA_GCS_BUCKET_NAME", "")
if not bucket:
print("ERROR: Set MEDIA_GCS_BUCKET_NAME to the prod GCS bucket.")
sys.exit(1)
os.makedirs(TRANSCRIPTS_DIR, exist_ok=True)
print(f"Downloading from GCS bucket: {bucket}")
print(f"User ID: {user_id}\n")
for sid in session_ids:
print(f"[{sid[:12]}] Downloading...")
try:
dl = await download_transcript(user_id, sid)
except Exception as e:
print(f"[{sid[:12]}] Failed: {e}")
continue
if not dl or not dl.content:
print(f"[{sid[:12]}] Not found in GCS")
continue
out = _transcript_path(sid)
with open(out, "w") as f:
f.write(dl.content)
lines = len(dl.content.strip().split("\n"))
meta = {
"session_id": sid,
"user_id": user_id,
"message_count": dl.message_count,
"uploaded_at": dl.uploaded_at,
"transcript_bytes": len(dl.content),
"transcript_lines": lines,
}
with open(_meta_path(sid), "w") as f:
json.dump(meta, f, indent=2)
print(
f"[{sid[:12]}] Saved: {lines} entries, "
f"{len(dl.content)} bytes, msg_count={dl.message_count}"
)
print("\nDone. Run 'load' command to import into local dev environment.")
# ── Load into local dev ───────────────────────────────────────────────────
def _parse_messages_from_transcript(content: str) -> list[dict]:
"""Extract user/assistant messages from JSONL transcript for DB seeding."""
messages: list[dict] = []
for line in content.strip().split("\n"):
if not line.strip():
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(entry, dict):
continue
msg = entry.get("message", {})
role = msg.get("role")
if role not in ("user", "assistant"):
continue
content_blocks = msg.get("content", "")
if isinstance(content_blocks, list):
# Flatten content blocks to text
text_parts = []
for block in content_blocks:
if isinstance(block, dict):
if block.get("type") == "text":
text_parts.append(block.get("text", ""))
elif isinstance(block, str):
text_parts.append(block)
text = "\n".join(text_parts)
elif isinstance(content_blocks, str):
text = content_blocks
else:
text = ""
if text:
messages.append({"role": role, "content": text})
return messages
async def cmd_load(session_ids: list[str]) -> None:
"""Load downloaded transcripts into local workspace storage + DB."""
from backend.copilot.sdk.transcript import upload_transcript
# Use the user_id from meta file or env var
default_user_id = os.environ.get("USER_ID", "")
for sid in session_ids:
transcript_file = _transcript_path(sid)
meta_file = _meta_path(sid)
if not os.path.exists(transcript_file):
print(f"[{sid[:12]}] No transcript file at {transcript_file}")
print(" Run 'download' first, or place the file manually.")
continue
with open(transcript_file) as f:
content = f.read()
# Load meta if available
user_id = default_user_id
msg_count = 0
if os.path.exists(meta_file):
with open(meta_file) as f:
meta = json.load(f)
user_id = meta.get("user_id", user_id)
msg_count = meta.get("message_count", 0)
if not user_id:
print(f"[{sid[:12]}] No user_id — set USER_ID env var or download first")
continue
lines = len(content.strip().split("\n"))
print(f"[{sid[:12]}] Loading transcript: {lines} entries, {len(content)} bytes")
# Parse messages from transcript for DB
messages = _parse_messages_from_transcript(content)
if not msg_count:
msg_count = len(messages)
print(f"[{sid[:12]}] Parsed {len(messages)} messages for DB")
# Create chat session in DB
try:
from backend.copilot.db import create_chat_session, get_chat_session
existing = await get_chat_session(sid)
if existing:
print(f"[{sid[:12]}] Session already exists in DB, skipping creation")
else:
await create_chat_session(sid, user_id)
print(f"[{sid[:12]}] Created ChatSession in DB")
except Exception as e:
print(f"[{sid[:12]}] DB session creation failed: {e}")
print(" You may need to create it manually or run with DB access.")
# Add messages to DB
if messages:
try:
from backend.copilot.db import add_chat_messages_batch
msg_dicts = [
{"role": m["role"], "content": m["content"]} for m in messages
]
await add_chat_messages_batch(sid, msg_dicts, start_sequence=0)
print(f"[{sid[:12]}] Added {len(messages)} messages to DB")
except Exception as e:
print(f"[{sid[:12]}] Message insertion failed: {e}")
print(" (Session may already have messages)")
# Store transcript in local workspace storage
try:
await upload_transcript(
user_id=user_id,
session_id=sid,
content=content,
message_count=msg_count,
)
print(f"[{sid[:12]}] Stored transcript in local workspace storage")
except Exception as e:
print(f"[{sid[:12]}] Transcript storage failed: {e}")
# Also store directly to filesystem as fallback
try:
from backend.util.settings import Settings
settings = Settings()
storage_dir = settings.config.workspace_storage_dir or os.path.join(
os.path.expanduser("~"), ".autogpt", "workspaces"
)
ts_dir = os.path.join(storage_dir, "chat-transcripts", _sanitize(user_id))
os.makedirs(ts_dir, exist_ok=True)
ts_path = os.path.join(ts_dir, f"{_sanitize(sid)}.jsonl")
with open(ts_path, "w") as f:
f.write(content)
meta_storage = {
"message_count": msg_count,
"uploaded_at": time.time(),
}
meta_storage_path = os.path.join(ts_dir, f"{_sanitize(sid)}.meta.json")
with open(meta_storage_path, "w") as f:
json.dump(meta_storage, f)
print(f"[{sid[:12]}] Also wrote to: {ts_path}")
except Exception as e:
print(f"[{sid[:12]}] Direct file write failed: {e}")
print(f"[{sid[:12]}] Ready — send a message to this session to test")
print()
print("Done. Start the backend and send a message to the session(s).")
print("The CoPilot will use --resume with the loaded transcript.")
# ── Main ──────────────────────────────────────────────────────────────────
async def main() -> None:
if len(sys.argv) < 3:
print(__doc__)
sys.exit(1)
command = sys.argv[1]
session_ids = sys.argv[2:]
if command == "download":
await cmd_download(session_ids)
elif command == "load":
await cmd_load(session_ids)
elif command == "both":
await cmd_download(session_ids)
print("\n" + "=" * 60 + "\n")
await cmd_load(session_ids)
else:
print(f"Unknown command: {command}")
print("Usage: download | load | both")
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())