fix(voice): omit tool result content from debug logs

This commit is contained in:
Sameh Khamis committed 2026-10-07 12:00:29 -07:00
1 parent ae3612a356
commit ae1b6abda1
5 files changed
+105 -7

No files matched your search

+2 -2
View File
@@ -72,8 +72,8 @@ OPENAI_REALTIME_CONTEXT_RETENTION=0.5
OPENAI_REALTIME_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe OPENAI_REALTIME_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe
# The voice debug log (.gev-logs/realtime-conversations.jsonl, rotated at # The voice debug log (.gev-logs/realtime-conversations.jsonl, rotated at
# 32 MB, never expired) omits transcripts, typed or spoken text, and tool # 32 MB, never expired) omits transcripts, typed or spoken text, and tool
# arguments by default. Set to 1 to keep them while debugging; delete the log # arguments/results (including serialized outputs) by default. Set to 1 to
# when you are done. # keep them while debugging; delete the log when you are done.
GEV_VOICE_LOG_CONTENT= GEV_VOICE_LOG_CONTENT=
OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano
# Per-IP rate limit for the OpenAI cost endpoints # Per-IP rate limit for the OpenAI cost endpoints
+4
View File
@@ -1,5 +1,9 @@
# Changelog # Changelog
- Voice debug logs omit complete tool-result bodies and serialized function
outputs by default, including location queries and generated spoken replies.
`GEV_VOICE_LOG_CONTENT=1` retains them for explicit content debugging.
- Unqualified Alps searches use the bundled European range for navigation - Unqualified Alps searches use the bundled European range for navigation
and annotations instead of a same-named peak returned by a geocoder. and annotations instead of a same-named peak returned by a geocoder.
Coordinates, supplied presets and geographically qualified place names Coordinates, supplied presets and geographically qualified place names
+4 -2
View File
@@ -383,8 +383,10 @@ the speaker (`mayVoiceClaimSpeaker()`). Progress lines stay outside the reply
lifecycle and Radio handoff. `turn.span` debug records carry generation and lifecycle and Radio handoff. `turn.span` debug records carry generation and
playback latency, silence, preamble and word-count figures. Caption playback latency, silence, preamble and word-count figures. Caption
transcription (`OPENAI_REALTIME_TRANSCRIBE_MODEL`, default on) is metered into transcription (`OPENAI_REALTIME_TRANSCRIBE_MODEL`, default on) is metered into
the session cost and cap. The debug log omits transcripts, text and tool the session cost and cap. The debug log omits transcripts, text, tool arguments
arguments unless `GEV_VOICE_LOG_CONTENT=1`. and complete tool-result bodies (including serialized function outputs) unless
`GEV_VOICE_LOG_CONTENT=1`.
Tool names, call IDs, protocol event metadata and usage remain available.
Point and ask: `pointerContext.js` reads what the cursor is on. A Space hold Point and ask: `pointerContext.js` reads what the cursor is on. A Space hold
is the pointing gesture: its keydown snapshot (released with the key) is sent is the pointing gesture: its keydown snapshot (released with the key) is sent
+11 -3
View File
@@ -25,7 +25,7 @@ const REALTIME_DEBUG_LOG_MAX_PER_MIN = 120;
/** /**
* Fields that carry what people said or heard: transcripts, typed and spoken * Fields that carry what people said or heard: transcripts, typed and spoken
* text, streamed deltas, structured tool arguments and the span's quoted * text, streamed deltas, tool arguments/results and the span's quoted
* replies. They are dropped from the persisted log unless content logging is explicitly enabled * replies. They are dropped from the persisted log unless content logging is explicitly enabled
* (GEV_VOICE_LOG_CONTENT=1), so timing and tool diagnostics never retain * (GEV_VOICE_LOG_CONTENT=1), so timing and tool diagnostics never retain
* speech by default. * speech by default.
@@ -40,7 +40,7 @@ const VOICE_TEXT_FIELDS = new Set([
const VOICE_CONTENT_MAX_DEPTH = 12; const VOICE_CONTENT_MAX_DEPTH = 12;
const VOICE_CONTENT_MAX_DEPTH_MARKER = '[omitted: max depth]'; const VOICE_CONTENT_MAX_DEPTH_MARKER = '[omitted: max depth]';
/** Replace spoken or typed content and structured tool arguments. */ /** Replace spoken or typed content and complete tool argument/result bodies. */
function omitVoiceContent(value, depth = 0) { function omitVoiceContent(value, depth = 0) {
if (depth > VOICE_CONTENT_MAX_DEPTH) return VOICE_CONTENT_MAX_DEPTH_MARKER; if (depth > VOICE_CONTENT_MAX_DEPTH) return VOICE_CONTENT_MAX_DEPTH_MARKER;
if (value === null || typeof value !== 'object') return value; if (value === null || typeof value !== 'object') return value;
@@ -48,7 +48,15 @@ function omitVoiceContent(value, depth = 0) {
return value.map((item) => omitVoiceContent(item, depth + 1)); return value.map((item) => omitVoiceContent(item, depth + 1));
const output = {}; const output = {};
for (const [key, item] of Object.entries(value)) { for (const [key, item] of Object.entries(value)) {
if (key === 'arguments') { // Tool bodies have open-ended schemas: omit the whole body rather than
// chasing query, label, say, display, or future tool-specific fields.
// Protocol response.output arrays still carry useful event metadata; only
// string output is a serialized function result and must be opaque.
if (
key === 'arguments' ||
key === 'result' ||
(key === 'output' && typeof item === 'string')
) {
output[key] = output[key] =
typeof item === 'string' typeof item === 'string'
? `[omitted ${item.length} chars]` ? `[omitted ${item.length} chars]`
+84
View File
@@ -22,6 +22,8 @@ import { keySetupEndpoint } from 'gods-eye-view/server/standalone/key-setup';
import { realtimeInstructions } from '../../server/providers/openai/instructions.js'; import { realtimeInstructions } from '../../server/providers/openai/instructions.js';
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js'; import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
import { createDebugLogHandler } from '../../server/providers/openai/debug-log.js'; import { createDebugLogHandler } from '../../server/providers/openai/debug-log.js';
import { attachVoiceResult } from '../voice/speech.js';
import { sanitizeDebugValue } from '../voice/realtimeDiagnostics.js';
import { standaloneVoiceTools } from '../../server/standalone/voiceTools.js'; import { standaloneVoiceTools } from '../../server/standalone/voiceTools.js';
function install(plugin, preview = false) { function install(plugin, preview = false) {
@@ -580,6 +582,88 @@ test('the debug-log sink omits what people said and tool arguments unless conten
} }
}); });
test('debug logs omit structured and serialized tool results unless content logging is enabled', async (t) => {
const secret = 'private-address-fixture';
const result = attachVoiceResult('fly_to_location', {
ok: true,
query: secret,
label: secret,
arrived: true,
// New tool-specific fields must not need their own redaction rule.
futureField: { nested: [secret] },
});
const records = [
{
event: 'tool.result',
payload: { name: 'fly_to_location', callId: 'call-1', result },
},
{
event: 'client.function_call_output',
payload: {
message: {
type: 'conversation.item.create',
item: {
type: 'function_call_output',
call_id: 'call-1',
output: JSON.stringify(result),
},
},
},
},
{
event: 'server.event',
payload: {
response: {
id: 'response-1',
output: [
{
type: 'message',
content: [{ type: 'output_text', text: secret }],
},
],
usage: { input_tokens: 40, output_tokens: 10 },
},
},
},
].map((record) => sanitizeDebugValue(record));
for (const includeContent of [false, true]) {
const sourceRoot = root(t);
const handler = createDebugLogHandler({ sourceRoot, includeContent });
for (const record of records) {
const response = await request(handler, {
method: 'POST',
body: JSON.stringify(record),
});
assert.equal(response.status, 204);
}
const logged = readFileSync(
path.join(sourceRoot, '.gev-logs/realtime-conversations.jsonl'),
'utf8',
);
assert.equal(logged.includes(secret), includeContent);
const [tool, client, server] = logged.trim().split('\n').map(JSON.parse);
assert.equal(tool.payload.name, 'fly_to_location');
assert.equal(tool.payload.callId, 'call-1');
assert.equal(client.payload.message.item.call_id, 'call-1');
assert.equal(server.payload.response.id, 'response-1');
assert.equal(server.payload.response.output[0].type, 'message');
assert.deepEqual(
server.payload.response.usage,
records[2].payload.response.usage,
);
if (includeContent) {
assert.deepEqual(tool.payload.result, result);
assert.equal(client.payload.message.item.output, JSON.stringify(result));
} else {
assert.equal(tool.payload.result, '[omitted]');
assert.match(
client.payload.message.item.output,
/^\[omitted \d+ chars\]$/,
);
}
}
});
test('the debug-log sink stays bounded, rate limited, and quiet about failures', async (t) => { test('the debug-log sink stays bounded, rate limited, and quiet about failures', async (t) => {
const sourceRoot = root(t); const sourceRoot = root(t);
const handler = install(openAiRealtimeProxy({ sourceRoot })).get( const handler = install(openAiRealtimeProxy({ sourceRoot })).get(