Skip to content

Prompt extraction migration

The migrated path composes the model-visible extraction directive and durable extraction separately. A directive tells the response model which operations to emit; MemoryExtractionService parses a separate extraction-model response and writes only validated records. Neither collaborator silently creates the other.

from symfonic import Agent
from symfonic.capabilities.memory import (
    LegacyGraphOperationsAdapter,
    MemoryExtractionService,
    memory_capabilities,
)
from symfonic.capabilities.prompting import ExtractionDirectiveSource, PromptingCapability, extraction_contribution

directive = ExtractionDirectiveSource(
    template_path=custom_template,
    cadence=2,
    scope_path=scope.path,
)
agent = Agent(provider, capabilities=[
    *memory_capabilities(
        store,
        scope,
        extractor=LegacyGraphOperationsAdapter(
            MemoryExtractionService(extraction_model)
        ),
    ),
    PromptingCapability(sources=[extraction_contribution(directive)]),
])

cadence=1 renders on every turn, cadence=N renders on turns divisible by N, and cadence=0 omits the directive. For N > 1, pass a stable session_id to Agent.run() or Agent.stream(); the counter is keyed by the full scope and session, so one conversation cannot advance another's cadence. The source requires a tenant scope and does not render on an unscoped turn. For a scoped AgentHost, pass the composed scope's .path as shown above. Custom templates retain MemoryExtractionSection validation.

When the response model emits a complete <MEMORY_EXTRACT> or <GRAPH_OPERATIONS> envelope, the public Agent removes that envelope from its result and stream. MemoryExtractionService persists an inline operation only when it uses its native action/properties classification schema; rejected tagged operations fall back to the configured extraction model. The bundled legacy templates use type/payload, so compose LegacyGraphOperationsAdapter explicitly around MemoryExtractionService for those templates. It translates only upsert_node, set_trigger, create_event, and register_skill into native record operations, validates the required layer and documented payload fields, and derives classification, scope, and origin from the native request. It rejects retract_node, mixed schemas, malformed payloads, and supplied scope or provenance fields; their envelopes remain hidden from the user and invoke the configured fallback. The adapter does not issue arbitrary graph writes or implement legacy retractions. A custom template can instead emit the native schema directly.

The total PromptBudget remains a compiler-wide ceiling. When a deployment also needs the legacy regional controls, compose them explicitly with RegionalPromptPolicy. Its HMS boundary is pinned and measures the authored HMS section with the legacy three-characters-per-token estimate. In warn mode it sends the whole section and reports the first overflow; in strict mode it reports before failing closed, before the provider receives a request. The observer receives only (region, requested_tokens, limit), never prompt bytes. The JIT manifest source has its own four-characters-per-token cap, so one regional cap cannot consume the other.

from examples.migration_parity.regional_prompt_budgets import build_agent

agent = build_agent(
    provider,
    hms_state=deployment_hms_state,
    domain=domain,
    jit_manifest=("search: find records", "calendar: schedule meetings"),
    on_hms_budget_exceeded=record_overflow,
)

The composition refuses a non-zero JIT budget without a manifest and refuses a manifest unless the caller explicitly declares the JIT strategy. It projects only DomainTemplate.description into the HMS source and applies the full-HMS or JIT member of domain_description_max_chars deliberately. The legacy JIT template itself is not adapted by this composition, so its exact body and query-keyword filtering remain a migration gap; this does not approve a blanket transition for Issue #187.