Skip to content

fix: stage media bytes across turns and coalesce approval prompts#950

Merged
njbrake merged 2 commits intomainfrom
fix/media-bytes-staging-and-approval-coalesce
Apr 14, 2026
Merged

fix: stage media bytes across turns and coalesce approval prompts#950
njbrake merged 2 commits intomainfrom
fix/media-bytes-staging-and-approval-coalesce

Conversation

@njbrake
Copy link
Copy Markdown
Collaborator

@njbrake njbrake commented Apr 14, 2026

Description

Two connected bugs observed in a live BlueBubbles conversation where a photo was sent, the agent clarified "which client?", then asked for upload approval several turns later.

  1. Bytes lifecycle gap. After fix: gate auto-save media on upload_to_storage permission #948 moved file persistence out of the pipeline for upload_to_storage permission levels ask/deny, the agent depends on pending_media to carry byte content. But pending_media is scoped to the current inbound message; if the agent takes a clarifying turn first, the bytes are garbage-collected and the tool fails with "No file content available to upload".
  2. Approval prompt cascade. feat: delete individual messages and fix double-approval prompts #943 told the agent not to ask "want me to save this?" in chat first, but adherence was spotty. Combined with feat: sequential per-tool approval instead of batched all-or-nothing #937's sequential per-tool approval, chains/retries (upload_to_storage → fails → retry) produced 2-3 prompts for one user intent.

Type

  • Bug fix

Changes

  • backend/app/agent/media_staging.py (new) -- in-memory, per-user, TTL-bounded (1h) cache keyed by original_url. Purges on access.
  • router.py -- prepare_media and prepare_media_step stash downloaded bytes into staging regardless of permission level. Guarded with if ctx.user is not None to respect existing test fixtures.
  • file_tools.py
    • _file_factory merges staged bytes into pending_media so upload_to_storage works on turns that have no attachments of their own.
    • upload_to_storage, organize_file, and auto_save_media evict after success.
    • Both file tools set resource_extractor = client_name so the approval system can key by intent.
  • core.py -- per-run _approval_cache keyed by (tool_name, resource). Repeat ASK calls with the same resource reuse the first APPROVED decision instead of re-prompting. ALWAYS_ALLOW is still persisted to PERMISSIONS.json and bypasses the cache naturally.
  • instructions.md File uploads section -- explicitly forbids conversational pre-checks; tells the agent to ask one clarifying question OR call the tool, not both.

Tests

  • tests/test_media_staging.py (10 tests):
    • Staging lifecycle: stage, retrieve, evict, TTL purge, per-user isolation.
    • _file_factory cross-turn recovery: staged bytes appear in pending_media when current turn has no attachments.
    • Current-turn bytes win over stale staging when URLs collide.
    • Upload / auto-save evict the staged entry.
    • Approval cache coalescing: three chained fake_upload(client_name="David Graham") calls in one agent run produce one gate.request_approval call, not three.
  • Full suite: 1573 passed, 13 deselected.

Checklist

  • Tests pass (uv run pytest)
  • Lint passes (ruff check backend/ tests/ + ruff format --check)
  • Type check passes (ty check)
  • New tests added for new functionality
  • Bug fixes include regression tests

AI Usage

  • AI-assisted -- Claude Opus 4.6 (1M context) diagnosed both bugs from production logs, designed the staging cache and approval coalescing, wrote the fix and regression tests.

🤖 Generated with Claude Code

njbrake and others added 2 commits April 14, 2026 14:17
…ompts

After #948 moved file persistence to the agent loop for the ask/deny
permission paths, upload_to_storage could fail with "no file content
available" whenever the agent gathered conversational context before
calling the tool -- because pending_media was scoped to the current
inbound message, and any prior turn's bytes were gone by the time the
tool actually fired.

Separately, chained or retried ASK tool calls prompted the user
multiple times for what was semantically one intent (save this photo
for David Graham), even though #943 told the agent not to pre-check
conversationally.

- Add backend/app/agent/media_staging.py: in-memory, per-user,
  TTL-bounded cache keyed by original_url. Populated at download time
  regardless of permission level; evicted on successful upload /
  organize / auto-save.
- _file_factory merges staged bytes into pending_media so
  upload_to_storage works on turns that have no attachments.
- Add resource_extractor=client_name to upload_to_storage and
  organize_file so the approval system can key by intent.
- core.py: per-run _approval_cache keyed by (tool_name, resource).
  Repeat ASK calls with the same resource reuse the first APPROVED
  decision instead of prompting again.
- instructions.md File uploads: forbid conversational pre-checks
  explicitly; tell the agent to ask one question OR call the tool,
  not both.
- tests/test_media_staging.py: 10 tests covering staging lifecycle,
  cross-turn recovery, cache eviction, and approval coalescing (three
  chained fake_upload calls -> one prompt).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Review follow-ups on #950:

- Drop the unused logger import from media_staging; nothing was being
  logged from this module.
- Add get_mime_type() accessor and use it in upload_to_storage so the
  authoritative mime type from the download layer overrides whatever
  the LLM guessed in the tool arguments (prevents PDFs from being saved
  with .jpg extensions when the agent forgets to pass mime_type).
- Refresh the upload_to_storage tool description so it reflects the
  staging cache -- "only works with media in the current message" was
  no longer accurate.
- Regression tests for get_mime_type and the staged-mime-over-argument
  precedence.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@njbrake njbrake merged commit edc556e into main Apr 14, 2026
8 checks passed
@njbrake njbrake deleted the fix/media-bytes-staging-and-approval-coalesce branch April 14, 2026 14:23
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