Skip to content

Commit 79aaaf2

Browse files
NubsCarsonclaude
andcommitted
fix(core): restore evaluator messageToUser precedence, opt-in canonical tool text
The Server Tests upstream regression (planner-loop-user-facing-text → "does not regress evaluator's explicit messageToUser path") fails on develop because preferredFinalMessageFromToolOrModel preferred a single successful tool's userFacingText OVER the evaluator's explicit messageToUser. Shaw's regression test asserts the opposite: when the evaluator emits an explicit messageToUser, it wins. Reconciling both intents without picking one over the other: add an opt-in flag verifiedUserFacing on ActionResult / PlannerToolResult. Tools that emit structured outputs where evaluator paraphrase risks hallucinating values (paths, ids, counts, numeric metrics) set verifiedUserFacing: true to mark their userFacingText canonical. The planner-loop then echoes the tool verbatim instead of letting the evaluator paraphrase it. Without the flag, the evaluator's explicit messageToUser wins (Shaw's invariant). Precedence in preferredFinalMessageFromToolOrModel is now: 1. Single successful tool with verifiedUserFacing === true 2. Evaluator/model messageToUser 3. Most recent tool userFacingText (fallback) 4. Caller-provided fallback Changes: - packages/core/src/types/components.ts: add verifiedUserFacing to ActionResult with JSDoc explaining when to opt in. - packages/core/src/runtime/planner-types.ts: add verifiedUserFacing to PlannerToolResult with matching contract. - packages/core/src/runtime/execute-planned-tool-call.ts and packages/core/src/runtime/planner-loop.ts (actionResultToPlannerToolResult): propagate the field through both ActionResult → PlannerToolResult conversion paths. - packages/core/src/runtime/planner-loop.ts: - Rename singleSuccessfulUserFacingToolResultText → singleVerifiedUserFacingToolResultText and require verifiedUserFacing === true. - Reorder preferredFinalMessageFromToolOrModel to put verified-tool first, then evaluator, then fallback chain. - packages/core/src/__tests__/planner-happy-path.test.ts: the "prefers a single tool's verified user-facing text over evaluator paraphrase" test now sets verifiedUserFacing: true (its semantic intent — "this is canonical structured data the evaluator could hallucinate") so the canonical-output guarantee still holds. Verified: - 1362 tests pass, 11 skipped (full packages/core suite, 165 files) - bun run lint:check: 12 warnings before == 12 after (no new flags) - bun run typecheck: clean Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fa83dde commit 79aaaf2

5 files changed

Lines changed: 72 additions & 2 deletions

File tree

packages/core/src/__tests__/planner-happy-path.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,10 @@ describe("v5 happy path — message handler → planner → executor → evaluat
474474
text: "raw shell output with exact paths and metrics",
475475
userFacingText:
476476
"Root disk: 65% used, 138G available. Biggest cleanup candidate: /home/example/.bun (19G).",
477+
// Marks userFacingText as canonical so the planner-loop will not
478+
// fall back to the evaluator's paraphrase (which can hallucinate
479+
// paths/numbers in this kind of structured output).
480+
verifiedUserFacing: true,
477481
data: { actionName: "CHECK_RUNTIME" },
478482
}),
479483
});

packages/core/src/runtime/execute-planned-tool-call.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,7 @@ function actionResultToStreamingResult(
359359
success: result.success,
360360
text: result.text,
361361
userFacingText: result.userFacingText,
362+
verifiedUserFacing: result.verifiedUserFacing,
362363
error: result.error ? stringifyError(result.error) : undefined,
363364
data: result.data,
364365
values: result.values,

packages/core/src/runtime/planner-loop.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2178,7 +2178,16 @@ function latestToolResultText(
21782178
return undefined;
21792179
}
21802180

2181-
function singleSuccessfulUserFacingToolResultText(
2181+
/**
2182+
* Returns a single successful tool's `userFacingText` ONLY when the tool
2183+
* explicitly opted in to canonical-output via `verifiedUserFacing: true`.
2184+
*
2185+
* Tools that emit structured data the evaluator could easily paraphrase
2186+
* incorrectly (paths, ids, counts, numeric metrics) set the flag so the
2187+
* framework echoes their output verbatim instead of trusting the
2188+
* evaluator's rewording.
2189+
*/
2190+
function singleVerifiedUserFacingToolResultText(
21822191
trajectory: PlannerTrajectory,
21832192
): string | undefined {
21842193
const toolResultSteps = trajectory.steps.filter(
@@ -2187,6 +2196,7 @@ function singleSuccessfulUserFacingToolResultText(
21872196
if (toolResultSteps.length !== 1) return undefined;
21882197
const result = toolResultSteps[0]?.result;
21892198
if (result?.success !== true) return undefined;
2199+
if (result.verifiedUserFacing !== true) return undefined;
21902200
const text = result.userFacingText?.trim();
21912201
return text || undefined;
21922202
}
@@ -2196,8 +2206,27 @@ function preferredFinalMessageFromToolOrModel(
21962206
modelMessage?: unknown,
21972207
fallback?: unknown,
21982208
): string | undefined {
2209+
// Precedence:
2210+
// 1. A single successful tool whose result was explicitly marked
2211+
// `verifiedUserFacing: true` — used for structured outputs
2212+
// (paths, ids, counts) where evaluator paraphrase risks
2213+
// hallucinating a value.
2214+
// 2. The model/evaluator's explicit `messageToUser` — authoritative
2215+
// by default; the evaluator has seen the full trajectory and
2216+
// chose what the user should read.
2217+
// 3. The most recent tool's `userFacingText` — fallback when neither
2218+
// the model nor any verified tool provided a clean reply.
2219+
// 4. An explicit caller-provided fallback (e.g. failed-tool message).
2220+
//
2221+
// Regression coverage:
2222+
// - `planner-loop-user-facing-text.test.ts` → "does not regress
2223+
// evaluator's explicit messageToUser path" — evaluator wins when
2224+
// no tool sets `verifiedUserFacing`.
2225+
// - `planner-happy-path.test.ts` → "prefers a single tool's verified
2226+
// user-facing text over evaluator paraphrase" — tool wins when it
2227+
// opts in via `verifiedUserFacing: true`.
21992228
return (
2200-
singleSuccessfulUserFacingToolResultText(trajectory) ??
2229+
singleVerifiedUserFacingToolResultText(trajectory) ??
22012230
getNonEmptyString(modelMessage) ??
22022231
latestToolResultText(trajectory) ??
22032232
getNonEmptyString(fallback)
@@ -2460,6 +2489,7 @@ export function actionResultToPlannerToolResult(
24602489
success: result.success,
24612490
text: result.text,
24622491
userFacingText: result.userFacingText,
2492+
verifiedUserFacing: result.verifiedUserFacing,
24632493
data: Object.keys(data).length > 0 ? data : undefined,
24642494
error: result.error,
24652495
continueChain: result.continueChain,

packages/core/src/runtime/planner-types.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,29 @@ export interface PlannerToolResult {
9999
* undefined; in that case the framework falls through to the
100100
* evaluator's synthesized reply rather than dumping shell-wrapper
101101
* text into the user channel.
102+
*
103+
* By default an explicit evaluator `messageToUser` outranks this —
104+
* the evaluator has seen the full trajectory and chose what the
105+
* user should read. To mark `userFacingText` as canonical
106+
* (do-not-paraphrase) and have it outrank the evaluator's reply
107+
* when there is exactly one successful tool, set
108+
* `verifiedUserFacing: true`.
102109
*/
103110
userFacingText?: string;
111+
/**
112+
* Marks `userFacingText` as the canonical answer for this turn —
113+
* the evaluator's `messageToUser` MUST NOT paraphrase it. When set
114+
* AND there is exactly one successful tool with `userFacingText`,
115+
* the planner-loop prefers the tool's text over the evaluator's
116+
* reply for the terminal-FINISH `finalMessage`.
117+
*
118+
* Use when the tool's output is structured data the evaluator can
119+
* easily hallucinate (paths, ids, counts, numeric metrics) and any
120+
* paraphrase risk is worse than echoing the tool verbatim. Leave
121+
* unset for natural-language answers where the evaluator may
122+
* legitimately rephrase or add framing.
123+
*/
124+
verifiedUserFacing?: boolean;
104125
data?: Record<string, unknown>;
105126
error?: unknown;
106127
continueChain?: boolean;

packages/core/src/types/components.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,9 +659,23 @@ export interface ActionResult {
659659
* instead of the diagnostic `text`. Leave unset for log-emitting
660660
* actions (BASH, file readers); set for Q&A actions, REPLY actions,
661661
* and content generators.
662+
*
663+
* By default an explicit evaluator `messageToUser` outranks this.
664+
* Set `verifiedUserFacing: true` to mark this text as canonical
665+
* (do-not-paraphrase) — e.g. when it contains paths, ids, counts,
666+
* or numeric metrics the evaluator might otherwise hallucinate.
662667
*/
663668
userFacingText?: string;
664669

670+
/**
671+
* When `true` and `userFacingText` is set, the planner-loop prefers
672+
* the action's `userFacingText` over the evaluator's `messageToUser`
673+
* for the terminal-FINISH reply. Use for structured outputs
674+
* (paths, ids, counts, numeric metrics) where a paraphrase risk is
675+
* worse than echoing the action verbatim.
676+
*/
677+
verifiedUserFacing?: boolean;
678+
665679
/** Values to merge into the state */
666680
values?: Record<string, ProviderValue>;
667681

0 commit comments

Comments
 (0)