From 59ee6cbb45b3ffa1b79892f32f573892e175cc2f Mon Sep 17 00:00:00 2001 From: riotcoder Date: Thu, 5 Nov 2026 12:58:15 +0000 Subject: [PATCH 1/2] fix(agent-phone): a person talking while a host speaks must be heard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found on a live episode, the worst way. Holding the floor meant every word a leg heard was credited to its co-host, and with two hosts talking in turn somebody holds the floor nearly all of the time — so the guest's answers were thrown away as the co-host's echo, and both hosts kept asking questions into what they could not hear. He was saying 'can you hear me?' and neither of them could. A floor claim now carries the words being said, and a line is credited to the co-host only when it actually matches them. Anything else is a person, and a person always gets through — including as an interruption, which the old order of checks also swallowed. Also: a model answering 'SAY: PASS' meant to stay quiet, and one leg read the word 'PASS' out loud on air. Verified against the transcript lines from the live call, then live: the guest's answers came through and the hosts followed up on them. Co-Authored-By: Claude Opus 5 (1M context) --- ops/agent-phone.mjs | 48 +++++++++++++++++++++++++++++++++++++-- test/agent-phone.test.mjs | 19 ++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/ops/agent-phone.mjs b/ops/agent-phone.mjs index 78542ca..e7c853e 100755 --- a/ops/agent-phone.mjs +++ b/ops/agent-phone.mjs @@ -300,7 +300,9 @@ export function takeFloor(pin, agent, text = '', minimumMs = 0) { 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() + Math.max(minimumMs, 2500 + (String(text).length / 14) * 1000) })); + // The words go in with the claim: a leg hearing something while the other + // holds the floor needs them to tell its co-host's voice from a person's. + writeFileSync(temp, JSON.stringify({ agent, at: Date.now(), until: Date.now() + Math.max(minimumMs, 2500 + (String(text).length / 14) * 1000), text: String(text).slice(0, 600) })); renameSync(temp, file); return true; }); @@ -317,6 +319,43 @@ function otherAgentTalking(pin, agent, afterMs = 1500) { return ''; } } +/** What the other agent is saying right now, as it claimed the floor. */ +function floorSpeech(pin, agent) { + try { + const f = JSON.parse(readFileSync(floorFile(pin), 'utf8')); + return f.agent !== agent ? String(f.text || '') : ''; + } catch { + return ''; + } +} + +/** + * Is this line the floor holder's own voice coming back, rather than somebody + * talking over it? + * + * Holding the floor used to mean every word a leg heard was credited to its + * co-host, which threw away the guest whenever he spoke while a host was + * talking — and two hosts talking in turn means somebody holds the floor nearly + * all of the time. So compare: words that are in what the co-host is saying are + * its echo; words that are not are a person, and a person always gets through. + */ +export function echoesFloor(heard, floorText) { + const words = (text) => + String(text) + .toLowerCase() + .replace(/[^a-z0-9' ]+/g, ' ') + .split(/\s+/) + .filter((w) => w.length > 2); + const said = words(floorText); + if (!said.length) return false; + const mine = words(heard); + if (!mine.length) return true; + const bag = new Set(said); + const hits = mine.filter((w) => bag.has(w)).length; + // Most of a short line has to match; speech recognition never gets it all. + return hits / mine.length >= 0.5; +} + /** When the other agent last started speaking (ms), or 0. */ function otherSpokeAt(pin, agent) { try { @@ -469,6 +508,8 @@ export function parseTurn(out) { if (!m) return { kind: 'pass', text: '' }; const kind = m[1].toLowerCase(); const text = m[2].trim().split('\n')[0].trim(); + // "SAY: PASS" is a model saying nothing, not a line to read out. + if (/^(?:pass|skip|silence|nothing)\.?$/i.test(text)) return { kind: 'pass', text: '' }; if (kind === 'text') { // What everyone on the call is texted: a link, a short note. Without one there is nothing to send. const message = String(/^MESSAGE:\s*(.+)$/m.exec(s)?.[1] || '').trim().slice(0, 600); @@ -1106,7 +1147,10 @@ async function main() { } catch {} break; } - const otherAgent = join ? otherAgentTalking(a.pin, agent) : ''; + const holder = join ? otherAgentTalking(a.pin, agent) : ''; + // Credited to the co-host only when it IS the co-host: anything else + // said while it holds the floor is a person, and falls through below. + const otherAgent = holder && echoesFloor(d.transcript, floorSpeech(a.pin, agent)) ? holder : ''; if (otherAgent) { // The other agent talking: context, not a question for us (unless it names us). transcript.push({ who: otherAgent, text: d.transcript.trim(), at: new Date().toISOString() }); diff --git a/test/agent-phone.test.mjs b/test/agent-phone.test.mjs index cfbb572..d995da7 100644 --- a/test/agent-phone.test.mjs +++ b/test/agent-phone.test.mjs @@ -189,3 +189,22 @@ test('a line our network cannot render is null, so the call falls back instead o assert.equal(await worker.renderOnC0mpute(' ', { agent: 'riotcoder', base: 'https://otr.test', key: 'k', id: '1', fetcher: spy }), null); assert.equal(called, false); }); + +test('a person talking while a host speaks is heard, not credited to the host', () => { + const floor = 'What was the first challenge you faced when deploying your AI coding agents'; + // The co-host's own voice coming back through the conference. + assert.equal(worker.echoesFloor('What was the first challenge you faced when deploying', floor), true); + // A person talking over it. This is the one that matters: crediting these to + // the co-host threw the guest's answers away on a live episode. + assert.equal(worker.echoesFloor('Can you hear me?', floor), false); + assert.equal(worker.echoesFloor("Yeah, let us focus this on the interview. Hello?", floor), false); + assert.equal(worker.echoesFloor('I am my own customer, but I pay myself using my balance', floor), false); + // Nothing claimed on the floor means nothing to echo. + assert.equal(worker.echoesFloor('anything at all', ''), false); +}); + +test('a model that says PASS is saying nothing, not reading the word out', () => { + assert.deepEqual(worker.parseTurn('SAY: PASS'), { kind: 'pass', text: '' }); + assert.deepEqual(worker.parseTurn('PASS'), { kind: 'pass', text: '' }); + assert.deepEqual(worker.parseTurn('SAY: Where did this start?'), { kind: 'say', text: 'Where did this start?' }); +}); From 20016f5a3b1916cd5eb51a3878c63905b6153f58 Mon Sep 17 00:00:00 2001 From: riotcoder Date: Thu, 5 Nov 2026 13:08:26 +0000 Subject: [PATCH 2/2] fix(agent-phone): a short answer means move on, not ask again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the first co-hosted episode the guest said 'I'm checking into that now', was asked the same thing again in different words, said 'I'm looking into it', and hung up — Telnyx has it as hangup_source callee at 13:06:31, so that was a decision, not a dropped line. A guest who answers in a few words has told you he has nothing more to say on the point. The turn hint now says so: when the last thing heard was eight words or fewer, do not ask about that point again in any words, change subject, and read the transcript before speaking so a question already asked is not asked twice. Co-Authored-By: Claude Opus 5 (1M context) --- ops/agent-phone.mjs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/ops/agent-phone.mjs b/ops/agent-phone.mjs index e7c853e..af63022 100755 --- a/ops/agent-phone.mjs +++ b/ops/agent-phone.mjs @@ -898,7 +898,17 @@ async function main() { // The co-host asked the guest something and the guest has not answered: // the worst thing either host can do is answer it for him. if (last && last.who === partnerAgent) return `\n\n${partner} just spoke and THE GUEST HAS NOT ANSWERED YET. Say nothing: PASS. Never answer a question ${partner} put to the guest.`; - return `\n\nTHE GUEST JUST FINISHED AN ANSWER and you have the floor. Ask your next question now with SAY: one or two sentences, specific, following up on what he actually said. Do not PASS, do not summarise his answer back to him, and do not ask ${partner} anything unless you are handing the topic over.`; + /** + * A short answer means move on. + * + * On the first co-hosted episode the guest said "I'm checking into that + * now", was asked the same thing again in different words, said "I'm + * looking into it", and hung up. A guest who has nothing more to say on + * a point has told you so; pressing it is how you lose him. + */ + const lastHeard = [...transcript].reverse().find((t) => t.who === 'them' && !t.history)?.text ?? ''; + const thin = lastHeard.split(/\s+/).filter(Boolean).length <= 8; + return `\n\nTHE GUEST JUST FINISHED AN ANSWER and you have the floor. Ask your next question now with SAY: one or two sentences, specific, following up on what he actually said. Do not PASS, do not summarise his answer back to him, and do not ask ${partner} anything unless you are handing the topic over.${thin ? ` HIS LAST ANSWER WAS SHORT ("${lastHeard.slice(0, 80)}"), which means he has nothing more to say about it. Do NOT ask about that point again in any words. Move to a different subject entirely.` : ''} Read the transcript above before you speak: if a question like yours has already been asked on this call, ask something else.`; } if (a.podcast) { const partnerLine = [...transcript].reverse().find((t) => t.who !== agent && !t.history);