Amendment (2026-05-22, #2634) — the persistence and dispatch layers are finished against one canonical execution-config shape. This ADR collapsed user-facing execution config to
(runtime, model)at the authoring layer (the manifestai:block), but the persistence and dispatch layers were never migrated. Execution config drifted into three representations: the authored manifest YAML (anai:block and a separateexecution:block, with the image declarable in either), the persisted definition JSON (DbAgentDefinitionProvider.ExtractExecutionreads anexecution:shape or a legacyai:shape), and the C#AgentExecutionConfigrecord (flatProvider/Modelstrings, a misnamedAgentslot). This amendment pins the target so the migration can be finished:
- One canonical authoring shape, one canonical persisted shape. The image is declared exactly once — top-level
execution.image, for agents and units alike.AiEnvironmentManifest/AiManifest.Environmentare removed; the image is not anai-block concept (a container image need not host an AI runtime — it may be a plain A2A agent).AgentExecutionConfigand the persistedexecution:JSON block carry exactly(runtime, model{provider, id}, image, hosting).modelis the structured{provider, id}pair this ADR already mandates — not a flat string. The flatProvider/Modelslots and the misnamedAgentslot (renamedruntime, matching theAgentRuntimeterm) are removed.- The dead execution fields are removed:
ExecutionManifest.runtime(the docker/podman container-runtime selector — already removed from operator surfaces by ADR-0039 §7) andExecutionManifest.model(duplicatesai.model).ExtractExecution's legacyai:branch is deleted.- Clean-deploy, no back-compat (v0.1), consistent with this ADR's original migration stance.
The decision here is unchanged — this is ADR-0038's
(runtime, model)collapse, applied to the two layers it never reached. The implementation breakdown and the full entanglement map live on #2634.
Amendment (2026-05-24, #2719) — the
AgentRuntime.SystemPromptInjectioncross-runtime field is removed; per-launcher prompt delivery is the authoritative source. ADR-0059 §5 supersedes this ADR'ssystemPromptInjectiondiscussion (the "universal cross-runtime fields" entry onruntime-catalog.yaml). The field was dead metadata — never read at dispatch time. Each launcher knows its own CLI's prompt-delivery mechanism (file path, env var, flag); the append-vs-replace choice is now an agent / unit author concern carried on the execution spec viasystem_prompt_mode, not a static catalogue property of the runtime. PR #2716 deleted the record property, the YAML schema, the catalogue loader, and the tests that pinned it. The other universal cross-runtime fields (threadBinding, per-edgecredentialEnvVar) and the rest of this ADR (the(runtime, model)split, the catalogue model, the wizard rules, the migration directive) are unaffected. The canonical reference for the assembled-prompt pipeline now lives in ADR-0059.
- Status: Accepted — 2026-05-06 —
AgentRuntime,ModelProvider, andModel{provider, id}become three distinct identities; the user-facing execution config collapses to(runtime, model); the model provider is intrinsic to the model and is the credential / routing boundary; both runtimes and providers are platform configuration in a checked-inruntime-catalog.yamlwith universal cross-runtime fields (threadBinding,systemPromptInjection, per-edgecredentialEnvVar); per-provider and per-runtime classes are replaced by small strategy registries;IAgentRuntime.Kindis removed; credentials re-key to(tenant, provider, authMethod)with unit-level inheritance carried forward per ADR-0003; the wizard hides the provider picker for fixed-provider runtimes and shows it as a model-list filter otherwise; Dapr component files renameconversation-*→llm-*; clean-deploy hard rename across CLI, manifest, OpenAPI, Kiota, portal, and docs. - Date: 2026-05-06
- Umbrella: #1761 — AgentRuntime and ModelProvider split — multi-PR initiative (ADR-0038).
- Related code: see "Surface affected" below — the change touches Core domain, Manifest, Host.Api, AgentRuntimes projects, ModelProviders (new), Cli, Web, Dapr components, and docs.
- Related ADRs: 0021, 0029, 0036, 0037, 0058 — the canonical container contract that consolidates every platform-managed artefact inside the agent instance; the per-edge credential env vars this ADR's
runtime-catalog.yamlderives (CLAUDE_CODE_OAUTH_TOKEN,OPENAI_API_KEY,GOOGLE_API_KEY,ANTHROPIC_API_KEY) are catalogued there in §1. Builds on the vocabulary alignment delivered in #1758.
The platform models the agent execution stack as a single concept, IAgentRuntime, which fuses three distinct things: the in-container execution engine, the LLM company whose API the engine calls, and a specific (engine, company, default-model) configuration.
This is visible in the project layout: Cvoya.Spring.AgentRuntimes.{Claude, Google, Ollama, OpenAI} register four IAgentRuntime instances. But:
OllamaAgentRuntime.Kind = "spring-voyage"andDisplayName = "Spring Voyage Agent (Ollama)". Ollama is not a runtime — it is the company hosting the LLM that the Spring Voyage Agent runtime talks to.OpenAiAgentRuntimehas the same shape — it is the Spring Voyage Agent runtime configured with OpenAI.ClaudeAgentRuntimeIS Claude Code (a real runtime), hardcoded to Anthropic.GoogleAgentRuntimeIS Gemini CLI (a real runtime), hardcoded to Google.
The conflation has visible costs:
- The portal wizard exposes a "Provider" field whose value is mostly redundant — for the three CLI runtimes it must equal a fixed value, and only for
spring-voyagedoes it actually pick anything. - Credential storage is keyed
(tenant, runtime-id), so a tenant using both Claude Code and Spring Voyage with Anthropic stores the same Anthropic credential twice under different keys. - New companies cannot be added without writing an
*AgentRuntimeclass that is not, by the documented terminology, a runtime at all. - The
Kindproperty exists only to dispatch launchers around the conflation. It is vestigial scaffolding. - The vocabulary in code, OpenAPI, CLI, manifest, and portal does not match the platform's own documentation, even after the rename pass in #1758.
The discussion that prompted this ADR settled on a clean three-concept split: AgentRuntime, ModelProvider, and Model. The user-facing execution config is just (runtime, model); provider is intrinsic to the model. This ADR records the resulting design.
The platform reasons in terms of three distinct identities:
- AgentRuntime — the in-container execution engine. Closed set in v0.1:
claude-code,codex,gemini,spring-voyage,custom. - ModelProvider — the company whose API hosts a set of LLMs. Open set:
anthropic,openai,google,ollama, future additions. The provider is not a user-facing pick; it is a property of the chosen model and is the routing / credential boundary the platform uses internally. - Model — a specific LLM, identified by
(provider, id). The provider is intrinsic to the model.
The user-facing execution config is the pair (runtime, model). The model carries its own provider; the platform validates that the model's provider is in the runtime's allowed set.
ai:
runtime: spring-voyage
model:
provider: ollama
id: llama3.2:3bOr, for a fixed-provider runtime:
ai:
runtime: claude-code
model:
provider: anthropic
id: claude-3-5-sonnet-20241022The wire format is always (runtime, model) with the provider intrinsic to the model. There is no separate provider field on the wire, in the manifest, or stored on a tenant install row.
The wizard's UX is keyed off IsProviderFixed:
- Fixed-provider runtimes (
claude-code,codex,gemini): the provider picker is hidden. The user picks a runtime; the model list shows only that runtime's single allowed provider's models. - Multi-provider runtimes (
spring-voyage, futurecustom): the provider picker is shown as a model-list filter. The user picks the runtime, optionally narrows to one provider to filter the model list, and picks a model. The selected provider is recorded only as themodel.providerfield — there is no separateproviderslot stored on the unit/agent.
Both UX paths produce the same wire form. The picker is a presentation aid, not a data axis.
Rejected: keep provider as a separate user-facing axis carrying its own data slot. Carries dead weight for fixed-provider runtimes and creates two ways to express the same fact for multi-provider runtimes (the picker would diverge from model.provider).
Rejected: hide the provider picker uniformly. With a long combined model list (Anthropic + OpenAI + Google + Ollama for spring-voyage), the filter is genuine UX value, not redundant.
Rejected: encode the model as a flat provider/id string. Some Ollama model ids contain /; ambiguity at parse time. The structured shape is unambiguous and maps cleanly to typed clients.
The same configuration-driven approach used for model providers (decision 3) applies to agent runtimes. The set of runtimes the platform supports — together with each runtime's allowed providers and per-edge auth method — lives as data in the same checked-in YAML file. There is no IAgentRuntime interface carrying static metadata, no ClaudeAgentRuntime / GoogleAgentRuntime class.
Per-runtime behaviour (preparing the per-invocation working directory, env vars, MCP wiring, in-container probe plan) stays as code, behind the existing IAgentRuntimeLauncher strategy interface registered in DI by the runtime entry's launcher id. This is the analogue of the small IModelProviderAdapter strategies in decision 3: per-wire-family code, not per-runtime metadata.
The catalogue is loaded once at startup; entries deserialise to a generic AgentRuntime data record. The dispatch dictionary keys directly on Id. The previous Kind property is removed — with one entry per real runtime, Id and Kind become 1:1 and Kind was scaffolding for the conflation this ADR removes.
The shape (full file in decision 3):
agentRuntimes:
- id: claude-code
displayName: Claude Code
defaultImage: ghcr.io/cvoya-com/spring-voyage-claude-code-base:latest
launcher: claude-code-cli # IAgentRuntimeLauncher strategy id
threadBinding:
kind: cli-arg
argName: --resume # claude resumes by --resume <session-id>
systemPromptInjection:
kind: file
filePath: AGENTS.md # workspace-relative; the runtime reads it
modelProviders:
- id: anthropic
authMethod: oauth
credentialEnvVar: CLAUDE_CODE_OAUTH_TOKEN
- id: codex
displayName: Codex
defaultImage: ghcr.io/cvoya-com/spring-voyage-codex-base:latest
launcher: codex-cli
threadBinding:
kind: cli-arg
argName: --conversation-id # codex's resume flag
systemPromptInjection:
kind: file
filePath: AGENTS.md
modelProviders:
- id: openai
authMethod: api-key
credentialEnvVar: OPENAI_API_KEY
- id: gemini
displayName: Gemini CLI
defaultImage: ghcr.io/cvoya-com/spring-voyage-gemini-base:latest
launcher: gemini-cli
threadBinding:
kind: cli-arg
argName: --session-id
systemPromptInjection:
kind: file
filePath: GEMINI.md # gemini's own file convention
modelProviders:
- id: google
authMethod: api-key
credentialEnvVar: GOOGLE_API_KEY
- id: spring-voyage
displayName: Spring Voyage Agent
defaultImage: ghcr.io/cvoya-com/spring-voyage-agent:latest
launcher: spring-voyage-agent
threadBinding:
kind: env-var
envVarName: SPRING_THREAD_ID
systemPromptInjection:
kind: env-var
envVarName: SPRING_SYSTEM_PROMPT
modelProviders:
- id: anthropic
authMethod: api-key
credentialEnvVar: ANTHROPIC_API_KEY
- id: openai
authMethod: api-key
credentialEnvVar: OPENAI_API_KEY
- id: google
authMethod: api-key
credentialEnvVar: GOOGLE_API_KEY
- id: ollama # no authMethod, no credentialEnvVar — Ollama requires no credentialEach agentRuntime entry's modelProviders: list carries the (provider, authMethod, credentialEnvVar) edges that decision 4's matrix used to enumerate in code. The matrix is now data; adding a runtime, a provider edge, or a new auth method on an existing edge is a config edit (and a launcher strategy addition only when behaviour is genuinely novel).
AllowedProviders and IsProviderFixed (used by the wizard rule in decision 1) are derived from modelProviders[].id on the runtime's entry. A custom runtime, when added in a future release (decision 8), declares its own non-empty modelProviders: list in this same file.
Three fields on each agentRuntime entry capture features the platform's cross-cutting code reasons about uniformly across runtimes. Each one carries data the platform would otherwise have to ask a per-runtime class. The line is: anything the platform-side code reads across runtimes goes in YAML; runtime-protocol details (argv shape, output parsing, A2A choreography, MCP stamping) stay in the launcher strategy.
threadBinding — how the platform thread reaches the runtime's session/conversation. Every runtime has some kind of session-resume mechanism — Claude Code calls it a "session," Codex calls it a "conversation," Spring Voyage Agent calls it a "thread." The platform's Thread is the binding key: every unique participant set gets one runtime session, stable across invocations. The platform persists (agent, thread) → runtime-session-id in its own store; the YAML describes how to deliver that id to the runtime.
threadBinding:
kind: cli-arg | env-var | none
argName: --resume # required when kind: cli-arg
envVarName: SPRING_THREAD_ID # required when kind: env-varkind: none is for runtimes that have no session concept (none ship today; reserved for completeness). The launcher strategy handles allocation flow — whether the runtime allocates the id and the launcher captures it from output (Claude Code, Codex, Gemini), or the platform passes the thread id verbatim (Spring Voyage Agent). Only the delivery axis (cli-arg vs env-var) is uniform enough to live in YAML.
systemPromptInjection — how the assembled prompt reaches the agent. The platform's prompt assembler (IPromptAssembler) produces the system prompt from the four-layer composition described in docs/concepts/agents.md. The runtime receives the result via one of three mechanisms:
systemPromptInjection:
kind: file | env-var | argv
filePath: AGENTS.md # required when kind: file (workspace-relative)
envVarName: SPRING_SYSTEM_PROMPT # required when kind: env-var
argName: --system-prompt # required when kind: argvCLI runtimes typically use kind: file because their CLIs already read a fixed-name file (AGENTS.md for Claude Code, GEMINI.md for Gemini). Spring Voyage Agent uses kind: env-var. A future runtime that takes the prompt as a flag uses kind: argv.
credentialEnvVar (per-edge) — the env var the launcher writes the resolved credential into. Naturally per-edge, because the env var name depends on both the runtime and the provider. Claude Code dispatching against Anthropic uses CLAUDE_CODE_OAUTH_TOKEN. Spring Voyage Agent dispatching against Anthropic uses ANTHROPIC_API_KEY. Modelling this on the (runtime, provider) edge is the only place it fits cleanly.
Edges whose authMethod is omitted (Ollama on Spring Voyage Agent) also omit credentialEnvVar — there is no credential to inject.
Rejected: lift the executable path / argv template into YAML. Each CLI runtime's argv is genuinely bespoke; YAML-level templating would need a placeholder language for ${THREAD_ID} substitution and stays in the strategy more cheaply.
Rejected: threadBinding.kind: file (write the thread id to a file in the workspace). No runtime ships this convention today; introduce when one does.
Rejected: keep IAgentRuntime as a code interface and persist the matrix in C# constants. Doubles the surface — every matrix change is a code change.
Rejected: split the YAML into one file per runtime. Loses the central audit surface; a contributor cannot answer "which auth method does Codex accept from OpenAI?" by reading one file.
Rejected: keep Kind for forward compatibility. There is no extant or planned use case in which two runtimes share a Kind. Reintroduce when there is.
Both the supported model providers and the supported agent runtimes live in the same checked-in configuration file. There are no AnthropicModelProvider / OllamaModelProvider classes.
# /eng/runtime-catalog/runtime-catalog.yaml
modelProviders:
- id: anthropic
displayName: Anthropic
apiBaseUrl: https://api.anthropic.com
modelsEndpoint: /v1/models
adapter: anthropic
authMethods: [oauth, api-key]
llmApiContract:
name: anthropic
version: v1
defaultModels:
- claude-opus-4-7
- claude-sonnet-4-6
- claude-haiku-4-5-20251001
- id: openai
displayName: OpenAI
apiBaseUrl: https://api.openai.com
modelsEndpoint: /v1/models
adapter: openai-compatible
authMethods: [api-key]
llmApiContract:
name: openai
version: v1
defaultModels:
- gpt-4o
- gpt-4o-mini
- gpt-4-turbo
- id: google
displayName: Google
apiBaseUrl: https://generativelanguage.googleapis.com
modelsEndpoint: /v1/models
adapter: google
authMethods: [api-key]
llmApiContract:
name: google
version: v1
defaultModels:
- gemini-2.0-flash
- gemini-1.5-pro
- gemini-1.5-flash
- id: ollama
displayName: Ollama
apiBaseUrl: http://localhost:11434
modelsEndpoint: /api/tags
adapter: openai-compatible
authMethods: [] # empty list — Ollama requires no credential
llmApiContract: # Ollama exposes an OpenAI-compatible chat-completions surface
name: openai
version: v1
defaultModels:
- llama3.2:3b
- llama3.2:1b
- qwen2.5:7b
agentRuntimes:
# See decision 2 for the agent-runtime entries; they reference modelProviders by id.
- id: claude-code
# ...A single generic ModelProvider class holds a ModelProviderConfig loaded from the modelProviders: section. Wire-format differences (parsing the live-models response, credential format checks, request envelope) are handled by a small set of IModelProviderAdapter strategies registered by adapter id (openai-compatible, anthropic, google). Provider names do not appear in class names — the strategy ids name the wire-format family, not the company.
New providers can be added config-only when they are OpenAI-compatible. Otherwise the author registers a new adapter strategy and a config entry.
authMethods lists the auth mechanisms the provider's API will accept. Closed enum: oauth | api-key. An empty list ([]) denotes a provider that requires no credential — Ollama's local endpoint, in v0.1. Per-runtime per-provider entries (decision 2) name a single authMethod from this list when one is required; for providers with an empty authMethods list the per-edge authMethod field is omitted entirely. There is no none token in the schema — absence is the representation.
llmApiContract names the LLM API surface the provider implements, as a structured {name, version} value. Closed enum on name: anthropic | openai | google for v0.1. The version is currently v1 for every contract; including it explicitly leaves room for a future Anthropic Messages API v2, OpenAI v2, etc., without rewriting the schema.
defaultModels is the seed list of model ids the platform ships for the provider until live-fetch (modelsEndpoint consumed by the adapter's FetchLiveModelsAsync) is wired up. Plain string ids — no display names, no context windows, no other metadata. Once live fetch is implemented, the live response replaces this list and the YAML's defaultModels becomes the cold-start seed used the first time the platform talks to the provider (and the fallback when live fetch is unavailable, e.g. an offline Ollama host or a provider rate-limit). Tenants can add or override models via per-install configuration (AgentRuntimeInstallConfig), unchanged by this ADR. Schema validation does not pin the model ids — the list is allowed to grow without a schema bump.
The platform maps llmApiContract → Dapr Conversation component by convention: the in-tree Dapr component file lives at eng/dapr/components/llm-{provider.id}.yaml with metadata.name: llm-{provider.id}. Two providers that share a contract still ship distinct component files because Dapr requires the connection metadata (base URL, credentials) on each component's YAML; the contract tells the platform which adapter strategy to dispatch through, the component file tells Dapr how to reach the actual endpoint. The Dapr type: field stays conversation.<provider> — that is Dapr's contract, not ours.
A JSON schema ships alongside the YAML at eng/runtime-catalog/runtime-catalog.schema.json and pins the closed enums (adapter, authMethods element values, llmApiContract.name, runtime-entry launcher ids). CI lints the YAML against the schema so a typo in a contract name or auth method fails fast at PR review rather than at startup.
Rejected: per-provider classes. Forces a class per company name in code, obstructs config-only addition, and turns trivial wire-format additions into PRs.
Rejected: keep the field name daprConversationComponent. Bakes Dapr terminology into a config file most operators read without knowing what a Dapr building block is. The field name should describe role and contract, not implementation.
Rejected: name the field by the route key it dispatches through (e.g. the Dapr component name) rather than by the contract. Loses the contract identity; "two providers share a contract" becomes invisible at the YAML level even though it is a real platform property.
Rejected: drop the version on llmApiContract. Cheap to include now; expensive to retrofit when the second version of any provider's API ships.
Rejected: split runtimes and providers into separate files. The whole point of putting them together is that runtime entries reference provider ids in the same document — keeping them together is the audit surface.
A provider declares the auth methods it accepts (decision 3's authMethods). Each agent runtime declares, per provider it can dispatch to, the single auth method it consumes (decision 2's per-edge authMethod). The runtime × provider × authMethod matrix is the projection of these two pieces of config.
For the v0.1 catalogue, the projection is:
| Runtime | Provider | authMethod | credentialEnvVar |
|---|---|---|---|
claude-code |
anthropic | oauth | CLAUDE_CODE_OAUTH_TOKEN |
codex |
openai | api-key | OPENAI_API_KEY |
gemini |
api-key | GOOGLE_API_KEY |
|
spring-voyage |
anthropic | api-key | ANTHROPIC_API_KEY |
spring-voyage |
openai | api-key | OPENAI_API_KEY |
spring-voyage |
api-key | GOOGLE_API_KEY |
|
spring-voyage |
ollama | — | — |
This table is documentation, not a code constant. The runtime constructs it at startup from the YAML; CI's schema check enforces that every per-edge authMethod is present in the corresponding provider's authMethods list.
Storage is keyed (tenant, provider, authMethod) — the credential row is shared across every runtime that consumes the same (provider, authMethod) pair (per decision 6). Dispatch resolves the runtime's per-edge authMethod against the catalogue, looks up that exact row, fails with a precise error if the matching credential is absent, and otherwise hands the resolved value to the launcher to inject into the container env under the same edge's credentialEnvVar name (per decision 2's universal cross-runtime fields). Edges whose provider has an empty authMethods list — and therefore no per-edge authMethod or credentialEnvVar — skip credential resolution entirely.
// AuthMethod entries are loaded from runtime-catalog.yaml and exposed as a
// closed enum; the C# representation is data, not behaviour. Format checks
// and env-var injection live on the provider adapter (decision 3) keyed by
// authMethod id.
public enum AuthMethod { Oauth, ApiKey }
// "no auth" is represented by an empty authMethods list on the provider
// and an absent authMethod field on the per-runtime per-provider edge.
// There is no None enum value — absence is the representation.The --bare flag (passing an Anthropic API key to the Claude Code CLI for degraded functionality) is not supported. The catalogue's per-edge entry for claude-code × anthropic names oauth and only oauth. This carries forward the strict per-path matrix from #1714; the dual-acceptance framing from #1690 stays rejected.
Rejected: store credentials per (tenant, provider) only, with method negotiation at dispatch. Negotiation hides errors and complicates the operator's mental model.
Rejected: store credentials per (tenant, runtime, provider). Forces the same Anthropic API key to be entered twice when a tenant uses both spring-voyage and a future Anthropic-fronted runtime — the current design's actual flaw.
Rejected: hardcode the matrix in C#. The whole point of decisions 2 + 3 is that the matrix is data; encoding it twice would be the kind of duplication that goes stale silently.
The provider field disappears from execution-config DTOs and manifests. The model is structured.
{
"execution": {
"runtime": "spring-voyage",
"model": { "provider": "ollama", "id": "llama3.2:3b" }
}
}CLI:
spring agent create --runtime spring-voyage --model-provider ollama --model llama3.2:3b
For fixed-provider runtimes, --model-provider is optional and inferred from --runtime; specifying it must equal the implied value or the CLI rejects the input.
The agent field on existing wire DTOs (renamed from tool in #1732) is replaced by the runtime + structured model pair. The toolKind-derived response field (renamed to kind in #1758) is removed — the runtime's identity and the model's provider supply everything callers were using kind for.
When a tenant configures a provider:
- One credential row per
(tenant, provider, authMethod)at the tenant default layer. - One Anthropic API key serves both
spring-voyage(with Anthropic) and any future Anthropic-fronted runtime that consumes the api-key method. - One Anthropic OAuth token serves
claude-code. - The tenant install surface has one row per provider, not per runtime — the wizard groups credential entry by provider.
Live-model fetch is per provider (/v1/models, /api/tags, …) rather than per runtime, so the catalog is shared across runtimes that target the same provider.
Unit-level overrides carry forward unchanged. ADR-0003 — automatic fall-through from unit to tenant with opt-out — applies to provider credentials the same way it applies to every other secret today. A unit may store its own (provider, authMethod) credential row that overrides the tenant default; absent a unit-level row, the resolver falls through to the tenant. Per-agent secrets remain deferred per ADR-0004 — the unit is still the trust boundary; agents inherit from their unit. This ADR does not change the inheritance shape; it only re-keys the row identity from (scope, runtime-id) to (scope, provider-id, authMethod).
This ADR re-shapes:
- The C# domain:
IAgentRuntimeinterface removed (replaced by anAgentRuntimedata record loaded from YAML, withAllowedProvidersderived from the entry'sproviders:list); newModelProviderdata record; newModelrecord; reshapedAgentExecutionConfig.Kindremoved. - Project layout: the four per-provider / per-runtime projects (
Cvoya.Spring.AgentRuntimes.{Claude, Google, Ollama, OpenAI}) collapse — their static metadata moves intoruntime-catalog.yaml; their per-provider seed model catalogues become provider-attached data (inline or sibling JSON, scope decision in the implementation PR).IAgentRuntimeLauncherstrategies (ClaudeCodeLauncher,CodexLauncher,GeminiLauncher,SpringVoyageAgentLauncher) consolidate in a singleCvoya.Spring.AgentRuntimesproject.IModelProviderAdapterstrategies (OpenAiCompatibleAdapter,AnthropicAdapter,GoogleAdapter) live in a newCvoya.Spring.ModelProvidersproject. - New files:
eng/runtime-catalog/runtime-catalog.yamlandeng/runtime-catalog/runtime-catalog.schema.json. - Dapr component files:
conversation-*→llm-*. - Manifest:
ai.agent→ai.runtime;ai.modelbecomes{provider, id}. - Wire DTOs (
UnitExecutionResponse,AgentExecutionResponse,InstalledAgentRuntimeResponse):agent→runtime; structuredmodel; no flatprovider; nokind. - Kiota and openapi-typescript regenerated.
- CLI:
--agentremoved in favour of--runtime;--model-provideradded;--modelcarries the provider-scoped id. - Portal: unit-create wizard, agent-create wizard, execution tab, execution panel, model selector, credential entry, tenant install screens shift to the (runtime, model) shape with provider grouping for credentials.
- All tests and docs.
There is no transitional flag, no dual-acceptance window, no shim. Old-shape signals surface as parse errors with precise migration hints:
| Old shape | Error | Migration hint |
|---|---|---|
Manifest has ai.agent: |
LegacyAiAgentField |
"ai.agent: is removed in ADR-0038; use ai.runtime: with a runtime id (claude-code, codex, gemini, spring-voyage, custom)" |
Manifest has ai.model: as a string |
LegacyAiModelStringForm |
"ai.model: is now a {provider, id} object in ADR-0038" |
Manifest has execution.provider: |
LegacyExecutionProviderField |
"execution.provider: is removed in ADR-0038; the provider is intrinsic to ai.model.provider" |
HTTP DTO sends provider at top level |
LegacyProviderField |
"provider is now nested inside model.provider" |
HTTP DTO sends agent field |
LegacyAgentField |
"agent is renamed to runtime in ADR-0038" |
HTTP DTO sends kind on execution responses |
LegacyKindField |
"kind is removed in ADR-0038; identity is supplied by runtime + model.provider" |
CLI uses --agent |
rejected with hint | "use --runtime in ADR-0038" |
Operators with persisted installs from the prior shape will lose their install configuration. Clean deployment is authorised for this refactor.
A custom runtime, when added in a future release, is an entry in the same agentRuntimes: list as the built-in runtimes. The entry declares a non-empty providers: list naming each (provider-id, authMethod) edge it supports. There is no sentinel "any" value — the runtime author lists the providers explicitly. The platform validates user input against this list at submit time. The runtime's behaviour ships as an IAgentRuntimeLauncher strategy registered against the entry's launcher id, exactly as the built-in runtimes do.
Easier:
- The vocabulary in code, OpenAPI, CLI, manifest, and portal matches the documentation. "Ollama is an agent runtime" disappears as a code surface.
- One credential per provider per tenant. Anthropic API keys, Google API keys, Anthropic OAuth tokens are entered once and served to every runtime that consumes them.
- New providers added by editing one config file when the wire format is OpenAI-compatible. New custom adapters are required only for genuinely non-compatible providers.
- The wizard's "Provider" pick disappears for fixed-provider runtimes; for
spring-voyageit becomes a model-list filter rather than a separate axis. - Live-model refresh is per provider, so two runtimes targeting the same provider share the catalog.
Kindremoval eliminates a property whose only purpose was scaffolding around the conflation.
Harder:
- The wire format change is breaking. CLI, Kiota client, portal, and any tenant-side external integrations release together.
- Provider-shape adapters (anthropic, google, openai-compatible, …) are a new extension surface. The split is at the right grain — one per wire-format family rather than per provider — but it is a surface to maintain.
- Operators with persisted installs from the prior shape lose their install configuration on the deployment that lands this ADR. Clean deploy is acknowledged, not absorbed.
Not abstracted:
- Per-tenant overrides of the shipped
runtime-catalog.yaml. v0.1 ships the file as platform-level configuration; tenants add models via per-install model lists, not by adding new providers or runtimes. A future ADR can introduce tenant-level catalogue extension if a real need surfaces. - Fully dynamic provider discovery (a tenant pasting an OpenAI-compatible endpoint URL and the platform inferring the provider). Out of scope; v0.1 admits providers via the checked-in YAML.
- Multi-shape credentials per
(tenant, provider, runtime). The matrix's per-edge single-shape rule is intentional. If a future runtime genuinely accepts both shapes for the same provider, a follow-up ADR introduces a discriminator.
This is a multi-PR initiative. The tracker issue #1761 breaks the work into per-area PRs sequenced by blocked-by:
- Core domain.
IAgentRuntimeremoval (replaced byAgentRuntimedata record);ModelProviderdata record;Modelrecord;Kindremoval;AgentExecutionConfigshape change; catalogue loader from YAML. Lands first; everything else depends on it. - Catalogue + adapters.
eng/runtime-catalog/runtime-catalog.yaml+runtime-catalog.schema.json;IModelProviderAdapter(OpenAiCompatibleAdapter,AnthropicAdapter,GoogleAdapter); the existingIAgentRuntimeLauncherstrategies move under the new layout. - Project re-layout. Per-provider / per-runtime projects (
AgentRuntimes.{Claude, Google, Ollama, OpenAI}) collapse; newCvoya.Spring.AgentRuntimes(launcher strategies) andCvoya.Spring.ModelProviders(adapter strategies) projects. - Manifest + parser.
ai.runtime,ai.model{provider,id}; legacy errors per the table above. - Web API / OpenAPI / Kiota. DTO restructure;
openapi.jsonregen;openapi-typescriptregen; Kiota regen. - CLI.
--runtime,--model-provider,--model; help text; legacy--agentrejection. - Web portal. Unit-create wizard, agent-create wizard, execution tab, execution panel, model selector, credential entry, tenant install screens. Credential entry moves from per-runtime to per-provider;
Provideraxis disappears for fixed-provider runtimes. - Dapr components. Rename files
conversation-*→llm-*; updatemetadata.name. - Docs.
docs/architecture/agent-runtime.mdrefresh;docs/architecture/identifiers.mdadd the (runtime, model) shape;docs/concepts/packages.mdAI block;docs/glossary.mdadd ModelProvider, refine AgentRuntime, retire Kind. - Tests. Every layer.
The tracker issue carries the per-area umbrella sub-issues with blocked-by wiring; the Core-domain umbrella blocks every other area.