Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 51 additions & 8 deletions ops/agent-phone.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
});
Expand Down Expand Up @@ -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 '';
}

Expand All @@ -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 {
Expand All @@ -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 {}
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 8 additions & 1 deletion test/agent-phone.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
12 changes: 10 additions & 2 deletions test/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading