Codumentor logo Codumentor

Thinking Modes Plugin

The Thinking Modes plugin gives every conversation a thinking mode — one
named setting that decides which model answers, which model each background
role uses, and how hard the assistant reasons — plus a reasoning effort
rung the user can move within that mode.

Mode and effort belong to the conversation: switching one conversation never
changes another, and each user can set which mode their new conversations
start in.

Overview

Two modes ship out of the box and need no configuration, named after the two
ways a person works a problem — go and look, or sit and think it through:

ModeWhat it doesStarts at
⚡ FastAnswers from what it can see and look up, without stopping to reason it out first. The fastest setting.off
◉ SlowWorks the problem out before it acts. For design, subtle bugs and unfamiliar code.medium

The third entry every picker shows is Custom — the user's own per-role map,
which is the only place the combination of the two can be expressed (Slow for
the developer, Fast for the summariser). It is not declared here because it is
not a curated mode; see below.

Both built-ins name no model and the default stays Slow. Neither restricts
the dial: a mode decides where deliberation starts, and every rung the model
offers stays one click away in either. (They used to split the scale between
them, which on a model with a two-rung ladder left exactly one selectable value
per mode — see rung mapping.)

Slow starts at medium, and what that costs is the model's call. The mode
names an intent; the capability record prices it. On the shipped ardincoder-1
/ Qwen 3.8 record medium maps to low — measured indistinguishable from the
xhigh Slow used to send (−2.9 pts at p = 0.63) for 42% less per attempt and
40% less wall clock. On a model whose record maps nothing, medium is simply
the middle, and a deployment that wants deeper writes
thinking_modes.modes.slow.effort.default: high.

The set used to be Fast / Balanced / Deep. balanced named no rung, so it
ran at the model's own default — which on a model like Qwen 3.8 is its top
rung, making Balanced and Deep the same request. Three names for two
behaviours, with the middle one secretly the deepest. Both retired ids still
resolve to slow on every surface (/mode deep, a stored user default, an old
client), so nothing that used to work stops working. A deployer who declares
their own mode called deep owns that name — a declared mode always wins
over the alias.

Why the modes are named after
Kahneman's book, what we measured,
and the three places the analogy stops being true are on that page. The short
version worth having here: Fast is not a smaller assistant. It starts with
deliberation off, and off is where our own benchmark measured more tool
calls per solved task, not fewer — 70% fewer output tokens, a third of the wall
clock, completion unchanged on that tier. It substitutes looking things up for
reasoning them out. That is why Fast is a usable mode rather than a corner cut,
and why nothing in this plugin's copy grades a mode's intelligence.

And it is not free. That tier was saturated — seven tasks the model
already solves, where completion cannot fall. On tasks the model only partly
solves, the same setting cost most of the completion: 37/105 → 4/105 on one
screen, 45/65 → 26/65 on another, per task 5/20 → 0/20, 10/20 → 0/20, 12/20 →
2/20. So Fast trades completion on hard work for speed, deliberately, and a
deployer replacing these modes should keep that trade visible rather than
describe Fast as the free win. low — Fast's second notch — is the rung with
the flattering numbers (−14% cost per solved task, no detectable completion
difference), and which of the two Fast should start at is being measured:
evals/harness-opt/passes/11-fast-mode-bottom-rung/.

One mechanical caveat too: off is a rung a model may not have. Where the
capability record says control: always, Fast clamps up to the cheapest rung
that model does offer, and the picker says so.

A deployer can replace both with their own curated set (below) — for example a
Slow mode that also switches the main agent to a stronger endpoint while
leaving the summariser on a cheap one.

Reasoning effort — labelled Deliberation in the UI — is a closed
ordinal scale, off · minimal · low · medium · high · xhigh · max, and an
intent, not a token budget. max sits strictly above xhigh. What it moves
is how long the assistant reasons before each step — not how many steps it
takes, and not which model answers. Each rung's behaviour, which is the tooltip
on a selectable segment:

RungWhat it does
offNo deliberation — it acts, and looks up what it needs.
minimalA glance before it acts.
lowA brief check before it acts.
mediumThe model's own habit, steered neither way.
highMost of the thinking before the first move.
xhighThe whole problem worked out before the first move.
maxAs long as it takes before the first move.

The ordinal words stay on the segments, because a dial has to be ordered — but
read the scale as which instruction the model is given, not as a quantity of
intelligence. On a Qwen 3.8 template the injected instruction measures 41
tokens at low, 11 at medium and 53 at xhigh, constant to the token: the
real ladder is be brief / nothing / think exhaustively. **medium is the
model unsteered, not a midpoint**, and on our own benchmark it completed 22
points fewer tasks than either steered rung — worth knowing before making it a
mode's default.

What a rung means on the wire is decided per model by the reasoning: record
on its profile: levels are that model's own tokens and are sent as-is (see
Model Profiles and the models: section of the admin
configuration). The picker shows those tokens, not a global seven-rung scale.
A rung a model cannot honour is never sent: it is clamped to the nearest one
the model supports, so the endpoint never sees a value it would reject.

Two rules are worth knowing because they are easy to be surprised by:

Choosing a mode

In Settings → Models

Settings → Models is where a user decides what their conversations run on
when they are not choosing per conversation. From top to bottom:

  1. Default for new conversations — a model (the composer's list, grouped by connection and badged by where it runs) and an effort dial for that model's ladder. It says whether this is the organization's default or the user's own choice, and Use the organization's default hands it back. Nothing here changes a conversation you already have — that is the composer's job, and one conversation never moves another.
  2. Connections — when the Model Profiles plugin is loaded, its <x-model-profiles-connections> element: the organization's connections with a per-user "show in picker" toggle, your own ones, and Add connection. Model Profiles does not list its own Extensions row then.
  3. Background work — Helpers (Same as the chat, the default, or a model) and Housekeeping (Automatic, the default, Same as the chat, or a model). Automatic says what it resolves to for the default chat model ("With Sol, Automatic is Luna on Company OpenAI"). A chosen model says under which chat connections it applies: Settings accepts any choice, and the privacy rule (one line under the two) skips it for a chat on a more private connection.
  4. Who does what — read-only, for the default chat model: each role's model, connection, rung and why (tier default, admin setting, your earlier setting, moved: more private, kept on the chat model: context too small). Per-role assignments migrated from Model Profiles are listed with a Clear button; the administrator's per-role pins are listed read-only with where they are configured.

There are no mode cards, no Custom and no role-map editor on the web any more
(thinking-modes-ux-rethink P5); the terminal UI's pane still has them until
P6.

Models is a root Settings item, not an Extensions row. This plugin declares
a settings_pane (id: models, titled Models / Modellek), one of the few
plugins that qualifies for one — see
Settings panes. The pane sits right after
Shared in the sidebar, Extensions lists no Thinking Modes row, and an old link
to that row (openSettings("extensions", { pluginId: "thinking_modes" }))
lands on the pane. The composer's "Manage models…" opens it too. In the
terminal UI it is the Models entry of the Settings sidebar, shown when
Model Profiles is loaded.

These API routes back that page, none of them conversation-scoped:

GET /ui/plugins/thinking_modes/settings
    → { default: { model, effort, resolved_effort, source, model_title,
                   model_short, connection },
        user_default: { model, model_explicit, effort, … } | null,
        models: [ …the grouped picker list, see below… ],
        edition,
        background: { …GET /background… },
        connections: { available, element },
        modes, default_mode, builtin, roles, role_maps }   (the TUI's, until P6)

PUT /ui/plugins/thinking_modes/user-default
    { "model": "company-openai/gpt-5.6-sol", "effort": "high" }
    (or the legacy { "mode": "slow" })        → the same payload, updated

GET /ui/plugins/thinking_modes/background
    → { helpers, housekeeping, defaults, effective, deployment_ref,
        applies: { helpers, housekeeping },
        chat, automatic, roles: [ …who does what… ],
        pins: [ … ], admin_pins: [ … ], admin_default }
PUT /ui/plugins/thinking_modes/background
    { "helpers": "same_as_chat", "housekeeping": "automatic" }
DELETE /ui/plugins/thinking_modes/pins           (all legacy pins)
DELETE /ui/plugins/thinking_modes/pins/{role}    (one; 404 when none)

Background work choices are same_as_chat, a model reference (@deployment
for the deployment model), or — for housekeeping only — automatic; null
means "not chosen", i.e. the deployment's model_selection.background.
applies[tier] is null for a choice that names no model, else {model,
model_title, available, connection, everywhere, under, not_under}
— the chat
connections (those the picker may list a model of) the chosen model applies
under, and the more private ones it does not. roles is one row per role for
the model new conversations start on: {role, tier, model, model_id,
model_title, connection, rung, why, why_text, refused}
. pins are the
per-role assignments migrated from Model Profiles — clearable;
admin_pins are models.subagent_profiles and a declared mode's roles
(origin: models.subagent_profiles, thinking_modes.modes.<mode>), and
admin_default is models.subagent_default — both read-only here.

connections.available is true only when Model Profiles is loaded, enabled,
and exposes user_model_overrides. The UI mounts
<x-model-profiles-connections> only then, once, and re-reads
GET /settings on its codumentor:model-profiles-changed event.

The PUT /user-default replaces the default, so an empty body ({}) clears
it; a body with only effort keeps the model inherited (model_explicit:
false
). An unknown model or a rung off the scale is a 400, one of the user's
own connections the policy withholds a 403 with the reason; a rung the model
cannot honour is not refused (it clamps, as everywhere else). role_maps is
keyed by mode id, each row {role, profile, model, source, editable} — read by
the terminal UI only.

In a conversation

Model and effort belong to the conversation, so they are changed with a patch
on it:

PATCH /ui/plugins/thinking_modes/conversations/{conversation_id}/thinking
{ "model": "company-openai/gpt-5.6-sol", "effort": "high", "set_as_user_default": false }

Every field is optional and an omitted one is left alone. model is a model
reference (<connection>/<model>, or a profile name); model: null is the
deployment model. effort: null is the model's default effort. Switching model
without an effort carries the current rung over, or restores the one last
used for that model in this conversation. The legacy mode is still accepted
and translated (fast → effort off on the conversation's model, slow → the
default effort, a declared mode → its model). set_as_user_default: true also
makes the selection the starting point for your new conversations — kept for
API callers; the web composer no longer offers it (Settings → Models owns the
default).

The response is the same object the conversation snapshot carries under
thinking, plus what changed:

{
  "selection": { "mode": "slow", "model": "company-openai/gpt-5.6-sol",
                 "effort": "high", "source": "conversation" },
  "model": "company-openai/gpt-5.6-sol",
  "model_title": "GPT-5.6 Sol", "model_short": "Sol",
  "connection": { "id": "company-openai", "title": "Company OpenAI",
                  "owner": "organization", "privacy": "org_cloud" },
  "models": [ { "id": "deployment", "title": "llm:1248", "kind": "deployment",
                "owner": "organization", "privacy": "private",
                "models": [ { "ref": null, "model_id": "ardincoder-1", "title": "…",
                              "short": "…", "rungs": [ … ], "default_effort": "medium",
                              "limited": null, "selected": false } ] }, … ],
  "resolved":  { "effort": "high", "requested_effort": "high",
                 "main_model": "gpt-5.6-sol", "effort_available": true,
                 "rungs": [ { "rung": "minimal", "enabled": true, … } ] },
  "modes": [ … ],
  "user_default": null,
  "change": { "changed": true, "model_changed": true, "effort_changed": true,
              "mode_changed": false },
  "seam":   { "text": "Switched to Sol · High (Company OpenAI)" }
}

models is the picker: the deployment model, every model of a declared
connection, and a legacy profile only when a model_selection.picker entry
names it — grouped by connection, the deployment model's group first, then the
user's own connections ("Yours"). The mode-shaped fields (selection.mode,
resolved.title, modes) are kept for the current composer chip and terminal
strip.

When personal connections are off for the caller
(model_selection.personal_connections: false, or their access profile's
personal_connections: false) the "Yours" groups are absent, and naming one
of those models in the PATCH, PUT /user-default or PUT /background answers
403 with the reason (an unknown model stays a 400).

A conversation whose stored model is no longer available runs its next
turn on model_selection.default.model instead (the deployment model when
that is unset), stores that, and gets the seam *ChatGPT is not available
here; using Ardincoder.* — the same plugin.thinking_modes.mode_changed
event, with unavailable naming what it fell back from and previous
describing the old selection (its reference, rung and model id). One path
for three causes:

Going back is the user's pick, with a seam of its own. A Background work
choice or a legacy pin that became another owner's is skipped like one that
dangles.

GET on the same path returns everything except change and seam.

Two answers are worth reading rather than assuming:

When the model or effort actually changes, a marker is added to the transcript
— "Switched to Sol · High (Company OpenAI)", or "Effort: High" when only the
effort moved — so the conversation records which brain answered which
question. Re-picking what is already in force changes nothing and adds no
marker; markers written before this release keep their wording.

Outside the web UI

SurfaceHow
WebThe chip at the left of the composer switches this conversation; Settings → Models (above) decides where new ones start.
Terminal UI/model <name>, /effort <rung> and /thinking, typed in the composer — the same words as Telegram. The pair in force is always on screen in the composer's status strip (◉ Slow · High, shortened to ◉ High on a narrow terminal), and a change leaves the same marker in the transcript that the web shows. With the plugin absent there is no strip and /model is an ordinary message.
Telegram/model <name>, /effort <rung>, and /thinking for the current pair. /model on its own lists the models by connection, /effort on its own the rungs. Anything else beginning with / is passed to the assistant untouched.
codumentor -p--model <name> and --effort <rung>, e.g. codumentor -p --model Sol --effort high "why is this test flaky?". --model selects a picker model only when the value is exactly its reference, short name or title; any other value is, as before, the agent model id (models.agent) for the run. The pair is echoed on stderr; with --output-format stream-json it also appears in the init event. --effort applies to print mode only.
OpenAI-compatible APISend a picker model's reference, short name or title as the request's model field — {"model": "Sol", …} — exactly, not fuzzily. A mode name ({"model": "fast"}) keeps working. Any other value keeps its existing meaning (the field is otherwise ignored). GET /v1/models lists codumentor-agent, the picker models by reference, and the mode names.

Naming a model. /model matches the short name, the title or the
reference, ignoring case: an exact name first, then a name it is the start of,
then the closest spelling when one model is clearly closest. When several
models fit equally (/model gpt with two GPT models) it lists them instead of
picking one; the reference always settles it.

/mode and --mode are retiring (one minor release): /mode fast is
/effort off, /mode slow is /effort at the model's default, a declared
mode is its model and rung, and each reply (or, for --mode, a warning on
stderr) says what to type instead. /mode custom points at Settings → Models.

A mode name that does not exist, or a rung that is not on the scale, is
rejected and told to you; a rung that exists but the model cannot honour is
accepted and clamped.

Users who already assign their own models

Custom is the mix of the other two, per role. Fast and Slow are
whole-assistant settings; a person cannot run one for part of a problem and the
other for the rest, but the assistant can — Slow for the developer role, Fast
for the summariser, the auto-titler and the web retriever. That is the whole
reason it is the third entry in the picker rather than an escape hatch.

Mechanically: if a user has set per-role assignments through
Model Profiles, those assignments keep working and appear
as an implicit Custom mode. They are read once (and again whenever they
change) into this plugin's own store: main_agent (else default) becomes the
model new conversations start on, default becomes the Helpers and
Housekeeping choice, and each subagent_overrides entry becomes a per-role pin
listed under GET /background, read-only and clearable. Model Profiles keeps
its copy for rollback. Nothing changes for such a user until they pick another
model or mode; their own named profiles stay available.

Custom is never lost: it is listed in Settings → Models
whether or not any assignment exists, so it can be made the default for new
conversations. In a conversation, pick the model with /model (/mode custom
now answers with a pointer to Settings → Models).

Configuration

plugins:
  - module: codumentor.plugins.thinking_modes
    class: ThinkingModesPlugin
    priority: 6
    args:
      enabled: true

Enabled in the default shipped config. Register it at a **higher priority
number than model_profiles** (which ships at 5) so a mode layers on top of a
user's own assignments rather than the other way round.

ParameterTypeDefaultDescription
enabledbooltrueWhether the plugin is active.
data_dirstringagent storage dir, else ./dataWhere the per-conversation selections are stored.
max_conversations_per_userint2000How many conversations' selections are kept per user before the oldest are dropped.

Declaring your own modes

Deprecated. thinking_modes: is read for one more minor release and
translated to model_selection:; the startup warning prints the replacement
lines. Since the translation a mode is only a shortcut for a model and an
effort: its roles are global per-role pins for every conversation (whichever
mode or model is picked, also when model_selection: is declared),
effort.allowed and prompt are no longer applied, and a mode's main no
longer overrides models.subagent_profiles.

Modes live in a top-level thinking_modes: section of the main configuration,
next to models: — not in the plugin's args:, because they reference the
profiles declared there and are cross-checked against them when the config
loads.

thinking_modes:
  default: slow                  # mode for a user who has expressed no preference
  modes:
    fast:
      title: Fast
      description: Answers from what it can see and look up. Quick turns.
      icon: bolt
      cost: 1                    # 1-3 relative badge shown in the picker
      main: local-qwen           # a name from models.profiles
      effort: { default: "off" }   # starts at off; the user can still turn it up
    slow:
      title: Slow
      description: Plans before acting, strongest model for code changes.
      icon: brain
      cost: 3
      main: cloud-strong
      roles:
        context_summarization: local-qwen
        explore: local-qwen
      effort: { default: medium, allowed: [off, minimal, low, medium, high, xhigh] }
                                   # a ceiling — never `max` on the cloud model
      prompt: |
        Before editing, write a short plan and verify it against the code.

Declaring any mode replaces both built-ins entirely, and takes the
balanced/deep aliases with them: a set that declares neither slow nor a
mode of its own by those names leaves those two words naming nothing. Keep the
descriptions describing behaviour rather than grading capability — a mode
called "the good one" tells the user their other choice is the bad one, which
is not what the dial does.

FieldTypeDefaultDescription
titlestringrequiredLabel shown in the picker.
descriptionstring""One line under the title.
iconstringunsetIcon token for the picker.
costint 1–32Relative cost/speed badge.
mainprofile nameunsetModel the chat runs on when the mode is picked; it becomes a picker entry. Unset is the deployment model (models.agent*).
rolesmap{}role → profile name: a per-role pin for every conversation, not just this mode's.
effort.defaultrungunsetRung the mode starts at. Unset means "whatever the model's own default is".
effort.allowedlist of rungsunsetAccepted and ignored: model_selection: has no effort ceiling (named in the startup warning).
promptstringunsetAccepted and ignored: mode prompt fragments are dropped (named in the startup warning).

Roles you may name in roles: are subagent, agentic_developer,
agentic_memory_store, context_summarization, explore, goal,
web_retrieve, web_search, auto_title, translation, voice_mediator,
plus any role you already point at a profile in models.subagent_profiles.

Write "off" in quotes: unquoted off is a YAML boolean. (Both are accepted —
this is a readability note, not a requirement.)

Notes