Skip to content

Commit 807c63c

Browse files
committed
Address Yutori native tool review
1 parent da5dcf9 commit 807c63c

7 files changed

Lines changed: 249 additions & 34 deletions

File tree

‎packages/agent/src/translator/translator.ts‎

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,52 +102,76 @@ type DragMouseButton = "left" | "right" | "middle";
102102
function toSdkAction(type: string, action: ModelAction): KernelBatchAction {
103103
switch (type) {
104104
case "click":
105+
const clickHoldKeys = readHoldKeys(action.hold_keys);
105106
return {
106107
type: "click_mouse",
107108
click_mouse: {
108109
x: toInt(action.x),
109110
y: toInt(action.y),
110111
button: clickMouseButtonOr(action.button, "left"),
112+
...(clickHoldKeys.length > 0 ? { hold_keys: clickHoldKeys } : {}),
111113
},
112114
};
113115
case "double_click":
116+
const doubleClickHoldKeys = readHoldKeys(action.hold_keys);
114117
return {
115118
type: "click_mouse",
116119
click_mouse: {
117120
x: toInt(action.x),
118121
y: toInt(action.y),
119122
num_clicks: 2,
123+
...(doubleClickHoldKeys.length > 0 ? { hold_keys: doubleClickHoldKeys } : {}),
120124
},
121125
};
122126
case "mouse_down":
123127
case "mouse_up":
128+
const mouseHoldKeys = readHoldKeys(action.hold_keys);
124129
return {
125130
type: "click_mouse",
126131
click_mouse: {
127132
x: toInt(action.x),
128133
y: toInt(action.y),
129134
button: clickMouseButtonOr(action.button, "left"),
130135
click_type: type === "mouse_down" ? "down" : "up",
136+
...(mouseHoldKeys.length > 0 ? { hold_keys: mouseHoldKeys } : {}),
131137
},
132138
};
133139
case "type":
134140
return { type: "type_text", type_text: { text: typeof action.text === "string" ? action.text : "" } };
135141
case "keypress":
136-
return keypress(toStringArray(action.keys));
142+
return keypress(toStringArray(action.keys), action.duration);
137143
case "scroll":
144+
const scrollHoldKeys = readHoldKeys(action.hold_keys);
138145
return {
139146
type: "scroll",
140147
scroll: {
141148
x: toInt(action.x),
142149
y: toInt(action.y),
143150
delta_x: toInt(action.scroll_x),
144151
delta_y: toInt(action.scroll_y),
152+
...(scrollHoldKeys.length > 0 ? { hold_keys: scrollHoldKeys } : {}),
145153
},
146154
};
147155
case "move":
148-
return { type: "move_mouse", move_mouse: { x: toInt(action.x), y: toInt(action.y) } };
156+
const moveHoldKeys = readHoldKeys(action.hold_keys);
157+
return {
158+
type: "move_mouse",
159+
move_mouse: {
160+
x: toInt(action.x),
161+
y: toInt(action.y),
162+
...(moveHoldKeys.length > 0 ? { hold_keys: moveHoldKeys } : {}),
163+
},
164+
};
149165
case "drag":
150-
return { type: "drag_mouse", drag_mouse: { path: toPath(action.path), button: dragMouseButtonOr(action.button, "left") } };
166+
const dragHoldKeys = readHoldKeys(action.hold_keys);
167+
return {
168+
type: "drag_mouse",
169+
drag_mouse: {
170+
path: toPath(action.path),
171+
button: dragMouseButtonOr(action.button, "left"),
172+
...(dragHoldKeys.length > 0 ? { hold_keys: dragHoldKeys } : {}),
173+
},
174+
};
151175
case "wait":
152176
return { type: "sleep", sleep: { duration_ms: typeof action.ms === "number" ? Math.trunc(action.ms) : 1000 } };
153177
default:
@@ -188,7 +212,11 @@ function toStringArray(value: unknown): string[] {
188212
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
189213
}
190214

191-
function keypress(keys: string[]): KernelBatchAction {
215+
function readHoldKeys(value: unknown): string[] {
216+
return toStringArray(value).flatMap((key) => translateKeys([key]));
217+
}
218+
219+
function keypress(keys: string[], duration: unknown = undefined): KernelBatchAction {
192220
const translated = translateKeys(keys);
193221
const pressedKeys = translated.filter((key) => !isModifierKey(key));
194222
const holdKeys = pressedKeys.length > 0 ? translated.filter(isModifierKey) : translated.slice(0, -1);
@@ -197,6 +225,7 @@ function keypress(keys: string[]): KernelBatchAction {
197225
press_key: {
198226
keys: pressedKeys.length > 0 ? pressedKeys : translated.slice(-1),
199227
...(holdKeys.length > 0 ? { hold_keys: holdKeys } : {}),
228+
...(typeof duration === "number" && Number.isFinite(duration) && duration > 0 ? { duration: Math.trunc(duration) } : {}),
200229
},
201230
};
202231
}
@@ -214,6 +243,7 @@ const KEY_ALIASES: Record<string, string> = {
214243
shiftleft: "Shift_L",
215244
meta: "Super_L",
216245
super: "Super_L",
246+
super_l: "Super_L",
217247
cmd: "Super_L",
218248
command: "Super_L",
219249
enter: "Return",

‎packages/agent/test/translator.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,23 @@ describe("InternalComputerTranslator", () => {
5656
[{ type: "press_key", press_key: { keys: ["l"], hold_keys: ["Control_L"] } }],
5757
]);
5858
});
59+
60+
it("passes canonical modifier and key duration fields through to Kernel actions", async () => {
61+
const { batches, client } = createClient();
62+
const translator = new InternalComputerTranslator({ browser, client });
63+
64+
await translator.executeBatch([
65+
{ type: "click", x: 10, y: 20, hold_keys: ["Control_L"] },
66+
{ type: "scroll", x: 10, y: 20, scroll_y: 120, hold_keys: ["Shift_L"] },
67+
{ type: "keypress", keys: ["Shift_L"], duration: 1500 },
68+
]);
69+
70+
expect(batches).toEqual([
71+
[
72+
{ type: "click_mouse", click_mouse: { x: 10, y: 20, button: "left", hold_keys: ["Control_L"] } },
73+
{ type: "scroll", scroll: { x: 10, y: 20, delta_x: 0, delta_y: 120, hold_keys: ["Shift_L"] } },
74+
{ type: "press_key", press_key: { keys: ["Shift_L"], duration: 1500 } },
75+
],
76+
]);
77+
});
5978
});

‎packages/ai/src/providers/common.ts‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,83 @@ export type CuaAction =
133133
| CuaActionUrl
134134
| CuaActionCursorPosition;
135135

136+
export const CUA_MODIFIER_KEYSYMS = ["Control_L", "Alt_L", "Shift_L", "Super_L"] as const;
137+
138+
export type CuaModifierKeysym = (typeof CUA_MODIFIER_KEYSYMS)[number];
139+
140+
const CUA_KEY_ALIASES: Record<string, string> = {
141+
alt: "Alt_L",
142+
alt_l: "Alt_L",
143+
altleft: "Alt_L",
144+
backspace: "BackSpace",
145+
cmd: "Super_L",
146+
command: "Super_L",
147+
control: "Control_L",
148+
control_l: "Control_L",
149+
controlleft: "Control_L",
150+
ctrl: "Control_L",
151+
delete: "Delete",
152+
down: "Down",
153+
end: "End",
154+
enter: "Return",
155+
esc: "Escape",
156+
escape: "Escape",
157+
home: "Home",
158+
left: "Left",
159+
meta: "Super_L",
160+
option: "Alt_L",
161+
pagedown: "Next",
162+
page_down: "Next",
163+
pageup: "Prior",
164+
page_up: "Prior",
165+
return: "Return",
166+
right: "Right",
167+
shift: "Shift_L",
168+
shift_l: "Shift_L",
169+
shiftleft: "Shift_L",
170+
space: "space",
171+
super: "Super_L",
172+
tab: "Tab",
173+
up: "Up",
174+
};
175+
176+
const CUA_MODIFIER_KEYSYM_SET = new Set<string>(CUA_MODIFIER_KEYSYMS);
177+
178+
/**
179+
* Normalize provider-emitted key aliases to Kernel's xdotool/X11 keysyms.
180+
* Unknown values pass through so provider-specific or printable keys can still
181+
* be executed when the Kernel computer API already understands them.
182+
*/
183+
export function normalizeCuaKey(value: string): string {
184+
const trimmed = value.trim();
185+
const lookup = trimmed.replace(/[-\s]/g, "_").toLowerCase();
186+
const alias = CUA_KEY_ALIASES[lookup];
187+
if (alias) return alias;
188+
if (/^f\d{1,2}$/i.test(trimmed)) return trimmed.toUpperCase();
189+
if (/^arrow/i.test(trimmed)) return normalizeCuaKey(trimmed.slice("arrow".length));
190+
if (trimmed.length === 1 && trimmed >= "A" && trimmed <= "Z") return trimmed.toLowerCase();
191+
return trimmed;
192+
}
193+
194+
export function normalizeCuaModifierKey(value: string): CuaModifierKeysym | undefined {
195+
const key = normalizeCuaKey(value);
196+
return CUA_MODIFIER_KEYSYM_SET.has(key) ? (key as CuaModifierKeysym) : undefined;
197+
}
198+
199+
export function normalizeCuaKeyCombo(value: string): string[] {
200+
return value
201+
.split("+")
202+
.map((part) => normalizeCuaKey(part))
203+
.filter(Boolean);
204+
}
205+
206+
export function normalizeCuaKeySequence(value: string): string[][] {
207+
return value
208+
.split(/\s+/)
209+
.map((part) => normalizeCuaKeyCombo(part))
210+
.filter((combo) => combo.length > 0);
211+
}
212+
136213
const PointSchema = Type.Object(
137214
{
138215
x: Type.Number(),

‎packages/ai/src/providers/yutori/actions.ts‎

Lines changed: 45 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,40 @@ import {
22
CUA_ACTION_TYPES,
33
CUA_BATCH_TOOL_NAME,
44
CUA_NAVIGATION_TOOL_NAME,
5+
normalizeCuaKeyCombo,
6+
normalizeCuaKeySequence,
7+
normalizeCuaModifierKey,
58
type CuaAction,
69
type CuaActionType,
710
} from "../common";
811

12+
/**
13+
* Native Yutori Navigator n1.5 tool-set ids.
14+
*
15+
* Source of truth:
16+
* - https://docs.yutori.com/reference/n1-5
17+
* - https://docs.yutori.com/llm-quickstart.md
18+
*/
919
export const YUTORI_N15_CORE_TOOL_SET = "browser_tools_core-20260403";
1020
export const YUTORI_N15_EXPANDED_TOOL_SET = "browser_tools_expanded-20260403";
1121

22+
/**
23+
* DOM/ref-backed Navigator n1.5 actions. We intentionally disable these until
24+
* CuaAgent has the ref/DOM execution path that Yutori documents for the
25+
* expanded tool set.
26+
*/
1227
export const YUTORI_N15_EXPANDED_ACTION_TYPES = [
1328
"extract_elements",
1429
"find",
1530
"set_element_value",
1631
"execute_js",
1732
] as const;
1833

34+
/**
35+
* Navigator n1's fixed legacy browser action space.
36+
*
37+
* Source of truth: https://docs.yutori.com/reference/n1
38+
*/
1939
export const YUTORI_N1_ACTION_TYPES = [
2040
"left_click",
2141
"double_click",
@@ -32,6 +52,13 @@ export const YUTORI_N1_ACTION_TYPES = [
3252
"wait",
3353
] as const;
3454

55+
/**
56+
* Navigator n1.5 core visual action space. These are the actions available
57+
* when `tool_set` is `browser_tools_core-20260403`, which keeps CuaAgent in the
58+
* pure screenshot/coordinate path and avoids DOM refs.
59+
*
60+
* Source of truth: https://docs.yutori.com/reference/n1-5
61+
*/
3562
export const YUTORI_N15_CORE_ACTION_TYPES = [
3663
"left_click",
3764
"double_click",
@@ -74,17 +101,11 @@ const DEFAULT_WAIT_MS = 2000;
74101
const NAVIGATION_WAIT_MS = 1500;
75102
const GOTO_WAIT_MS = 2000;
76103

77-
const MODIFIER_MAP: Record<string, string> = {
78-
alt: "alt",
79-
command: "super",
80-
control: "ctrl",
81-
ctrl: "ctrl",
82-
cmd: "super",
83-
meta: "super",
84-
shift: "shift",
85-
super: "super",
86-
};
87-
104+
/**
105+
* Yutori n1.5 exposes built-in browser tools via `tool_set`, not by accepting
106+
* client-supplied JSON tool definitions. Keeping this empty prevents us from
107+
* sending duplicate CUA batch/browser tools alongside Yutori's native tools.
108+
*/
88109
export function createComputerToolDefinitions(_options?: unknown): [] {
89110
return [];
90111
}
@@ -195,34 +216,36 @@ function toTypeActions(args: Record<string, unknown>): CuaAction[] | undefined {
195216
if (text === undefined) return undefined;
196217
const actions: CuaAction[] = [];
197218
if (args.clear_before_typing === true) {
198-
actions.push({ type: "keypress", keys: ["Control", "a"] }, { type: "keypress", keys: ["Backspace"] });
219+
actions.push({ type: "keypress", keys: ["Control_L", "a"] }, { type: "keypress", keys: ["BackSpace"] });
199220
}
200221
actions.push({ type: "type", text });
201-
if (args.press_enter_after === true) actions.push({ type: "keypress", keys: ["Enter"] });
222+
if (args.press_enter_after === true) actions.push({ type: "keypress", keys: ["Return"] });
202223
return actions;
203224
}
204225

205226
function toKeypressAction(args: Record<string, unknown>): CuaAction[] | undefined {
206-
const keys = readKeys(args.key_comb ?? args.key);
207-
return keys.length > 0 ? [{ type: "keypress", keys }] : undefined;
227+
const sequence = readKeySequence(args.key_comb ?? args.key);
228+
return sequence.length > 0 ? sequence.map((keys) => ({ type: "keypress", keys })) : undefined;
208229
}
209230

210231
function toHoldKeyAction(args: Record<string, unknown>): CuaAction[] | undefined {
211-
const keys = readKeys(args.key_comb ?? args.key);
232+
const keys = readKeyCombo(args.key_comb ?? args.key);
212233
return keys.length > 0 ? [{ type: "keypress", keys, duration: secondsToMs(args.duration, 1000) }] : undefined;
213234
}
214235

215-
function readKeys(value: unknown): string[] {
236+
function readKeyCombo(value: unknown): string[] {
237+
if (typeof value !== "string") return [];
238+
return normalizeCuaKeyCombo(value);
239+
}
240+
241+
function readKeySequence(value: unknown): string[][] {
216242
if (typeof value !== "string") return [];
217-
return value
218-
.split("+")
219-
.map((part) => part.trim())
220-
.filter(Boolean);
243+
return normalizeCuaKeySequence(value);
221244
}
222245

223246
function holdKeys(value: unknown): { hold_keys?: string[] } {
224247
if (typeof value !== "string") return {};
225-
const key = MODIFIER_MAP[value.trim().toLowerCase()];
248+
const key = normalizeCuaModifierKey(value);
226249
return key ? { hold_keys: [key] } : {};
227250
}
228251

‎packages/ai/src/providers/yutori/index.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,15 @@ export {
3333
// n1.5 expanded (browser_tools_expanded-20260403): core +
3434
// extract_elements, find, set_element_value, execute_js
3535
// Sources:
36-
// https://github.com/yutori-ai/yutori-sdk-python/blob/main/api.md
37-
// https://github.com/yutori-ai/yutori-sdk-python/blob/main/yutori/navigator/models.py
36+
// https://docs.yutori.com/reference/n1
3837
// https://docs.yutori.com/reference/n1-5
38+
// https://docs.yutori.com/llm-quickstart.md
3939
// https://github.com/yutori-ai/yutori-sdk-python/blob/main/yutori/navigator/coordinates.py
4040
export const COMPUTER_TOOL_COORDINATES = { type: "normalized", range: [0, 1000] } as const satisfies ComputerToolCoordinateSystem;
4141

42+
// Yutori's Navigator quickstart recommends putting extra instructions in the
43+
// first user message instead of supplying a custom system prompt.
44+
// Source: https://docs.yutori.com/llm-quickstart.md
4245
export const YUTORI_INSTRUCTIONS_RAW = "";
4346

4447
export function buildYutoriSystemPrompt(opts: { suffix?: string } = {}): string {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
normalizeCuaKey,
4+
normalizeCuaKeyCombo,
5+
normalizeCuaKeySequence,
6+
normalizeCuaModifierKey,
7+
} from "../src/index";
8+
9+
describe("canonical CUA key normalization", () => {
10+
it("normalizes common provider key names to X11 keysyms", () => {
11+
expect(normalizeCuaModifierKey("ctrl")).toBe("Control_L");
12+
expect(normalizeCuaModifierKey("command")).toBe("Super_L");
13+
expect(normalizeCuaKey("Backspace")).toBe("BackSpace");
14+
expect(normalizeCuaKey("ArrowLeft")).toBe("Left");
15+
expect(normalizeCuaKey("enter")).toBe("Return");
16+
expect(normalizeCuaKey("F5")).toBe("F5");
17+
});
18+
19+
it("normalizes key combos and sequential key expressions separately", () => {
20+
expect(normalizeCuaKeyCombo("ctrl+shift+Tab")).toEqual(["Control_L", "Shift_L", "Tab"]);
21+
expect(normalizeCuaKeySequence("down down enter")).toEqual([["Down"], ["Down"], ["Return"]]);
22+
});
23+
});

0 commit comments

Comments
 (0)