Skip to content

Synthetic tool-error recovery (CORE-227)

symfonic.evals.error_recovery.run_trial(provider, "native", agent_factory) runs public Agent.run once with fresh tools and a fresh in-process ledger; agent_factory(provider, tools) returns the Agent under test. The module's run_pair adds a second, counted legacy route: it calls a caller-supplied legacy_factory(provider, tools) and counts entries into the returned host's _legacy_run_impl, so the factory must return a SymfonicAgent-shaped host (run(...).final_response, flush_background_tasks()). The module imports and builds no SymfonicAgent itself; the repository's paired comparison supplies a SymfonicAgent factory, and that composition is kept in the source repository, not shipped. From 11.3.0 SymfonicAgent(...) refuses at construction, so an adopter evaluates the native route only. read_value raises a synthetic ValueError on its first attempt, then returns 42. record_value applies the value once; attempted duplicate records are counted even when rejected.

The report retains final answer, read/record attempts, applied/duplicate effects, recorded values, model-call count, exposed stop reason and the actual synthetic error observation delivered on a later model round. passed requires the read retry, one applied record, zero duplicates, correct final value, error evidence at the second provider call and correct route/body count. Exactly four calls and read rounds (1, 2) / record round (3,) are required; two reads batched before observing the failure do not count as recovery. A correct-looking final answer alone cannot pass. On the repository's legacy route, stop reason is not exposed: null means UNKNOWN.

Offline instrumentation check

This runs on an installed package with no provider and no network. The scripted model replies the same way whatever it observes, so a pass here proves only that the harness counts reads, records, rounds and the error observation; it says nothing about how a real model recovers.

import asyncio

from langchain_core.messages import AIMessage

from symfonic import Agent
from symfonic.core.testing.scripted import ScriptedChatModel, ScriptedModelProvider
from symfonic.evals.error_recovery import run_trial


class BoundedScript(ScriptedChatModel):
    max_tokens: int = 256
    max_retries: int = 0


def call(name, args, call_id):
    return AIMessage(content="", tool_calls=[{"name": name, "args": args, "id": call_id}])


def native_factory(provider, tools):
    return Agent(provider, tools=tools, max_model_rounds=4)


provider = ScriptedModelProvider()
provider.model = BoundedScript(
    script=[
        call("read_value", {}, "first"),
        call("read_value", {}, "retry"),
        call("record_value", {"value": 42}, "record"),
        AIMessage(content="42", response_metadata={"finish_reason": "stop"}),
    ],
    calls=[],
)
evidence = asyncio.run(run_trial(provider, "native", native_factory))
assert evidence.passed, evidence
print(evidence)

Bounded invocation, pending approval

No approved provider/model configuration was available for this work. No real model calls ran, no .env was sourced, and real recovery outcomes are UNKNOWN. The following is an invocation template, not an existing approved provider:

import asyncio

from approved_local_eval import create_provider  # owner-supplied, not shipped

from symfonic import Agent
from symfonic.evals.error_recovery import run_trial


def native_factory(provider, tools):
    return Agent(provider, tools=tools, max_model_rounds=4)


evidence = asyncio.run(run_trial(create_provider(), "native", native_factory))
print(evidence)  # synthetic answers/errors only; never print provider/config

The owner must approve the provider/endpoint/model and configure its ordinary LangChain chat model with max_tokens=256, max_retries=0, cache disabled and list/absent callbacks. Call the factory once per trial so every trial gets a fresh provider and model state with the same approved model/config, irrespective of the framework's model-config argument. Do not source ambient credentials or invent a fallback model. Model construction itself must not make network calls. Run from a clean eval environment with symfonic-core installed.

The adapter refuses missing/unverified token, retry, callback or cache controls. A raising chat-start callback blocks request five; each trial has a 60-second asyncio timeout. Native also limits model rounds to four. Counts are allowed chat-boundary requests, not hidden HTTP attempts. Before approving real calls, verify the particular provider honours token/retry controls, invokes callbacks before network, and has no hidden SDK retries/cache/alternate model path. The outer timeout relies on cooperative async cancellation. The caller owns provider clients and their cleanup. No production/staging/data access is needed.

Evidence limits and decision

Offline scripts prove adapter instrumentation and oracle controls only. They deliver fixed replies regardless of error observation. The routes also use different framework system prompts when the repository's paired comparison runs; that is a route comparison, not a causal experiment isolating error wording. Only run is covered here, not stream or typed projection, constructor-warning parity or default admission. Native uses the public Agent composition directly; its zero local compatibility counter is not a global probe for legacy-body unreachability. On the repository's legacy route, the actual legacy instance's body is wrapped and counted without replacing its result. Failed or cancelled trials do not prove background-task/client cleanup; isolated provider clients and post-trial cleanup remain caller responsibilities.

One real trial per route is exploratory, not proof of superiority/equivalence. Before choosing native v11 error observations over scoped compatibility, predeclare outcome criteria for retry/recovery, final correctness, attempted and duplicate effects, call counts and exhausted budgets. Repeat sufficiently for the intended models and add controlled same-prompt error-wording trials if attributing causality. This harness authorizes neither option: CORE-227 remains blocked on provider approval, outcome evidence and the explicit contract decision. No envelope widening or RET-6 reduction follows from it.