From f936f8db5aa70e8a5f619e6cf338a59a19674bdc Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 31 Oct 2026 08:08:09 +0000 Subject: [PATCH] Route one substantive room answer --- ops/agent-phone.mjs | 59 +++++++++++++++++++++++++++++++++------ src/service.ts | 2 +- test/agent-phone.test.mjs | 9 +++++- test/app.test.ts | 12 ++++++-- 4 files changed, 70 insertions(+), 12 deletions(-) diff --git a/ops/agent-phone.mjs b/ops/agent-phone.mjs index 7352702..c08c361 100755 --- a/ops/agent-phone.mjs +++ b/ops/agent-phone.mjs @@ -69,6 +69,18 @@ export function roomQuestion(text) { || /^(?:you(?: all| guys)?|everybody|everyone)\s+(?:hear|listen|understand|there|on the line)\b/i.test(body) || isGreeting; } +export function roomQuestionFallback(question) { + const body = roomBody(question); + if (/^(?:what was that|what did you say|what was that about)\b/i.test(body)) + return "Sorry, I missed what you meant. Could you repeat the part you want me to explain?"; + return "I heard your question, but my answer didn't come through. Could you ask it again?"; +} +export function weakRoomAnswer(question, answer) { + const response = String(answer).trim().replace(/[.!?]+$/, ''); + if (!/^(?:i'?m here(?: and listening| and ready to assist)?|i'?m listening|here and listening)$/i.test(response)) return false; + return !/\b(?:are you there|can (?:anyone|anybody|you) hear|who(?:'s| is) here|is anyone there)\b/i.test(String(question)); +} +const roomBody = (text) => String(text).replace(/(^|\s)[^:\s]*( ending \d+)?:\s*/g, ' ').trim(); const QUIET_MS = 1000; // speak once they have paused this long (1.5 s on a group call: more voices, longer gaps) const TURN_CWD = `${HOME}/.local/share/agent-phone/turn-cwd`; mkdirSync(TURN_CWD, { recursive: true }); @@ -120,6 +132,14 @@ export const addressed = (agent, text) => { } return false; }; +export function shouldConsiderRoomTurn(agent, text, { firstTurn = false, greeter = false, podcast = false } = {}) { + if (firstTurn || greeter || podcast) return true; + const mine = addressed(agent, text); + const someoneElse = Object.keys(NAME_ALIASES).some((other) => other !== agent && addressed(other, text)); + if (someoneElse && !mine) return false; + const body = roomBody(text); + return mine || roomQuestion(body) || looksLikeGoodbye(body); +} /** * The floor, shared by our agents on this box: one file per room, touched by whoever * speaks. A line meant for nobody in particular gets one agent answering, not two. @@ -166,13 +186,13 @@ function withFloorLock(pin, update) { } /** Hold the floor for about as long as the line takes to say (roughly 14 chars a second). */ -export function takeFloor(pin, agent, text = '') { +export function takeFloor(pin, agent, text = '', minimumMs = 0) { try { return withFloorLock(pin, () => { if (floorTakenByOther(pin, agent)) return false; const file = floorFile(pin); const temp = `${file}.${process.pid}.${Date.now()}.tmp`; - writeFileSync(temp, JSON.stringify({ agent, at: Date.now(), until: Date.now() + 2500 + (String(text).length / 14) * 1000 })); + writeFileSync(temp, JSON.stringify({ agent, at: Date.now(), until: Date.now() + Math.max(minimumMs, 2500 + (String(text).length / 14) * 1000) })); renameSync(temp, file); return true; }); @@ -603,7 +623,7 @@ async function main() { if (addressed(agent, newest)) return `\n\nTHE NEWEST LINE IS ADDRESSED TO YOU (${agent}; the name is misspelled by speech recognition). Answer it now with SAY. Do not PASS.`; const other = Object.keys(NAME_ALIASES).find((o) => o !== agent && addressed(o, newest)); if (other) return `\n\nThe newest line is addressed to ${other}, not you: PASS.`; - if (roomQuestion(newest)) return '\n\nTHE NEWEST LINE IS A PERSON ASKING THE ROOM (that includes you). Answer it now with SAY, briefly, even if a similar question was answered earlier in the call. Do not PASS.'; + if (roomQuestion(newest)) return '\n\nTHE NEWEST LINE IS A PERSON ASKING THE ROOM (that includes you). Answer the substance of their question now with SAY, briefly, even if a similar question was answered earlier in the call. Do not PASS. Do not just say you are here or listening unless that is what they asked.'; return ''; } @@ -619,7 +639,23 @@ async function main() { } } thinking = true; + let reservedQuestionFloor = false; try { + const newest = join ? recentSpeech() : ''; + const firstTurn = join && shouldIntroduce(agent, transcript, Date.now()); + if (join && !shouldConsiderRoomTurn(agent, newest, { firstTurn, greeter, podcast: a.podcast })) return; + const mine = addressed(agent, newest); + const namedToAnother = join && !a.podcast && Object.keys(NAME_ALIASES).some((other) => other !== agent && addressed(other, newest)) && !mine; + if (namedToAnother) return; + const roomQuestionTurn = join && !a.podcast && roomQuestion(newest) && !mine && !greeter; + const greetingTurn = firstTurn && !roomQuestionTurn; + if (roomQuestionTurn) { + // Elect one agent before asking any model, avoiding six simultaneous model calls. + const waitUntil = Date.now() + 8000; + while (Date.now() < waitUntil && !ended && (floorTakenByOther(a.pin, agent) || Date.now() - lastVoice < 2000)) await new Promise((r) => setTimeout(r, 150)); + if (ended || !takeFloor(a.pin, agent, '', 60_000)) return; + reservedQuestionFloor = true; + } // Re-read each turn, so a prompt fix reaches agents already on a call. let current = template; try { @@ -634,23 +670,29 @@ async function main() { .replace('{room}', join ? `"${a.room || 'a group call'}", code ${a.pin}, on ${to}.${addressedHint()}` : '') .replace('{transcript}', transcript.map((t) => `${t.who === 'them' ? (join ? 'room' : 'them') : t.who === agent ? `${agent} (you)` : `${t.who} (the other agent)`}: ${t.text}`).join('\n') || '(the call just connected; nobody has spoken yet)') .replace('{now}', `It is ${new Date().toUTCString()}. The call has lasted ${Math.round((Date.now() - started) / 1000)} s of at most ${maxMinutes * 60} s.`) - .replace('{intro}', !shouldIntroduce(agent, transcript, Date.now()) + .replace('{intro}', !firstTurn ? 'You have already introduced yourself on this call: do not do it again.' - : join && rejoin + : roomQuestionTurn + ? 'A person asked the room a question as you joined. Answer the substance of it now; do not replace the answer with a generic introduction or listening line.' + : join && rejoin ? 'You were on this call earlier and just reconnected; the transcript above is what was said. Do NOT introduce yourself again and do not announce that you are back unless someone asks. Otherwise follow the rules below as usual: answer people when they talk to you or ask the room something.' : join ? `You just joined. Unless someone is mid-sentence, say a short hello: "Hi everyone, ${agent} here, Anthony's AI agent." Do not PASS on this first turn.` : `In this first line, say who you are: "Hi, this is ${agent}, an AI assistant calling for Anthony."`); const t0 = Date.now(); const heardBefore = transcript.filter((t) => t.who === 'them').length; - const firstTurn = join && shouldIntroduce(agent, transcript, Date.now()); - let raw = firstTurn && !a.podcast ? `SAY: ${agentIntro(agent)}` : await brain(agent, prompt); + let raw = greetingTurn && !a.podcast ? `SAY: ${agentIntro(agent)}` : await brain(agent, prompt); let { kind, text, brief: turnBrief, number: callNumber, message: textMessage } = parseTurn(raw); - if (firstTurn && !text && !ended) { + if (greetingTurn && !text && !ended) { // The hello is not optional: one more try before going quiet. raw = await brain(agent, prompt); ({ kind, text, brief: turnBrief, number: callNumber, message: textMessage } = parseTurn(raw)); } + if (roomQuestionTurn && (kind === 'pass' || !text || (kind === 'say' && weakRoomAnswer(newest, text)))) { + text = roomQuestionFallback(newest); + kind = 'say'; + appendFileSync(logFile, `${new Date().toISOString().slice(11, 19)} [room-question fallback]\n`); + } try { appendFileSync(logFile, `${new Date().toISOString().slice(11, 19)} [turn ${Date.now() - t0} ms] ${text ? kind.toUpperCase() : raw.trim() ? 'PASS' : 'EMPTY (model failed or timed out)'}\n`); } catch {} @@ -738,6 +780,7 @@ async function main() { await speak(text); if (hangupAfterSpeech) setTimeout(hangup, 15_000).unref(); // in case speak.ended never comes } finally { + if (reservedQuestionFloor && !speaking) releaseFloor(a.pin, agent); thinking = false; if (staleRetry && !ended) { staleRetry = false; diff --git a/src/service.ts b/src/service.ts index cb927fc..748982b 100644 --- a/src/service.ts +++ b/src/service.ts @@ -1174,7 +1174,7 @@ export class GroupCallService { // An invitee leaves after three minutes if nobody has arrived. Bring that // team back when the host joins, rather than leaving stale names behind. for (const previous of this.store.invites(code, 50)) { - if (previous.status !== 'done' || !/\bnobody came\b/i.test(previous.note) || Date.now() - previous.createdAt > 15 * 60_000) continue + if (previous.status !== 'done' || !/\b(?:nobody came|everyone else left)\b/i.test(previous.note) || Date.now() - previous.createdAt > 15 * 60_000) continue if (AGENTS[previous.agent]) { this.store.finishInvite(previous.id, 'done', 'requeued after host arrival') this.queueAgent(room, previous.agent, previous.sub) diff --git a/test/agent-phone.test.mjs b/test/agent-phone.test.mjs index 5735e1a..e886997 100644 --- a/test/agent-phone.test.mjs +++ b/test/agent-phone.test.mjs @@ -62,11 +62,18 @@ test('room questions work when transcription omitted punctuation', () => { assert.equal(worker.roomQuestion('Anthony: What was that'), true); assert.equal(worker.roomQuestion('Anthony: How does this work?'), true); assert.equal(worker.roomQuestion('Anthony: I told everyone to listen'), false); + assert.equal(worker.roomQuestionFallback('Anthony: What was that?'), "Sorry, I missed what you meant. Could you repeat the part you want me to explain?"); + assert.equal(worker.weakRoomAnswer('Anthony: What was that?', "I'm here and listening."), true); + assert.equal(worker.weakRoomAnswer('Anthony: Can you hear me?', "I'm here and listening."), false); + assert.equal(worker.shouldConsiderRoomTurn('riotcoder', 'Anthony: I said no.'), false); + assert.equal(worker.shouldConsiderRoomTurn('riotcoder', 'Anthony: What was that?'), true); + assert.equal(worker.shouldConsiderRoomTurn('nora', 'Anthony: Nora, are you there?'), true); + assert.equal(worker.shouldConsiderRoomTurn('riotcoder', 'Anthony: Nora, are you there?'), false); }); test('only one agent can claim the room floor until it releases it', () => { const pin = `floor-test-${process.pid}`; - assert.equal(worker.takeFloor(pin, 'riotcoder', 'I will answer.'), true); + assert.equal(worker.takeFloor(pin, 'riotcoder', 'I will answer.', 60_000), true); assert.equal(worker.floorHolder(pin), 'riotcoder'); assert.equal(worker.takeFloor(pin, 'nora', 'I will also answer.'), false); worker.releaseFloor(pin, 'riotcoder'); diff --git a/test/app.test.ts b/test/app.test.ts index 81b07f0..2e0034a 100644 --- a/test/app.test.ts +++ b/test/app.test.ts @@ -472,18 +472,26 @@ test('an invite stuck "dialling" from an agent that never arrived does not block expect(store.invite(first.id)?.status).toBe('failed') }) -test('agents who timed out before the host arrived are dialled back into the room', async () => { - const { call, event, store, telnyx } = setup({ agentSip: { riotcoder: 'sip:+15106169327@sip.rtc.elevenlabs.io' } }) +test('agents who timed out or left before the host returned are dialled back into the room', async () => { + const { call, event, store, telnyx } = setup({ agentSip: { riotcoder: 'sip:+15106169327@sip.rtc.elevenlabs.io', coinpay: 'sip:+15106169328@sip.rtc.elevenlabs.io' } }) const room = (await (await call('gc_op', 'POST', '/api/v1/rooms', { name: 'Late host' })).json()) as any const expired = store.addInvite({ code: room.code, agent: 'riotcoder', sub: 'sub-op' }) store.finishInvite(expired.id, 'done', 'on the call 182 s, nobody came') + const left = store.addInvite({ code: room.code, agent: 'coinpay', sub: 'sub-op' }) + store.finishInvite(left.id, 'done', 'on the call 182 s, everyone else left') await event('call.initiated', { call_control_id: 'late-host', direction: 'incoming', from: '+15550001111' }) await event('call.gather.ended', { call_control_id: 'late-host', digits: room.code }) expect(store.invite(expired.id)?.note).toBe('requeued after host arrival') + expect(store.invite(left.id)?.note).toBe('requeued after host arrival') expect(store.invites(room.code, 10).some((invite) => invite.id !== expired.id && invite.agent === 'riotcoder')).toBe(true) + expect(store.invites(room.code, 10).some((invite) => invite.id !== left.id && invite.agent === 'coinpay')).toBe(true) expect(telnyx.calls.some((c) => c.path === '/calls' && c.body.to.includes('sip.rtc.elevenlabs.io'))).toBe(true) + + await event('call.initiated', { call_control_id: 'second-host', direction: 'incoming', from: '+15550001112' }) + await event('call.gather.ended', { call_control_id: 'second-host', digits: room.code }) + expect(telnyx.calls.filter((c) => c.path === '/calls')).toHaveLength(2) }) test("a known caller is named, and agents can see which recent lines came from someone trusted", async () => {