diff --git a/.env.example b/.env.example index c1df70a08..16cc479b8 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index a0c0eda35..1086e5ed3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5284a67b8..920d508e7 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/server/providers/openai/debug-log.js b/server/providers/openai/debug-log.js index f411128d4..956569642 100644 --- a/server/providers/openai/debug-log.js +++ b/server/providers/openai/debug-log.js @@ -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]` diff --git a/src/tooling/localServices.test.mjs b/src/tooling/localServices.test.mjs index ec4cf2a56..9e5034e8c 100644 --- a/src/tooling/localServices.test.mjs +++ b/src/tooling/localServices.test.mjs @@ -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(