Skip to content

Commit f46fced

Browse files
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 keys
1 parent 670e4c1 commit f46fced

34 files changed

Lines changed: 2038 additions & 123 deletions
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { readdirSync, readFileSync } from "node:fs";
2+
import { join } from "node:path";
3+
4+
import { approvalOutcome } from "../approvalOutcome";
5+
6+
describe("approvalOutcome", () => {
7+
it("nests the decision and source under a reserved approval key", () => {
8+
expect(approvalOutcome({ decision: "accepted", source: "user" })).toEqual({
9+
approval: { decision: "accepted", source: "user" },
10+
});
11+
expect(approvalOutcome({ decision: "rejected", source: "user" })).toEqual({
12+
approval: { decision: "rejected", source: "user" },
13+
});
14+
});
15+
16+
it("survives being spread after a payload that carries its own approval key", () => {
17+
const toolPayload = { status: "saved", approval: "not-the-marker" };
18+
const output = {
19+
...toolPayload,
20+
...approvalOutcome({ decision: "accepted", source: "auto" }),
21+
};
22+
expect(output.approval).toEqual({ decision: "accepted", source: "auto" });
23+
});
24+
});
25+
26+
describe("approval marker coverage", () => {
27+
const agentDir = join(__dirname, "..", "..", "..");
28+
29+
function sourceFiles(dir: string): string[] {
30+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
31+
const path = join(dir, entry.name);
32+
if (entry.isDirectory()) {
33+
return entry.name === "__tests__" ? [] : sourceFiles(path);
34+
}
35+
return entry.name.endsWith(".ts") ? [path] : [];
36+
});
37+
}
38+
39+
const ACCEPT_EVIDENCE = /acceptedBy:|approvalStatus:|status: "accepted"/;
40+
const REJECT_EVIDENCE = /status: "rejected"/;
41+
const EMITS_TOOL_OUTPUT = /addToolOutput\(|AgentClientActionResult/;
42+
43+
const emitters = sourceFiles(agentDir)
44+
.map((path) => ({ path, source: readFileSync(path, "utf-8") }))
45+
.filter(
46+
({ source }) =>
47+
EMITS_TOOL_OUTPUT.test(source) &&
48+
(ACCEPT_EVIDENCE.test(source) || REJECT_EVIDENCE.test(source))
49+
);
50+
51+
it("finds every module that emits an approval payload", () => {
52+
expect(emitters.map((e) => e.path.slice(agentDir.length + 1)).sort())
53+
.toMatchInlineSnapshot(`
54+
[
55+
"shared/pendingApproval/bindPendingApproval.ts",
56+
"tools/approval.ts",
57+
"tools/batchSpanAnnotate/pendingBatchSpanAnnotate.ts",
58+
"tools/codeEvaluatorDraft/pendingCodeEvaluatorEdit.ts",
59+
"tools/llmEvaluatorDraft/pendingLlmEvaluatorEdit.ts",
60+
"tools/patchExperiment/pendingPatchExperiment.ts",
61+
"tools/playgroundLoadDataset/pendingLoadDataset.ts",
62+
"tools/playgroundPrompt/pendingPromptEdit.ts",
63+
"tools/playgroundPrompt/pendingPromptInstanceRemoval.ts",
64+
"tools/playgroundPromptTools/pendingPromptToolWrite.ts",
65+
"tools/playgroundSavePrompt/pendingSavePrompt.ts",
66+
]
67+
`);
68+
});
69+
70+
it.each(emitters.map((e) => [e.path, e.source] as const))(
71+
"%s stamps every approval branch it emits",
72+
(_path, source) => {
73+
if (ACCEPT_EVIDENCE.test(source)) {
74+
expect(source).toContain('decision: "accepted"');
75+
}
76+
if (REJECT_EVIDENCE.test(source)) {
77+
expect(source).toContain('decision: "rejected"');
78+
}
79+
}
80+
);
81+
});

app/src/agent/shared/pendingApproval/__tests__/pendingApproval.test.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,23 @@ describe("bindPendingApproval", () => {
4242
state: "output-available",
4343
tool: "some_write_tool",
4444
toolCallId: "call-1",
45-
output: { status: "accepted", acceptedBy: "user", message: "done" },
45+
output: {
46+
status: "accepted",
47+
acceptedBy: "user",
48+
message: "done",
49+
approval: { decision: "accepted", source: "user" },
50+
},
4651
},
4752
]);
4853
});
4954

5055
it("records the approval source for an auto (bypass) accept", async () => {
5156
const { pending, outputs } = setup({ ok: true, output: "done" });
5257
await pending.accept?.({ approvalSource: "auto" });
53-
expect(outputs[0].output).toMatchObject({ acceptedBy: "auto" });
58+
expect(outputs[0].output).toMatchObject({
59+
acceptedBy: "auto",
60+
approval: { decision: "accepted", source: "auto" },
61+
});
5462
});
5563

5664
it("emits an error output without applying again when apply fails", async () => {
@@ -79,7 +87,11 @@ describe("bindPendingApproval", () => {
7987
state: "output-available",
8088
tool: "some_write_tool",
8189
toolCallId: "call-1",
82-
output: { status: "rejected", message: "nothing written" },
90+
output: {
91+
status: "rejected",
92+
message: "nothing written",
93+
approval: { decision: "rejected", source: "user" },
94+
},
8395
},
8496
]);
8597
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { ApprovalSource } from "./types";
2+
3+
export type ApprovalDecision = "accepted" | "rejected";
4+
5+
export type ApprovalOutcome = {
6+
approval: {
7+
decision: ApprovalDecision;
8+
source: ApprovalSource;
9+
};
10+
};
11+
12+
/** Build the approval marker promoted to `pxi.approval.*` span attributes. */
13+
export function approvalOutcome({
14+
decision,
15+
source,
16+
}: {
17+
decision: ApprovalDecision;
18+
source: ApprovalSource;
19+
}): ApprovalOutcome {
20+
return { approval: { decision, source } };
21+
}

app/src/agent/shared/pendingApproval/bindPendingApproval.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { approvalOutcome } from "./approvalOutcome";
12
import type { BindPendingApprovalOptions, PendingApproval } from "./types";
23

34
/**
@@ -40,6 +41,7 @@ export function bindPendingApproval<TPreview>({
4041
status: "accepted",
4142
acceptedBy: approvalSource,
4243
message: result.output,
44+
...approvalOutcome({ decision: "accepted", source: approvalSource }),
4345
},
4446
});
4547
},
@@ -49,7 +51,11 @@ export function bindPendingApproval<TPreview>({
4951
state: "output-available",
5052
tool: toolName,
5153
toolCallId,
52-
output: { status: "rejected", message: rejectedMessage },
54+
output: {
55+
status: "rejected",
56+
message: rejectedMessage,
57+
...approvalOutcome({ decision: "rejected", source: "user" }),
58+
},
5359
});
5460
},
5561
};

app/src/agent/shared/pendingApproval/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
export { bindPendingApproval } from "./bindPendingApproval";
2+
export { approvalOutcome } from "./approvalOutcome";
3+
export type { ApprovalDecision, ApprovalOutcome } from "./approvalOutcome";
24
export type {
35
ApprovalApplyResult,
46
ApprovalSource,

app/src/agent/shared/pendingApproval/types.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import type { Chat } from "@ai-sdk/react";
22
import type { UIMessage } from "ai";
33

4-
import type { ApprovalSource } from "@phoenix/agent/tools/approval";
5-
6-
export type { ApprovalSource };
4+
export type ApprovalSource = "user" | "auto";
75

86
export type ApprovalToolOutputSender = Chat<UIMessage>["addToolOutput"];
97

app/src/agent/tools/approval.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1+
import {
2+
approvalOutcome,
3+
type ApprovalOutcome,
4+
type ApprovalSource,
5+
} from "@phoenix/agent/shared/pendingApproval";
16
import type { AgentClientActionResult } from "@phoenix/store/agentStore";
27

3-
export type ApprovalSource = "user" | "auto";
8+
export type { ApprovalSource };
49

510
export type EvaluatorSubmitResult =
611
| {
@@ -41,12 +46,12 @@ type EvaluatorSubmitHost = {
4146
};
4247

4348
export type EvaluatorSubmitToolOutput =
44-
| {
49+
| ({
4550
status: "saved";
4651
persisted: true;
4752
acceptedBy: ApprovalSource;
4853
evaluator: { id: string; name: string };
49-
}
54+
} & ApprovalOutcome)
5055
| {
5156
status: "awaiting_user";
5257
persisted: false;
@@ -98,6 +103,7 @@ export function createEvaluatorSubmitClientAction<
98103
persisted: true,
99104
acceptedBy: result.acceptedBy,
100105
evaluator: result.evaluator,
106+
...approvalOutcome({ decision: "accepted", source: "auto" }),
101107
};
102108
return { ok: true, output: JSON.stringify(output) };
103109
};

app/src/agent/tools/batchSpanAnnotate/pendingBatchSpanAnnotate.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { approvalOutcome } from "@phoenix/agent/shared/pendingApproval";
2+
13
import { BATCH_SPAN_ANNOTATE_TOOL_NAME } from "./constants";
24
import type {
35
AnnotateSpanInput,
@@ -61,6 +63,7 @@ export function bindPendingBatchSpanAnnotateActions({
6163
approvalSource === "auto"
6264
? `${count} span ${noun} auto-approved.`
6365
: `${count} span ${noun} applied.`,
66+
...approvalOutcome({ decision: "accepted", source: approvalSource }),
6467
},
6568
});
6669
},
@@ -75,6 +78,7 @@ export function bindPendingBatchSpanAnnotateActions({
7578
count,
7679
annotations: annotations.map(toAnnotationOutput),
7780
message: `User rejected the proposed span ${noun}.`,
81+
...approvalOutcome({ decision: "rejected", source: "user" }),
7882
},
7983
});
8084
},

app/src/agent/tools/codeEvaluatorDraft/__tests__/submitCodeEvaluatorDraft.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ describe("submit_code_evaluator_draft agent tool", () => {
157157
persisted: true,
158158
acceptedBy: "auto",
159159
evaluator: { id: "ev-1", name: "hallucination" },
160+
approval: { decision: "accepted", source: "auto" },
160161
});
161162
});
162163

app/src/agent/tools/codeEvaluatorDraft/pendingCodeEvaluatorEdit.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { approvalOutcome } from "@phoenix/agent/shared/pendingApproval";
2+
13
import {
24
EDIT_CODE_EVALUATOR_DRAFT_NAVIGATION_CANCEL_ERROR,
35
EDIT_CODE_EVALUATOR_DRAFT_TOOL_NAME,
@@ -38,6 +40,7 @@ export function bindPendingCodeEvaluatorEditActions({
3840
approvalSource === "auto"
3941
? "Code-evaluator draft edit auto-approved."
4042
: "Code-evaluator draft edit applied.",
43+
...approvalOutcome({ decision: "accepted", source: approvalSource }),
4144
},
4245
});
4346
},
@@ -50,6 +53,7 @@ export function bindPendingCodeEvaluatorEditActions({
5053
output: {
5154
status: "rejected",
5255
message: "User rejected the proposed code-evaluator draft edit.",
56+
...approvalOutcome({ decision: "rejected", source: "user" }),
5357
},
5458
});
5559
},

0 commit comments

Comments
 (0)