Repository navigation
fix(agent): extract and wire the pre-action verifier at the live tool seam (#14031) - #14119
Conversation
Applied Black, isort, and autoflake to match code-quality checks. Triggered by workflow auto-fix-formatting.yml.
✅ SSOT Configuration Compliance: Passing🎉 No hardcoded values detected that have SSOT config equivalents! |
… without session_id (#14031)
Review response — the wiring guard now guards wiring, and CI is root-causedPushed as The wiring assertion was a presence check, not a reachability checksource = inspect.getsource(ToolHandlerMixin._dispatch_tool_call)
assert "_enforce_pre_action_verifier(" in sourceThat stays green if the call sits after an early Replaced with The source-text test is kept, relabelled as a rename guard, with a docstring saying explicitly that it is not relied on for wiring proof. Mutation: dead rather than absentThe call-site text is still present and the rename guard is among the 7 that pass — only the behavioural test fails. That is the proof that was missing. CI failures — root-caused, both mine
This is the "sweep by the type, not the directory" lesson again: I swept
Decision — verifier scope, recorded rather than decided silentlyWiring this in means every call to a Shipping the full canonical scope. Reasons, in order:
What should be measured before leaving it on: p50/p95 added latency on Labelling #14031 Left for the follow-up slice
|
|
Part of #14031.
Thinking Path
#14031 lists two extractions: the pre-action verifier and belief-state
tracking. This PR does only the first —
PreActionVerifier— because itspre_action_verifier_enableddefaults toTrue(agent_loop/types.py:266),so today it reads as an active guard while executing on no request (
AgentLoophas no production caller). Belief-state's flag defaults
False, so it is offand unreachable — a smaller, separable problem left for a follow-up.
Template: #13590 (closed). Extract the decision surface into
autobot_shared/,leave the original module as a re-export (the
fact_forcingprecedent), callit from the live seam in
chat_workflow/tool_handler.py.Unlike
repetition_guard/fact_forcing_guard, the verifier's job is an LLMcall — it cannot be made fully pure. What moved to
autobot_shared/and ISpure/unit-tested: action-class threshold resolution, response parsing, and
verdict determination (
determine_verdict/panel_decision— the surface thatpreserves the
HARD_BLOCKsemantics exactly). The LLM call orchestration(
PreActionVerifier.verify) moved alongside it soagent_loop/pre_action_verifier.pycan stay a thin re-export, same as
fact_forcing.What Changed
autobot_shared/pre_action_verifier_guard.py— the full decisionsurface +
PreActionVerifierclass, dependency-free ofagent_loop(onlylazy-imports
agent_loop.guard_profile/agent_loop.typesinsidepre_action_verifier_enabled(), mirroringrepetition_guard.max_identical_tool_calls()).agent_loop/pre_action_verifier.pyis now a re-export. Scalarconstants (
HARD_BLOCK,THRESHOLD_*, ...) are proxied through module__getattr__rather than statically imported —agent_loop/loop.pyre-imports
HARD_BLOCKfresh on every call inside_check_approvals, and astatic import would have frozen a stale copy, invisible to anything patching
the source module (this broke one existing test during verification; fixed
by proxying instead of copying).
chat_workflow/tool_handler.py— new_enforce_pre_action_verifierenforcer, called from
_dispatch_tool_callbetween fact-forcing and thework-item approval hold (matching where
AgentLoop._check_approvalsran theverifier: before the approval step). Scoped to
autobot_shared.tool_catalogue.SENSITIVE_TOOLS— the same set
AgentLoop._sensitive_tool_namegated on — and gated onpre_action_verifier_enabled()(guard-profile resolved, defaultsTrue). ABLOCKverdict withVERIFIER_HARD_BLOCK=1hard-blocks the call, exactlypreserving the original semantics; without it, the call is held
pending_approvalwith the verifier's rationale attached, reusing the shape_enforce_work_item_approvalalready uses at this seam. A broken/unavailableverifier fails open (unchanged from the original).
autobot_shared/pre_action_verifier_guard_test.py(puredecision-surface unit tests) and
autobot-backend/tests/unit/chat_workflow/test_tool_handler_pre_action_verifier.py(seam wiring, including the required wiring assertion).
Seam insertion point
chat_workflow/tool_handler.py::_dispatch_tool_call, right after_enforce_fact_forcingand before_enforce_work_item_approval. Traced theenforcer chain end to end first: forbidden_work → config_protection →
fact_forcing → verifier (new) → work_item_approval → repetition, each an
early-return gate before the tool actually dispatches. This position mirrors
AgentLoop._check_approvals, where the verifier ran immediately before thehuman-approval request — so a
HARD_BLOCKshort-circuits before any approvalplumbing, and a soft block's rationale is available to attach to the
subsequent hold.
What the now-live guard will newly block
Nothing was gated on
PreActionVerifierin production before this PR — theguard executed on no request. With this PR,
pre_action_verifier_enabledbeing
Trueby default means every call to aSENSITIVE_TOOLStool (31 names:write_file,delete_file,bash/execute_command/shell,git_push,git_force_push,deploy/kubectl/terraform,http_post, ...) now gets anindependent adversarial LLM pass before it executes.
VERIFIER_HARD_BLOCKdefaults to
0, so by default aBLOCKverdict does not reject the call —it holds it
pending_approvalwith the rationale attached, which is newbehaviour for tools that previously had no approval gate at this seam at all
(e.g.
write_file,git_push— onlyexecute_commandhad its own approvalflow before).
Running the pure decision logic (
determine_verdict+threshold_for_tool)against representative refutation probabilities:
Finding, not just a detail:
execute_commandis inSENSITIVE_TOOLSandis almost certainly the highest-volume sensitive tool in production. It
already has its own approval flow (
_handle_pending_approval, session-scopedpending state). This PR adds the verifier's LLM round trip (up to
VERIFIER_TIMEOUT_S=30s, fails open on timeout/no-provider) in front of thatexisting flow for every shell command, not just risky ones — matching the
original
AgentLoopscope exactly (it verified every sensitive tool, not anarrower "risky" subset), but that scope was never exercised before because
AgentLoopnever ran in production. This is a real latency/cost tradeoff, nota bug: I did not narrow the guard to work around it. If per-environment
latency turns out to be a problem, the options are (a) tune
VERIFIER_TIMEOUT_S/thresholds, (b) exempt read-only-flavouredexecute_commandinvocations via a cheap pre-check, or (c) accept it as the cost of the guard
finally running. That's a product call, not mine to make unilaterally, so I'm
flagging it here and on #14031 rather than quietly loosening the gate.
Verification
Wiring-assertion mutation test: removed the
await self._enforce_pre_action_verifier(...)call site from_dispatch_tool_callby direct file edit (notgit stash), confirmed viagrepthat the call site was gone, re-rantest_the_guard_is_reached_from_the_dispatch_seam— it failed(
AssertionError: assert '_enforce_pre_action_verifier(' in '...'). Restoredthe file from the pre-mutation copy, confirmed the call site was back via
grep, and reran the full targeted suite green (51 passed).ruff checkclean on every touched/added file.Left for the follow-up (explicitly out of scope here)
BeliefState/BeliefStateUpdaterintoautobot_shared/andwiring it into the tool seam where results are already collected.
belief_state_enabled's default deliberately once it is reachable.Both tracked on #14031, which stays open until the belief-state slice lands.
Model Used
Sonnet 5