Commit f46fced
authored
feat(agents): emit a machine-readable approval decision on gated PXI tools (#15029)
* feat(agents): stamp a machine-readable approval decision on gated tools
Approval-gated PXI tools each hand-rolled their accept/reject output, so the
payload vocabulary had drifted: most emit `status: "accepted" | "rejected"`,
while `save_prompt` demotes the verdict to `approvalStatus` and sets
`status: "saved"`, and `load_dataset` / `patch_experiment` use `"loaded"` /
`"applied"`. Nothing in span metadata marked a call as approval-gated at all —
rejections complete with status OK like any other tool call.
That left trace consumers no way to identify an approval decision except by
matching a hand-maintained list of tool names, which had already gone stale
(every dataset-write tool is gated and was missing from it).
Add a reserved, nested `approval: { decision, source }` marker, stamped by a
shared `approvalOutcome()` helper in every accept/reject path. It is:
- nested, so tools that spread their own action result into the output
(`save_prompt`, `write_prompt_tools`) cannot clobber it;
- additive, leaving existing `status`/`acceptedBy` keys untouched, so the
tool-card UI parses exactly what it parses today;
- meaningful by its absence — cancellations and still-pending proposals stay
unmarked, so consumers can skip them rather than guess.
The server promotes the marker onto the emitted TOOL span as the
`pxi.approval.decision` / `pxi.approval.source` attributes, in the single place
every client-tool result becomes output attributes. This makes approval
decisions filterable server-side via `get_spans(attributes=...)` instead of
requiring consumers to fetch every TOOL span and scan `output.value`, and
centralizes the defensive payload parsing once rather than per consumer.
A source-level drift guard asserts every `pending*.ts` module that emits tool
output stamps both branches, so a newly gated tool cannot silently go unmarked.
* fix(agents): promote the approval marker where client tool spans are made
The first pass set the span attributes in `ToolSpanMixin.set_output`, which
approval-gated tools never reach: they are external tools, so they never execute
server-side (`execute_tool_call` raises before the hooks run, and resumption
assigns the browser's result directly). Their TOOL spans are synthesized from
the request body in `_synthesize_client_tool_spans`, which is the only seam
where the marker can reach the span — verified against real spans, whose
`session.id`/`tool.id` attributes only that path emits.
Move the extractor to `phoenix.server.agents.approval`, call it where the
output attributes are actually built, and revert the mixin. Add a router test
asserting the attributes land on a synthesized gated span and stay absent on a
non-gated one — the test that would have caught this.
Also from review:
- Guard the extractor's frozenset membership tests with isinstance checks. The
marker is browser-supplied JSON, so an unhashable `decision` raised TypeError
out of span emission, contradicting the "never fails a tool call" contract.
- Stamp `source: "auto"` as a literal in the evaluator auto-accept branch rather
than echoing `result.acceptedBy`, which a future unwrapped submit host could
set to "user".
- Find approval payloads by content rather than by `pending*.ts` filename. The
old guard missed `agent/tools/approval.ts` — a payload site this very change
had to patch — and now pins the full emitter list.
- Document the pre-existing `submit_*_evaluator_draft` gap: those decisions
happen in a dialog that never writes tool output, so they stay invisible.
* docs(agents): link the submit_* approval gap to its tracking issue
The gap is documented where a reader of the marker would hit it; point it at
issue #15033 so the follow-up is findable from the code rather than only from
the tracker.
* refactor(agents): simplify approval marker contract
* refactor(agents): colocate approval marker extraction
* feat(evals): add suggestion_accepted online eval for approval decisions (#15030)
* feat(evals): add suggestion_accepted online eval targeting approval-gated TOOL spans
* test(evals): cover suggestion_accepted semantics and mixed root/TOOL runner targeting
* test(evals): add sanitized real-shape suggestion approval trace fixtures
* docs(evals): document suggestion_accepted target discovery and approval semantics
* fix(evals): enforce span selector parent matching
* refactor(evals): discover suggestion outcomes by approval attributes
`suggestion_accepted` identified approval decisions by matching `tool.name`
against a hand-maintained `APPROVAL_GATED_TOOLS` list, then parsing
`output.value` through a defensive multi-shape decoder because each tool spells
its outcome differently (`accepted`, `saved`, `loaded`, `applied`, `removed`,
and `save_prompt`'s `approvalStatus`).
That list was already stale before shipping: every dataset-write tool is
approval-gated via `stageDatasetWrite` and none were listed, so those decisions
went unmeasured. Keeping it correct meant maintaining a cross-language contract
with the frontend by hand, with nothing enforcing it.
PXI tools now stamp a uniform approval marker that the server promotes onto the
span, so:
- Delete the allowlist and the name-based applicability gate. Discovery selects
on `pxi.approval.source = "user"` — one server-side query that yields exactly
the annotated set, since rejections are always a user action and automatic
accepts are never annotated.
- Delete the `output.value` decoder. Classification reads
`pxi.approval.decision` only, so a tool's own status vocabulary is irrelevant
and a look-alike payload on a non-gated tool can no longer be misread.
- Extend `SpanSelector` with hashable attribute filters, letting an evaluator
target spans by what they record rather than by which tool produced them.
A selector still requires a name or attribute filter so discovery stays
bounded.
A newly approval-gated tool is now measured the day it ships. Fixtures keep each
tool's original status vocabulary alongside the marker, which is what makes them
worth keeping: they prove classification no longer depends on it.
* fix(evals): harden approval-attribute discovery after review
- Import the attribute names from `phoenix.server.agents.approval` rather than
re-declaring the literals. The eval reads what the server writes, and a
drifted name would not raise: discovery would return nothing, forever, and
look like a quiet window. A test pins the two modules together.
- Isolate per-selector discovery failures. Attribute filtering requires a newer
Phoenix server than name filtering, so an old server or a transient error on
one selector previously aborted the whole scheduled job, taking
`tool_count_per_turn` and `user_friction` with it. The candidate-limit guard
stays fatal via its own `CandidateLimitError`: unlike a failed query, a
truncated candidate set makes the run's results quietly incomplete.
- Reject non-string attribute values in `SpanSelector`, which would otherwise
serialize into the query and then never match locally.
- Apply the attribute filter in the runner's fake `get_spans`, so a test would
notice if server-side filtering silently stopped working.
- Docs: soften the drift-guard claim (it recognizes payloads by the known
accept/reject vocabulary, so a hand-rolled tool inventing new wording is not
covered — tools on `bindPendingApproval` are covered by construction), and
state that pre-marker spans are invisible to discovery and cannot be
backfilled.
* docs(evals): link the unmeasured submit_* tools to their tracking issue
Note in both eval docs that accept/reject rates exclude the two submit tools
until #15033 lands, so the gap is visible to anyone reading the numbers.
* refactor(evals): simplify suggestion outcome eval
* style(evals): format rebased online evals
* fix(evals): type suggestion fixture payload
* fix(agents): use client tool metadata wire keys1 parent 670e4c1 commit f46fced
34 files changed
Lines changed: 2038 additions & 123 deletions
File tree
- app/src/agent
- tools
- batchSpanAnnotate
- codeEvaluatorDraft
- __tests__
- llmEvaluatorDraft
- __tests__
- patchExperiment
- playgroundLoadDataset
- playgroundPromptTools
- playgroundPrompt
- playgroundSavePrompt
- evals/pxi
- online_evals
- evaluators
- src/phoenix/server/api/routers
- tests/unit
- pxi/evals/online_evals
- fixtures
- server/agents
Lines changed: 81 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
Lines changed: 15 additions & 3 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
42 | 42 | | |
43 | 43 | | |
44 | 44 | | |
45 | | - | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
46 | 51 | | |
47 | 52 | | |
48 | 53 | | |
49 | 54 | | |
50 | 55 | | |
51 | 56 | | |
52 | 57 | | |
53 | | - | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
54 | 62 | | |
55 | 63 | | |
56 | 64 | | |
| |||
79 | 87 | | |
80 | 88 | | |
81 | 89 | | |
82 | | - | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
83 | 95 | | |
84 | 96 | | |
85 | 97 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
Lines changed: 7 additions & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
1 | 2 | | |
2 | 3 | | |
3 | 4 | | |
| |||
40 | 41 | | |
41 | 42 | | |
42 | 43 | | |
| 44 | + | |
43 | 45 | | |
44 | 46 | | |
45 | 47 | | |
| |||
49 | 51 | | |
50 | 52 | | |
51 | 53 | | |
52 | | - | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
53 | 59 | | |
54 | 60 | | |
55 | 61 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
| 2 | + | |
| 3 | + | |
2 | 4 | | |
3 | 5 | | |
4 | 6 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
5 | | - | |
6 | | - | |
| 4 | + | |
7 | 5 | | |
8 | 6 | | |
9 | 7 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
1 | 6 | | |
2 | 7 | | |
3 | | - | |
| 8 | + | |
4 | 9 | | |
5 | 10 | | |
6 | 11 | | |
| |||
41 | 46 | | |
42 | 47 | | |
43 | 48 | | |
44 | | - | |
| 49 | + | |
45 | 50 | | |
46 | 51 | | |
47 | 52 | | |
48 | 53 | | |
49 | | - | |
| 54 | + | |
50 | 55 | | |
51 | 56 | | |
52 | 57 | | |
| |||
98 | 103 | | |
99 | 104 | | |
100 | 105 | | |
| 106 | + | |
101 | 107 | | |
102 | 108 | | |
103 | 109 | | |
| |||
Lines changed: 4 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
1 | 3 | | |
2 | 4 | | |
3 | 5 | | |
| |||
61 | 63 | | |
62 | 64 | | |
63 | 65 | | |
| 66 | + | |
64 | 67 | | |
65 | 68 | | |
66 | 69 | | |
| |||
75 | 78 | | |
76 | 79 | | |
77 | 80 | | |
| 81 | + | |
78 | 82 | | |
79 | 83 | | |
80 | 84 | | |
| |||
Lines changed: 1 addition & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
157 | 157 | | |
158 | 158 | | |
159 | 159 | | |
| 160 | + | |
160 | 161 | | |
161 | 162 | | |
162 | 163 | | |
| |||
Lines changed: 4 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
1 | 3 | | |
2 | 4 | | |
3 | 5 | | |
| |||
38 | 40 | | |
39 | 41 | | |
40 | 42 | | |
| 43 | + | |
41 | 44 | | |
42 | 45 | | |
43 | 46 | | |
| |||
50 | 53 | | |
51 | 54 | | |
52 | 55 | | |
| 56 | + | |
53 | 57 | | |
54 | 58 | | |
55 | 59 | | |
| |||
0 commit comments