Skip to content

korvid v0.4.0

Unpublished audit tag

The v0.4.0 tag was blocked before publication during release verification. It remains an immutable audit record, not an installable release. Install korvid v0.4.1 instead; that is the first published 0.4 release. The commands below are commented historical examples, not instructions to run.

v0.4.0 brings named AI profiles, searchable model selection, and local MCP stdio connections to the running TUI. It also hardens provider streams, credential masking, and write-target identity checks.

Read the migration sections before upgrading: agent configuration and provider extension points changed, and direct HTTP MCP clients now require internal transport authentication. OAuth, remote MCP, and headless mode are not part of this release.

Install or upgrade

Install the full feature set:

# uv tool install 'korvid[all]==0.4.0'

For an existing install, reinstall with the complete desired extra set:

# uv tool install --force 'korvid[all]==0.4.0'
# or
# pipx install --force 'korvid[all]==0.4.0'

Python 3.11, 3.12, and 3.13 are qualified on Linux, macOS, and Windows. Base TUI and MCP-only installs remain independent of the optional agent stack.

Named profiles and model search (user-visible)

korvid now ships a named-profile configuration model and a searchable model catalog with over 2,000 entries.

  • Named profiles. agent.profiles.<name> holds each connection; agent.active picks the one in use. A profile's model is always a <prefix>/<tag> reference — the prefix routes the call, the tag identifies the model. Example:
agent:
  active: local
  profiles:
    local:
      model: ollama/qwen3:8b
      endpoint: http://localhost:11434
      auth: {method: none}
    gpt:
      model: openai/gpt-4o
      auth: {method: environment, key: OPENAI_API_KEY}

auth.method: none requires an endpoint: a keyless request without one would go to whichever default host the SDK picks, so korvid refuses it. Inside the TUI, :ai opens the profile manager once profiles exist and Enter activates the highlighted one; :model <name> switches the active profile's model.

  • Model catalog. The :ai wizard's model step is a fuzzy search over LiteLLM's bundled table (2,000+ models, no internet required). An optional enrichment layer from models.dev adds context lengths and quantization info; it is fetched only when you press Ctrl+R on that screen, which always revalidates — conditionally, so an unchanged document is a round trip and not a download — while korvid's own reads stay behind a 24-hour cache. The fetch is verified with network.ca_bundle like every other korvid HTTPS call. agent.model_search.models_dev defaults to true and setting it false disables the layer permanently: no source, no client, no socket. Only true and false parse; anything else warns at startup and fails closed to false. See Model search and the airgap guide.

  • Model-first selection. The catalog is the starting point for choosing a model: search for what you want, select it, and the profile is saved. The routing prefix is not something an operator needs to know in advance.

  • Endpoint discovery. For a profile with its own endpoint, the search screen can also list what that endpoint actually serves. The listing now uses network.ca_bundle like every other korvid HTTPS call — an endpoint the running agent trusts is one the setup screen trusts, with no insecure fallback — and the whole attempt is bounded by a single 5-second deadline rather than a per-read one. The profile's key is sent as a bearer token to that endpoint only: no redirect is followed, and it is never logged or stored. Failures stay best-effort: an empty listing, never an error dialog.

Local MCP: stdio with internal transport authentication

Configure external hosts to run korvid mcp stdio after starting the TUI with MCP enabled. The adapter discovers the running TUI, or requires --instance PID when more than one is available. It shares that TUI's cluster context, UI, proposal queue, and approval/audit boundary rather than creating a headless Kubernetes client.

Migration required for direct HTTP clients: every MCP request now requires the per-run capability in an HTTP authorization header, including reads and UI actions. The stdio adapter handles this credential privately; do not copy it into host configuration or prompts. The old capability tool argument is rejected. Restart the host's MCP connection after a kube-context switch or an MCP/TUI restart; existing connections do not silently follow a new server run. OAuth and remote/headless MCP remain deferred.

Kubernetes JSON request failures are also normalized into actionable errors, so connectivity, TLS, and malformed-response failures do not escape describe and hierarchy workflows as raw transport exceptions. See MCP setup and migration for host configuration examples.

Breaking: the agent's configuration and plugin APIs

The embedded agent was rebuilt as one interaction harness — a single AgentSession over one AgentEngine, with the provider boundary, tool dispatch, prompt composition, durable history and evidence ledger each owned by exactly one component. Two public surfaces changed with it.

agent.profile and agent.prompts were removed

korvid refuses to start when either key is present and prints the replacement. Silently ignoring a prompt override would leave a deployment believing its wording was still in effect.

removed replacement
agent.profile: small agent.model_tier: low — or omit the key
agent.profile: full agent.model_tier: high — or omit the key
agent.prompts.append agent.rules (a list of short house rules)
agent.prompts.system / system_file (none) — grind the tier pack in the eval harness
agent.prompts.tool_descriptions (none) — deployment overrides removed: the low tier ships its own versioned, bounded wording by exact tool name; the high tier and the MCP server still use the registry's wording
# before
agent:
  profile: small
  prompts:
    append: "House rule: never include node names in an answer."

# after
agent:
  model_tier: low
  rules:
    - "Never include node names in an answer."

Omitting agent.model_tier is now the recommended setting: routing resolves the tier from what the provider reports, then korvid's shipped model catalog, then a conservative low fallback. The agent panel header shows the resolved route as tier (source) — for example low (catalog) — so both the tier and the reason for it are visible without re-reading configuration.

Provider scalar keys removed

The flat agent.provider/agent.model/agent.base_url scalar shape was the old way to configure a single connection. These keys are no longer written on save, but an existing config that still has them is migrated automatically into a named profile called default on first load, and the scalars are removed from the file on next save. No manual editing is required.

# before (migrated automatically)
agent:
  provider: ollama
  base_url: http://localhost:11434
  model: qwen3:8b

# after (written back on save)
agent:
  active: default
  profiles:
    default:
      model: ollama/qwen3:8b
      endpoint: http://localhost:11434
      auth: {method: none}

agent.model_tier, agent.rules, agent.follow and agent.disable_in_protected all keep their meaning. The agent.ollama.* tuning block does not: it is read only by this migration, which folds its six knobs into the new profile's options. Once agent.profiles is present in the file the block is ignored entirely, and the next save removes it along with agent.enabled and the other legacy scalars — active: null is the new off switch. Put those knobs in a profile's options instead.

Migration warning: a large agent.rules block can now fail to start

The low tier's static prompt — the safety contract, the common role, the tier pack, and the armed-capability clauses, fully armed (writes and both screen tools) — is exactly 4,871 characters of the 6,000-character budget PromptHarness enforces for it (25% of the tier's 24,000-character history budget), leaving 1,129 characters of headroom for agent.rules and any provider/exact-model overlay. This budget is new: the retired agent.prompts.append mechanism enforced no such share of the history budget, so a rule set (or system/append text migrated verbatim into agent.rules) that the old profile-based agent accepted without complaint can now be too large. Agent composition raises StaticPromptTooLargeError instead of silently crowding out the conversation; initial application startup catches that error, warns, and starts with the agent disabled. Shorten the rule set (or move guidance the tool descriptions and safety contract already cover) before reconnecting the agent.

Provider extensions now declare special flows

The korvid.provider entry-point group now loads SpecialFlow declarations; it no longer loads the former provider factories. Most OpenAI-compatible backends need only a named profile with an endpoint, not a plugin. Custom authentication for a standard transport belongs in a korvid.credential chain.

A special flow claims a reference prefix and constructs an LLMProvider from its profile. The provider exposes descriptor -> ModelDescriptor and capabilities -> ModelCapabilities, rather than the retired name property. Unknown capabilities fall back to the catalog and then to the low tier; supports_tools=False refuses agent startup. The declaration's prefix must match its normalized entry-point name. See provider plugins for the current declaration, credential-chain, and provider contracts.

What tiers actually change

low high
iterations per turn 6 15
retained history 24,000 chars (hard bound) 120,000 chars
per tool result 3,000 chars 8,000 chars
tool calls per response 1 provider-confirmed parallel, else 1
screen tools armed open_logs, open_describe all five

The safety perimeter is unchanged

Nothing in this rebuild moved a security boundary. Every write tool the environment arms still opens the same approval dialog, at every tier, and only a user keystroke executes it; a write still carries the preconditions for the exact target UID established before approval; audit logging is still fail-closed, so a write whose audit record cannot be written does not run; read-only mode still means no write schema is offered at all; there is still no shell or free-form kubectl tool at either tier, so the agent's whole cluster surface remains the structured tool registry — the resolved policy arms only the registry's own exact tool names, the registry validates every dispatch target against its import-time metadata, and the ToolExecutor rejects any name outside that registry as an unknown tool and performs its own explicit, typed argument validation before a write reaches the cluster (the tool's declared JSON schema is model-facing wording, not the runtime check); and every tool result still passes the masking pipeline before it reaches the model or the provider. House rules are composed after the immutable safety contract and cannot widen it.

Other agent-visible changes

  • Device-login prefixes are refused, not resolved. LiteLLM 1.98 resolves both github_copilot/… and chatgpt/… by starting an interactive device-code sign-in inside its routing call — writing a credential file under ~/.config/litellm/ and ignoring the profile's own key (and, for chatgpt, its endpoint). korvid claims both prefixes before it routes, so such a profile is served by a flow korvid ships or refused with a logged reason. Neither appears in model search unless a flow serves it.
  • Evidence citations. Each successful cluster read mints a numbered reference the answer cites; opening a citation navigates to the exact object the read looked at. Screen actions and writes never mint evidence. Switching Kubernetes context clears the ledger.
  • Direct control. The agent drives the same panes your keys drive. Pressing a normal key mid-turn keeps working, and the next turn starts from wherever you left the screen.
  • Optional install unchanged in shape, stricter in behaviour. The agent still ships in the [agent] extra; a base or MCP-only start now provably loads no engine, gateway or provider module, and an enabled agent with the extra missing fails with the exact install command instead of degrading silently.
  • Eval artifacts. Journey artifacts publish successful_journeys (whole conversations whose every turn succeeded) and the markdown tables label their verdict columns all-turn journeys and correct diagnosis, so the two different measurements are no longer both called "success". A run's per-turn success is derived from its outcome and can no longer contradict it.
  • Entra ID actually resolves for an azure profile. auth.method: provider-default used to mean only "pass no API key and let the SDK look", and the SDK's own Entra chain is behind a process-wide opt-in that is off by default — so an azure profile relying on az login or a managed identity authenticated with nothing. korvid now declares the credential chain, per reference prefix, and resolves it while the profile is being built. A missing entra extra is refused there, naming the install command, instead of surfacing as an ImportError from inside the provider SDK on the first message. Third parties can declare a chain the same way, on the korvid.credential entry-point group — see Provider plugins.
  • A malformed profile block disables its profile instead of being ignored. auth: or options: written as anything other than a block — auth: environment, options: 8192 — used to be replaced by an empty one, so korvid connected with no credential, or without the settings the file named, and said nothing. Such a profile is now reported and refused until it is fixed, and the line is preserved verbatim by the next save instead of being overwritten. :model is refused on such a profile as well: the block is repaired in :ai, never dropped by a model change. An absent block, and auth: with nothing after it, still mean "not set". Entries filed under a key that is not a name (1:) keep that exact key through a save, so they can no longer collide with, or be promoted into, the profile named "1".
  • Truncation marker. A tool result too large for the tier's budget is still shortened from the middle, and the marker the model reads there now names the budget it hit: … [middle truncated — tier result budget]. It is a small change to what the model sees, so a campaign comparing scores across this release is comparing two slightly different prompts.
  • A cut-off answer is refused instead of reported as a whole one. Each built-in transport now needs its protocol's own end-of-answer signal before it will report success: the wire's [DONE] for the Copilot dialect, done: true for Ollama's native API, and — measured against LiteLLM 1.98, which puts a finish_reason on the last chunk of every stream — a finish_reason the provider itself sent for the shared transport. Without it the turn ends with an error: text that really streamed stays on screen, but the tool calls, the token counts and the answer are not accepted. Reading also stops at that signal, so a host that keeps writing cannot append to a turn korvid has closed. A whole non-streaming response is unaffected; it is complete on its own terms. Self-hosted and proxied endpoints: a clean end of the response body is not the signal, and for an OpenAI-compatible endpoint neither is a bare data: [DONE] — some gateways append one of their own. If :ai's connection test now fails against a gateway that used to work, stream a request through it and count finish_reason in the raw frames; none means the field is dropped or rewritten in transit, and the proxy needs upgrading or its chunk-rewriting middleware turning off. The agent guide has the command.
  • Hidden buffers are bounded. One tool call's arguments (64 KiB), the number of calls one response may open (64), the reasoning an adapter holds for the next request (64 KiB) and the wizard's connection-test reply (4,096 characters) all have limits now — they used to grow with whatever the provider sent, out of reach of the per-response budget. An answer past the connection-test bound fails the test rather than being shown cut short: trimming it would end the read before the adapter could tell whether the stream had finished at all. Reasoning that streams through is charged to that budget by its real length rather than as one character. Every provider failure is reported in korvid's own written words: a refused status, an unreadable frame or a dropped connection no longer quotes the host's response body, which is where a provider echoes the credential it just refused. :ai's connection test holds the same line for the failures that happen before and around the stream — a locked keyring, a credential store that refuses, a request the outbound policy blocks — which used to be printed in the wizard exactly as the library phrased them, quoting the key and the endpoint. The wizard now shows one written sentence and the real reason goes to the log. Its interactive sign-in answers the same way: a device login that cannot start, or that never completes, says which of the two halves failed and nothing else, because an authorization exchange quotes the very token it is minting.
  • The models.dev cache is staged under a name nobody can pre-empt. The envelope was written to a fixed models-dev.tmp beside it, so a file already sitting at that name was truncated and then renamed into place — keeping whatever permissions it had been created with — and two korvid processes refreshing at once wrote through each other. Each write now takes its own staging file in the same directory, created exclusively and set to 0600 on POSIX, and removes it on every failure path. On Windows the confidentiality is still the per-user cache directory's inherited ACL, as it always was.

Dependency note: [agent] grew

The [agent] extra now transitively installs approximately 55 distributions, up from a handful, because it includes litellm, which brings boto3, openai, tiktoken, tokenizers, and their dependencies. This is a real increase.

A base install (pip install korvid, no extra) is unchanged — it reaches no AI library and starts no HTTP client. The import-graph test in tests/test_optional_extras.py pins that boundary.

The alternative to LiteLLM — maintaining vendor adapters by hand — is a correctness risk: stale routing tables silently send credentials to the wrong host. The increased dependency count is the tradeoff for a maintained catalog.

Local safety and usability fixes

  • MCP log and event output now passes credential-pattern masking, and outbound masking also recognizes structural private-key material and supported PEM formats. Masking is defense in depth, not a guarantee that arbitrary log content contains no sensitive information.
  • Switching Kubernetes context resets the namespace safely. An inline input that owns focus cannot accidentally confirm a write approval.
  • Pod transfer/debug refuses to proceed when the target UID cannot be revalidated. Helm mutations revalidate the release incarnation so a replaced release cannot inherit an earlier approval.
  • Uploads close the exec stream's stdin correctly, including empty files. Describe search, filtered resource hints, and Secret reveal/remask column sizing were corrected through actual TUI testing.

Verify

The GitHub Release provides the wheel, source distribution, checksums, SBOM, offline bundles, and provenance from the tagged commit:

# set -eu
# gh release download v0.4.0 --dir dist/v0.4.0
# gh attestation verify dist/v0.4.0/korvid-0.4.0-py3-none-any.whl \
#   --repo hellices/korvid
# gh attestation verify dist/v0.4.0/SHA256SUMS --repo hellices/korvid
# (cd dist/v0.4.0 && shasum --algorithm 256 --check SHA256SUMS)