Skip to content

Show what an agent tool call will do on its approval card - #2088

Merged
ppXD merged 1 commit into
mainfrom
fix/show-what-an-agent-tool-call-will-do
Oct 8, 2026
Merged

ppXD merged 1 commit into
mainfrom
fix/show-what-an-agent-tool-call-will-do

Conversation

@ppXD

@ppXD ppXD commented Oct 7, 2026

Copy link
Copy Markdown
Owner

Summary

  • An agent's side-effecting tool call parked for approval now shows the reviewer what it will do: the repository, the pull request's title, head and base (a fork head is flagged), the method, the commit text, and the command and its arguments. The card is plain text, because chat shows a message body exactly as written. The call's own arguments are shown whole, so a long command can't hide its tail; a call whose arguments total more than 8,000 characters is refused instead of parked. The preview is stored on the ledger row (migration 0242, approval_preview_jsonb and approval_target) and shown by the run's tool-call audit and the canvas approval bar.
  • A rejection sticks to the call's target. For a merge that is the repository and pull request at the head and base its card pinned; for any other call, its arguments as the card shows them. A target holds at most one live card at a time. A rejection fails every undecided call on that target, and an approved call re-checks the target before it runs, so rejecting one card can't leave an approvable twin. New commits on a pull request count as a new request.
  • git.merge_pr pins the head and base its card showed (expectedHeadSha, expectedBaseBranch), and git.pr_review pins the head. PullRequestService reads the pull request again just before a pinned merge or review and refuses one that moved (PullRequestMovedException, code pull_request_moved). The providers also send the head as their own precondition: GitHub merge sha and review commit_id, GitLab accept and approve sha. Submitting a review now takes SubmitPullRequestReviewInput, like a merge.

Test plan

  • Integration (Postgres, real handler, registry, previewer and providers, loopback GitHub), AgentToolApprovalPreviewFlowTests:
    • exact card text, and the preview read back through the paged audit query the UI uses
    • a head pushed after approval and a base retargeted after approval are both refused before the merge is sent
    • a review is pinned to the head; a head moved after approval submits nothing
    • a rejected merge gets a new card once its head moves
    • two connections can't put a second live card on one target
    • an approved call on a target rejected meanwhile does not run
    • long commands are shown whole, and too-long arguments are refused
    • a re-ask that only changes whitespace is denied
  • Integration: ToolCallApprovalResolverTests (a rejection reaches undecided siblings), ToolCallLedgerServiceTests (awaiting-target lookup)
  • Unit: ToolCallPreviewsTests, AgentToolPreviewerTests, McpRequestHandlerTests, PullRequestHeadPinTests (GitHub commit_id, GitLab approve sha and 409), PullRequestServiceTests (the moved check), merge and review node tests, ToolCallAuditReaderQueryTests (the page query selects the preview), FailureTaxonomyTests
  • Frontend: MessageBody renders an approval card verbatim with no mention; pnpm lint, tsc, vitest
  • Mutation: seven new code paths reverted one at a time, each caught by at least one unit test and one integration test

At the default tier an approval card was the only human check on an
agent's side-effecting tool call, and it named only the run id, the tool
and the tool's generic description; its own doc promised an argument
summary that was never built, and the ledger kept only a hash of the
arguments. A reviewer approved a merge without seeing the repository,
the pull request, an outsider's fork head, the method, the branch
deletion or the commit text, and a command without seeing what would run
where. A rejected merge could be put to the reviewer again on a
byte-identical card by adding an inert key, and an approved merge was not
pinned to any commit, so a fork's owner could push between approval and
execution.

Before a call is parked, its tool now resolves what it will do
(IAgentTool.PreviewAsync). A node tool's preview comes from its manifest:
every declared input, the repository by its path and how the run is
bound to it, and for a node naming a pull request its title, head
(repository, branch, commit) and base, read with the connection
credential. Anything outside the run's bound repositories, such as a
fork's head, is flagged. The handler redacts it and puts each value on
one line. The call's own arguments are shown whole, so a long command
cannot hide its tail past a cut, and a call whose arguments exceed
8,000 characters is answered instead of parked; values the platform
read (a pull request's title) are bounded. The card is plain text,
because the chat shows a body as typed, and text the model chose cannot
mention anyone. The preview is stamped on the ledger row in the same
park CAS as the approval token, and the run's tool-call audit and the
canvas approval bar show it. A call whose pull request cannot be read
is answered without parking.

A node tool now refuses input keys its schema does not declare, and
advertises additionalProperties false, so an inert key reaches nothing.

A reviewer's rejection now sticks to the call's target, keyed
server-side: a merge's repository and pull request at the head and base
its card pinned, any other call's arguments as the node reads them and
the card shows them, so spacing a card shows as one space is one target.
A fresh call on a target rejected earlier in the run is Denied without a
card, while new commits on a pull request are a new request. Refusing
rather than marking the re-ask: the reviewer's reason never reaches the
model, so a reworded re-ask is not an informed correction, and a marked
card would still invite approval fatigue on an irreversible merge. An
expired, unanswered card is not a rejection and can be asked again.

A target also holds at most one live card. An agent can open several
endpoint connections, and two of them could park the same merge on two
cards: rejecting one left the other approvable, and approving it merged
the pull request the reviewer had just rejected. A fresh call on a
target another call of the run awaits a reviewer on is now Denied, a
rejection fails every undecided call of the run on its target, and an
approved call re-checks its target before it runs, so a rejection
outranks an approval that has not run yet.

git.merge_pr pins the head and base its card showed (expectedHeadSha,
expectedBaseBranch) and git.pr_review the head (expectedHeadSha), as
declared inputs a workflow can bind too. The approved call runs with
the row's pins over its own arguments. The pull-request service reads
the pull request again just before a pinned merge or review and refuses
one that moved (PullRequestMovedException): neither provider takes the
base as a precondition, and GitHub records a review against an older
commit rather than refusing it. The providers also send the head as
their own precondition (GitHub merge sha and review commit_id, GitLab
accept and approve sha), so a head that moves after that read fails
too. Submitting a review takes a SubmitPullRequestReviewInput, like a
merge.

Migration 0242 adds approval_preview_jsonb and approval_target
(nullable, no backfill). A row parked before it runs as it did.
@ppXD
ppXD merged commit 49861c9 into main Oct 8, 2026
7 of 8 checks passed
@ppXD
ppXD deleted the fix/show-what-an-agent-tool-call-will-do branch October 8, 2026 02:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant