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
# The voice debug log (.gev-logs/realtime-conversations.jsonl, rotated at
# 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
# when you are done.
# arguments/results (including serialized outputs) by default. Set to 1 to
# keep them while debugging; delete the log when you are done.
GEV_VOICE_LOG_CONTENT=
OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano
# Per-IP rate limit for the OpenAI cost endpoints
+4
View File
@@ -1,5 +1,9 @@
# 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
and annotations instead of a same-named peak returned by a geocoder.
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
playback latency, silence, preamble and word-count figures. Caption
transcription (`OPENAI_REALTIME_TRANSCRIBE_MODEL`, default on) is metered into
the session cost and cap. The debug log omits transcripts, text and tool
arguments unless `GEV_VOICE_LOG_CONTENT=1`.
the session cost and cap. The debug log omits transcripts, text, tool arguments
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
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
* 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
* (GEV_VOICE_LOG_CONTENT=1), so timing and tool diagnostics never retain
* speech by default.
@@ -40,7 +40,7 @@ const VOICE_TEXT_FIELDS = new Set([
const VOICE_CONTENT_MAX_DEPTH = 12;
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) {
if (depth > VOICE_CONTENT_MAX_DEPTH) return VOICE_CONTENT_MAX_DEPTH_MARKER;
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));
const output = {};
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] =
typeof item === 'string'
? `[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 { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.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';
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) => {
const sourceRoot = root(t);
const handler = install(openAiRealtimeProxy({ sourceRoot })).get(