Merge main into feat/voice-core

Keeps the voice-core prompt rules and adds main's world-query and
vessels-vs-visuals rules; the tool catalog wraps the deixis-aware runner.

Also fixes two things the branch had before the merge:
- Live voice could not start: OpenAI rejects track_entity's top-level
  anyOf. Model-facing tools now omit top-level combinators; the runner
  and benchmark still require a query or a referent.
- The Contacts-centred analyst answer dropped the viewport coverage note.
This commit is contained in:
Bilawal Sidhu committed 2026-10-03 23:30:02 -07:00
commit e7cb9b02b5
191 files changed
+20005 -1450

No files matched your search

+12 -4
View File
@@ -901,12 +901,20 @@ export function createActionTools(descriptions = {}) {
if (!schemas.some((schema) => schema.name === name))
throw new TypeError('Unknown action description: ' + name);
}
return schemas.map((schema) => ({
type: 'function',
...describe(schema, descriptions[schema.name] || {}),
}));
return schemas.map((schema) => {
const tool = {
type: 'function',
...describe(schema, descriptions[schema.name] || {}),
};
// Function-calling APIs reject anyOf/oneOf/allOf at the top level of
// parameters; the runner and benchmark validate those rules themselves.
for (const key of TOP_LEVEL_COMBINATORS) delete tool.parameters?.[key];
return tool;
});
}
const TOP_LEVEL_COMBINATORS = ['anyOf', 'oneOf', 'allOf'];
function describe(schema, metadata, allowDescription = true) {
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata))
throw new TypeError('Descriptions must be an object');
+33 -4
View File
@@ -3,7 +3,10 @@ import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { GEV_ACTION_SCHEMAS, createActionTools } from './actionSchemas.js';
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
import { validateArgs } from '../../scripts/voice-bench/grade.mjs';
import {
validateArgs,
validateCall,
} from '../../scripts/voice-bench/grade.mjs';
const stable = (value) =>
Array.isArray(value)
@@ -30,8 +33,9 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
digest,
// Re-derived for the voice layer manifest (generated layer enums and
// aliases), point-and-ask (pointer sentinels, referent args) and the
// consolidated tool wording (each policy stated once).
'a824c2ed0ceb553eb3041350856db05dbfc5abad8a3fa17d29dc378bf7a732d2',
// consolidated tool wording (each policy stated once); model-facing tools
// omit top-level anyOf (OpenAI rejects it).
'66c955a41e36c6e7d2d255a950993128528288be13946fe4f7dacb05ad2ba291',
);
});
@@ -139,7 +143,7 @@ test('all legacy action arguments are byte-identical after removing the delibera
});
test('track_entity accepts a referent alone but rejects an empty target', () => {
const schema = GEV_REALTIME_TOOLS.find(
const schema = GEV_ACTION_SCHEMAS.find(
(tool) => tool.name === 'track_entity',
).parameters;
assert.deepEqual(validateArgs(schema, { referent: 2 }), []);
@@ -151,3 +155,28 @@ test('track_entity accepts a referent alone but rejects an empty target', () =>
assert.ok(validateArgs(schema, { referent: 0 }).length > 0);
assert.ok(validateArgs(schema, { referent: 6 }).length > 0);
});
test('tools sent to the model never carry a top-level schema combinator', () => {
for (const tool of createActionTools()) {
assert.equal(tool.parameters.type, 'object', tool.name);
for (const key of ['anyOf', 'oneOf', 'allOf', 'enum', 'not'])
assert.equal(tool.parameters[key], undefined, `${tool.name}.${key}`);
}
// The runner still knows track_entity needs a query or a referent.
const track = GEV_ACTION_SCHEMAS.find((s) => s.name === 'track_entity');
assert.ok(track.parameters.anyOf);
});
test('the benchmark still rejects an empty track_entity although the model-facing tool omits anyOf', () => {
assert.ok(
validateCall(GEV_REALTIME_TOOLS, { name: 'track_entity', args: {} })
.length > 0,
);
assert.deepEqual(
validateCall(GEV_REALTIME_TOOLS, {
name: 'track_entity',
args: { referent: 2 },
}),
[],
);
});
+8 -1
View File
@@ -691,7 +691,7 @@ export function createGevActionRunner({
const style = normalizeStyle(args.style);
if (!style)
throw new Error(`Unknown visual style: ${args.style || 'missing'}`);
styleManager.setStyle(style);
styleManager.setStyle(style, { userInitiated: true });
return { ok: true, action: 'set_visual_style', style };
}
@@ -4870,6 +4870,13 @@ async function runAnalystQuery(
// arbitrary points — only "how many aircraft around <this contact>" is
// unified, because that is the question the panel is already answering.
if (entityWindow) {
// The detached Contacts snapshot keeps its own coverage; carry the same
// viewport note so both answer shapes say what they measured.
if (viewportScoped && entityWindow.coverage)
entityWindow.coverage = {
...entityWindow.coverage,
note: result.coverage?.note,
};
return entityWindow;
}
+25 -1
View File
@@ -1,3 +1,5 @@
import { toFunctionOutput } from '../tools/functions.js';
import { GEV_ACTION_SCHEMAS } from './actionSchemas.js';
import { createGevActionRunner } from './gevActions.js';
import { createVoiceCommands } from './commands.js';
import { cameraPoseKey, createPointerTracker } from './pointerContext.js';
@@ -5,6 +7,25 @@ import { createPointerPicker } from './pointerPick.js';
import { createReferentRegistry } from './referents.js';
export * from './realtimeController.js';
const ACTION_NAMES = new Set(GEV_ACTION_SCHEMAS.map((schema) => schema.name));
/**
* Run app actions through `runner` and every other tool the catalog has
* through the catalog. `loadCatalog` resolves the catalog when first needed.
*/
export function withToolCatalog(runner, loadCatalog) {
if (typeof loadCatalog !== 'function') return runner;
return async function runGevTool(name, args, options = {}) {
if (ACTION_NAMES.has(name)) return runner(name, args, options);
const catalog = await loadCatalog();
if (!catalog?.get(name)) return runner(name, args, options);
const result = await catalog.call(name, args ?? {}, {
signal: options.signal,
});
return toFunctionOutput(name, result);
};
}
/** Compose the standalone action runner with the voice controls. */
export function initGevVoiceCommands(options) {
const canvas = options.viewer?.scene?.canvas || null;
@@ -29,7 +50,10 @@ export function initGevVoiceCommands(options) {
...options,
pointer,
referents,
runner: createGevActionRunner({ ...options, deixis }),
runner: withToolCatalog(
createGevActionRunner({ ...options, deixis }),
options.toolCatalog,
),
});
deixis.imageFrame = () => commands.retainedImageFrame?.() || null;
deixis.onPointerUsed = () => commands.announcePointer?.();
+41
View File
@@ -31,6 +31,7 @@ import {
readStoredVoiceLimits,
writeStoredVoiceTier,
writeStoredVoiceLimits,
withToolCatalog,
} from './gevRealtime.js';
import { createVoiceCostTracker } from './voiceCost.js';
@@ -3725,3 +3726,43 @@ test('a claimed Space hold barges in on the assistant; a short tap never does',
const testPlaceSearch = () => createStandalonePlaceSearch({ resolveApiKey: () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ });
function createGevActionRunner(options) { return createActionRunner({ placeSearch: testPlaceSearch(), ...options }); }
function controlRadio(viewer, manager, args, options) { return runControlRadio(viewer, manager, args, { placeSearch: testPlaceSearch(), ...options }); }
test('voice runs app actions itself and other tools through the catalog', async () => {
const actions = [];
const runner = async (name, args) => {
actions.push([name, args]);
return { ok: true, action: name };
};
const calls = [];
let loads = 0;
const catalog = {
get: (name) => (name === 'get_wind' ? { name } : undefined),
async call(name, args, options) {
calls.push([name, args, options.signal]);
return { summary: 'Calm.', data: { calm: true } };
},
};
const run = withToolCatalog(runner, async () => {
loads += 1;
return catalog;
});
const signal = new AbortController().signal;
assert.deepEqual(await run('zoom_to_globe', {}), {
ok: true,
action: 'zoom_to_globe',
});
assert.equal(loads, 0);
assert.deepEqual(
await run('get_wind', { location: { place: 'Oslo' } }, { signal }),
{ ok: true, tool: 'get_wind', summary: 'Calm.', data: { calm: true } },
);
assert.deepEqual(calls, [
['get_wind', { location: { place: 'Oslo' } }, signal],
]);
await run('not_a_tool', {});
assert.deepEqual(
actions.map(([name]) => name),
['zoom_to_globe', 'not_a_tool'],
);
assert.equal(withToolCatalog(runner, undefined), runner);
});