This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

replace rejected images with a placeholder telling the model its not its fault

+87
+23
src/runner/loop-completion.ts
··· 22 22 fallbackClient, 23 23 fallbackContextWindow, 24 24 findSummaryMessageIndex, 25 + isContentFilterError, 25 26 isPromptTooLargeError, 26 27 retryDelayMs, 27 28 sanitizeMessages, 29 + scrubImagesFromConversation, 28 30 shouldFallback, 29 31 summaryClient, 30 32 summarizeConversationViaLLM, ··· 574 576 baseConversation: OpenAI.Chat.ChatCompletionMessageParam[] = state.conversation, 575 577 ): Promise<CompletionTurnResult> { 576 578 let promptTooLargeAttempts = 0 579 + let contentFilterScrubbed = false 580 + 581 + const recoverFromContentFilter = (err: unknown, label: string): boolean => { 582 + if (contentFilterScrubbed) return false 583 + if (!isContentFilterError(err)) return false 584 + const scrubbed = scrubImagesFromConversation(state.conversation) 585 + contentFilterScrubbed = true 586 + if (scrubbed > 0) { 587 + console.warn( 588 + `[api] ${label} content-filter rejection; scrubbed ${scrubbed} image attachment(s) from conversation and retrying`, 589 + ) 590 + return true 591 + } 592 + console.warn(`[api] ${label} content-filter rejection but no images found to scrub`) 593 + return false 594 + } 595 + 577 596 while (true) { 578 597 if (baseConversation === state.conversation) { 579 598 state.conversation = sanitizeMessages(state.conversation) ··· 612 631 promptTooLargeAttempts++ 613 632 if (recovered) continue 614 633 } 634 + if (recoverFromContentFilter(fallbackErr, "fallback")) continue 615 635 if (shouldFallback(fallbackErr)) { 616 636 const retryAfter = retryDelayMs(fallbackErr) 617 637 console.warn( ··· 646 666 throw primaryErr 647 667 } 648 668 669 + if (recoverFromContentFilter(primaryErr, "primary")) continue 670 + 649 671 if (!shouldFallback(primaryErr)) { 650 672 logApiError(primaryErr, `model=${MODEL} api=${API_BASE}`) 651 673 throw primaryErr ··· 686 708 promptTooLargeAttempts++ 687 709 if (recovered) continue 688 710 } 711 + if (recoverFromContentFilter(fallbackErr, "fallback-failover")) continue 689 712 console.warn( 690 713 `[api] fallback failed (${errorSummary(fallbackErr)}) after primary failure (${errorSummary(primaryErr)}); retrying primary after backoff`, 691 714 )
+64
src/runner/util.ts
··· 698 698 return PROMPT_TOO_LARGE_PHRASES.some((phrase) => message.includes(phrase)) 699 699 } 700 700 701 + const CONTENT_FILTER_PHRASES = [ 702 + "potentially unsafe or sensitive content", 703 + "sensitive content in input or generation", 704 + "content filter", 705 + "content_filter", 706 + "may generate sensitive content", 707 + ] 708 + 709 + /** 710 + * Detects provider content-safety rejections (typically 400-class). 711 + * 712 + * These errors can stick across turns when the offending content lives in the 713 + * persisted conversation (e.g. a previously attached image); the caller is 714 + * expected to scrub the conversation before retrying. 715 + */ 716 + export function isContentFilterError(err: unknown): boolean { 717 + if (!(err instanceof OpenAI.APIError)) return false 718 + if (err.status !== 400) return false 719 + 720 + const errorRecord = err as unknown as { code?: unknown; error?: { code?: unknown; type?: unknown } } 721 + const rootCode = typeof errorRecord.code === "string" ? errorRecord.code.toLowerCase() : "" 722 + const innerCode = typeof errorRecord.error?.code === "string" ? (errorRecord.error.code as string).toLowerCase() : "" 723 + const innerType = typeof errorRecord.error?.type === "string" ? (errorRecord.error.type as string).toLowerCase() : "" 724 + if (rootCode === "content_filter" || innerCode === "content_filter" || innerType === "content_filter") return true 725 + 726 + const message = (err.message || "").toLowerCase() 727 + return CONTENT_FILTER_PHRASES.some((phrase) => message.includes(phrase)) 728 + } 729 + 730 + const SCRUBBED_IMAGE_PLACEHOLDER = "[the system has rejected this :( its not your fault]" 731 + 732 + /** 733 + * Replaces multimodal image parts in the conversation with a text placeholder. 734 + * 735 + * Used after a provider content-filter rejection so the offending image stops 736 + * being re-sent on every subsequent turn. 737 + * 738 + * @returns The number of image parts that were scrubbed. 739 + */ 740 + export function scrubImagesFromConversation(msgs: Message[]): number { 741 + let scrubbed = 0 742 + for (const msg of msgs) { 743 + const record = asRecord(msg) 744 + if (!record) continue 745 + const content = record.content 746 + if (!Array.isArray(content)) continue 747 + 748 + let changed = false 749 + const next: unknown[] = [] 750 + for (const part of content) { 751 + const partRecord = asRecord(part) 752 + if (partRecord && partRecord.type === "image_url") { 753 + next.push({ type: "text", text: SCRUBBED_IMAGE_PLACEHOLDER }) 754 + scrubbed++ 755 + changed = true 756 + continue 757 + } 758 + next.push(part) 759 + } 760 + if (changed) record.content = next 761 + } 762 + return scrubbed 763 + } 764 + 701 765 /** 702 766 * Produces a concise, log-friendly error summary. 703 767 *