Design: Task-aware model roles & a single model resolver¶
Status: proposal · Owner: TBD · Last updated: see git
1. Problem¶
Soulacy currently treats "the LLM" as one global thing, but different tasks need different models, and the wiring that picks a model is duplicated and inconsistent across the codebase. There are at least four independent "get me a model" paths, each constructed differently and each able to break on its own:
| Path | Where | How it resolves the model | Gets the provider's base_url/key? |
|---|---|---|---|
| Classic chat / tool loop | provider registry (internal/llm, wired in wire_subsystems.go) |
registered provider instance | yes (from config/env) |
| Reasoning loop (ReAct / Plan-Execute) | internal/reasoning.DefaultBackendFor |
builds its own backend from def.LLM.BaseURL |
no — empty agent base_url → localhost:11434 |
| Studio compiler / agent builders | gateway.studioLLM() |
separate selection | partial |
| Embeddings | wire_subsystems.go embedders |
its own Ollama base_url | yes |
Because these are separate, fixing the provider in config.yaml fixes one path
but not the others. Concrete failures observed:
- ReAct "loop produced empty output" — the reasoning backend called
http://localhost:11434inside the container (agentbase_urlwas empty, soDefaultBackendForfell back todefaultOllamaBase). The classic loop worked because it used the registered provider withhost.docker.internal. Same model, different code path, different result. - Studio inventing tool names / ReAct empty output on small models — the compiler and reasoning planner ran on whatever the agent used (e.g. a small local model), which is unreliable at structured-JSON / planning tasks. These tasks need a stronger model than the one that runs the agent.
The root cause is architectural: model selection is ad hoc and not aware of the task it's selecting for.
2. Goals¶
- One central resolver that every code path uses to obtain a model client —
always applying the configured provider's
base_url, key, and options. This makes the localhost-style bug structurally impossible. - Task-aware roles: the model that builds an agent (Studio) or plans for it (reasoning) is chosen independently of the model that runs it.
- Smart defaults that "just work," surfaced to the user, fully overridable.
- GUI and CLI configuration for all of it.
Non-goals: changing provider adapters themselves, or the prompt content of any task (covered elsewhere).
3. Model roles¶
Introduce a small fixed set of roles. A role is a named slot that resolves
to {provider, model, base_url?, options?}:
| Role | Used by | Needs |
|---|---|---|
chat (default) |
agent conversations + classic tool loop | cost-sensitive; local is fine |
reasoner |
ReAct / Plan-Execute planning & reflection | reliable structured JSON, decent reasoning |
studio |
Studio compiler, agent/skill builders, suggestions | strong instruction-following + JSON |
embedder |
knowledge / vector embeddings | embedding model (already separate) |
Resolution order for any role (first hit wins):
- Per-agent override (SOUL.yaml
llm:/reasoning:block) — only forchat/reasonerwhere an agent legitimately overrides. - Explicit role config in
config.yaml(llm.roles.<role>). - Role default derived by detection (§5).
Every resolution returns a client built by the same central resolver, so it
always carries the provider's base_url + key.
4. Central resolver¶
A single function (sketch):
// package llm (or a new package modelresolver)
type Role string
const (
RoleChat Role = "chat"
RoleReasoner Role = "reasoner"
RoleStudio Role = "studio"
RoleEmbedder Role = "embedder"
)
// Resolve returns a ready-to-use client for the role, with provider base_url,
// api_key and options already applied. def may be nil for non-agent roles
// (studio/embedder) or carry per-agent overrides (chat/reasoner).
func (r *Resolver) Resolve(role Role, def *agent.Definition) (Client, ResolvedModel, error)
ResolvedModel{Provider, Model, BaseURL} is also returned so callers can log
and surface it ("compiling with gemini-2.5-pro"). The reasoning loop,
studioLLM(), and the chat loop all call Resolve(...) instead of constructing
backends by hand. DefaultBackendFor's hand-rolled Ollama construction is
deleted.
This step alone fixes the ReAct localhost bug: the reasoner role resolves
through the provider config that already has host.docker.internal.
5. Defaults & detection¶
The resolver computes role defaults from what's actually available, so most users never configure anything:
- Detect cloud keys: if a provider with a key exists (anthropic, openai, google/gemini, groq, …), it's a candidate for the "strong" roles.
- Detect local models: query Ollama
/api/tagsfor installed models. chat→ the user'sllm.default_provider(unchanged behavior).studio/reasoner→ prefer the strongest configured cloud model; else the best installed local model (e.g. a 70B / qwen-class model) for JSON reliability. Never silently fall back to an unreachable local default.
Rationale, from observed failures: run agents on cheap local models if you want, but compile/plan with your strongest model — the build-time model and the run-time model have different requirements.
6. Config schema¶
Additive and backward-compatible (llm.default_provider keeps working as the
chat default):
llm:
default_provider: ollama # = chat role default (unchanged)
providers:
ollama: { base_url: "http://host.docker.internal:11434", model: "llama3:70b" }
google: { api_key: "...", model: "gemini-2.5-pro" }
roles: # NEW — all optional; detection fills the gaps
reasoner: { provider: google, model: "gemini-2.5-pro" }
studio: { provider: google, model: "gemini-2.5-pro" }
# chat / embedder omitted -> defaults
7. Guidance: smart → surfaced → override¶
- Auto — detection picks per-role defaults; most users do nothing.
- Surfaced — show the choice. Extends the Studio recommendation banner ("compiling with gemini-2.5-pro; agent runs on llama3:70b") and a per-run note in chat/reasoning.
- Override —
llm.roles.*in config, per-agent in SOUL.yaml.
8. GUI & CLI surfaces¶
- GUI — a "Models" section on the Config page (same pattern as the new
web-search control and the default-provider dropdown): one row per role →
provider dropdown + model dropdown, validated against registered providers /
pulled models. Backend: add
RolestoPatchableConfig+applyPatch, and exposellm.rolesinsafeConfigView. - CLI — an
sy onboardstep ("Which model should build agents and do reasoning?") andsy config set llm.roles.<role>.{provider,model} …for scripting. - Per-agent — the Agents page already exposes provider/model/strategy; add
base_urlso an agent can't silently fall back to localhost, and a warning when an agent has both aworkflow:and areasoning.strategy(the workflow silently wins — see the Studio mode-recommendation work).
9. Phased rollout¶
- Central resolver +
reasoner/studiorole defaults (highest leverage): fixes the ReAct localhost bug and routes Studio/agent-building to the strongest available model. DeleteDefaultBackendFor's hand-rolled Ollama path; routestudioLLM()and the reasoning loop throughResolve. llm.rolesconfig schema + detection — make defaults explicit and overridable inconfig.yaml.- GUI Models section — provider/model dropdowns per role on the Config
page (backend
Rolespatch support + a Config.svelte panel). - CLI —
sy onboardstep +sy config set llm.roles.*. - Per-agent base_url + workflow/strategy conflict warning.
10. Risks & notes¶
- Backward compatibility:
llm.rolesis additive; absent → current behavior (chat fromdefault_provider, reasoner/studio from detection). No migration required. - Reachability validation: detection must verify a chosen model is actually reachable/pulled (boot probe already exists for agents) and degrade with a loud log rather than a silent empty-output failure.
- Cost surprise: defaulting studio/reasoner to a cloud model when a key exists could surprise cost-sensitive users — hence "surfaced" guidance and an easy override.