diff --git a/sdk/typescript/src/types.ts b/sdk/typescript/src/types.ts index 7402dfb7..21e685ae 100644 --- a/sdk/typescript/src/types.ts +++ b/sdk/typescript/src/types.ts @@ -96,8 +96,7 @@ export interface AttachmentInfo { filename: string; mime_type: string; size_bytes: number; - /** "image", "text", "pdf", or "audio". */ - kind: string; + kind: "image" | "text" | "pdf" | "audio"; } export type UploadAttachmentResponse = AttachmentInfo; diff --git a/turnstone/core/pdf.py b/turnstone/core/pdf.py index 6664ae25..55ef018f 100644 --- a/turnstone/core/pdf.py +++ b/turnstone/core/pdf.py @@ -54,7 +54,10 @@ def extract_pdf_text(data: bytes) -> str: textpage.close() page.close() text = "\n\n".join(p.strip() for p in parts if p.strip()) - if truncated: + # Only annotate truncation when there's actual text — otherwise a scanned + # (no text layer) PDF over the page cap would return just the marker, i.e. + # a content-free document part. Empty stays empty → caller placeholders it. + if truncated and text: text += f"\n\n[PDF truncated at {_MAX_PAGES} pages]" return text except Exception as exc: diff --git a/turnstone/core/perception.py b/turnstone/core/perception.py index a70dc645..991800eb 100644 --- a/turnstone/core/perception.py +++ b/turnstone/core/perception.py @@ -50,10 +50,6 @@ _DESCRIBE_PROMPT = ( ) -class PerceptionUnavailableError(RuntimeError): - """No usable perception backend is configured/resolvable (maps to a placeholder).""" - - class PerceptionBackendError(RuntimeError): """A configured perception backend failed during the perceive call.""" diff --git a/turnstone/core/providers/_anthropic.py b/turnstone/core/providers/_anthropic.py index 81537094..50044070 100644 --- a/turnstone/core/providers/_anthropic.py +++ b/turnstone/core/providers/_anthropic.py @@ -692,9 +692,10 @@ class AnthropicProvider: converted.append(block) continue if part.get("type") == "input_audio": - # Anthropic has no audio-input API. Phase 3 transcribes via the - # STT role upstream of this translator; until then surface a - # placeholder rather than passing an unsupported block to the API. + # Anthropic has no audio-input API. The STT-role fallback runs + # upstream of this translator (audio → text transcript when a + # model lacks supports_audio_input), so by here any remaining + # input_audio is a defensive placeholder, not the live path. converted.append( {"type": "text", "text": "[audio attachment — not supported by this model]"} ) diff --git a/turnstone/core/providers/_openai_responses.py b/turnstone/core/providers/_openai_responses.py index 849418ce..2402eb74 100644 --- a/turnstone/core/providers/_openai_responses.py +++ b/turnstone/core/providers/_openai_responses.py @@ -84,9 +84,10 @@ def convert_content_parts(parts: list[Any]) -> list[dict[str, Any]]: } ) elif ptype == "input_audio": - # Audio-input is not wired on the Responses lane; until the Phase 3 - # capability-gated fallback (STT) lands upstream, surface a - # placeholder rather than leaking an unhandled part to the API. + # Audio-input is not wired on the Responses lane. The capability-gated + # fallback (STT / perception) runs upstream of this translator, so by + # here any remaining input_audio is a defensive placeholder rather than + # an unhandled part leaking to the API. converted.append( {"type": "input_text", "text": "[audio attachment — not supported by this model]"} ) diff --git a/turnstone/core/session.py b/turnstone/core/session.py index 9b87c7b5..a603dafe 100644 --- a/turnstone/core/session.py +++ b/turnstone/core/session.py @@ -3015,8 +3015,9 @@ class ChatSession: perceived = self._perception_fallback_part(att, "image") if perceived is not None: return perceived - # No perception backend configured: emit the native image_url - # unchanged (the model may ignore it) — pre-existing behavior. + # No usable perception backend (none configured, or it can't see): + # emit the native image_url unchanged — a no-vision model ignores it. + # Image is intentionally left ungated here (pre-existing behavior). if kind == "audio" and not caps.supports_audio_input: return self._audio_fallback_part(att) return attachment_to_content_part(att)