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
85 changes: 67 additions & 18 deletions ops/agent-phone.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,17 @@ export function speechChunks(text, { max = 240, limit = 2 } = {}) {
const line = String(text).replace(/\s+/g, ' ').trim();
if (!line) return [];
const sentences = (line.match(/[^.!?]+(?:[.!?]+|$)/g) ?? [line]).map((s) => s.trim()).filter(Boolean);
/**
* A long opening sentence is the whole wait: the guest hears nothing until it
* has rendered. So break it at its last clause boundary instead, which Kokoro
* reads as naturally as a full stop and which halves the silence before the
* first word.
*/
const first = sentences[0] ?? '';
if (first.length > 90) {
const comma = first.lastIndexOf(', ', 90);
if (comma > 25) sentences.splice(0, 1, first.slice(0, comma + 1), first.slice(comma + 2));
}
if (sentences.length <= 1) return [line];
// The first sentence goes alone: it is what the caller waits for, and a short
// one renders in well under a second.
Expand Down Expand Up @@ -149,6 +160,22 @@ export async function renderOnC0mpute(text, { agent, base, key, id, fetcher = fe
}
}

/**
* A line as it should be heard, not as it is written.
*
* A host read "fleetsysops.com" out loud on air. Addresses are for the show
* notes; spoken, they are just noise with a dot in them.
*/
export function sayable(text) {
return String(text)
// A whole address becomes just its name: the first label of the host.
.replace(/https?:\/\/\S+/gi, (m) => m.replace(/^https?:\/\//i, '').split('/')[0].replace(/^www\./i, '').split('.')[0])
// A bare address, however many labels deep, becomes its first name.
.replace(/\b(?:www\.)?([a-z0-9][a-z0-9-]*)(?:\.[a-z0-9-]+)*\.(?:com|net|org|io|dev|sh|work|news|world|ai|app|co|me|tech|now|vote|help|info|podcast|agent|hacker)\b/gi, '$1')
.replace(/\s{2,}/g, ' ')
.trim();
}

export function identityDtmf(token) {
if (!/^\d{8}$/.test(String(token))) throw new Error('Agent join token must be eight digits');
return `*${token}#`;
Expand Down Expand Up @@ -444,39 +471,53 @@ const CF_TOKEN = fromWorkerEnv('CF_AI_TOKEN');
* One turn of the conversation. Use the agent's configured model first; Cloudflare
* Workers AI and Gemini remain fast fallbacks if that run is unavailable.
*/
async function brain(agent, prompt) {
const selected = await configuredTurn(agent, prompt);
if (selected.trim()) return selected;
// Keep the existing short-latency providers as fallbacks if the selected engine is unavailable.
if (CF_ACCOUNT && CF_TOKEN) {
/**
* The fast providers, on their own.
*
* A phone call cannot wait for a thorough answer. The agent's configured model
* runs locally and took 4.5-9.7 s a turn on the first co-hosted episodes —
* sometimes returning nothing at all — which put ten to thirteen seconds of
* silence after every answer the guest gave. On a live show speed beats depth:
* a host that replies in two seconds with a decent question is a better host.
*/
export async function quickTurn(prompt, { fetcher = fetch } = {}) {
if (GEMINI_KEY) {
try {
const res = await fetch(`https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT}/ai/run/${process.env.AGENT_PHONE_CF_MODEL || '@cf/meta/llama-3.3-70b-instruct-fp8-fast'}`, {
const res = await fetcher(`https://generativelanguage.googleapis.com/v1beta/models/${process.env.AGENT_PHONE_GEMINI_MODEL || 'gemini-flash-lite-latest'}:generateContent`, {
method: 'POST',
headers: { authorization: `Bearer ${CF_TOKEN}`, 'content-type': 'application/json' },
body: JSON.stringify({ messages: [{ role: 'user', content: prompt }], max_tokens: 300, temperature: 0.5 }),
headers: { 'x-goog-api-key': GEMINI_KEY, 'content-type': 'application/json' },
body: JSON.stringify({ contents: [{ role: 'user', parts: [{ text: prompt }] }], generationConfig: { temperature: 0.5, maxOutputTokens: 300 } }),
signal: AbortSignal.timeout(6000),
});
const data = await res.json();
const text = String(data?.result?.response || '').trim();
const text = (data?.candidates?.[0]?.content?.parts || []).map((x) => x.text || '').join('').trim();
if (res.ok && text) return text;
} catch {}
}
if (GEMINI_KEY) {
if (CF_ACCOUNT && CF_TOKEN) {
try {
const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${process.env.AGENT_PHONE_GEMINI_MODEL || 'gemini-flash-lite-latest'}:generateContent`, {
const res = await fetcher(`https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT}/ai/run/${process.env.AGENT_PHONE_CF_MODEL || '@cf/meta/llama-3.3-70b-instruct-fp8-fast'}`, {
method: 'POST',
headers: { 'x-goog-api-key': GEMINI_KEY, 'content-type': 'application/json' },
body: JSON.stringify({ contents: [{ role: 'user', parts: [{ text: prompt }] }], generationConfig: { temperature: 0.5, maxOutputTokens: 300 } }),
signal: AbortSignal.timeout(8000),
headers: { authorization: `Bearer ${CF_TOKEN}`, 'content-type': 'application/json' },
body: JSON.stringify({ messages: [{ role: 'user', content: prompt }], max_tokens: 300, temperature: 0.5 }),
signal: AbortSignal.timeout(6000),
});
const data = await res.json();
const text = (data?.candidates?.[0]?.content?.parts || []).map((p) => p.text || '').join('').trim();
const text = String(data?.result?.response || '').trim();
if (res.ok && text) return text;
} catch {}
}
return '';
}

async function brain(agent, prompt) {
const selected = await configuredTurn(agent, prompt);
if (selected.trim()) return selected;
const quick = await quickTurn(prompt);
if (quick) return quick;
return '';
}

function configuredTurn(agent, prompt) {
return new Promise((resolve) => {
let output = '';
Expand Down Expand Up @@ -783,7 +824,8 @@ async function main() {
try { appendFileSync(logFile, `${new Date().toISOString().slice(11, 23)} [speak-timeout]\n`); } catch {}
}, Math.max(30_000, (seconds + 20) * 1000)).unref();
};
const speak = async (text) => {
const speak = async (rawText) => {
const text = sayable(rawText);
try {
appendFileSync(logFile, `${new Date().toISOString().slice(11, 23)} [spoke-start]\n`);
} catch {}
Expand Down Expand Up @@ -908,6 +950,10 @@ async function main() {
*/
const lastHeard = [...transcript].reverse().find((t) => t.who === 'them' && !t.history)?.text ?? '';
const thin = lastHeard.split(/\s+/).filter(Boolean).length <= 8;
// He asked US something. Ignoring it to read the next question off the
// brief is how the second episode ended: "what's gonna end this?",
// unanswered, and he hung up.
if (roomQuestion(newest)) return `\n\nTHE GUEST JUST ASKED YOU SOMETHING: "${newest.slice(0, 120)}". Answer it with SAY, directly, in one or two sentences, before anything else. If he is asking how the show ends or how to stop, tell him plainly that he can say "let's wrap up" any time and you will close the episode. Do not ignore him and do not ask your next question in the same breath.`;
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) {
Expand Down Expand Up @@ -1007,11 +1053,14 @@ async function main() {
const t0 = Date.now();
const heardBefore = transcript.filter((t) => t.who === 'them').length;
// A show opens the way its brief says to, not with the stock hello.
let raw = greetingTurn && !a.podcast && !a.interview ? `SAY: ${agentIntro(agent)}` : await brain(agent, prompt);
// On air, ask the fast provider first and only fall back to the slower
// configured model if it gives nothing.
const think = async () => (a.interview ? (await quickTurn(prompt)) || (await brain(agent, prompt)) : brain(agent, prompt));
let raw = greetingTurn && !a.podcast && !a.interview ? `SAY: ${agentIntro(agent)}` : await think();
let { kind, text, brief: turnBrief, number: callNumber, message: textMessage } = parseTurn(raw);
if (greetingTurn && !text && !ended) {
// The hello is not optional: one more try before going quiet.
raw = await brain(agent, prompt);
raw = await think();
({ kind, text, brief: turnBrief, number: callNumber, message: textMessage } = parseTurn(raw));
}
if ((roomQuestionTurn || mine || greeter) && shouldFallbackRoomResponse(agent, newest, text)) {
Expand Down
22 changes: 22 additions & 0 deletions test/agent-phone.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -208,3 +208,25 @@ test('a model that says PASS is saying nothing, not reading the word out', () =>
assert.deepEqual(worker.parseTurn('PASS'), { kind: 'pass', text: '' });
assert.deepEqual(worker.parseTurn('SAY: Where did this start?'), { kind: 'say', text: 'Where did this start?' });
});

test('a spoken line never reads an address out', () => {
// A host read "fleetsysops.com" out loud on air.
assert.equal(worker.sayable('the manifesto on fleetsysops.com influences CoinPay'), 'the manifesto on fleetsysops influences CoinPay');
assert.equal(worker.sayable('see https://offtherecord.podcast.pit.world/e/7 for notes'), 'see offtherecord for notes');
assert.equal(worker.sayable('go to www.ugig.net today'), 'go to ugig today');
// Ordinary speech is untouched, and so is a sentence that merely has a full stop.
assert.equal(worker.sayable('What broke, and what did it cost?'), 'What broke, and what did it cost?');
assert.equal(worker.sayable('It worked. Then it did not.'), 'It worked. Then it did not.');
});

test('a long opening sentence is broken at a clause so the first word comes sooner', () => {
const opener = 'Anthony, welcome to Off The Record, I am RiotCoder, and I am here with ChaosPunk, we are your own AI agents hosting this show, does that work for you?';
const chunks = worker.speechChunks(opener);
assert.ok(chunks.length >= 2, 'expected the opener to be split');
assert.ok(chunks[0].length <= 90, `first chunk should be short, got ${chunks[0].length}`);
// Broken at a clause boundary, not mid-word, and nothing is lost.
assert.ok(chunks[0].endsWith(','), `expected a clause boundary, got ${JSON.stringify(chunks[0])}`);
assert.equal(chunks.join(' '), opener);
// A short line is still said in one go.
assert.deepEqual(worker.speechChunks('What broke?'), ['What broke?']);
});
Loading