Skip to content

Commit 5123707

Browse files
alex-norkclaude
andauthored
fix(live-voice,computer-use): narration budget, AX tree size, and the scripting story (#42806)
* fix(live-voice): give progress narration a budget it can actually meet A live-voice turn that runs tools speaks short progress updates into the dead air between them. The budget for generating that text was 1500ms, which sits at the edge of the narrator model's real roundtrip: across one three-minute computer-use turn, 9 of 22 attempts (41%) aborted at the deadline, every one of them at totalMs 1500-1503 against timeoutMs 1500. A budget that only sometimes covers the roundtrip does not make narration faster, it thins it out - and it thins out precisely where the updates are worth most, because the beats that take longest to generate are the ones with the longest silence behind them. This is the failure mode the sibling endpointDecisionTimeoutMs already documents. The budget is explicitly not latency-critical (it speaks into dead air), so raise it to 5000ms, still under the 6000ms minGapMs so an update that spends the whole budget lands before the next one is due. Two things hid this. The timeout aborted with kind "voice_session_aborted", which reads in the logs as the session dying once per update rather than one beat going unspoken, so give it its own kind. And every drop logged the same line whatever the cause, so name the outcome (timed_out / session_ended / error) as one greppable field. Neither the module nor the log says anything on the success path - the spoken count lives in the live-voice-metrics `progressUpdatesSpoken` field - so a drop rate was only ever legible by pairing the two, which is how a 41% rate went unnoticed. Also reflows one pre-existing over-long assertion in conversation-error test so the touched file passes the formatting hook. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(live-voice): state the spoken-update count in the log even at zero The turn log carried progressUpdatesSpoken only when it was above zero, so the one value that distinguishes "this turn was asked for fewer updates and correctly spoke none" from "narration failed every attempt" was the one value the log never showed. Both render as an absent field. That is not hypothetical: reading these logs, an absent field was twice read as "narration never ran" on a turn that had in fact suppressed updates exactly as the caller asked out loud. Only the log changes. The telemetry payload keeps the omission, because there the absence means something different and deliberate - a turn that never engaged the feature is unchanged on the wire (see optionalTurnFields, whose contract the existing "omits endpoint and progress fields" test pins). No new test: the log line is one field on a module-level logger, and mocking that module here risks the cross-file mock leakage this repo already has trouble with. The wire-shape guard that could actually regress is the existing test, and it still passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(computer-use): leave scrolled-away rows out of the AX tree A scroll view shows part of its contents and hides the rest, but the accessibility tree reports a scrolled-away row at the frame it would have if it were on screen. The CU tree walked the raw element tree and listed every one of them, so a Finder window of ~700 files came back as ~735 interactive rows spanning y=224 to y=15460 on an 1117pt screen - roughly 45 of them actually drawn, the other ~94% reported at real-looking positions with nothing there. At ~20 tokens a line that is ~15k tokens per step, and since 42762 every action returns one. The fix is already in this file: flattenClipped carries each ancestor's crop down the tree and its doc comment states this exact problem, but it had one caller (the coachmark measurement path in main.swift) and the tree the model reads was not it. Thread the same AXClip.narrowed through collectFormatted and drop what the crop leaves nothing of, reusing AXDisplayMatch.frame for the geometric test so "any overlap counts" has one definition rather than two - a row half out of the pane stays listed. The count is reported rather than silently dropped. A list quietly reduced to its visible rows reads as a short list, and the model needs to know the rest are reachable by scrolling. A tree with nothing cropping in it keeps a nil clip the whole way down and is reported exactly as before, so apps that scroll nothing are unaffected. Not unit-tested: formatAXTree lives in MacHelperExecutable, which has a top-level main.swift and no test target. The geometric rule it now leans on is covered in MacHelperCoreTests (AXClipTests already asserts a scrolled-away row fails standsOn its pane's clip). Verify in the dev app against a long Finder list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(computer-use): teach the scripting dictionary before the menu click The scripting section lists three things AppleScript is for - "a menu item (<worked example>), an app's own scripting dictionary, or window management" - and then spends the rest of the paragraph on the menu item. The dictionary gets four words and no example. Models copy the example. They did. Asked to open Finder, go to the Desktop and sort by name, a voice turn reached for computer_use_run_applescript exactly as told and used the one pattern it had been shown: System Events clicking Go > Desktop. It then fell back to two raw clicks on the column header to sort. Finder has a scripting dictionary in which that whole task is four lines, and the run cost an app open, a 7,497-element accessibility walk and two clicks instead. So lead with the dictionary and give it the worked example, and demote System Events to what it actually is: the fallback for apps with no dictionary entry for what you need, and UI automation in a script's clothes - still dependent on the menu sitting where you expect and the app being frontmost. The iMovie menu example stays, one rung down. The tool description carried the same ordering ("menu items, an app's scripting dictionary, windows"), so both copies of it move too - the manifest-regression test pins them to each other. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * perf(computer-use): stop walking rows a scroll view is not showing Clipping the serialized tree cut what the model reads; it did not cut what the helper does to produce it. The walk still visited every element: the Finder observation that printed 735 rows enumerated 7,497 elements and took 28.5 seconds, against 39-653 elements for every other step in the same session. The cost scales with how many files are in the folder rather than with how many are on screen, and each skipped element is ~9 AX attribute reads over Mach IPC plus its whole subtree. So carry the same AXClip down the walk and skip children their scrolling ancestor leaves nothing of, paying one frame read to save the rest. Off by default, and behind a flag rather than applied to the enumerator at large, because the other two callers need the opposite. `ax.locate` (the coachmark path) exists to answer "where is this element", and a scrolled-away element has to be in the tree for `flattenClipped` to answer "it exists and is not on screen" - dropped during the walk, that answer becomes "not found", which puts a coachmark somewhere else entirely. Only the CU observation path, whose tree is a list of things to act on right now, opts in. Two deliberate asymmetries with the formatter's clipping: - What the walk skips never reaches the formatter to be counted, so the count is passed across and summed. Otherwise the "scrolled out of view" note - the thing that tells the model to scroll rather than conclude the list is short - would silently go to zero. - The walk does not reuse AXDisplayMatch's test, where an element with no area is on nothing. That is right for one being listed and wrong for one being walked, since skipping here discards the whole subtree under it, and a wrapper reporting no frame (web content does) must be descended into rather than judged. Not unit-tested, same reason as the formatter change: MacHelperExecutable has a top-level main.swift and no test target. Worth QAing against both a long Finder list and a scrolled web page, and worth re-checking a coachmark lands correctly since that path deliberately did not change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: drop the em dashes this branch added AGENTS.md bans them outright, and the accessibility-tree note was the strict case: a string the model reads, written in punctuation the assistant's own prompt forbids. Replaced with a colon there and with periods or a colon in the four comments. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a170659 commit 5123707

15 files changed

Lines changed: 207 additions & 32 deletions

File tree

assistant/src/__tests__/config-schema.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1021,7 +1021,7 @@ describe("AssistantConfigSchema", () => {
10211021
maxSilenceMs: 35000,
10221022
longOpMs: 15000,
10231023
minGapMs: 6000,
1024-
generationTimeoutMs: 1500,
1024+
generationTimeoutMs: 5000,
10251025
},
10261026
},
10271027
});

assistant/src/__tests__/conversation-error.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1490,6 +1490,7 @@ describe("classifyConversationError", () => {
14901490
"subagent_aborted",
14911491
"signal_cancel",
14921492
"voice_session_aborted",
1493+
"voice_progress_narration_timeout",
14931494
];
14941495

14951496
for (const kind of taggedKinds) {
@@ -1727,7 +1728,9 @@ describe("ConnectionResolutionError classification", () => {
17271728
expect(result.userMessage).toContain("qwen/qwen3-8b");
17281729
expect(result.userMessage).toContain("Vellum GPU route");
17291730
expect(result.userMessage).toContain('profile "steer"');
1730-
expect(result.userMessage).toContain("was not sent through another provider");
1731+
expect(result.userMessage).toContain(
1732+
"was not sent through another provider",
1733+
);
17311734
});
17321735

17331736
it("classifies missing_credential naming the connection and fix", () => {

assistant/src/calls/__tests__/progress-narration.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,29 @@ describe("createVoiceProgressNarrator", () => {
185185
expect(Date.now() - startedAt).toBeLessThan(1000);
186186
});
187187

188+
test("an update that runs out of budget aborts as a narration timeout, not a session abort", async () => {
189+
// The budget lapsing means this beat goes unspoken; it does not mean the
190+
// call ended. Sharing `voice_session_aborted` made a narrator whose budget
191+
// sat below the model's roundtrip read in the logs as a session dying once
192+
// per update, which is what hid a turn that had gone silent for minutes.
193+
let seen: AbortSignal | undefined;
194+
const narrator = createVoiceProgressNarrator({
195+
config: VoiceProgressConfigSchema.parse({ generationTimeoutMs: 20 }),
196+
getProvider: async () =>
197+
stubProvider((_messages, options) => {
198+
seen = options?.signal;
199+
return new Promise<ProviderResponse>(() => {});
200+
}),
201+
});
202+
203+
expect(await narrator.generateProgressText(progressInput)).toBeNull();
204+
expect(seen?.aborted).toBe(true);
205+
expect(seen?.reason).toMatchObject({
206+
kind: "voice_progress_narration_timeout",
207+
source: "voice-progress-narration",
208+
});
209+
});
210+
188211
test("a caller abort settles promptly", async () => {
189212
const narrator = createVoiceProgressNarrator({
190213
config: VoiceProgressConfigSchema.parse({

assistant/src/calls/progress-narration.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import type {
1616
ProviderResponse,
1717
ToolDefinition,
1818
} from "../providers/types.js";
19-
import { createAbortReason } from "../util/abort-reasons.js";
19+
import { createAbortReason, isAbortReason } from "../util/abort-reasons.js";
2020
import { getLogger } from "../util/logger.js";
2121

2222
const log = getLogger("voice-progress-narration");
@@ -164,7 +164,10 @@ async function requestBoundedResponse(args: {
164164
const timeoutTimer = setTimeout(
165165
() =>
166166
timeoutController.abort(
167-
createAbortReason("voice_session_aborted", "voice-progress-narration"),
167+
createAbortReason(
168+
"voice_progress_narration_timeout",
169+
"voice-progress-narration",
170+
),
168171
),
169172
args.timeoutMs,
170173
);
@@ -260,9 +263,19 @@ export function createVoiceProgressNarrator(options: {
260263
}
261264
return trimmed;
262265
} catch (error) {
266+
// Why the beat went unspoken, as one greppable field. A narrator whose
267+
// budget is below the model's real roundtrip drops every update at the
268+
// deadline and reads in the logs as a run of unrelated failures; naming
269+
// the outcome is what makes that run legible as "the feature is off".
270+
const outcome = isAbortReason(error)
271+
? error.kind === "voice_progress_narration_timeout"
272+
? "timed_out"
273+
: "session_ended"
274+
: "error";
263275
log.info(
264276
{
265277
error,
278+
outcome,
266279
providerResolveMs,
267280
totalMs: Math.round(performance.now() - startedAt),
268281
timeoutMs: options.config.generationTimeoutMs,

assistant/src/config/bundled-skills/computer-use/SKILL.md

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,32 @@ cut off. If the element you need is not in it, call `computer_use_observe` with
4040
## Scripting apps (macOS)
4141

4242
Reach for `computer_use_run_applescript` first when an app can be driven by
43-
script: a menu item (`click menu item "Split Clip" of menu "Modify" of menu bar
44-
1` inside `tell application "System Events" to tell process "iMovie"`), an
45-
app's own scripting dictionary, or window management. It does not take the
46-
cursor. A menu item that needs a selection or a playhead position does nothing
47-
when that context is missing, so set it up first, and read
48-
`enabled of menu item` when unsure. Click and type for everything a script
49-
cannot reach. `host_bash` is for shell commands, not for driving apps.
43+
script. It does not take the cursor.
44+
45+
**Try the app's own scripting dictionary before anything else.** Ask it for the
46+
state you want, not for the clicks that would produce that state. Finder, Mail,
47+
Music, Notes, Safari, Terminal and many third-party apps have one, and where
48+
there is a dictionary the whole task is usually a sentence with no window to
49+
open, no tree to read and nothing to click:
50+
51+
```applescript
52+
tell application "Finder"
53+
set target of front window to desktop
54+
set current view of front window to list view
55+
set sort column of list view options of front window to name column
56+
end tell
57+
```
58+
59+
**System Events menu clicking is the fallback**, for apps with no dictionary
60+
entry for what you need: `click menu item "Split Clip" of menu "Modify" of menu
61+
bar 1` inside `tell application "System Events" to tell process "iMovie"`. It is
62+
UI automation in a script's clothes: it still depends on the menu sitting where
63+
you expect and on the app being frontmost. A menu item that needs a selection or
64+
a playhead position does nothing when that context is missing, so set it up
65+
first, and read `enabled of menu item` when unsure.
66+
67+
Click and type for everything a script cannot reach. `host_bash` is for shell
68+
commands, not for driving apps.
5069

5170
## Typing is not sending
5271

assistant/src/config/bundled-skills/computer-use/TOOLS.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@
269269
},
270270
{
271271
"name": "computer_use_run_applescript",
272-
"description": "Run an AppleScript on the Mac: menu items, an app's scripting dictionary, windows. Prefer this over click and type when the app supports it; it does not move the cursor. The result is the script's return value, and the accessibility tree may not change even when the script worked. Never use 'do shell script' inside AppleScript (blocked for security).",
272+
"description": "Run an AppleScript on the Mac. Try the target app's own scripting dictionary first, asking it for the state you want rather than for the clicks that would produce it; fall back to System Events menu clicking for apps with no dictionary entry for what you need. Prefer this over click and type when the app supports it; it does not move the cursor. The result is the script's return value, and the accessibility tree may not change even when the script worked. Never use 'do shell script' inside AppleScript (blocked for security).",
273273
"category": "computer-use",
274274
"risk": "medium",
275275
"input_schema": {

assistant/src/config/schemas/__tests__/voice.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const PROGRESS_DEFAULTS = {
99
maxSilenceMs: 35_000,
1010
longOpMs: 15_000,
1111
minGapMs: 6_000,
12-
generationTimeoutMs: 1_500,
12+
generationTimeoutMs: 5_000,
1313
};
1414

1515
const FRONT_MODEL_DEFAULTS = {
@@ -75,7 +75,7 @@ describe("VoiceFrontModelConfigSchema", () => {
7575
expect(parsed.progress.maxSilenceMs).toBe(35_000);
7676
expect(parsed.progress.longOpMs).toBe(15_000);
7777
expect(parsed.progress.minGapMs).toBe(6_000);
78-
expect(parsed.progress.generationTimeoutMs).toBe(1_500);
78+
expect(parsed.progress.generationTimeoutMs).toBe(5_000);
7979
});
8080

8181
test("a stale maxPerTurn key is stripped, not rejected", () => {

assistant/src/config/schemas/voice.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,9 @@ export const VoiceProgressConfigSchema = z
7474
.positive(
7575
"voice.frontModel.progress.generationTimeoutMs must be a positive integer",
7676
)
77-
.default(1_500)
77+
.default(5_000)
7878
.describe(
79-
"Budget (ms) for LLM-generated progress text. Not latency-critical: it speaks into dead air",
79+
"Budget (ms) for LLM-generated progress text. Not latency-critical: it speaks into dead air, so the budget is set to cover the narrator's real roundtrip rather than to be tight. A budget that only sometimes covers that roundtrip thins narration out rather than speeding it up, and it thins out exactly where the updates matter most, since the slow beats are the ones with the longest silence behind them (see endpointDecisionTimeoutMs for the same failure mode). Kept under progress.minGapMs so an update that spends the whole budget still lands before the next one is due",
8080
),
8181
})
8282
// The heartbeat is checked when the idle tick finds the turn silent, so a

assistant/src/live-voice/live-voice-metrics.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,15 @@ export class LiveVoiceMetricsCollector {
511511
turnId: turn.turnId,
512512
finishReason,
513513
...aggregateFieldsForTurn(snapshotTurn(turn)),
514+
// Stated even at zero, unlike the telemetry field this overrides.
515+
// Zero is the single most informative value this count takes: it is
516+
// what a turn asked for fewer updates looks like, and equally what a
517+
// narrator that is failing every attempt looks like. Left absent, the
518+
// two read identically to anyone reading the log, and "the feature is
519+
// off" becomes indistinguishable from "the feature did its job".
520+
// The wire payload keeps the omission (see optionalTurnFields), so
521+
// turns that never engage narration are unchanged for telemetry.
522+
progressUpdatesSpoken: turn.progressUpdatesSpoken,
514523
},
515524
"Live voice turn latency",
516525
);

assistant/src/tools/computer-use/definitions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,7 @@ export const computerUseOpenAppTool = {
352352
export const computerUseRunAppleScriptTool = {
353353
name: "computer_use_run_applescript",
354354
description:
355-
"Run an AppleScript on the Mac: menu items, an app's scripting dictionary, windows. Prefer this over click and type when the app supports it; it does not move the cursor. The result is the script's return value, and the accessibility tree may not change even when the script worked. Never use 'do shell script' inside AppleScript (blocked for security).",
355+
"Run an AppleScript on the Mac. Try the target app's own scripting dictionary first, asking it for the state you want rather than for the clicks that would produce it; fall back to System Events menu clicking for apps with no dictionary entry for what you need. Prefer this over click and type when the app supports it; it does not move the cursor. The result is the script's return value, and the accessibility tree may not change even when the script worked. Never use 'do shell script' inside AppleScript (blocked for security).",
356356
category: "computer-use",
357357
defaultRiskLevel: RiskLevel.Low,
358358
executionTarget: "host",

0 commit comments

Comments
 (0)