Bounded web-tool fallback with failure receipts in AutoGen #8085
auxiliar-ag
started this conversation in
Show and tell
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Sharing a small, self-contained pattern I keep re-deriving whenever an AutoGen agent is given web access: bound the tool, and make its failures structured data the model can reason over.
This is a reusable pattern and example code, not an official AutoGen integration and not a package anyone needs to depend on. It is a couple hundred lines you can paste into your own project and adapt. Everything runs on
autogen-core0.7.5 with no API key and no network — the providers are deterministic scripted transports, whileFunctionTool,CancellationTokenandAssistantAgentare the real AutoGen ones.The problem
A naive web tool tends to look like this:
Three separate things go wrong with it, and they compound.
1. The failure is prose.
"Search failed: HTTPSConnectionPool(host='...', port=443): Read timed out"lands in the model's context as an English sentence. The model has to infer whether to retry, answer from memory, or apologise. Different phrasings on different days produce different behaviour, and you cannot write an assertion against it.2. Transport success is treated as usefulness.
HTTP 200means a server answered. It does not mean the answer helps. Search APIs return empty result sets, consent interstitials, cookie walls and "enable JavaScript" pages with a 200 and a well-formed body. The code above returns all of those as success, and the model — which cannot see that the body is empty of evidence — cites them.3. There is no bound. Add a fallback provider and a retry loop and the tool call's worst case is now
providers × retries × timeout. Two providers at 30s each is a 60s tool call inside a turn the user is watching.The four rules
The receipt
The vocabulary is the whole point. An agent's behaviour becomes a function of a small finite set of tokens instead of an open set of English error strings — so a prompt can enumerate it exhaustively and a test can assert on it exactly.
Each outcome maps to exactly one instruction:
The mapping is total, and one branch is worth calling out:
auth_failedcollapses tostoprather thanretry_later, because a rejected credential will still be rejected in thirty seconds. Telling the agent to retry there just buys a second failed turn.The
Receiptmodel isfrozen=True, extra="forbid"and has no field capable of holding a response body — nobody, nohtml, noheaders, noraw. Bodies cannot leak through it by accident, because there is nowhere to put them. A test asserts that property directly rather than trusting the reviewer to notice:Rule 1: arriving is not the same as being useful
The engine asks two independent questions. Did a payload arrive — a
TransportResponserather than aTransportError? And is that payload usable? The second question belongs to a validator, and its answer is a closed-vocabulary reason:The ordering is fixed so that the reported reason is deterministic — that matters when you are diffing evaluation runs. The thresholds (
min_snippet_chars, the login-wall markers, whether query terms must overlap) are the part you should expect to tune against your own traffic rather than inherit; more on that below.The practical consequence is that a
200carrying zero hits is recorded asok_not_usefulwithnot_useful_reason=empty_result_set, and it triggers the fallback. Naive code returns it as a success.Rule 3: one shared deadline
The budget is deliberately not per-attempt. A single
Budgetinstance is threaded through the entire plan, and each attempt may only borrow a slice of what remains:An attempt that cannot get a meaningful slice is never started. It is recorded as
skipped_no_budgetinstead — a distinct term, so the receipt distinguishes "the fallback was tried and failed" from "the fallback never got a chance":The slice is handed to the transport explicitly, the way any real HTTP client takes a timeout, with
wait_foras a backstop for a transport that ignores it.CancellationToken.link_futureis what wires AutoGen's cancellation through to the in-flight request:The clock is injectable (
clock: Clock = time.monotonic). That is what makes the tests and the demo deterministic: aManualClockadvances only by the latency a scripted transport declares, so simulated 5-second providers cost zero real seconds and the timing assertions are exact rather than flaky.Rule 2: at most one fallback, structurally
There is no provider list and no loop. The fallback is a single optional argument,
FallbackPolicyrejectsmax_fallbacks > 1at construction time, and the engine body contains exactly one conditional second attempt:Bounding by construction rather than by convention means the invariant survives the next person editing the file.
Reducing the trail to one outcome has one subtle branch, which I got wrong first and only noticed because a test disagreed with me:
My first version checked "a payload arrived" before "the budget ran out", which produced
no_useful_result→ask_user_to_narrowfor a run where the primary returned an empty page and the fallback was then starved of runway. Asking the user to rephrase their question is actively misleading when the real story is that the plan never finished.Wiring it to AutoGen
This is the only module that imports AutoGen.
FunctionToolderives the argument schema from the wrapped function's annotations, andBaseTool.return_value_as_stringserialises a pydantic return value withmodel_dump()+json.dumps. So returning aReceiptputs the entire receipt into the model's context as JSON with no formatting code of my own:Transports, policy, validator and clock live in the closure, so the model-visible schema is exactly one field:
The system-message fragment that teaches the model the vocabulary is generated from the enums, so the prompt cannot drift away from the code — a test iterates
OutcomeandNextActionand asserts every member appears in it.A complete runnable example
This is self-contained; it needs
autogen-coreand this package, no key and no network:which prints:
What the demo shows
python examples/demo.pywalks eight scenarios. Abridged transcript (real output, byte-for-byte reproducible because the clock is injected):Scenario 4 is the one I care about most: two providers that each take five seconds, under a two-second budget, return in exactly 2000ms of simulated time — 1200ms for the primary's slice, then only the 800ms that was left.
Scenario 8 runs the same tool inside a real
AssistantAgent, driven byReplayChatCompletionClientfromautogen-extso the model turns are pre-recorded and no key is needed. That exercises AutoGen's actual tool-execution path, and the tool result the model receives is the receipt JSON:To be precise about what that proves: the model there is scripted, so it demonstrates the plumbing — that a receipt survives tool execution intact and arrives in the model context as JSON the next turn can condition on. It does not demonstrate that a live model obeys
next_action. Measuring that needs real models and a real corpus.Redaction
Two mechanisms, because one is not enough:
contentextract of a useful result — goes through redaction. Sensitive query parameters are masked, URL userinfo is dropped, and credential-shaped substrings (bearer tokens, JWTs,sk-/AKIA/ghp_/xoxprefixes, long hex blobs,key=valueassignments for sensitive names) are replaced.Redaction deliberately over-matches. A redacted URL keeps its non-sensitive parameters so it stays recognisable in a receipt, but
?q=x&api_key=abcd1234&page=2becomes?q=x&api_key=[REDACTED]&page=2.Trade-offs and limitations
EvidenceValidatoris a starting point, not a general answer. Marker lists and length thresholds are exactly the kind of heuristic that looks fine on your fixtures and misclassifies real traffic; treat the defaults here as a scaffold to replace. Calibrating thresholds like these against real pages is its own exercise — I've written up a longer public web-access evaluation methodology covering how to assemble that kind of corpus.Outcomemember is a breaking change for every prompt and evaluation that enumerates it, which is whyschema_versionis on the receipt and a test pins the exact member lists.Running it
Verification
Rather than assert that this is well tested, here is what was actually run:
autogen-core,autogen-agentchat,autogen-ext0.7.5, Python 3.11.15, in a clean venv.pytestexit code 0. The suite is fully offline and uses no clock-dependent sleeps.ruff checkandmypy --strictboth clean.Disclosure
I work at NativePort, which builds tooling in the web-access space, so the methodology link above is to my employer's site; the pattern and code here are framework-agnostic and carry no dependency on anything we sell. This post and the accompanying code were drafted with AI assistance. I am deliberately not making claims about model behaviour that the offline test suite cannot support — the verification section above lists what was actually executed, and nothing beyond that has been measured.
Happy to hear where this breaks down, particularly on the validator design — that is the piece I am least confident generalises.
All reactions