Skip to content

symfonic.agent.config

config

FrameworkConfig -- unified configuration for the symfonic agent framework.

Composes core AgentConfig and memory OrchestratorConfig with framework-specific behaviour flags.

FrameworkConfig

Bases: BaseModel

Immutable configuration composing core and memory configs.

Framework-specific flags control automatic hydration, consolidation, lazy tool routing, and streaming.

v8.7.1: unknown kwargs are ignored but logged once (WARNING). The class previously set only frozen=True while Pydantic's extra default is "ignore", so a typo'd or renamed flag on this 100+-flag config was dropped silently. Rather than hard-fail (extra="forbid" would break adopters passing stale flags on upgrade), unknown fields are still dropped -- preserving the original wire behaviour -- but each is logged once via _warn_unknown_fields so the typo is visible.

Retired or replaced on the 11.0 line (TA8.26, TA8.42, TA8.39, TA8.43, TA8.38). Thirty-eight fields below still exist, still validate, and still hold the value you give them -- and run(), stream() and stream_typed() refuse by name before dispatch when one of them is actually used. The refusal is RetiredConfigurationError, it cites the line, it names the contractual group, and it says what replaced the field or that nothing did. The fields are kept here rather than deleted so the failure is that named error rather than the silent drop _warn_unknown_fields performs for an unknown key.

Setting one at its default, or at a value the contract calls inactive (nightly_nap_cron="", quick_nap_interval=0, metacognition_sensitive_tags=[]), is not use and does not refuse. The twelve groups and the forty-three names are declared once, as data, in :mod:symfonic.agent.cutover.retired_settings -- except the five nested ones, which are declared in :mod:symfonic.agent.cutover.orchestrator_policy because that table's predicate reads flat attributes by design:

  • phase-12-extraction -- extraction_template_path, phase_12_action_type_from_tool_calls, phase_12_llm_max_drafts_per_run, phase_12_llm_max_episodes_per_run, phase_12_llm_model, phase_12_promote_assistant_content, phase_12_use_llm_extractor
  • consolidation-promotion -- promotion_max_drafts_per_run, promotion_min_pattern_count, promotion_recency_days, synthetic_link_min_co_count
  • nap-scheduling -- nightly_nap_cron, nightly_nap_enabled, quick_nap_interval
  • metacognition-tuning -- metacognition_confidence_threshold, metacognition_gate, metacognition_selective_gate, metacognition_sensitive_tags, metacognition_trivial_ack_patterns
  • prompt-mechanics -- activation_log_mode, prompt_block_self_edit
  • engine-internals -- dev_sqlite_checkpoint_path, scorer_on_hot_path, state_class
  • already-unconditional -- experimental_tool_decorator
  • human-pause-policy -- ask_user_pause_ttl_seconds, ask_user_tool_descriptions
  • tool-force-lever -- procedural_force_first_action_tool
  • governance-policy -- credential_patterns, fabrication_refuse_min_confidence
  • prompt-assembly-policy -- hms_budget_mode, hms_system_prompt_token_budget, jit_manifest_token_budget, manifest_cache_position, memory_extract_cadence, messages_cache_policy, on_hms_budget_exceeded, system_prefix_cache_ttl
  • memory-orchestrator-policy -- orchestrator.compaction_threshold, orchestrator.enabled_layers, orchestrator.llm_model, orchestrator.llm_temperature, orchestrator.scope_blend

memory-orchestrator-policy is the fifth replacement group and the only one whose every member is nested, so its five rows are declared in :mod:symfonic.agent.cutover.orchestrator_policy rather than in the flat table above -- same class, same group, same line. It is also the only group whose members were already doing nothing where you wrote them, and that is the news to read first. orchestrator.enabled_layers and orchestrator.scope_blend are overwritten from the top-level enabled_layers and scope_blend_mode before the orchestrator is built, so setting either was a silent no-op; write the top-level field, which is the one that already won. compaction_threshold, llm_model and llm_temperature say "deprecated -- never read at runtime" in their own field descriptions: schedule SleepConsolidator phases for the first, and use agent.model and agent.model.temperature for the other two -- per-memory-layer model routing does not arrive with the move, because it never existed. The container's other six fields are untouched: default_top_k and scoring_weights are honoured on the migrated route when your memory bundle travels, and context_budget, write_policy and working_graph_retention name a write boundary this line does not cross yet and keep working where they always did.

prompt-assembly-policy is the fourth replacement group and the largest: eight spellings of prompt layout -- a token ceiling, a cache marker's offset, a directive's cadence -- whose outcomes continue because this line compiles the prompt rather than splicing it. PromptingCapability orders contributions on the L0/L1/L2 volatility ladder, admit_within_budget drops the most volatile first under a PromptBudget your composition root binds through fold_retrieval_bundle(context_window=..., prompt_share=...), and plan_regions derives the cache breakpoints from the layers. Three breaks are worth reading before you migrate: the budget covers the whole compiled prompt rather than the HMS section alone, so the same number binds tighter; there is no warn/strict pair, because the compiler drops before it raises and raises only when pinned content alone will not fit; and on_hms_budget_exceeded becomes something you read (CompiledPrompt.budget, CompiledPrompt.diagnostics) rather than a hook that fires. Two C1-G fields are deliberately not in this group: domain_description_max_chars, which the migrated path reads and which is therefore honoured rather than refused, and procedural_render_preflight_in_l1, which switches on a synthesiser this line has no replacement for and which keeps working where it always did.

governance-policy is the third replacement group and the one whose members are safety controls: both outcomes continue on this line, composed on the governance capability your root wires rather than spelled here. GovernanceCapability.compose(patterns=...) carries the credential list into CredentialHygieneStage, which scrubs more than the legacy scrubber did -- tool-call arguments as well as properties, and a whole tool result string when the pattern matches inside it -- and records an explicit verdict when a deployment disables it. GovernanceCapability.compose(refuse_min_confidence=..., min_confidence=...) carries the fabrication grade into FabricationStage, where one number becomes two and the refusal default moves from 0.8 to 0.9: write refuse_min_confidence=0.8 to keep today's behaviour, and raise min_confidence if you relied on sub-threshold findings staying advisory, because they now steer a revision. The per-call is_admin= keyword is replaced in the same lane and is not a field: it refuses through :mod:symfonic.agent.cutover.authority, where the whole contract and its adopter-visible breaks are published.

tool-force-lever is also a replacement, and the narrowest one: the procedural force lever continues under procedural_force_tool_choice: Literal["off", "soft", "hard"], which has shipped in this same model since v7.20.0 and which resolve_force_tool_choice() already prefers. Write procedural_force_tool_choice="soft" where you wrote procedural_force_first_action_tool=True -- that is the mapping the resolver already applies, so the mode you get is unchanged -- or "hard" for the strict-fail-on-refusal semantics the bool could never reach.

human-pause-policy is the other group that is a replacement rather than a retirement: the pause capability continues on this line, configured on the HumanInteractionCapability your composition root wires -- its lifetime by TTLPolicy, its presentation by InteractionToolSpec -- and only these spellings stop. The nested third spelling of the same lifetime, agent.ask_user_pause_ttl_seconds, refuses in the same group with the same class through :mod:symfonic.agent.cutover.pause_policy, which is also where the whole contract and its adopter-visible breaks are published.

A 9.12 or 10.4 install runs its own code, which still honours all thirty-eight; the compatibility window is kept by not backporting the retirement module, never by a version branch inside this line.

task_scheduler property

task_scheduler: TaskSchedulerProtocol

Return the configured scheduler or a NullScheduler fallback.

child classmethod

child(parent: FrameworkConfig, *, name: str | None = None, description: str | None = None, model_name: str | None = None, temperature: float | None = None, max_tokens: int | None = None, **overrides: Any) -> FrameworkConfig

Derive a sub-agent config from parent, overriding only what's named.

v9.1.0 (issue #28). The inheritance primitive the declarative SubAgentSpec (#27) builds on. A child agent inherits every behaviour flag from parent (auto_hydrate, auto_consolidate, lazy_tooling, enable_hms_prompt, the whole agent/orchestrator config, ...) so sub-agents don't drift from the parent's settings; only the arguments you pass are overridden.

Parameters:

Name Type Description Default
parent FrameworkConfig

The config to inherit from.

required
name str | None

Sets the child DomainTemplate.name (a copy of the parent domain with this name). Ignored when a full domain is passed via overrides.

None
description str | None

Sets the child DomainTemplate.description (same copy rules as name).

None
model_name str | None

Override the child ModelConfig.model_name while inheriting the rest of the parent's model (temperature, max_tokens, sampling, retries).

None
temperature float | None

Override the child ModelConfig.temperature.

None
max_tokens int | None

Override the child ModelConfig.max_tokens.

None
**overrides Any

Any other top-level FrameworkConfig field to override verbatim (e.g. domain=..., lazy_tooling=False).

{}

Returns:

Type Description
FrameworkConfig

A new frozen FrameworkConfig; parent is unmodified.

Source code in symfonic/agent/config.py
@classmethod
def child(
    cls,
    parent: FrameworkConfig,
    *,
    name: str | None = None,
    description: str | None = None,
    model_name: str | None = None,
    temperature: float | None = None,
    max_tokens: int | None = None,
    **overrides: Any,
) -> FrameworkConfig:
    """Derive a sub-agent config from ``parent``, overriding only what's named.

    v9.1.0 (issue #28). The inheritance primitive the declarative
    ``SubAgentSpec`` (#27) builds on. A child agent inherits **every**
    behaviour flag from ``parent`` (auto_hydrate, auto_consolidate,
    lazy_tooling, enable_hms_prompt, the whole ``agent``/``orchestrator``
    config, ...) so sub-agents don't drift from the parent's settings;
    only the arguments you pass are overridden.

    Args:
        parent: The config to inherit from.
        name: Sets the child ``DomainTemplate.name`` (a copy of the
            parent domain with this name). Ignored when a full ``domain``
            is passed via ``overrides``.
        description: Sets the child ``DomainTemplate.description`` (same
            copy rules as ``name``).
        model_name: Override the child ``ModelConfig.model_name`` while
            inheriting the rest of the parent's model (temperature,
            max_tokens, sampling, retries).
        temperature: Override the child ``ModelConfig.temperature``.
        max_tokens: Override the child ``ModelConfig.max_tokens``.
        **overrides: Any other top-level ``FrameworkConfig`` field to
            override verbatim (e.g. ``domain=...``, ``lazy_tooling=False``).

    Returns:
        A new frozen ``FrameworkConfig``; ``parent`` is unmodified.
    """
    import dataclasses as _dc

    update: dict[str, Any] = {}

    # Domain: a full ``domain`` override always wins; otherwise derive a
    # copy of the parent domain with the given name/description.
    if "domain" not in overrides and (
        name is not None or description is not None
    ):
        dom_update: dict[str, Any] = {}
        if name is not None:
            dom_update["name"] = name
        if description is not None:
            dom_update["description"] = description
        update["domain"] = parent.domain.model_copy(update=dom_update)

    # Model: rebuild the nested frozen ``ModelConfig`` on a copied
    # ``AgentConfig`` when any model knob is overridden.
    if model_name is not None or temperature is not None or max_tokens is not None:
        model_update: dict[str, Any] = {}
        if model_name is not None:
            model_update["model_name"] = model_name
        if temperature is not None:
            model_update["temperature"] = temperature
        if max_tokens is not None:
            model_update["max_tokens"] = max_tokens
        new_model = _dc.replace(parent.agent.model, **model_update)
        update["agent"] = _dc.replace(parent.agent, model=new_model)

    update.update(overrides)
    assembled = parent.model_copy(update=update)
    # v9.2.1 (review P2a): ``model_copy(update=...)`` skips validation, so
    # an out-of-range override (e.g. hydration_threshold=99) or a typo'd
    # field would be silently retained. Re-validate the assembled mapping
    # so field constraints are enforced and unknown keys are dropped +
    # warned (the ``_warn_unknown_fields`` before-validator). Nested model
    # instances are passed through unchanged (pydantic does not revalidate
    # instances), so object identity of ``agent``/``domain``/``scheduler``
    # is preserved.
    return cls.model_validate(dict(assembled.__dict__))

resolve_force_tool_choice

resolve_force_tool_choice() -> Literal['off', 'soft', 'hard']

v7.20.0 T-7.20.0.8: single source of truth for the effective force-lever mode the engine should use.

Precedence (deliberate):

  1. procedural_force_tool_choice set explicitly to non-default ("soft" or "hard") wins — that's the precise field.
  2. Legacy procedural_force_first_action_tool=True with the enum at its default "off" resolves to "soft" — preserves v7.19.5 runtime semantics (clean abstain on provider refusal).
  3. Both defaults → "off" (no forcing).

The engine's force-resolver site at engine.py::_maybe_resolve_forced_tool_choice MUST consult this method rather than reading either field directly so the precedence rule lives in exactly one place.

Source code in symfonic/agent/config.py
def resolve_force_tool_choice(self) -> Literal["off", "soft", "hard"]:
    """v7.20.0 T-7.20.0.8: single source of truth for the effective
    force-lever mode the engine should use.

    Precedence (deliberate):

    1. ``procedural_force_tool_choice`` set explicitly to non-default
       (``"soft"`` or ``"hard"``) wins — that's the precise field.
    2. Legacy ``procedural_force_first_action_tool=True`` with the
       enum at its default ``"off"`` resolves to ``"soft"`` —
       preserves v7.19.5 runtime semantics (clean abstain on
       provider refusal).
    3. Both defaults → ``"off"`` (no forcing).

    The engine's force-resolver site at
    ``engine.py::_maybe_resolve_forced_tool_choice`` MUST consult
    this method rather than reading either field directly so the
    precedence rule lives in exactly one place.
    """
    # Step 1: enum-set-explicit wins (covers "soft" and "hard").
    if self.procedural_force_tool_choice != "off":
        return self.procedural_force_tool_choice
    # Step 2: legacy bool with default enum maps to "soft".
    if self.procedural_force_first_action_tool:
        return "soft"
    # Step 3: both at default.
    return "off"

with_defaults classmethod

with_defaults() -> FrameworkConfig

Create a config with all default values.

Source code in symfonic/agent/config.py
@classmethod
def with_defaults(cls) -> FrameworkConfig:
    """Create a config with all default values."""
    return cls()