Adapter: record CHAP human-decision events from Pydantic AI tool approvals#5
Conversation
…c AI tool approvals Adapter mirroring chap-langgraph: when a Pydantic AI run pauses for tool approval, record the human's decision as a CHAP envelope. True / ToolApproved() -> decide.approve ToolApproved(override_args=...) -> decide.override (RFC 6902 diff of args) False / ToolDenied(message=...) -> decide.reject - pydantic-ai is an optional dependency; resolution objects are read structurally, so the bridge and its tests run without it installed. - intent_preserved defaults to true on an args edit but is overridable via the metadata channel (refine vs replace stays the human's signal). - approver identity flows per-decision; participant type follows the URI scheme; the approver is joined just-in-time. - a no-op override (override_args equal to the original) records an approve, not a phantom override. Closes #2.
brightbeamarsalan
left a comment
There was a problem hiding this comment.
Reviewed in full, and this is excellent work Bogdan, thank you. Approving.
The structure mirrors chap-langgraph cleanly, the bridge stays free of any pydantic-ai import, and the two things we discussed are handled exactly right: intent_preserved is the human's signal with a sensible default and an explicit override, and the per-decision approver is joined and recorded. Nice touch that an edit producing no real change records as decide.approve rather than an empty override. And the comment versus rationale split is right (comment for decide.approve / decide.reject, rationale for decide.override), which matches the spec.
It also lines up with the 0.2.6 authorisation rules by construction: each call becomes its own task whose review.request is addressed to that approver, so from is always in the review's to set and the approver is always a member. Well done.
GitHub shows no conflicts and a single commit, so no rebase needed. Two small things before merge:
- The example shows approve, a refining edit, and a deny, but not the replace-in-disguise case (
intent_preserved=false) you said it would carry. The tests coverfalse; please add one step to the example so the runnable path teaches the refine-versus-replace distinction too. - There's no CI on the repo yet, so could you run the adapter's
pytestand the conformance harness locally against currentmain(0.2.6) and confirm both are green? The 0.2.6 authorisation tightening is enforced in core now, and the no-conflicts banner doesn't exercise it, so a local run onmainis what proves the adapter still passes.
Add the example step and confirm the local 0.2.6 run, and I'll merge. Great work.
Add an intent_preserved=false step so the runnable example teaches the refine-vs-replace distinction, and surface intent_preserved in the printed chain.
|
Both asks handled — one is blocked on a repo setting, not the code. 1. Example — added a substituting-override step ( 2. Local run against
Blocker on pushing the example commit: the |
|
great thanks Bogdan, please proceed with the push now. |
|
Pushed |
Closes #2.
A small adapter mirroring
chap-langgraph: when a Pydantic AI run pauses for tool approval, the human's decision becomes a CHAP envelope.The pending
ToolCallPart(tool, validated args,tool_call_id) is the artefact under review; the resolution is the human's decision on it. Targets the stabledeferred_tool_resultspath rather than the inline hook while pydantic-ai/pydantic-ai#3959 settles.Addressing the review
intent_preservedis not hardcoded. It defaults totruefor an args edit but the resolver can override it, viaresults.metadata[tool_call_id]— the same channel that carriesrationaleandtags. Refine-vs-replace stays the human's signal.from, which it stamps ontodecisions[].reviewer, the override artefact, and task history. The bridge takes a defaultreviewerbut a per-decisionapproveroverrides it; the participant type is derived from the URI scheme and the approver is joined just-in-time. The example uses real named approvers (alice approves/denies, sam signs the capped override).Design notes
pydantic-aiis an optional dependency. The resolution objects are read structurally, so the bridge and its tests run without it installed.commentis used fordecide.approve/decide.rejectandrationalefordecide.override— the field each handler actually records.override_argsequal to the original args) records anapprove, not an empty-diff override, so it doesn't show up as a phantom in the overrides view.Verification
pydantic-aiinstalled (structural stand-ins).examples/01-approve-edit-deny.pyruns all three decisions against real pydantic-ai 2.0 usingTestModel(offline, no API key); resuming with the override confirms the edited args take effect. The emitted chain is hash-linked and accepted by a conformant Coordinator. Happy to add an explicit harness-binary run against a live server if you'd like that demonstrated literally rather than by construction.Two spec things worth a look (not changed here)
decide.approve/decide.rejectrecord the reviewer's note ascomment, whiledecide.overrideusesrationale— two field names for "the why."decide.*don't verifyfromis a joined member (only workspace/task existence andreview_requestedstate), so a decision could currently name a non-member.Files