ADR-0043: Per-role configuration resolution¶
- Status: Proposed
- Kind: Aspirational
- Area: agent-roles
- Date: 2026-07-09
- Relations: supersedes v0-0074
Context¶
LionAGI currently resolves agent configuration in several places. Pack parses per-Role model, effort, default Mode, allowed Mode, active, and prompt-directive values. CLI orchestration selects some of those fields. AgentSpec independently composes the prompt and loads a pack. External Markdown AgentProfile files provide still another prompt and provider overlay.
This ADR defines the target state. It does not claim the resolver described below is shipped.
Five current failure scenarios require one resolution contract.
P1 — selected-pack drift. CLI worker construction reads model, effort, and Modes from the selected custom Pack, but its later AgentSpec.compose() call omits pack=env.pack. The spec therefore defaults to the packaged "default" pack when rendering directives. One worker can receive runtime tuning from one pack and prompt guidance from another (lionagi/cli/orchestrate/_orchestration.py; lionagi/agent/spec.py).
P2 — precedence is distributed and partly implicit. Task model overrides, orchestration defaults, external profiles, pack configuration, and provider defaults are selected in different branches of build_worker_branch(). A maintainer cannot inspect one value to explain why the effective model, effort, Modes, prompt, and roster membership were chosen.
P3 — same-name external profiles silently change identity. available_roles() unions built-in Role names and external agent-profile names. resolve_worker_spec(role) attempts the external profile before the casts-role construction path. A same-named file can therefore replace the casts prompt with a verbatim prompt merely by existing on disk.
P4 — invalid configuration degrades silently. resolve_modes() logs and drops unknown or disallowed Mode names. An explicit empty list is also treated like “not specified,” so pack defaults return. The worker still starts with behavior different from the request.
P5 — roster membership is not enforced. RoleConfig.active is parsed and exposed in build_catalog(), but available_roles() lists every built-in Role and planner assignment does not filter or reject inactive Roles.
A naming collision increases the ambiguity: casts Profile is one Role plus ordered Modes; CLI AgentProfile is a Markdown prompt/provider overlay. The target gives the external type and its disposition explicit names.
The target boundary is a pure core inside the existing effectful construction shell:
filesystem/provider discovery
│ supplies typed inputs
▼
resolve_agent_spec(...) pure, deterministic, no mutation
│
▼
ResolvedAgentSpec immutable effective values + provenance
│
▼
Role-backed factory or explicit raw-prompt materializer
│
▼
Branch
| Concern | Decision |
|---|---|
| Resolution boundary | D1: one pure function returns one immutable ResolvedAgentSpec and performs no discovery or construction. |
| Precedence | D2: model, effort, Modes, tools, permissions, and prompt sources have explicit per-field precedence. |
| External prompts | D3: an external profile declares overlay or replacement; filename collision never chooses a disposition. |
| Roster | D4: selected-pack active controls built-in roster membership and assignment validation. |
| Validation | D5: unknown, disallowed, conflicting, or structurally invalid inputs fail before provider or Branch creation. |
| Pack continuity and explainability | D6: the selected Pack and per-field provenance pass unchanged into construction. |
This ADR does not decide:
- How pack prompt fields are renamed. ADR-0044 calls the target type
RolePromptDirectives; the resolver treats currentRolePolicyas its compatibility input. - How tool permission checks execute. ADR-0044 owns interception.
- How external Markdown files are discovered, which directories are trusted, or how frontmatter is parsed. Discovery produces typed resolver input.
- Provider endpoint creation, credential lookup, or model-specific effort translation. Those occur after resolution.
- The exact release count for accepting a legacy profile with no disposition. Compatibility policy owns the duration; this ADR fixes the warning and semantic fallback while that window exists.
- User-defined casts Roles. A replacement profile is a raw identity, not an extension to the built-in Role catalog.
Decision¶
D1 — resolve configuration through one pure typed function¶
The target public contract is:
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from enum import Enum
from typing import Any, Literal
class PromptDisposition(str, Enum):
OVERLAY = "overlay"
REPLACEMENT = "replacement"
@dataclass(frozen=True, slots=True)
class TaskAgentOverrides:
model: str | None = None
effort: str | None = None
modes: tuple[str, ...] | None = None
extra_prompt: str | None = None
tools: tuple[str, ...] | None = None
permissions: PermissionPolicy | None = None
yolo: bool | None = None
bypass: bool | None = None
fast_mode: bool | None = None
lion_system: bool | None = None
@dataclass(frozen=True, slots=True)
class ExternalAgentProfileInput:
name: str
body: str
disposition: PromptDisposition | None
base_role: str | None = None
model: str | None = None
effort: str | None = None
yolo: bool = False
bypass: bool = False
fast_mode: bool = False
lion_system: bool | None = None
artifact_defaults: Mapping[str, Any] | None = None
timeout: int | None = None
resume_on_timeout: bool = False
enabled: bool = True
@dataclass(frozen=True, slots=True)
class RuntimeAgentDefaults:
model: str | None
effort: str | None = None
tools: tuple[str, ...] = ()
permissions: PermissionPolicy | None = None
yolo: bool = False
bypass: bool = False
fast_mode: bool = False
lion_system: bool = True
@dataclass(frozen=True, slots=True)
class RawProfileIdentity:
name: str
@dataclass(frozen=True, slots=True)
class ExternalProfileRef:
"""Explicit selector for a raw replacement identity.
String Role names always select the built-in casts catalog; callers use
this type (or an `external:<name>` surface token parsed into it) when they
intend an external replacement, including on a name collision.
"""
name: str
@dataclass(frozen=True, slots=True)
class ResolutionDiagnostic:
code: Literal["legacy_profile_replacement"]
message: str
@dataclass(frozen=True, slots=True)
class ResolvedAgentSpec:
identity: Profile | RawProfileIdentity
selected_pack: Pack
active: bool
model: str
effort: str | None
tools: tuple[str, ...]
permissions: PermissionPolicy | None
yolo: bool
bypass: bool
fast_mode: bool
lion_system: bool
prompt_disposition: Literal["casts", "overlay", "replacement"]
prompt_body: str
prompt_overlays: tuple[str, ...]
grant_role_emissions: bool
artifact_defaults: Mapping[str, Any] | None
timeout: int | None
resume_on_timeout: bool
sources: Mapping[str, str]
diagnostics: tuple[ResolutionDiagnostic, ...]
def resolve_agent_spec(
role: str | Role | ExternalProfileRef,
*,
requested_modes: Sequence[str | Mode] | None,
selected_pack: Pack,
task_overrides: TaskAgentOverrides,
external_profile: ExternalAgentProfileInput | None,
runtime_defaults: RuntimeAgentDefaults,
) -> ResolvedAgentSpec: ...
The concrete implementation belongs in the agent-configuration boundary, not CLI orchestration. The resolver may depend on casts and the agent permission type. It must not depend on filesystem search, environment variables, provider endpoints, iModel, Branch, Session, or CLI parser objects.
ResolvedAgentSpec is an effective snapshot:
- all sequences are tuples;
sourcesandartifact_defaultsare exposed as read-only mappings or defensive copies;- permission rule mappings are defensively copied before exposure;
- no method mutates input
Role,Mode,Pack, external profile, task overrides, or defaults; - equal inputs yield equal resolved values and equal diagnostics.
The selected Pack is retained by identity for construction and explanation, but is treated as read-only. A materializer must not re-run precedence or silently replace it with "default".
Exact semantics.
selected_pack,task_overrides, andruntime_defaultsare required typed inputs. Callers construct defaults explicitly; ambient process settings are not read.roleas a string is resolved only against the built-in casts catalog. An external replacement is selected withExternalProfileRef; command/API surfaces may parse the explicitexternal:<name>token into that type before calling the resolver.- A Role object passes through after its canonical name is checked against selected-pack config.
ExternalProfileRef(name)requires an explicitly supplied external profile with the same name. The resolver never searches for it.- The resolver returns no
Branch, opens no path, imports no user module, and allocates no provider. modelmust resolve to a non-empty string or resolution raisesAgentResolutionErrorwith codemodel_unresolved.effort=Noneis valid after all precedence levels; it means the later model/provider default.- Diagnostics are non-fatal compatibility facts only. Invalid configuration is represented by a typed exception, never a diagnostic plus altered behavior.
Why this way. A pure snapshot makes configuration explainable and table-testable. The effectful shell can discover inputs and materialize outputs without owning policy.
D2 — field precedence is explicit and preserves empty overrides¶
The target precedence table is normative:
| Concern | Highest to lowest precedence |
|---|---|
| Model | task or CLI override → external profile → selected pack → runtime default |
| Effort | task or CLI override → external profile → selected pack → runtime/model default |
| Modes | explicit task value, including empty → selected-pack defaults → none |
| Tools | explicit task value, including empty → runtime default |
| Permissions | explicit task policy → runtime default |
Boolean provider flags (yolo, bypass, fast_mode) | explicit task value → external profile true → runtime default |
| Lion system preamble | explicit task value → explicit external profile value → runtime default |
| Prompt | explicit raw replacement, or Role → ordered Modes → selected-pack directives → external overlay → explicit task overlay |
| Roster | active built-in Roles from selected pack, plus explicitly enabled raw replacement profiles |
| Artifact defaults | external profile override → Role declaration → none |
| Timeout/resume | external profile → no deadline/no resume |
For model and effort, “external profile” participates only when a profile was explicitly selected or supplied by the discovery layer. The resolver never searches by name and never lets a file win because its name matches a Role.
Mode resolution distinguishes absence from emptiness:
requested_modes is None → use selected-pack default_modes
requested_modes == () → use no Modes; do not restore defaults
requested_modes non-empty → validate exactly that ordered sequence
modes_allow=() retains its current meaning of unrestricted. A non-empty allowlist constrains only explicit task Modes and pack defaults alike in the target; an invalid pack default is a pack configuration error rather than trusted input.
Prompt construction uses exactly two newlines between non-empty blocks. For a casts or overlay identity:
Role.body
Mode[0].behaviors
...
selected-pack directive block
external overlay body, when present
task extra_prompt, when present
The global LION_SYSTEM_MESSAGE preamble is not part of prompt_body. lion_system tells the materializer whether to prepend it. This keeps resolution deterministic without importing a process-specific prompt resource.
prompt_overlays retains only the non-empty external and task overlay blocks, in that order. It is empty for casts without overlays and for replacement identities. prompt_body remains the complete rendered result used for explanation and raw replacement; the retained overlay tuple lets the Role-backed adapter reconstruct the same prompt through AgentSpec without duplicating Role, Mode, or selected-pack text.
Boolean flags use explicit optional task fields so False can override an external/profile True. An implementation must not use truthiness such as task_value or profile_value for these fields.
The sources mapping records at least:
{
"identity": "casts:architect" | "external:<name>",
"model": "task" | "external_profile" | "pack" | "runtime_default",
"effort": "task" | "external_profile" | "pack" | "runtime_default" | "provider_default",
"modes": "task" | "pack" | "none",
"tools": "task" | "runtime_default",
"permissions": "task" | "runtime_default" | "none",
"prompt": "casts" | "external_overlay" | "external_replacement",
"active": "pack" | "external_profile",
}
Miss, empty, and conflict semantics.
- An unknown Role raises
role_unknownunless an explicitly supplied, enabled replacement profile is selected byExternalProfileRef. - Missing pack config for a built-in Role raises
role_missing_from_pack. Falling back to another pack is forbidden. - An empty prompt block is skipped without adding separators.
- Duplicate Mode names raise
mode_duplicate; the target is stricter than currentProfileconstruction so effective configuration has one unambiguous occurrence. - A Mode pair conflict raises
mode_conflictand names both Modes. - A task extra prompt of
""is an explicit empty overlay and has no rendered effect; it does not select replacement semantics. - Empty tools means register no configured tools.
permissions=Noneafter precedence means no agentPermissionPolicy; the separate governance gate may still apply.
Why this way. Per-field precedence reflects the current strongest-source intent while making absence and explicit denial distinguishable. Provenance turns “why did this worker run this way?” into data rather than source archaeology.
D3 — external prompt profiles declare overlay or replacement¶
The external profile input has two legal dispositions.
Overlay¶
disposition="overlay" requires base_role. The resolver:
- loads that built-in Role;
- verifies the requested/selected Role agrees with
base_role; - resolves and validates Modes;
- renders Role, Modes, selected-pack directives, external body, then task overlay;
- retains
Profileidentity and Role emission eligibility.
An overlay may override model, effort, runtime flags, artifact defaults, timeout, and resume settings. It does not redefine the Role's emits tuple or Mode catalog.
Replacement¶
disposition="replacement" requires base_role is None. The resolver:
- creates
RawProfileIdentity(name); - rejects non-empty requested Modes with
modes_on_replacement; - uses the external body, followed only by an explicit task prompt overlay;
- sets
grant_role_emissions=False; - does not render pack Role directives or claim casts Role identity.
A replacement remains a supported raw-prompt escape hatch. It is not added to Role.load(), list_roles(), or the casts catalog.
Collision semantics.
- A same-named external profile never shadows a built-in Role.
- Supplying a replacement is an explicit caller choice and must use
ExternalProfileRef; name equality alone is insufficient. A same-named built-in Role remains selected by its bare string. - An
ExternalProfileRefwith no supplied profile raisesexternal_profile_missing; a supplied profile with a different name raisesexternal_profile_name_mismatch. - An
ExternalProfileRefpaired withdisposition="overlay"raisesexternal_ref_requires_replacement. A replacement profile supplied alongside a bare built-in Role raisesreplacement_requires_external_ref; it is never silently ignored or applied. - An overlay whose
base_roleis missing raisesoverlay_role_missing. - An overlay supplied for a different requested Role raises
overlay_role_mismatch. - A replacement with a
base_roleraisesreplacement_has_base_role. - A disabled external profile raises
external_profile_inactivewhen explicitly selected and is omitted from roster output.
Legacy compatibility.
During the bounded compatibility window, a profile with disposition=None is interpreted as replacement and returns:
ResolutionDiagnostic(
code="legacy_profile_replacement",
message=(
"External profile '<name>' has no disposition; treating it as "
"'replacement' during the compatibility window."
),
)
The warning is emitted by the effectful caller once per loaded profile, not by the pure resolver through logging. When the compatibility window closes, the same input raises profile_disposition_required. The number of releases is deliberately not fixed here.
Why this way. Overlay and replacement are both valid, but they have different identity and capability consequences. Making the disposition data prevents filesystem presence from changing Role semantics.
D4 — selected-pack active state controls the planner roster¶
For each built-in Role, selected-pack RoleConfig.active is authoritative.
Target roster construction is:
def resolved_role_roster(
*,
selected_pack: Pack,
external_profiles: Sequence[ExternalAgentProfileInput],
) -> tuple[str, ...]:
"""Active built-in Role names plus explicit external:<name> replacement tokens."""
Exact semantics.
- A built-in Role is listed only when its selected-pack config exists and
active is True. - An inactive Role remains loadable for catalog inspection but cannot be assigned through the resolved orchestration surface.
- Explicit assignment to an inactive built-in Role raises
role_inactivebefore provider creation. - Overlay profiles do not add a second roster identity; they decorate their base Role when explicitly selected.
- Enabled replacement profiles add
external:<name>tokens. External identities are always namespaced this way, not only on collision; a parser converts that token toExternalProfileRef. - Bare names remain reserved for built-in Roles, so a replacement name colliding with a built-in Role is still independently addressable without ambiguity.
- Duplicate external replacement names after discovery are a discovery/configuration error; the resolver does not choose search-order winners.
- Planner guidance and assignment validation consume the same tuple. Separate roster formatting may not recalculate membership.
Why this way. active otherwise has no runtime meaning. One roster value prevents the planner from advertising identities that execution later rejects or, worse, quietly changes.
D5 — invalid configuration fails before materialization¶
The target exception contract is:
class AgentResolutionError(ValueError):
code: Literal[
"model_unresolved",
"role_unknown",
"role_missing_from_pack",
"role_inactive",
"mode_unknown",
"mode_disallowed",
"mode_duplicate",
"mode_conflict",
"pack_default_invalid",
"overlay_role_missing",
"overlay_role_mismatch",
"replacement_has_base_role",
"modes_on_replacement",
"external_profile_missing",
"external_profile_name_mismatch",
"external_ref_requires_replacement",
"replacement_requires_external_ref",
"external_profile_inactive",
"profile_disposition_required",
"resume_without_timeout",
]
subject: str | None
details: Mapping[str, Any]
All errors are raised before iModel, Branch, tool, MCP, or capability allocation.
Validation order is deterministic:
1. classify explicit casts vs external identity
2. validate profile disposition and base-role relationship
3. require selected-pack entry and active state for casts identity
4. resolve model and effort sources
5. choose requested or default Modes
6. validate Mode existence, uniqueness, allowlist, and pair conflicts
7. resolve tools, permissions, flags, artifacts, and deadline values
8. compose prompt and source map
9. return ResolvedAgentSpec
The first failure in that order is returned. Bulk diagnostic accumulation is not used for fatal errors because later checks may depend on an identity or pack entry that did not resolve.
Specific behavior:
- Unknown explicit Modes raise
mode_unknown, not warnings. - Disallowed explicit Modes raise
mode_disallowedand retain the rejected name and allowlist indetails. - Unknown or disallowed pack defaults raise
pack_default_invalid, identifying the pack and Role. - External timeout accepts only a positive non-boolean integer; invalid values are discovery/schema errors before resolver invocation.
resume_on_timeout=Truewithtimeout=Noneraisesresume_without_timeout; auto-resume cannot have meaning without a deadline.- A prompt replacement never receives a Role emission contract even if its name equals a Role.
Why this way. Silent dropping changes requested behavior while returning a seemingly valid agent. Early typed failure is cheaper than debugging a worker that started under unintended Modes or directives.
D6 — construction consumes the selected pack and resolved values unchanged¶
The Role-backed materializer converts the snapshot without re-resolving it:
def agent_spec_from_resolved(resolved: ResolvedAgentSpec) -> AgentSpec:
"""Pure adapter for casts/overlay identities; rejects replacement identity."""
async def create_resolved_agent(
resolved: ResolvedAgentSpec,
*,
chat_model=None,
log_config=None,
load_settings: bool = True,
project_dir: str | None = None,
trust_project_settings: bool = False,
) -> Branch:
"""Materialize casts/overlay through create_agent; replacement through
the explicit raw-prompt path."""
For casts and overlay identities, the adapter must preserve:
- the resolved
Profileand ordered Modes; - the exact
selected_packobject; - resolved model, effort, tools, permission policy,
yolo,lion_system, and the exactprompt_overlayssequence; grant_emissions=resolved.grant_role_emissions.
The adapter sets pack=resolved.selected_pack and joins prompt_overlays with exactly two newlines into AgentSpec.extra_prompt. Because the resolved Profile already contains the Role and ordered Modes, AgentSpec.build_system_message() then reconstructs prompt_body exactly once: Profile body, selected-pack directives, then the retained overlays. An adapter test must assert that the reconstructed text equals resolved.prompt_body before the global Lion preamble is applied.
For replacement identity it installs prompt_body verbatim according to lion_system and does not synthesize a Role, pack directives, or Role grants.
bypass, fast_mode, artifact_defaults, timeout, and resume_on_timeout remain on ResolvedAgentSpec; they are provider/orchestration inputs, not current AgentSpec fields. The materializer consumes them when it builds the provider and run envelope. If a caller supplies an already-built chat_model, that caller owns provider-only flag application; resolution is not run again.
Construction may add runtime-only state such as settings hooks, an existing chat_model, log configuration, MCP discovery, and Branch name. It may not repeat precedence, consult a different pack, restore default Modes after an explicit empty tuple, or search again for a same-named external profile.
Why this way. Resolution is useful only if materialization cannot reinterpret it. Passing the pack and provenance through makes configuration drift observable and prevents the custom-pack bug that motivated this ADR.
Consequences¶
- Library and CLI callers can explain effective configuration from one immutable value.
- Custom-pack runtime tuning and prompt directives cannot diverge.
- Same-named files stop silently changing casts identity.
- Planner roster and assignment validation share one active-set contract.
- Explicit empty Modes and tools become meaningful denial/disable states.
- Previously tolerated invalid Modes and pack defaults become startup errors. This is an intentional behavior change and requires error handling at CLI/API boundaries.
- External profile frontmatter gains a required disposition after a compatibility window.
- The resolver adds public types, error codes, and provenance that must remain stable or be migrated.
- Provider discovery still occurs separately; callers must construct typed inputs before resolution.
- Reversing the pure resolver is moderate-to-high cost once library and CLI surfaces depend on the error and source maps.
- Reversing explicit profile disposition would reintroduce hidden identity changes and potential capability over-grant.
- A frozen top-level result does not make referenced model classes or Pack internals magically immutable; adapters must keep defensive-copy and no-mutation rules.
Alternatives considered¶
Keep today's profile-first lookup¶
This preserves compatibility and requires no new disposition field. It lost because adding a file can replace a built-in Role's body, directives, Modes, and emission eligibility without an explicit request. The behavior is not explainable from the task assignment alone.
Remove external raw profiles¶
A casts-only system would have one identity model and one factory path. It lost because verbatim prompts are a valid escape hatch for specialized workers and existing profiles carry useful model and timeout defaults. The decision makes replacement explicit instead of removing it.
Treat every external profile as an overlay¶
This would keep all workers Role-backed and preserve factory wiring. It lost because some profiles are intentionally complete prompts with no truthful base Role or emission declaration. Forcing a base Role would add prompt text and capabilities the author did not request.
Treat every external profile as a replacement¶
This matches legacy behavior and is simple. It lost because users also need to tune or extend a known Role while retaining its typed identity, Modes, directives, and emissions. Overlay expresses that intent without shadowing.
Resolve configuration inside create_agent()¶
The factory already owns construction and could read pack, task, profile, and runtime settings. It lost because resolution would inherit filesystem/provider effects and become hard to test or reuse. It would also leave CLI and library callers unable to inspect the effective values before allocation.
Add a second settings hierarchy outside Pack¶
A separate agent defaults file could own model/effort precedence and leave Pack prompt-only. It lost because RoleConfig already owns those per-Role fields. A second hierarchy would require cross-file precedence and could drift from the directives selected for the same Role.
Keep warning-and-drop Mode validation¶
This maximizes run completion when planner output is imperfect. It lost because the worker then runs with different reasoning behavior from the request, and pack typos can persist unnoticed. A typed configuration error lets the planner or caller correct the assignment before side effects.
Make active=False advisory planner text only¶
This would avoid hard failures for direct assignments. It lost because an advisory flag still allows stale or malicious assignments and gives catalog state no enforceable meaning. The same resolved roster must drive both guidance and validation.
Store only effective values, not provenance¶
A smaller result type would be sufficient for construction. It lost because the core motivation is distributed precedence. Without sources, maintainers still cannot explain why a value won or detect that selected-pack policy drifted.
Notes¶
Contra proferentem resolves ambiguous external profiles toward the minimal explicit contract: composition and replacement are both supported, but the caller chooses. During migration only, a missing disposition retains legacy replacement behavior with a typed warning.
Source evidence for the current gaps: lionagi/casts/pack.py, lionagi/casts/catalog.py, lionagi/agent/spec.py, lionagi/cli/_providers.py, and lionagi/cli/orchestrate/_orchestration.py.