Skip to content

Compile-time image policy for text-only models — recipe-declared, store untouched #100

Description

@ajaniramon

Problem

An agent whose model has no image input support cannot run on a store that contains images. Compiles ship the live window's image blocks (and compression prompts ship the chunk's) to the provider, which rejects the whole request — every turn and every compression, deterministically, until the images age out of the window. There is no configuration surface that says "this model is text-only."

Field repro (2026-09-16, connectome-host main + #108, NanoGPT xiaomi/mimo-v2.5-pro:thinking): a resident with ~3 idle weeks — so images from an older era still sat in the recent window — hard-failed every activation and every hierarchical compression with 400 image_input_not_supported. The carrier-degrade retry path kicked in for one compression lane; nothing else had a fallback. The agent was unrunnable on that model while being perfectly runnable, with the same store, on a multimodal one.

This matters beyond one gateway: the entire local-model world this stack just gained access to (host #108 — Ollama, vLLM, llama.cpp servers) is full of text-only models.

Why not the obvious alternatives

  • React to the provider 400. Error taxonomies are per-provider and volatile (image_input_not_supported is NanoGPT's spelling, not an OpenAI-standard code), and this repo has already paid for string-matching provider errors once (membrane fix: MessageStore.get returns slot index as sequence — silently corrupts branchAt #17/fix(kv-control): boundary-cut groups are foldable — protected leaves exempt, not vetoes #48). Also retry-after-strip means the failing bytes were already sent — wasted call, and the "fix" is invisible in config.
  • A hardcoded capability list (the NO_TEMPERATURE_MODELS pattern in membrane). That precedent is for a sampling parameter; here the list would silently drop content, which collides with the strategy's own invariant that nothing is dropped silently (fatal coverage invariant; budget-stripped images become loud placeholders). The temperature list's own NB comment documents how one wrong entry silently discarded a valid parameter on the most-used model — a wrong entry here would silently blind a model that can see. Capability lists also cannot keep up with gateway catalogs (NanoGPT alone fronts hundreds of models).
  • Strip in membrane. Membrane's contract is explicit that its size check is "a true invariant, never a silent editor." Editing content belongs to the context compiler.

Proposed direction

An explicit, recipe-declared capability, enforced at compile time in context-manager, with the store untouched:

  1. context-manager: a strategy option — working name imagePolicy: 'live' | 'placeholders' (default 'live', today's behavior) — that renders every image block as the existing loud text placeholder, in the compiled live window and in compression/merge prompts. A new option rather than the existing knobs because those can't express "none": maxLiveImages: 0 means unlimited, and imageStripDepthTokens is depth-based, so an image in the newest message always leaks through.
  2. connectome-host (companion): agent.vision: false in the recipe (default true), validated at load, mapped to imagePolicy: 'placeholders'. Recipes fail loudly on typos, per the existing validation posture.
  3. The store keeps the images. Chronicle is model-agnostic and append-only; stripping is a render decision. Switching the same resident back to a multimodal model restores sight over the same memories — nothing was lost, it just wasn't rendered. This is also why the ingestion surfaces (e.g. discord-mcpl attachment inlining) need no changes: adapters shouldn't know what model is running.

Acceptance evidence

  • A store containing image blocks (live window and un-compressed chunks) compiles and compresses under imagePolicy: 'placeholders' with zero image blocks in any outbound request; placeholders are visibly marked, not empty.
  • An image arriving in the triggering message is placeholder-rendered in that same turn (the depth-based knob's leak case).
  • Round-trip: the same store compiled under 'live' again renders the original images (store untouched).
  • Existing image policies (maxLiveImages, imageStripDepthTokens, maxLiveImageBytes, maxCompressionImageBytes) are unchanged under 'live' and moot under 'placeholders'.
  • Host: vision: false maps through; vision with a non-boolean is rejected at recipe load.

Out of scope

  • Auto-detection of model capabilities (list- or error-driven) — deliberately, per above.
  • Sending stripped images to a vision sidecar for text description (the pattern some Discord deployments use); composes on top of this later, doesn't belong in the compiler.

Happy to send both PRs (CM option first, host companion after) if the direction lands.

🤖 Filed via Claude Code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions