Skip to content

feat(images): enable multi-turn conversations about image context parts#2604

Closed
mike-inkeep wants to merge 3 commits intomainfrom
images/hydrate_image_history
Closed

feat(images): enable multi-turn conversations about image context parts#2604
mike-inkeep wants to merge 3 commits intomainfrom
images/hydrate_image_history

Conversation

@mike-inkeep
Copy link
Copy Markdown
Contributor

No description provided.

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 10, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agents-api Ready Ready Preview, Comment Mar 11, 2026 0:18am
agents-docs Ready Ready Preview, Comment Mar 11, 2026 0:18am
agents-manage-ui Ready Ready Preview, Comment Mar 11, 2026 0:18am

Request Review

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 10, 2026

⚠️ No Changeset found

Latest commit: 651d0bd

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog bot commented Mar 10, 2026

Enables multi-turn image conversations by hydrating blob-stored images back into data URLs when replaying conversation history, so models that support images can "see" previously uploaded images across turns.

Key changes:

  • New createBlobToDataUrlHydrator factory with per-request cache for downloading and converting blob URIs to base64 data URLs (blob-to-data-url.ts, blob-storage/index.ts)
  • AgentRunContext gains an optional hydrateBlobToDataUrl field, initialized in Agent.ts at run start
  • buildConversationHistory now returns structured MessageSelect[] alongside the string form — messages with successfully hydrated image parts are split out as conversationHistoryWithFileData (conversation-history.ts)
  • buildInitialMessages accepts the hydrated messages and injects them as separate user image messages before the current turn (conversation-history.ts)
  • getConversationHistoryWithCompression returns MessageSelect[] instead of a pre-formatted string; formatting is deferred to the caller (conversations.ts)
  • formatMessagesAsConversationHistory exported and updated to skip messages with no reconstructable text, and reconstructMessageText now handles kind-based parts alongside type-based parts
  • Type tightening: any[] replaced with MessageSelect[] across getScopedHistory, applyContextWindowManagement, and getConversationHistory (conversations.ts, agents-core/conversations.ts)
  • Both function signatures (buildConversationHistory, buildInitialMessages) migrated from positional args to named-object params
  • Tests updated to match the new MessageSelect[] return type and use hoisted mocks with importOriginal for partial mocking

Pullfrog  | View workflow run | Using Claude Code | Triggered by Pullfrogpullfrog.com𝕏

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The approach of splitting hydrated image messages from text-only messages and sending them as separate ModelMessage[] entries is sound. There are a few issues that should be addressed before merge — a leftover console.log, an ordering bug, and dropped text context for hydrated messages.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow runpullfrog.com𝕏

conversationHistory = nonHydrated;
}

console.log('conversationHistoryWithFileData', conversationHistoryWithFileData);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leftover console.log — must be removed before merge. Use logger.debug if debug output is still needed.

Comment on lines +25 to +57
await Promise.all(
messages.map(async (msg) => {
const content = msg.content;
if (!content?.parts?.length) {
nonHydrated.push(msg);
return;
}

let hasHydratedParts = false;
const parts = await Promise.all(
content.parts.map(async (part: NonNullable<MessageSelect['content']['parts']>[number]) => {
if (part.kind !== 'file' || typeof part.data !== 'string' || !isBlobUri(part.data)) {
return part;
}
const mimeType =
typeof part.metadata?.mimeType === 'string' ? part.metadata.mimeType : undefined;
const dataUrl = await hydrate(part.data, mimeType);
if (dataUrl) {
hasHydratedParts = true;
return { ...part, data: dataUrl };
}
return part;
})
);

const processedMsg = { ...msg, content: { ...content, parts } };
if (hasHydratedParts) {
hydrated.push(processedMsg);
} else {
nonHydrated.push(processedMsg);
}
})
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ordering bug. Promise.all runs all callbacks concurrently and the push calls into hydrated/nonHydrated happen whenever each message's inner Promise.all (for parts) resolves — not in the original array order. If downstream code or the model relies on chronological ordering of image messages, this will silently reorder them.

Consider mapping to Promise<{ index, msg, wasHydrated }>, awaiting all, then partitioning by index order.

Comment on lines +177 to +195
if (conversationHistoryWithFileData?.length) {
conversationHistoryWithFileData.forEach((msg) => {
const content = (msg.content?.parts ?? []).flatMap((part) => {
if (part.kind !== 'file' || typeof part.data !== 'string') {
return [];
}

return [
{
type: 'image' as const,
image: part.data,
},
];
});

if (content?.length && content.length > 0) {
messages.push({ role: 'user', content });
}
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Text context is dropped. Hydrated messages only extract file parts — all text parts from the same message are discarded via flatMap. The model will receive image content with no accompanying text from that original message (e.g. a user's description of the image). Consider also including text parts so the model can associate each image with its context.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mike-inkeep Is this true?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was true in the original code but has been fixed in the "Review feedback" commit. Looking at the current buildInitialMessages (line ~189), the reduce now handles both text and file parts — text parts are included as { type: 'text', text: part.text } alongside the image parts. So text context is preserved in the hydrated messages now.

Pullfrog  | View workflow run | Using Claude Code | Triggered by Pullfrogpullfrog.com𝕏

Comment on lines +137 to +139
const conversationHistoryString = formatMessagesAsConversationHistory(conversationHistory);

const conversationHistoryTokens = estimateTokens(conversationHistoryString);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Token estimation gap: conversationHistoryTokens is computed only from conversationHistoryString. The hydrated image messages — potentially large base64 data URLs — are excluded from the context breakdown entirely. Context window management won't account for image data.

Comment on lines +18 to +60
async function hydrateConversationHistoryBlobParts(
messages: MessageSelect[],
hydrate: HydrateBlobToDataUrl
): Promise<{ hydrated: MessageSelect[]; nonHydrated: MessageSelect[] }> {
const hydrated: MessageSelect[] = [];
const nonHydrated: MessageSelect[] = [];

await Promise.all(
messages.map(async (msg) => {
const content = msg.content;
if (!content?.parts?.length) {
nonHydrated.push(msg);
return;
}

let hasHydratedParts = false;
const parts = await Promise.all(
content.parts.map(async (part: NonNullable<MessageSelect['content']['parts']>[number]) => {
if (part.kind !== 'file' || typeof part.data !== 'string' || !isBlobUri(part.data)) {
return part;
}
const mimeType =
typeof part.metadata?.mimeType === 'string' ? part.metadata.mimeType : undefined;
const dataUrl = await hydrate(part.data, mimeType);
if (dataUrl) {
hasHydratedParts = true;
return { ...part, data: dataUrl };
}
return part;
})
);

const processedMsg = { ...msg, content: { ...content, parts } };
if (hasHydratedParts) {
hydrated.push(processedMsg);
} else {
nonHydrated.push(processedMsg);
}
})
);

return { hydrated, nonHydrated };
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No test coverage. hydrateConversationHistoryBlobParts is the core of this feature but has no dedicated tests. Key scenarios to cover: successful blob hydration, messages with mixed blob/non-blob parts, failed hydration (hydrate() returns null), and ordering preservation.

Comment on lines +250 to +251
const summaryMessage: MessageSelect = {
...messageHistory[0],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...messageHistory[0] spreads the entire MessageSelect (including content, metadata, conversationId, taskId, etc.) into the summary, then overwrites only a few fields. This leaks unrelated data from the first message into the summary. Consider explicitly constructing only the required fields.

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review Summary

(5) Total Issues | Risk: High

🔴❗ Critical (1) ❗🔴

Inline Comments:

  • 🔴 Critical: conversation-history.ts:135 Debug console.log left in production code — outputs potentially sensitive image/message data to stdout

🟠⚠️ Major (4) 🟠⚠️

🟠 1) blob-to-data-url.ts Missing test coverage for new critical functionality

Issue: The new createBlobToDataUrlHydrator function (lines 11-38) has zero unit test coverage. This function handles blob storage downloads, base64 encoding, MIME type resolution, caching, and error handling — all critical paths for the image replay feature.

Why: Without tests: (1) A regression in base64 encoding could cause model API failures. (2) The cache could fail silently, causing repeated downloads that degrade performance. (3) Error handling caches null but behavior isn't verified. (4) MIME type fallback chain isn't tested.

Fix: Add a dedicated test file agents-api/src/__tests__/run/services/blob-storage/blob-to-data-url.test.ts with tests for:

  • Successful blob-to-data-url conversion
  • Cache hit behavior on repeated calls
  • MIME type precedence (downloaded.contentType → fallbackMimeType → 'application/octet-stream')
  • Error handling when storage.download fails
  • Logger.warn called on failure

Refs:

Inline Comments:

  • 🟠 Major: conversation-history.ts:25-57 Race condition causes non-deterministic message ordering in hydration
  • 🟠 Major: blob-to-data-url.ts:15 In-memory cache without size limits risks memory exhaustion
  • 🟠 Major: blob-to-data-url.ts:29-36 Silent failure pattern may cause confusing model behavior

🟡 Minor (0) 🟡

No minor issues.

💭 Consider (3) 💭

💭 1) conversation-history.ts:18-60 Add tests for hydrateConversationHistoryBlobParts

Issue: The new hydrateConversationHistoryBlobParts function has complex async logic with multiple edge cases that aren't covered by existing tests.
Why: The function has 4+ conditional branches and mixed parts handling that could regress silently.
Fix: Consider adding tests for: messages without parts, non-blob URIs, hydrator returning null, messages with mixed blob/non-blob parts.

💭 2) conversation-history.ts:25 Add concurrency limit for parallel blob downloads

Issue: Promise.all downloads all blob parts in parallel without a concurrency limit.
Why: For conversations with many images (e.g., 20+), this fires concurrent requests that could exhaust connection pools or trigger rate limiting.
Fix: Consider using p-limit with 5-10 concurrent downloads: import pLimit from 'p-limit'; const limit = pLimit(5);

💭 3) blob-to-data-url.ts:22-28 Add blob size validation before base64 encoding

Issue: Downloaded blob data is converted to base64 without checking size. A large blob (50MB) would require ~67MB heap for the base64 string.
Why: Edge case, but could cause memory spikes on unusually large images.
Fix: Add size check: if (downloaded.data.length > MAX_SIZE) { logger.warn(...); return null; }


🚫 REQUEST CHANGES

Summary: This PR adds valuable functionality for multi-turn image conversations, but has a critical debug statement that must be removed before merge. The race condition in message ordering is also a significant concern that could cause non-deterministic behavior. Additionally, the new blob hydration code path lacks test coverage for a feature that's critical to the user experience.

The architecture is sound — the separation of hydrated vs non-hydrated messages, the caching strategy, and the factory pattern all follow good patterns. Once the critical/major issues are addressed, this should be ready to ship.

Discarded (5)
Location Issue Reason Discarded
types.ts Function signature mismatch Assessed as working correctly — index.ts wrapper provides the zero-arg signature
conversation-history.ts:179-194 Silent filtering of non-file parts Intentional design — image-only replay is the feature goal
local-provider.ts:109-125 No timeout on local file reads Dev-only concern, out of PR scope
Agent.ts:114 Hydrator cache not cleared on cleanup Agent is cleaned up per-request, cache released via GC
conversations.ts:163-164 Unsafe as unknown as any cast Pre-existing pattern for legacy metadata format, not introduced by this PR
Reviewers (6)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
pr-review-standards 2 0 0 0 2 0 0
pr-review-tests 4 1 1 0 0 0 2
pr-review-types 6 0 0 0 0 0 6
pr-review-errors 3 0 0 0 1 0 2
pr-review-consistency 6 0 0 0 1 0 5
pr-review-sre 6 0 2 0 2 0 2
Total 27 1 3 0 6 0 17

Note: High discard rate due to significant overlap — 5 reviewers independently flagged the console.log issue, merged into single Critical finding.

conversationHistory = nonHydrated;
}

console.log('conversationHistoryWithFileData', conversationHistoryWithFileData);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 CRITICAL: Debug console.log left in production code

Issue: This console.log will output potentially large base64-encoded image data and message content to stdout on every request with images.

Why: This creates (1) excessive log volume and storage costs, (2) potential PII/data exposure of user-uploaded images in logs, (3) performance overhead from serialization. All 5 reviewers flagged this.

Fix: (1-click apply)

Suggested change
console.log('conversationHistoryWithFileData', conversationHistoryWithFileData);

Refs:

Comment on lines +25 to +57
await Promise.all(
messages.map(async (msg) => {
const content = msg.content;
if (!content?.parts?.length) {
nonHydrated.push(msg);
return;
}

let hasHydratedParts = false;
const parts = await Promise.all(
content.parts.map(async (part: NonNullable<MessageSelect['content']['parts']>[number]) => {
if (part.kind !== 'file' || typeof part.data !== 'string' || !isBlobUri(part.data)) {
return part;
}
const mimeType =
typeof part.metadata?.mimeType === 'string' ? part.metadata.mimeType : undefined;
const dataUrl = await hydrate(part.data, mimeType);
if (dataUrl) {
hasHydratedParts = true;
return { ...part, data: dataUrl };
}
return part;
})
);

const processedMsg = { ...msg, content: { ...content, parts } };
if (hasHydratedParts) {
hydrated.push(processedMsg);
} else {
nonHydrated.push(processedMsg);
}
})
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR: Race condition causes non-deterministic message ordering

Issue: Promise.all processes messages concurrently, but results are pushed to shared arrays (hydrated, nonHydrated) inside async callbacks. This causes messages to appear in arbitrary order based on which async operations complete first.

Why: Conversation history order is semantically important. Downstream code like buildInitialMessages will receive messages in random order, potentially confusing the model about conversation flow.

Fix: Use Promise.all to return indexed results, then partition while preserving order:

async function hydrateConversationHistoryBlobParts(
  messages: MessageSelect[],
  hydrate: HydrateBlobToDataUrl
): Promise<{ hydrated: MessageSelect[]; nonHydrated: MessageSelect[] }> {
  const results = await Promise.all(
    messages.map(async (msg, index) => {
      const content = msg.content;
      if (!content?.parts?.length) {
        return { index, msg, wasHydrated: false };
      }

      let hasHydratedParts = false;
      const parts = await Promise.all(
        content.parts.map(async (part) => {
          if (part.kind !== 'file' || typeof part.data !== 'string' || !isBlobUri(part.data)) {
            return part;
          }
          const mimeType = typeof part.metadata?.mimeType === 'string' ? part.metadata.mimeType : undefined;
          const dataUrl = await hydrate(part.data, mimeType);
          if (dataUrl) {
            hasHydratedParts = true;
            return { ...part, data: dataUrl };
          }
          return part;
        })
      );

      return { 
        index, 
        msg: { ...msg, content: { ...content, parts } }, 
        wasHydrated: hasHydratedParts 
      };
    })
  );

  // Sort by original index to preserve order
  results.sort((a, b) => a.index - b.index);

  const hydrated: MessageSelect[] = [];
  const nonHydrated: MessageSelect[] = [];
  for (const { msg, wasHydrated } of results) {
    (wasHydrated ? hydrated : nonHydrated).push(msg);
  }

  return { hydrated, nonHydrated };
}

Refs:

  • MDN Promise.all — "The returned promise fulfills when all of the input's promises fulfill... with an array of the fulfillment values, in the order of the promises passed"

storage: BlobStorageProvider,
fromBlobUri: (uri: string) => string
): HydrateBlobToDataUrl {
const cache = new Map<string, string | null>();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR: In-memory cache without size limits risks memory exhaustion

Issue: The Map<string, string | null> cache has no eviction policy. Each cached data URL stores the full base64-encoded image (~33% larger than original binary). In conversations with many images, this cache grows unbounded.

Why: While the hydrator is created per-Agent and Agents are cleaned up per-request, during a single multi-turn conversation with many images, memory can spike significantly. A conversation with 10 images at 5MB each = ~65MB of cached strings just for this map.

Fix: Consider one of these approaches:

  1. LRU cache with size limit (recommended):
import { LRUCache } from 'lru-cache';

const cache = new LRUCache<string, string | null>({
  max: 50, // max entries
  maxSize: 50 * 1024 * 1024, // 50MB total
  sizeCalculation: (value) => value?.length ?? 1,
});
  1. Simple size limit:
const MAX_CACHE_SIZE_BYTES = 50 * 1024 * 1024;
let cacheSize = 0;

// Before caching:
if (cacheSize + dataUrl.length > MAX_CACHE_SIZE_BYTES) {
  cache.clear();
  cacheSize = 0;
}
cache.set(blobUri, dataUrl);
cacheSize += dataUrl.length;

Refs:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A size limit also seems like a valid point to make, we dont want people to abuse it? Maybe we could have an env for max image bytes per agent session?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An env-based max is a reasonable approach. A few thoughts on the design:

  1. Scope: A per-session byte limit (total cached data URLs) is simpler and more predictable than per-image limits. Something like INKEEP_MAX_IMAGE_CACHE_BYTES with a sensible default (e.g. 50MB) would cover most cases without config burden.

  2. Eviction vs. rejection: When the limit is hit, the simplest approach is to stop caching new entries (still hydrate, just don't cache) rather than evicting old ones. An LRU adds complexity that may not be warranted given the per-request lifecycle.

  3. Alternatively: Since the cache lives only for a single request (Agent is per-request), the practical risk is limited to conversations with many large images in a single turn. A simple entry count cap (e.g. max 20 cached images) might be sufficient and avoids the overhead of tracking byte sizes.

Pullfrog  | View workflow run | Using Claude Code | Triggered by Pullfrogpullfrog.com𝕏

Comment on lines +29 to +36
} catch (error) {
logger.warn(
{ error, blobUri },
'Failed to hydrate blob URI for multimodal conversation history replay'
);
cache.set(blobUri, null);
return null;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR: Silent failure pattern may cause confusing model behavior

Issue: The catch block handles ALL errors identically — caches null and returns null. This means the original blob:// URI remains in the message, which the model will receive as an invalid image URL.

Why: When hydration fails, the model receives a message with image: "blob://..." instead of actual image data. This could cause: (1) model API errors, (2) model confusion trying to interpret the string, (3) silent degradation where users don't understand why the model "can't see" previous images.

Fix: Consider differentiating error types and improving visibility:

} catch (error) {
  const isNotFound = error instanceof Error && 
    (error.message.includes('NoSuchKey') || 
     error.message.includes('404') ||
     error.message.includes('not found'));
  
  if (isNotFound) {
    logger.info({ blobUri }, 'Blob no longer exists, excluding from history');
  } else {
    logger.error({ error, blobUri }, 'Failed to hydrate blob - check storage configuration');
  }
  cache.set(blobUri, null);
  return null;
}

Also consider: instead of keeping the unhydrated part in the message, filter it out entirely so the model doesn't receive invalid URIs.

Refs:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dont think the model could do much with failed hydration since this is a purely backend operation. Maybe we should have some replacement for it, but dont think model needs to know how it failed. More that an image was supposed to be there?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — the model doesn't need to know why hydration failed, just that an image was expected there. A good pattern would be to replace the failed part with a text placeholder like [Image unavailable] so the model understands there was supposed to be visual context without receiving an invalid blob:// URI. This is cleaner than the current approach where null is returned and the part is kept with its original blob URI (which the model can't interpret).

In buildInitialMessages, parts where part.data is still a blob URI after hydration could be mapped to { type: 'text', text: '[Image unavailable]' } instead.

Pullfrog  | View workflow run | Using Claude Code | Triggered by Pullfrogpullfrog.com𝕏

@github-actions github-actions bot deleted a comment from claude bot Mar 10, 2026
Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review Summary

(5) Total Issues | Risk: Medium

🔴❗ Critical (0) ❗🔴

None — the console.log issue from the prior review was fixed in the "Review feedback" commit.

🟠⚠️ Major (2) 🟠⚠️

Inline Comments:

  • 🟠 Major: conversation-history.ts:30 Unbounded parallel blob downloads may exhaust connections
  • 🟠 Major: conversations.ts:430 Return type change breaks established API contract

🟡 Minor (1) 🟡

Inline Comments:

  • 🟡 Minor: conversation-history.ts:195-199 Hardcoded assumption that all file parts are images

💭 Consider (0) 💭

Items from prior review either addressed or carried forward as Pending Recommendations.

🕐 Pending Recommendations (3)

Prior issues still unresolved:


💡 APPROVE WITH SUGGESTIONS

Summary: The "Review feedback" commit addressed the 4 most critical issues from the prior review:

  • ✅ Removed debug console.log
  • ✅ Fixed race condition in message ordering (changed from push-based to map-then-filter)
  • ✅ Fixed token estimation to include hydrated file data
  • ✅ Fixed text context being dropped from hydrated messages (now includes both text and file parts)

The architecture is sound and the PR is ready for merge with the understanding that test coverage and concurrency limiting are follow-up items. The new inline comments highlight:

  1. Concurrency limit for blob downloads (matches existing upload pattern)
  2. API naming consideration for the return type change
  3. Type safety for non-image file types (low risk given feature scope)
Discarded (8)
Location Issue Reason Discarded
conversations.ts:163-165 Unsafe type assertion for compressionType Pre-existing pattern for backward compatibility, not introduced by this PR
conversations.ts:272-275 getScopedHistory returns empty on error Pre-existing behavior, out of PR scope
Agent.ts:114 Hydrator cache not cleared on cleanup Agent is cleaned up per-request, cache released via GC
conversation-history.ts:21-59 Partition invariant not typed Logic is correct even if type doesn't encode invariant; medium confidence
blob-to-data-url.ts:6-9 Hydration result type ambiguity Pragmatic tradeoff for the feature scope; low confidence
conversation-history.ts Function signature inconsistency Mixed pattern is acceptable; generation-layer vs data-layer distinction
conversations.ts:916 formatMessages export visibility Informational only - appropriate API expansion
core/conversations.ts:251-275 Inline message construction Low confidence - minor code clarity issue
Reviewers (6)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
pr-review-standards 0 0 0 0 0 0 0
pr-review-consistency 5 0 0 0 1 0 4
pr-review-tests 4 0 0 0 0 3 1
pr-review-sre 3 0 0 0 1 0 2
pr-review-errors 4 0 0 0 0 0 4
pr-review-types 5 0 0 0 1 0 4
Total 21 0 0 0 3 3 15

Note: High discard rate due to (1) issues already raised in prior review routed to Pending Recommendations, (2) pre-existing patterns not introduced by this PR, (3) low-confidence findings filtered out.

}

let hasHydratedParts = false;
const parts = await Promise.all(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR: Unbounded parallel blob downloads may exhaust connections

Issue: The nested Promise.all at lines 22-49 downloads all blob URIs concurrently without any concurrency limit. With up to 50 messages in conversation history, each potentially containing multiple image parts, this could trigger dozens of concurrent downloads.

Why: The upload path uses FILE_UPLOAD_CONCURRENCY = 3 (image-upload.ts:13) for good reason — to avoid connection pool exhaustion and rate limiting. Under load, unbounded downloads could:

  • Exhaust connection pools to S3/Vercel Blob storage
  • Trigger rate limiting (especially Vercel Blob with per-second limits)
  • Create memory pressure from buffering many large images simultaneously

Fix: Apply the same worker pool pattern used in image-upload.ts with 3-5 concurrent downloads. Consider using p-limit:

import pLimit from 'p-limit';
const limit = pLimit(3);

const parts = await Promise.all(
  content.parts.map((part) => limit(async () => {
    // existing hydration logic
  }))
);

Refs:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think bounding by a concurrency measure is valid

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Using p-limit with a concurrency of 3–5 (matching the FILE_UPLOAD_CONCURRENCY = 3 pattern in image-upload.ts) would be the cleanest approach. The inner Promise.all over parts within a single message is fine unbounded (a message typically has 1–3 parts), but the outer Promise.all over all messages should be bounded since conversation history can have dozens of messages with images.

Pullfrog  | View workflow run | Using Claude Code | Triggered by Pullfrogpullfrog.com𝕏

streamRequestId?: string;
fullContextSize?: number;
}): Promise<string> {
}): Promise<MessageSelect[]> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR: Return type change breaks established API contract

Issue: getConversationHistoryWithCompression changed from Promise<string> to Promise<MessageSelect[]>. This is a breaking change to an internal API that requires callers to now call formatMessagesAsConversationHistory() separately.

Why: The sibling function getFormattedConversationHistory (lines 319-401) returns Promise<string>. The naming convention get*ConversationHistory* suggested a string return type. This creates a split-world pattern where some history functions return strings and others return message arrays.

Fix: Consider one of:

  1. Rename to getConversationMessagesWithCompression to indicate it returns messages not a formatted string
  2. Document this as an intentional API evolution in a code comment
  3. Consider whether getFormattedConversationHistory should follow the same pattern for consistency

Refs:

Comment on lines +195 to +199
if (part.kind === 'file' && typeof part.data === 'string') {
// Temporarily hard code to image. Must update when we add other file types.
acc.push({ type: 'image', image: part.data });
return acc;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor: Hardcoded assumption that all file parts are images

Issue: The comment acknowledges this ("Temporarily hard code to image. Must update when we add other file types."), but the type system allows any kind: 'file' part to reach this code path, including PDFs or other binary data.

Why: If a non-image file part is stored in conversation history, it will be sent to the model as an image, which will likely cause API errors or undefined behavior.

Fix: Add a type guard for image MIME types:

Suggested change
if (part.kind === 'file' && typeof part.data === 'string') {
// Temporarily hard code to image. Must update when we add other file types.
acc.push({ type: 'image', image: part.data });
return acc;
}
if (part.kind === 'file' && typeof part.data === 'string') {
const mimeType = part.metadata?.mimeType;
if (typeof mimeType === 'string' && mimeType.startsWith('image/')) {
acc.push({ type: 'image', image: part.data });
}
return acc;
}

Refs:

  • PR title — Feature is specifically for "image context parts"

@github-actions github-actions bot deleted a comment from claude bot Mar 10, 2026
@itoqa
Copy link
Copy Markdown

itoqa bot commented Mar 10, 2026

Ito Test Report ❌

8 test cases ran. 7 passed, 1 failed.

The run confirms stable behavior for the validated resilience and adversarial checks, and code review identifies one likely real authorization/isolation defect in conversation scoping. Findings are based on runtime evidence cross-checked against production code paths.

✅ Passed (7)
Test Case Summary Timestamp Screenshot
LOGIC-4 Both large-payload and follow-up calls completed with HTTP 200 and valid JSON response body in local bypass mode. 24:30 LOGIC-4_24-30.png
ADV-1 Three parallel image requests and a follow-up on C_ADV1 completed with HTTP 200 and no service instability. 27:50 ADV-1_27-50.png
ADV-2 Malformed payloads were handled safely with validation rejection/sanitized response and no server crash. 27:50 ADV-2_27-50.png
ADV-3 Injection attempt did not return system prompt, API keys, or hidden metadata. 27:50 ADV-3_27-50.png
ADV-5 Spoofed blob URI inputs did not produce unauthorized retrieval and the API remained stable. 27:50 ADV-5_27-50.png
RES-1 Parallel SSE requests completed with stable stream chunks and [DONE] markers, with no stream crash observed during tab duplication conditions. 22:18 RES-1_22-18.png
RES-2 Six sequential calls succeeded with HTTP 200 and stable low latency across follow-ups in local bypass mode. 24:30 RES-2_24-30.png
❌ Failed (1)
Test Case Summary Timestamp Screenshot
ADV-4 Conversation continuation with a different bearer token succeeded (HTTP 200) instead of being denied or isolated. 27:50 ADV-4_27-50.png
Cross-project conversationId isolation – Failed
  • Where: Run API conversation creation/retrieval path for /run/api/chat and shared conversation storage.

  • Steps to reproduce: 1) Start a conversation with key A and fixed conversationId; 2) send a follow-up using key B from a different project scope but same conversationId; 3) observe that request is accepted.

  • What failed: Cross-project continuation is accepted with 200 instead of being hard-rejected for an existing conversation identifier from another project scope.

  • Code analysis: The route always calls conversation create/get before execution. In the core data layer, createOrGetConversation checks existing records by tenantId + id (missing projectId) and updates by id only, which can cross project boundaries and violates strict project scoping expectations.

  • Relevant code:

    packages/agents-core/src/data-access/runtime/conversations.ts (lines 167-170)

    if (input.id) {
      const existing = await db.query.conversations.findFirst({
        where: and(eq(conversations.tenantId, input.tenantId), eq(conversations.id, input.id)),
      });
    }

    packages/agents-core/src/data-access/runtime/conversations.ts (lines 174-180)

    await db
      .update(conversations)
      .set({
        activeSubAgentId: input.activeSubAgentId,
        updatedAt: new Date().toISOString(),
      })
      .where(eq(conversations.id, input.id));

    agents-api/src/domains/run/routes/chat.ts (lines 246-253)

    await createOrGetConversation(runDbClient)({
      tenantId,
      projectId,
      id: conversationId,
      agentId: agentId,
      activeSubAgentId: defaultSubAgentId,
      ref: executionContext.resolvedRef,
    });
  • Why this is likely a bug: Conversation lookup/update is not fully scoped by project in production code, creating a plausible cross-project authorization boundary break for shared conversationId values.

  • Introduced by this PR: No - pre-existing bug (code not changed in this PR).

  • Timestamp: 27:50

📋 View Recording

Screen Recording

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review Summary

(0) New Issues | Risk: Low | Delta: Merge from main only

Delta Analysis

Since the last review (acb773c2e9e7), the only change is a merge commit from main:

Check Status
PR implementation files changed ✅ None — all 10 files unchanged
Merge conflicts ✅ None
Breaking changes from main ✅ None affecting this feature
Test coverage added ❌ Still missing (see Pending Recommendations)

The merge brought in the App Credentials feature (#2501) and security dependency bumps, but none of these affect the image hydration implementation.

🕐 Pending Recommendations (3)

These issues from the prior review remain unaddressed:

Additionally from the second review:


💡 APPROVE WITH SUGGESTIONS

Summary: No new issues introduced. The merge from main was clean with no conflicts affecting the image hydration feature. The prior review's verdict of APPROVE WITH SUGGESTIONS remains valid.

Ready to merge with the understanding that the pending recommendations (test coverage, concurrency limiting, cache bounds) can be addressed in follow-up work. The core functionality is sound and addresses a real user need for multi-turn image conversations.

Reviewers (0)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
None dispatched 0 0 0 0 0 5 0
Total 0 0 0 0 0 5 0

Note: Delta review — no reviewers dispatched as PR files are unchanged since the last review.

Copy link
Copy Markdown
Contributor

@tim-inkeep tim-inkeep left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have some comments that want your thoughts on, also wanted to check which part is the next step for you from this?

storage: BlobStorageProvider,
fromBlobUri: (uri: string) => string
): HydrateBlobToDataUrl {
const cache = new Map<string, string | null>();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A size limit also seems like a valid point to make, we dont want people to abuse it? Maybe we could have an env for max image bytes per agent session?

Comment on lines +29 to +36
} catch (error) {
logger.warn(
{ error, blobUri },
'Failed to hydrate blob URI for multimodal conversation history replay'
);
cache.set(blobUri, null);
return null;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dont think the model could do much with failed hydration since this is a purely backend operation. Maybe we should have some replacement for it, but dont think model needs to know how it failed. More that an image was supposed to be there?

fallbackMimeType?: string
) => Promise<string | null>;

export function createBlobToDataUrlHydrator(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we add an explicit garbage collector for this as well as all other things in AgentContext like this?

destroy() { // Explicitly clean up resources this.ctx.mcpManager?.destroy?.(); this.ctx.contextResolver = undefined; this.ctx.credentialStuffer = undefined; this.ctx.credentialStoreRegistry = undefined; this.ctx.hydrateBlobToDataUrl = undefined; // Clear the hydrator & its cache this.ctx.functionToolRelationshipIdByName.clear(); this.ctx = null; }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea. Right now the Agent is created per-request and the hydrator + its cache are released by GC when the Agent is collected, so there's no leak in the current architecture. But adding explicit cleanup in a destroy() method is a defensive best practice — it makes the lifecycle explicit, prevents surprises if the Agent lifecycle ever changes, and helps with debugging memory. The snippet you outlined looks right. Setting ctx.hydrateBlobToDataUrl = undefined would release the closure and its captured Map.

Pullfrog  | View workflow run | Using Claude Code | Triggered by Pullfrogpullfrog.com𝕏


// Remove current message if it matches the last message (same as legacy)
let messagesToFormat = conversationHistory;
if (currentMessage && conversationHistory.length > 0) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thank you for simplifying this!

currentCompressor: MidGenerationCompressor | null;
functionToolRelationshipIdByName: Map<string, string>;
taskDenialRedirects: Array<{ toolName: string; toolCallId: string; reason: string }>;
/** When set, conversation history image parts with blob URIs are hydrated to data URLs (e.g. when the model supports images). */
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we probably dont need the comment here

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — the comment restates what the type name already conveys. Safe to remove.

Pullfrog  | View workflow run | Using Claude Code | Triggered by Pullfrogpullfrog.com𝕏

currentCompressor: MidGenerationCompressor | null;
functionToolRelationshipIdByName: Map<string, string>;
taskDenialRedirects: Array<{ toolName: string; toolCallId: string; reason: string }>;
/** When set, conversation history image parts with blob URIs are hydrated to data URLs (e.g. when the model supports images). */
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also where is the check for models supporting images?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at the code, there isn't one — ctx.hydrateBlobToDataUrl is set unconditionally in Agent.ts:114 via createBlobToDataUrlHydrator(), so hydration runs for every agent regardless of whether the model supports images. Today that's probably fine if all models in use do support images, but if a text-only model is ever configured, it would receive data-URL image parts it can't process. Worth gating on a model capability check (or at least a config flag) before this ships more broadly.

Pullfrog  | View workflow run | Using Claude Code | Triggered by Pullfrogpullfrog.com𝕏

Comment on lines +177 to +195
if (conversationHistoryWithFileData?.length) {
conversationHistoryWithFileData.forEach((msg) => {
const content = (msg.content?.parts ?? []).flatMap((part) => {
if (part.kind !== 'file' || typeof part.data !== 'string') {
return [];
}

return [
{
type: 'image' as const,
image: part.data,
},
];
});

if (content?.length && content.length > 0) {
messages.push({ role: 'user', content });
}
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mike-inkeep Is this true?

@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog bot commented Mar 11, 2026

Responded to all 7 review comment threads from @tim-inkeep with thoughts and recommendations. Here's a summary:

Replies posted

Thread Response
agent-types.ts:239 — remove comment Agreed, it restates what the type name conveys
agent-types.ts:239 — model image support check No check exists — hydrateBlobToDataUrl is set unconditionally in Agent.ts:114. Worth gating on model capability before shipping broadly
conversation-history.ts:177-207 — text context dropped? Was true initially but fixed in the "Review feedback" commit — buildInitialMessages now includes both text and file parts
blob-to-data-url.ts:11 — explicit GC Good defensive practice. The destroy() snippet is the right approach
blob-to-data-url.ts:15 — cache size limit / env var Suggested per-session byte limit with sensible default, or simpler entry count cap given per-request lifecycle
blob-to-data-url.ts:29-36 — failed hydration handling Agreed with replacing failed parts with [Image unavailable] text placeholder instead of passing blob URIs to the model
conversation-history.ts:30 — concurrency limit p-limit with concurrency 3–5 on the outer Promise.all (matching FILE_UPLOAD_CONCURRENCY pattern)

Suggested next steps for the PR

Ordered by priority:

  1. Remove the comment on hydrateBlobToDataUrl in agent-types.ts
  2. Add concurrency limit (p-limit) to hydrateConversationHistoryBlobParts
  3. Replace failed hydration parts with [Image unavailable] text placeholder in buildInitialMessages
  4. Add model capability gate — only set ctx.hydrateBlobToDataUrl when the model supports images
  5. Add cache bounds — simple entry count cap or env-based byte limit
  6. Add explicit cleanup in Agent destroy() for the hydrator

Items 1–3 are straightforward changes for this PR. Items 4–6 could be follow-up work depending on scope preference.

Pullfrog  | View workflow run | Using Claude Code | Triggered by Pullfrogpullfrog.com𝕏

@mike-inkeep
Copy link
Copy Markdown
Contributor Author

Closing in favor of stack:
#2743
#2744
#2745

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.

3 participants