callkaro-cli-unofficial 0.9.5 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/dist/commands/liveTest.js +330 -0
- package/dist/commands/simulations.js +25 -2
- package/dist/index.js +47 -0
- package/dist/lib/liveTest.js +361 -0
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -200,8 +200,21 @@ npm run catalog:check # fail if catalog.json disagrees with the snapshot
|
|
|
200
200
|
|
|
201
201
|
`catalog:check` runs offline against a committed snapshot, so it works in CI with no credentials. On 1 October 2026 it caught shipped tables that listed withdrawn models *and* missed live ones in both directions. A wrong voice model otherwise persists silently and only surfaces as a broken call.
|
|
202
202
|
|
|
203
|
+
### Live test calls, with you or an LLM as the caller
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
cku test start <agentId> --yes # a coding agent (or you) plays the caller, one turn per command
|
|
207
|
+
cku test say <sessionId> "Kitne ka hai?"
|
|
208
|
+
cku test end <sessionId> # transcript with function calls and node switches
|
|
209
|
+
|
|
210
|
+
cku test run <agentId> --test <testCaseId> --llm anthropic --yes # the CLI plays the caller and grades the call
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
This is the agent editor's **Test Chat** tab from a terminal: a real test call on the real call runtime, over the same LiveKit room the dashboard joins, with the caller typing instead of speaking. Unlike a simulation, nothing is mocked, so node switching, function calls and post-call analysis are what production does, and every function runs for real. `run` uses any OpenAI-compatible LLM (OpenAI, Anthropic, Gemini, DeepSeek, Groq, OpenRouter, Ollama, or `--llm-url`), with the key read from the environment, and exits 1 when the judge fails the call. It needs `@livekit/rtc-node`, an optional dependency with prebuilt binaries for Linux, macOS and Windows.
|
|
214
|
+
|
|
203
215
|
### Smaller things
|
|
204
216
|
|
|
217
|
+
- `cku sim create --real-functions` — every function on the version runs for real in the simulation, like switching every Mock off in the dashboard
|
|
205
218
|
- `cku agents chat` — text chat with an agent's real prompt and tools, no phone call. Scriptable with `--say`, replayable with `--history`. Custom in-call functions run for real
|
|
206
219
|
- `cku scheduled list|queued|get` — scheduled calls and the queue of what will actually dial (dashboard login)
|
|
207
220
|
- `cku sim update` — edit a test case in place
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.testStartCommand = testStartCommand;
|
|
7
|
+
exports.testSayCommand = testSayCommand;
|
|
8
|
+
exports.testEndCommand = testEndCommand;
|
|
9
|
+
exports.testServeCommand = testServeCommand;
|
|
10
|
+
exports.testRunCommand = testRunCommand;
|
|
11
|
+
const child_process_1 = require("child_process");
|
|
12
|
+
const crypto_1 = require("crypto");
|
|
13
|
+
const http_1 = require("http");
|
|
14
|
+
const fs_1 = require("fs");
|
|
15
|
+
const path_1 = require("path");
|
|
16
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
17
|
+
const session_1 = require("../lib/session");
|
|
18
|
+
const config_1 = require("../lib/config");
|
|
19
|
+
const ui_1 = require("../lib/ui");
|
|
20
|
+
const versions_1 = require("../lib/versions");
|
|
21
|
+
const liveTest_1 = require("../lib/liveTest");
|
|
22
|
+
const jsonInput_1 = require("./jsonInput");
|
|
23
|
+
/**
|
|
24
|
+
* `cku test`: live test calls over the dashboard's Test Chat path (lib/liveTest.ts).
|
|
25
|
+
*
|
|
26
|
+
* Two ways to play the caller:
|
|
27
|
+
* - start / say / end: a coding agent (or a person) plays the caller one turn per command.
|
|
28
|
+
* A detached background process holds the LiveKit room between commands and serves them on
|
|
29
|
+
* 127.0.0.1, guarded by a per-session secret.
|
|
30
|
+
* - run: the CLI plays the caller with an LLM and grades the call against criteria.
|
|
31
|
+
*/
|
|
32
|
+
const SESSIONS_DIR = (0, path_1.join)(config_1.CONFIG_DIR, 'test-sessions');
|
|
33
|
+
const IDLE_MS = 10 * 60_000;
|
|
34
|
+
const WARNING = 'This places a real test call: every agent function runs for real (CRM writes, WhatsApp, webhooks), '
|
|
35
|
+
+ 'credits are used, and the call appears in call history with post-call analysis.';
|
|
36
|
+
const sessionPath = (id) => (0, path_1.join)(SESSIONS_DIR, `${id}.json`);
|
|
37
|
+
function printEvents(events) {
|
|
38
|
+
for (const e of events) {
|
|
39
|
+
if (e.type === 'agent')
|
|
40
|
+
(0, ui_1.info)(`${chalk_1.default.cyan('agent>')} ${e.text}`);
|
|
41
|
+
else if (e.type === 'user')
|
|
42
|
+
(0, ui_1.info)(`${chalk_1.default.green('caller>')} ${e.text}`);
|
|
43
|
+
else if (e.type === 'function')
|
|
44
|
+
(0, ui_1.info)(chalk_1.default.dim(` → ${e.name} ${JSON.stringify(e.arguments ?? {})}`));
|
|
45
|
+
else if (e.type === 'capability')
|
|
46
|
+
(0, ui_1.info)(chalk_1.default.dim(` [node: ${e.capability}]`));
|
|
47
|
+
else if (e.type === 'ended')
|
|
48
|
+
(0, ui_1.info)(chalk_1.default.dim(' (call ended)'));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function readMetadata(opts) {
|
|
52
|
+
if (!opts.variables)
|
|
53
|
+
return {};
|
|
54
|
+
const v = await (0, jsonInput_1.readJsonFlag)(opts.variables, '--variables');
|
|
55
|
+
if (!v || typeof v !== 'object' || Array.isArray(v))
|
|
56
|
+
throw new Error('--variables must be a JSON object, e.g. {"customer_name":"Ravi"}');
|
|
57
|
+
return v;
|
|
58
|
+
}
|
|
59
|
+
/** The test call is created under the agent's owner, which is also what the dashboard sends. */
|
|
60
|
+
async function startToken(agentId, version, metadata) {
|
|
61
|
+
const res = await (0, session_1.requireApi)().getAgent(agentId, version);
|
|
62
|
+
const agent = res.agent || res;
|
|
63
|
+
const userId = agent?.userId;
|
|
64
|
+
if (!userId)
|
|
65
|
+
throw new Error(`Agent ${agentId} came back without a userId, so the test-call host cannot be chosen.`);
|
|
66
|
+
return (0, liveTest_1.requestTestToken)({ userId: String(userId), agentId, version, metadata });
|
|
67
|
+
}
|
|
68
|
+
// --- start / say / end ----------------------------------------------------------------------
|
|
69
|
+
async function testStartCommand(agentId, opts) {
|
|
70
|
+
try {
|
|
71
|
+
if (!(await (0, ui_1.confirm)(`${WARNING} Start it?`, opts.yes)))
|
|
72
|
+
return;
|
|
73
|
+
const version = (0, versions_1.firstVersion)(opts.versions);
|
|
74
|
+
const { token, callSid } = await startToken(agentId, version, await readMetadata(opts));
|
|
75
|
+
(0, fs_1.mkdirSync)(SESSIONS_DIR, { recursive: true, mode: 0o700 });
|
|
76
|
+
const id = (0, crypto_1.randomBytes)(4).toString('hex');
|
|
77
|
+
(0, fs_1.writeFileSync)((0, path_1.join)(SESSIONS_DIR, `${id}.req.json`), JSON.stringify({ token, callSid, agentId }), { mode: 0o600 });
|
|
78
|
+
(0, child_process_1.spawn)(process.execPath, [process.argv[1], 'test', '_serve', id], {
|
|
79
|
+
detached: true, stdio: 'ignore', windowsHide: true, env: { ...process.env, CK_NO_UPDATE_CHECK: '1' },
|
|
80
|
+
}).unref();
|
|
81
|
+
const deadline = Date.now() + 60_000;
|
|
82
|
+
while (!(0, fs_1.existsSync)(sessionPath(id))) {
|
|
83
|
+
if (Date.now() > deadline)
|
|
84
|
+
throw new Error('The test session did not come up within 60s. If the call started, find it with: cku calls list --sid ${callSid}');
|
|
85
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
86
|
+
}
|
|
87
|
+
const s = JSON.parse((0, fs_1.readFileSync)(sessionPath(id), 'utf8'));
|
|
88
|
+
if (s.error) {
|
|
89
|
+
(0, fs_1.unlinkSync)(sessionPath(id));
|
|
90
|
+
throw new Error(s.error);
|
|
91
|
+
}
|
|
92
|
+
if (opts.json)
|
|
93
|
+
return void console.log(JSON.stringify({ sessionId: id, callSid, events: s.greeting }, null, 2));
|
|
94
|
+
(0, ui_1.info)(chalk_1.default.dim(`session ${id} call ${callSid}`));
|
|
95
|
+
if (s.greeting.length)
|
|
96
|
+
printEvents(s.greeting);
|
|
97
|
+
else
|
|
98
|
+
(0, ui_1.info)(chalk_1.default.dim('(the agent is waiting for the caller to speak first)'));
|
|
99
|
+
(0, ui_1.info)(chalk_1.default.dim(`Next: cku test say ${id} "<what the caller says>" · cku test end ${id}`));
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
(0, ui_1.handleError)(err);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async function callSession(id, path, body) {
|
|
106
|
+
if (!/^[0-9a-f]{8}$/.test(id) || !(0, fs_1.existsSync)(sessionPath(id))) {
|
|
107
|
+
throw new Error(`No test session "${id}". Start one with: cku test start <agentId>`);
|
|
108
|
+
}
|
|
109
|
+
const s = JSON.parse((0, fs_1.readFileSync)(sessionPath(id), 'utf8'));
|
|
110
|
+
try {
|
|
111
|
+
const res = await fetch(`http://127.0.0.1:${s.port}${path}`, {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
headers: { 'Content-Type': 'application/json', 'x-session-secret': s.secret },
|
|
114
|
+
body: JSON.stringify(body),
|
|
115
|
+
});
|
|
116
|
+
const data = await res.json();
|
|
117
|
+
if (!res.ok)
|
|
118
|
+
throw new Error(data.error || `Session answered HTTP ${res.status}`);
|
|
119
|
+
return data;
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
if (err?.cause?.code === 'ECONNREFUSED') {
|
|
123
|
+
try {
|
|
124
|
+
(0, fs_1.unlinkSync)(sessionPath(id));
|
|
125
|
+
}
|
|
126
|
+
catch { /* gone */ }
|
|
127
|
+
throw new Error(`Test session ${id} is no longer running (it ends after 10 idle minutes). Find the call with: cku calls list --sid ${s.callSid}`);
|
|
128
|
+
}
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async function testSayCommand(id, words, opts) {
|
|
133
|
+
try {
|
|
134
|
+
const text = words.join(' ').trim();
|
|
135
|
+
if (!text)
|
|
136
|
+
throw new Error('Nothing to say. Usage: cku test say <sessionId> "<what the caller says>"');
|
|
137
|
+
const timeoutMs = (0, ui_1.intFlag)(opts.timeout, '--timeout', 60) * 1000;
|
|
138
|
+
const data = await callSession(id, '/say', { text, timeoutMs });
|
|
139
|
+
if (opts.json)
|
|
140
|
+
return void console.log(JSON.stringify(data, null, 2));
|
|
141
|
+
printEvents(data.events.filter((e) => e.type !== 'user'));
|
|
142
|
+
if (!data.events.some((e) => e.type === 'agent') && !data.ended)
|
|
143
|
+
(0, ui_1.info)(chalk_1.default.dim('(no agent reply before the timeout)'));
|
|
144
|
+
if (data.ended)
|
|
145
|
+
(0, ui_1.info)(chalk_1.default.dim(`The call is over. Collect it with: cku test end ${id}`));
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
(0, ui_1.handleError)(err);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async function testEndCommand(id, opts) {
|
|
152
|
+
try {
|
|
153
|
+
const data = await callSession(id, '/end', {});
|
|
154
|
+
if (opts.json)
|
|
155
|
+
return void console.log(JSON.stringify(data, null, 2));
|
|
156
|
+
(0, ui_1.info)((0, liveTest_1.formatTranscript)(data.events));
|
|
157
|
+
(0, ui_1.info)(chalk_1.default.dim(`call ${data.callSid}: post-call results: cku calls list --sid ${data.callSid}`));
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
(0, ui_1.handleError)(err);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/** The background half of `start`: owns the room, answers say/end on localhost, exits when done or idle. */
|
|
164
|
+
async function testServeCommand(id) {
|
|
165
|
+
const reqPath = (0, path_1.join)(SESSIONS_DIR, `${id}.req.json`);
|
|
166
|
+
const { token, callSid, agentId } = JSON.parse((0, fs_1.readFileSync)(reqPath, 'utf8'));
|
|
167
|
+
(0, fs_1.unlinkSync)(reqPath);
|
|
168
|
+
const write = (s) => (0, fs_1.writeFileSync)(sessionPath(id), JSON.stringify(s), { mode: 0o600 });
|
|
169
|
+
let session;
|
|
170
|
+
let greeting;
|
|
171
|
+
try {
|
|
172
|
+
({ session, greeting } = await liveTest_1.LiveSession.open(token, callSid));
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
write({ error: `Could not join the test call room: ${err.message}` });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const secret = (0, crypto_1.randomBytes)(16).toString('hex');
|
|
179
|
+
let idle;
|
|
180
|
+
let busy = false;
|
|
181
|
+
const shutdown = async () => {
|
|
182
|
+
server.close();
|
|
183
|
+
try {
|
|
184
|
+
(0, fs_1.unlinkSync)(sessionPath(id));
|
|
185
|
+
}
|
|
186
|
+
catch { /* gone */ }
|
|
187
|
+
await session.close();
|
|
188
|
+
process.exit(0);
|
|
189
|
+
};
|
|
190
|
+
const touch = () => { clearTimeout(idle); idle = setTimeout(shutdown, IDLE_MS); };
|
|
191
|
+
const server = (0, http_1.createServer)((req, res) => {
|
|
192
|
+
const reply = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); };
|
|
193
|
+
if (req.headers['x-session-secret'] !== secret || req.method !== 'POST')
|
|
194
|
+
return reply(403, { error: 'forbidden' });
|
|
195
|
+
let raw = '';
|
|
196
|
+
req.on('data', (c) => { raw += c; if (raw.length > 100_000)
|
|
197
|
+
req.destroy(); });
|
|
198
|
+
req.on('end', async () => {
|
|
199
|
+
touch();
|
|
200
|
+
try {
|
|
201
|
+
const body = raw ? JSON.parse(raw) : {};
|
|
202
|
+
if (req.url === '/say') {
|
|
203
|
+
if (busy)
|
|
204
|
+
return reply(409, { error: 'The agent is still answering the previous turn. Wait for that command to finish.' });
|
|
205
|
+
busy = true;
|
|
206
|
+
try {
|
|
207
|
+
const events = await session.say(String(body.text || ''), Number(body.timeoutMs) || 60_000);
|
|
208
|
+
return reply(200, { sessionId: id, callSid, events, ended: session.ended });
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
busy = false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (req.url === '/end') {
|
|
215
|
+
reply(200, { sessionId: id, callSid, agentId, events: session.events, ended: session.ended });
|
|
216
|
+
return void setTimeout(shutdown, 100);
|
|
217
|
+
}
|
|
218
|
+
reply(404, { error: 'unknown route' });
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
reply(500, { error: err.message });
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
server.listen(0, '127.0.0.1', () => {
|
|
226
|
+
const port = server.address().port;
|
|
227
|
+
write({ pid: process.pid, port, secret, callSid, agentId, greeting });
|
|
228
|
+
touch();
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
async function scenario(agentId, opts) {
|
|
232
|
+
let s = { name: '', persona: opts.persona, prompt: opts.prompt, criteria: opts.criteria, variables: {} };
|
|
233
|
+
if (opts.test) {
|
|
234
|
+
const res = await (0, session_1.requireApi)().listTestCases(agentId);
|
|
235
|
+
const t = (res.testCases || res.data || []).find((x) => x._id === opts.test);
|
|
236
|
+
if (!t)
|
|
237
|
+
throw new Error(`Test case ${opts.test} is not one of this agent's. List them with: cku sim tests ${agentId}`);
|
|
238
|
+
s = { name: t.testcaseName || '', persona: t.userPersona, prompt: t.userPrompt, criteria: t.successCriteria, variables: t.testVariables || {} };
|
|
239
|
+
if (opts.persona)
|
|
240
|
+
s.persona = opts.persona;
|
|
241
|
+
if (opts.prompt)
|
|
242
|
+
s.prompt = opts.prompt;
|
|
243
|
+
if (opts.criteria)
|
|
244
|
+
s.criteria = opts.criteria;
|
|
245
|
+
}
|
|
246
|
+
s.variables = { ...s.variables, ...(await readMetadata(opts)) };
|
|
247
|
+
if (!s.prompt)
|
|
248
|
+
throw new Error('Say what the caller wants: --prompt "<goal and beats>" (or --test <testCaseId> to reuse a saved simulation test case).');
|
|
249
|
+
return { ...s, prompt: s.prompt };
|
|
250
|
+
}
|
|
251
|
+
async function testRunCommand(agentId, opts) {
|
|
252
|
+
let session;
|
|
253
|
+
try {
|
|
254
|
+
const s = await scenario(agentId, opts);
|
|
255
|
+
const llm = (0, liveTest_1.resolveLlm)(opts);
|
|
256
|
+
const judge = { ...llm, model: opts.judgeModel || llm.model };
|
|
257
|
+
const maxTurns = (0, ui_1.intFlag)(opts.maxTurns, '--max-turns', 20);
|
|
258
|
+
const turnMs = (0, ui_1.intFlag)(opts.timeout, '--timeout', 60) * 1000;
|
|
259
|
+
if (!(await (0, ui_1.confirm)(`${WARNING} Run it?`, opts.yes)))
|
|
260
|
+
return;
|
|
261
|
+
const log = (events) => { if (!opts.json)
|
|
262
|
+
printEvents(events); };
|
|
263
|
+
const { token, callSid } = await startToken(agentId, (0, versions_1.firstVersion)(opts.versions), s.variables);
|
|
264
|
+
if (!opts.json)
|
|
265
|
+
(0, ui_1.info)(chalk_1.default.dim(`call ${callSid} caller: ${llm.name}/${llm.model}`));
|
|
266
|
+
const opened = await liveTest_1.LiveSession.open(token, callSid);
|
|
267
|
+
session = opened.session;
|
|
268
|
+
log(opened.greeting);
|
|
269
|
+
const history = [{ role: 'system', content: (0, liveTest_1.callerSystemPrompt)(s) }];
|
|
270
|
+
const agentSaid = (events) => events.filter((e) => e.type === 'agent').map((e) => e.text).join(' ');
|
|
271
|
+
let heard = agentSaid(opened.greeting);
|
|
272
|
+
let endedBy = 'max-turns';
|
|
273
|
+
for (let turn = 0; turn < maxTurns; turn++) {
|
|
274
|
+
if (session.ended) {
|
|
275
|
+
endedBy = 'agent';
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
history.push({ role: 'user', content: heard || '(The call is connected. The agent has not said anything yet.)' });
|
|
279
|
+
const line = (0, liveTest_1.parseCallerLine)(await (0, liveTest_1.chatComplete)(llm, history));
|
|
280
|
+
history.push({ role: 'assistant', content: line.text || '(hangs up)' });
|
|
281
|
+
if (line.text) {
|
|
282
|
+
if (session.ended) {
|
|
283
|
+
endedBy = 'agent';
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
const events = await session.say(line.text, turnMs);
|
|
287
|
+
log([{ type: 'user', text: line.text, t: Date.now() }, ...events]);
|
|
288
|
+
heard = agentSaid(events);
|
|
289
|
+
if (session.ended) {
|
|
290
|
+
endedBy = 'agent';
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
if (!events.length) {
|
|
294
|
+
endedBy = 'timeout';
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (line.end) {
|
|
299
|
+
endedBy = 'caller';
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const events = session.events;
|
|
304
|
+
await session.close();
|
|
305
|
+
session = undefined;
|
|
306
|
+
let verdict;
|
|
307
|
+
if (s.criteria)
|
|
308
|
+
verdict = (0, liveTest_1.parseVerdict)(await (0, liveTest_1.chatComplete)(judge, (0, liveTest_1.judgeMessages)(s.criteria, events), 0));
|
|
309
|
+
const out = { callSid, agentId, test: opts.test, endedBy, caller: `${llm.name}/${llm.model}`, events, verdict };
|
|
310
|
+
if (opts.json)
|
|
311
|
+
console.log(JSON.stringify(out, null, 2));
|
|
312
|
+
else {
|
|
313
|
+
(0, ui_1.info)(chalk_1.default.dim(`ended by: ${endedBy}`));
|
|
314
|
+
if (verdict) {
|
|
315
|
+
const label = verdict.invalid ? chalk_1.default.yellow('GRADING FAILED') : verdict.pass ? chalk_1.default.green('PASS') : chalk_1.default.red('FAIL');
|
|
316
|
+
(0, ui_1.info)(`${label} ${verdict.summary}`);
|
|
317
|
+
for (const c of verdict.criteria)
|
|
318
|
+
(0, ui_1.info)(` ${c.pass ? chalk_1.default.green('✔') : chalk_1.default.red('✖')} ${c.criterion}${c.evidence ? chalk_1.default.dim(` (${c.evidence})`) : ''}${c.reason && !c.pass ? ` — ${c.reason}` : ''}`);
|
|
319
|
+
}
|
|
320
|
+
(0, ui_1.info)(chalk_1.default.dim(`post-call results: cku calls list --sid ${callSid}`));
|
|
321
|
+
}
|
|
322
|
+
if (verdict && !verdict.pass)
|
|
323
|
+
process.exitCode = 1;
|
|
324
|
+
}
|
|
325
|
+
catch (err) {
|
|
326
|
+
if (session)
|
|
327
|
+
await session.close();
|
|
328
|
+
(0, ui_1.handleError)(err);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.sleep = void 0;
|
|
7
7
|
exports.toTestVariables = toTestVariables;
|
|
8
8
|
exports.toTestFunctions = toTestFunctions;
|
|
9
|
+
exports.realFunctionList = realFunctionList;
|
|
9
10
|
exports.simTestsListCommand = simTestsListCommand;
|
|
10
11
|
exports.simCreateCommand = simCreateCommand;
|
|
11
12
|
exports.simUpdateCommand = simUpdateCommand;
|
|
@@ -58,6 +59,21 @@ function toTestFunctions(input) {
|
|
|
58
59
|
return { name: f.name, ...(f.mock === undefined ? {} : { mock: f.mock }), ...('response' in f ? { response: f.response } : {}) };
|
|
59
60
|
});
|
|
60
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* `--real-functions`: every function on the agent version (version-level + node/capability), as
|
|
64
|
+
* `{name, mock:false}`, the CLI twin of switching every Mock toggle off in the dashboard. Entries
|
|
65
|
+
* from `--functions` win by name. WhatsApp functions stay out (they must never run for real).
|
|
66
|
+
*/
|
|
67
|
+
function realFunctionList(version, explicit = []) {
|
|
68
|
+
const all = [...(version.functions || []), ...(version.capabilities || []).flatMap((c) => c.functions || [])];
|
|
69
|
+
const names = [...new Set(all.filter((f) => f?.name && !/whatsapp/i.test(String(f.type || ''))).map((f) => f.name))];
|
|
70
|
+
const byName = new Map(explicit.map((f) => [f.name, f]));
|
|
71
|
+
return [...names.map((n) => byName.get(n) || { name: n, mock: false }), ...explicit.filter((f) => !names.includes(f.name))];
|
|
72
|
+
}
|
|
73
|
+
async function withRealFunctions(api, agentId, version, explicit) {
|
|
74
|
+
const res = await api.getAgent(agentId, version);
|
|
75
|
+
return realFunctionList(res.agent || res, explicit);
|
|
76
|
+
}
|
|
61
77
|
async function simTestsListCommand(agentId, opts) {
|
|
62
78
|
const api = (0, session_1.requireApi)();
|
|
63
79
|
try {
|
|
@@ -78,7 +94,9 @@ async function simCreateCommand(agentId, opts) {
|
|
|
78
94
|
try {
|
|
79
95
|
// Parse the JSON flags before prompting, so a typo fails before any typing.
|
|
80
96
|
const testVariables = opts.variables ? toTestVariables(await (0, jsonInput_1.readJsonFlag)(opts.variables, '--variables')) : {};
|
|
81
|
-
|
|
97
|
+
let testFunctions = opts.functions ? toTestFunctions(await (0, jsonInput_1.readJsonFlag)(opts.functions, '--functions')) : [];
|
|
98
|
+
if (opts.realFunctions)
|
|
99
|
+
testFunctions = await withRealFunctions(api, agentId, opts.versions, testFunctions);
|
|
82
100
|
let { name, persona, prompt, criteria } = opts;
|
|
83
101
|
if (!name || !prompt || !criteria) {
|
|
84
102
|
(0, ui_1.needTty)('Pass --name, --prompt and --criteria.');
|
|
@@ -143,8 +161,13 @@ async function simUpdateCommand(testCaseId, opts) {
|
|
|
143
161
|
body.testVariables = toTestVariables(await (0, jsonInput_1.readJsonFlag)(opts.variables, '--variables'));
|
|
144
162
|
if (opts.functions !== undefined)
|
|
145
163
|
body.testFunctions = toTestFunctions(await (0, jsonInput_1.readJsonFlag)(opts.functions, '--functions'));
|
|
164
|
+
if (opts.realFunctions) {
|
|
165
|
+
if (!opts.agent)
|
|
166
|
+
return (0, ui_1.handleError)(new Error('--real-functions on update needs --agent <agentId> (the test case does not carry it).'));
|
|
167
|
+
body.testFunctions = await withRealFunctions(api, opts.agent, opts.versions, body.testFunctions || []);
|
|
168
|
+
}
|
|
146
169
|
if (!Object.keys(body).length) {
|
|
147
|
-
return (0, ui_1.handleError)(new Error('Nothing to update. Pass at least one of --name, --persona, --prompt, --criteria, --model, --variables, --functions.'));
|
|
170
|
+
return (0, ui_1.handleError)(new Error('Nothing to update. Pass at least one of --name, --persona, --prompt, --criteria, --model, --variables, --functions, --real-functions.'));
|
|
148
171
|
}
|
|
149
172
|
const res = await api.updateTestCase(testCaseId, body);
|
|
150
173
|
(0, ui_1.success)(`Updated test case ${res.testCase?._id || testCaseId}: ${Object.keys(body).join(', ')}.`);
|
package/dist/index.js
CHANGED
|
@@ -76,6 +76,7 @@ const knowledge_1 = require("./commands/knowledge");
|
|
|
76
76
|
const aiTools_1 = require("./commands/aiTools");
|
|
77
77
|
const batches_1 = require("./commands/batches");
|
|
78
78
|
const simulations_1 = require("./commands/simulations");
|
|
79
|
+
const liveTest_1 = require("./commands/liveTest");
|
|
79
80
|
const simVariables_1 = require("./commands/simVariables");
|
|
80
81
|
const dashboardApiCmd_1 = require("./commands/dashboardApiCmd");
|
|
81
82
|
const adminAuth_1 = require("./lib/adminAuth");
|
|
@@ -1397,6 +1398,8 @@ sim.command('create <agentId>').description('create a test case')
|
|
|
1397
1398
|
.option('--model <model>', 'test LLM model (default: platform default; see `cku sim models`)')
|
|
1398
1399
|
.option('--variables <json|@file>', 'testVariables: a JSON object of name -> value (non-strings are stringified)')
|
|
1399
1400
|
.option('--functions <json|@file>', 'a JSON array of {"name":"fn","mock":true,"response":{}}; "mock":false runs the REAL function (never for WhatsApp functions)')
|
|
1401
|
+
.option('--real-functions', 'run every agent function for real (mock:false), like switching every Mock off in the dashboard; --functions entries win by name')
|
|
1402
|
+
.option('--versions <versionId>', 'with --real-functions: read the functions from this version (default: the agent\'s default version)')
|
|
1400
1403
|
.action(simulations_1.simCreateCommand);
|
|
1401
1404
|
sim.command('update <testCaseId>').description('edit a test case (only the fields you pass are sent)')
|
|
1402
1405
|
.option('--name <name>', 'test case name')
|
|
@@ -1406,6 +1409,9 @@ sim.command('update <testCaseId>').description('edit a test case (only the field
|
|
|
1406
1409
|
.option('--model <model>', 'test LLM model')
|
|
1407
1410
|
.option('--variables <json|@file>', 'testVariables: replaces the whole map')
|
|
1408
1411
|
.option('--functions <json|@file>', 'function list, replaces the whole list; entries are {name, mock?, response?}, "mock":false runs the REAL function')
|
|
1412
|
+
.option('--real-functions', 'replace the list with every agent function run for real (mock:false); --functions entries win by name; needs --agent')
|
|
1413
|
+
.option('--agent <agentId>', 'agent to read functions from (for --real-functions)')
|
|
1414
|
+
.option('--versions <versionId>', 'with --real-functions: read the functions from this version')
|
|
1409
1415
|
.action(simulations_1.simUpdateCommand);
|
|
1410
1416
|
sim.command('models').description('models that can play the simulated caller').option('--json', 'raw JSON').action(simulations_1.simModelsCommand);
|
|
1411
1417
|
sim.command('delete <testCaseId>').description('delete a test case').option('--yes', 'skip confirmation')
|
|
@@ -1449,6 +1455,47 @@ simVars.command('create <agentId> <name>').description('save a variable set')
|
|
|
1449
1455
|
.action(simVariables_1.simVarsCreateCommand);
|
|
1450
1456
|
simVars.command('delete <agentId> <nameOrId>').description('delete a saved set').option('--yes', 'skip confirmation')
|
|
1451
1457
|
.action(simVariables_1.simVarsDeleteCommand);
|
|
1458
|
+
// --- live test calls (the editor's Test Chat, from a terminal) -------------
|
|
1459
|
+
const liveTest = program.command('test').description('live test calls against the real call runtime; you or an LLM play the caller (functions RUN FOR REAL)');
|
|
1460
|
+
liveTest.command('start <agentId>').description('start a test call and print the agent\'s opening turn; then `test say` / `test end`')
|
|
1461
|
+
.option('--versions <versionId>', 'version to test (default: the published one)')
|
|
1462
|
+
.option('--variables <json|@file>', 'call metadata as a JSON object')
|
|
1463
|
+
.option('--yes', 'confirm the real test call without asking')
|
|
1464
|
+
.option('--json', 'print {sessionId, callSid, events} as JSON')
|
|
1465
|
+
.action(liveTest_1.testStartCommand);
|
|
1466
|
+
liveTest.command('say <sessionId> <text...>').description('send one caller turn and print the agent\'s reply')
|
|
1467
|
+
.option('--timeout <seconds>', 'wait this long for the agent to finish its turn (default 60)')
|
|
1468
|
+
.option('--json', 'print {events, ended} as JSON')
|
|
1469
|
+
.action(liveTest_1.testSayCommand);
|
|
1470
|
+
liveTest.command('end <sessionId>').description('hang up and print the full transcript with function calls')
|
|
1471
|
+
.option('--json', 'print {callSid, events, ended} as JSON')
|
|
1472
|
+
.action(liveTest_1.testEndCommand);
|
|
1473
|
+
liveTest.command('_serve <sessionId>', { hidden: true }).action(liveTest_1.testServeCommand);
|
|
1474
|
+
liveTest.command('run <agentId>').description('the CLI plays the caller with an LLM, then grades the call against --criteria')
|
|
1475
|
+
.option('--test <testCaseId>', 'reuse a saved simulation test case (persona, prompt, criteria, variables)')
|
|
1476
|
+
.option('--persona <text>', 'who the caller is')
|
|
1477
|
+
.option('--prompt <text>', 'what the caller wants and does in this call')
|
|
1478
|
+
.option('--criteria <text>', 'success criteria for the judge; without it the call is not graded')
|
|
1479
|
+
.option('--variables <json|@file>', 'call metadata as a JSON object (merged over the test case\'s)')
|
|
1480
|
+
.option('--versions <versionId>', 'version to test (default: the published one)')
|
|
1481
|
+
.option('--llm <provider>', 'openai | anthropic | gemini | deepseek | groq | openrouter | ollama | custom (default: first key found in the environment)')
|
|
1482
|
+
.option('--llm-model <model>', 'caller model (default per provider)')
|
|
1483
|
+
.option('--llm-url <url>', 'OpenAI-compatible base URL (custom provider; key in CKU_LLM_API_KEY)')
|
|
1484
|
+
.option('--judge-model <model>', 'judge model on the same provider (default: the caller model)')
|
|
1485
|
+
.option('--max-turns <n>', 'caller turns before giving up (default 20)')
|
|
1486
|
+
.option('--timeout <seconds>', 'per agent turn (default 60)')
|
|
1487
|
+
.option('--yes', 'confirm the real test call without asking')
|
|
1488
|
+
.option('--json', 'print {callSid, endedBy, events, verdict} as JSON')
|
|
1489
|
+
.addHelpText('after', `
|
|
1490
|
+
Keys come from the environment only: OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY,
|
|
1491
|
+
DEEPSEEK_API_KEY, GROQ_API_KEY, OPENROUTER_API_KEY, or CKU_LLM_API_KEY for any provider.
|
|
1492
|
+
Exit 1 when the judge fails the call (or cannot grade it).
|
|
1493
|
+
|
|
1494
|
+
Examples:
|
|
1495
|
+
cku test run <agentId> --test <testCaseId> --yes
|
|
1496
|
+
cku test run <agentId> --persona "Busy dealer in Pune" --prompt "Ask for the price, then say you will think about it" \\
|
|
1497
|
+
--criteria "1. Agent states the price from call data. 2. Agent ends politely with end_call." --llm anthropic --yes`)
|
|
1498
|
+
.action(liveTest_1.testRunCommand);
|
|
1452
1499
|
// --- audit strategies ------------------------------------------------------
|
|
1453
1500
|
const chatSim = program.command('chat-sim').description('chat agent simulations & test cases');
|
|
1454
1501
|
chatSim.command('tests <chatAgentId>').description('list chat test cases').option('--json', 'raw JSON').action(chatSimulations_1.chatSimTestsListCommand);
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Live test calls: the agent editor's "Test Chat" tab, from a terminal.
|
|
4
|
+
*
|
|
5
|
+
* Read out of the dashboard bundle (no upstream CLI has this):
|
|
6
|
+
* 1. POST {base}/outbound_call?is_test=true&chat=true[&version=] with {userId, agent_id, metadata}
|
|
7
|
+
* returns {token, call_sid}. The request carries no credential: the server only checks that
|
|
8
|
+
* `Origin` is the dashboard's (403 "Caller not allowed" otherwise), so it is sent here too.
|
|
9
|
+
* 2. Join the LiveKit room at wss://livekit-inbound.callkaro.ai with that token.
|
|
10
|
+
* 3. User turns go out as text on topic `lk.chat`. Agent text comes back as `lk.transcription`
|
|
11
|
+
* text streams (LiveKit Agents 1.x), function calls as data packets on `lk.history`, the
|
|
12
|
+
* active node as the agent participant's `current_capability` attribute, and the agent's
|
|
13
|
+
* turn state as `lk.agent.state`. `lk.history` also carries a `{type:"message"}` item per
|
|
14
|
+
* finished message, which is what marks an agent turn complete: seen live, `lk.agent.state`
|
|
15
|
+
* returns to `listening` up to 1.2s before the transcript of that turn arrives.
|
|
16
|
+
*
|
|
17
|
+
* This is the real call runtime, not a simulation: every function runs for real and the call
|
|
18
|
+
* lands in call history with post-call analysis.
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.END_MARK = exports.LLM_PRESETS = exports.LiveSession = exports.deps = exports.TEST_IDENTITY = exports.LIVEKIT_URL = void 0;
|
|
22
|
+
exports.testCallBase = testCallBase;
|
|
23
|
+
exports.requestTestToken = requestTestToken;
|
|
24
|
+
exports.formatTranscript = formatTranscript;
|
|
25
|
+
exports.resolveLlm = resolveLlm;
|
|
26
|
+
exports.chatComplete = chatComplete;
|
|
27
|
+
exports.callerSystemPrompt = callerSystemPrompt;
|
|
28
|
+
exports.parseCallerLine = parseCallerLine;
|
|
29
|
+
exports.judgeMessages = judgeMessages;
|
|
30
|
+
exports.parseVerdict = parseVerdict;
|
|
31
|
+
const fs_1 = require("fs");
|
|
32
|
+
const config_1 = require("./config");
|
|
33
|
+
exports.LIVEKIT_URL = 'wss://livekit-inbound.callkaro.ai';
|
|
34
|
+
exports.TEST_IDENTITY = 'test_participant';
|
|
35
|
+
// ponytail: copied from the dashboard bundle's host picker; re-read the bundle if a Spinny
|
|
36
|
+
// account's test call 404s. CK_TEST_CALL_URL overrides the host for every account.
|
|
37
|
+
const SPINNY_USER_IDS = new Set([
|
|
38
|
+
'681b6d70d73a629e2f9f9b5b', '683761f141599a2ac50570c7', '684980f1ad348c30b0155cc3',
|
|
39
|
+
'686eb5e262c7062950b41a23', '686eb58c62c7062950b41a20', '68e4e4da4a8835a2360e8aa6',
|
|
40
|
+
'6915a152a205df5fd2b576d7', '691c3d20409942e2d0492f76', '698dfcedbbb4b9d3496cb860',
|
|
41
|
+
'6a06f48caabb1c14cb9311b4',
|
|
42
|
+
]);
|
|
43
|
+
function testCallBase(userId) {
|
|
44
|
+
if (process.env.CK_TEST_CALL_URL)
|
|
45
|
+
return process.env.CK_TEST_CALL_URL.replace(/\/+$/, '');
|
|
46
|
+
return SPINNY_USER_IDS.has(userId) ? 'https://spinny.callkaro.ai' : 'https://api.callkaro.ai';
|
|
47
|
+
}
|
|
48
|
+
async function requestTestToken(input) {
|
|
49
|
+
let url = `${testCallBase(input.userId)}/outbound_call?is_test=true&chat=true`;
|
|
50
|
+
if (input.version)
|
|
51
|
+
url += `&version=${encodeURIComponent(input.version)}`;
|
|
52
|
+
const res = await fetch(url, {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: { 'Content-Type': 'application/json', Origin: (0, config_1.webUrl)(), Referer: `${(0, config_1.webUrl)()}/` },
|
|
55
|
+
body: JSON.stringify({ userId: input.userId, agent_id: input.agentId, metadata: input.metadata || {} }),
|
|
56
|
+
signal: AbortSignal.timeout(30_000),
|
|
57
|
+
});
|
|
58
|
+
const body = await res.text();
|
|
59
|
+
let data = {};
|
|
60
|
+
try {
|
|
61
|
+
data = JSON.parse(body);
|
|
62
|
+
}
|
|
63
|
+
catch { /* not JSON */ }
|
|
64
|
+
if (res.status !== 200) {
|
|
65
|
+
throw new Error(`Starting the test call failed (HTTP ${res.status}): ${data.message || body.slice(0, 300) || 'no body'}`);
|
|
66
|
+
}
|
|
67
|
+
if (!data.token)
|
|
68
|
+
throw new Error('The test-call server answered without a LiveKit token, so there is no room to join.');
|
|
69
|
+
return { token: data.token, callSid: data.call_sid };
|
|
70
|
+
}
|
|
71
|
+
/** Swappable for tests: the native LiveKit module is loaded only when a test call starts. */
|
|
72
|
+
exports.deps = {
|
|
73
|
+
rtc: () => {
|
|
74
|
+
try {
|
|
75
|
+
return require('@livekit/rtc-node');
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
throw new Error('Live tests need @livekit/rtc-node, which did not install on this machine. '
|
|
79
|
+
+ 'Run: npm install --global @livekit/rtc-node, or reinstall cku on a supported platform '
|
|
80
|
+
+ '(Linux, macOS or Windows, x64 or arm64).');
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
85
|
+
class LiveSession {
|
|
86
|
+
callSid;
|
|
87
|
+
events = [];
|
|
88
|
+
ended = false;
|
|
89
|
+
room;
|
|
90
|
+
agentState = '';
|
|
91
|
+
sawState = false;
|
|
92
|
+
sawHistory = false;
|
|
93
|
+
agentMessages = 0;
|
|
94
|
+
openStreams = 0;
|
|
95
|
+
lastActivity = Date.now();
|
|
96
|
+
lastSent = '';
|
|
97
|
+
segments = new Map();
|
|
98
|
+
constructor(callSid) {
|
|
99
|
+
this.callSid = callSid;
|
|
100
|
+
}
|
|
101
|
+
/** Join the room and collect the agent's opening turn (empty when the agent waits for the caller). */
|
|
102
|
+
static async open(token, callSid, opts = {}) {
|
|
103
|
+
const rtc = exports.deps.rtc();
|
|
104
|
+
const s = new LiveSession(callSid);
|
|
105
|
+
const room = new rtc.Room();
|
|
106
|
+
s.room = room;
|
|
107
|
+
const touch = () => { s.lastActivity = Date.now(); };
|
|
108
|
+
const push = (e) => { const ev = { ...e, t: Date.now() }; s.events.push(ev); touch(); return ev; };
|
|
109
|
+
const readAttrs = (attrs) => {
|
|
110
|
+
if (!attrs)
|
|
111
|
+
return;
|
|
112
|
+
if ('lk.agent.state' in attrs) {
|
|
113
|
+
s.agentState = attrs['lk.agent.state'];
|
|
114
|
+
s.sawState = true;
|
|
115
|
+
touch();
|
|
116
|
+
}
|
|
117
|
+
if (attrs.current_capability)
|
|
118
|
+
push({ type: 'capability', capability: attrs.current_capability });
|
|
119
|
+
};
|
|
120
|
+
// CK_TEST_DEBUG=<file> appends every raw room event, for when the worker's protocol drifts.
|
|
121
|
+
const debug = (kind, data) => {
|
|
122
|
+
if (process.env.CK_TEST_DEBUG)
|
|
123
|
+
(0, fs_1.appendFileSync)(process.env.CK_TEST_DEBUG, `${new Date().toISOString()} ${kind} ${JSON.stringify(data)}\n`);
|
|
124
|
+
};
|
|
125
|
+
room.registerTextStreamHandler('lk.transcription', async (reader, from) => {
|
|
126
|
+
s.openStreams++;
|
|
127
|
+
touch();
|
|
128
|
+
try {
|
|
129
|
+
const text = String(await reader.readAll()).trim();
|
|
130
|
+
debug('transcription', { from: from.identity, attributes: reader.info?.attributes, text });
|
|
131
|
+
if (!text || from.identity === exports.TEST_IDENTITY || text === s.lastSent)
|
|
132
|
+
return;
|
|
133
|
+
const seg = reader.info?.attributes?.['lk.segment_id'];
|
|
134
|
+
const prior = seg && s.segments.get(seg);
|
|
135
|
+
if (prior) {
|
|
136
|
+
prior.text = text;
|
|
137
|
+
touch();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const ev = push({ type: 'agent', text });
|
|
141
|
+
if (seg)
|
|
142
|
+
s.segments.set(seg, ev);
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
s.openStreams--;
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
room.on('dataReceived', (payload, p, _k, topic) => {
|
|
149
|
+
debug('data', { from: p?.identity, topic, payload: new TextDecoder().decode(payload).slice(0, 2000) });
|
|
150
|
+
if (topic !== 'lk.history')
|
|
151
|
+
return;
|
|
152
|
+
try {
|
|
153
|
+
const data = JSON.parse(new TextDecoder().decode(payload));
|
|
154
|
+
if (data.type === 'message') {
|
|
155
|
+
// Duplicates the transcript text; only the count matters (see the header).
|
|
156
|
+
s.sawHistory = true;
|
|
157
|
+
if (data.role === 'assistant')
|
|
158
|
+
s.agentMessages++;
|
|
159
|
+
touch();
|
|
160
|
+
}
|
|
161
|
+
else if (data.type === 'function') {
|
|
162
|
+
let args = data.arguments;
|
|
163
|
+
try {
|
|
164
|
+
if (typeof args === 'string')
|
|
165
|
+
args = JSON.parse(args || '{}');
|
|
166
|
+
}
|
|
167
|
+
catch { /* keep the string */ }
|
|
168
|
+
push({ type: 'function', name: data.name, arguments: args });
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
push({ type: 'history', data });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch { /* not JSON */ }
|
|
175
|
+
});
|
|
176
|
+
room.on('participantConnected', (p) => { debug('joined', { identity: p.identity, kind: p.kind, attributes: p.attributes }); readAttrs(p.attributes); });
|
|
177
|
+
room.on('participantAttributesChanged', (changed, p) => {
|
|
178
|
+
debug('attributes', { from: p?.identity, changed });
|
|
179
|
+
if (p?.identity !== exports.TEST_IDENTITY)
|
|
180
|
+
readAttrs(changed);
|
|
181
|
+
});
|
|
182
|
+
const end = () => { if (!s.ended) {
|
|
183
|
+
s.ended = true;
|
|
184
|
+
push({ type: 'ended' });
|
|
185
|
+
} };
|
|
186
|
+
room.on('participantDisconnected', (p) => { if (p?.identity !== exports.TEST_IDENTITY)
|
|
187
|
+
end(); });
|
|
188
|
+
room.on('disconnected', (reason) => { debug('disconnected', { reason }); end(); });
|
|
189
|
+
await room.connect(exports.LIVEKIT_URL, token, { autoSubscribe: false });
|
|
190
|
+
for (const p of room.remoteParticipants?.values?.() || [])
|
|
191
|
+
readAttrs(p.attributes);
|
|
192
|
+
const greeting = await s.waitTurn(opts.greetingTimeoutMs ?? 20_000, 0);
|
|
193
|
+
return { session: s, greeting };
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Wait until the agent has finished its turn: it spoke or called a function since `from` (and,
|
|
197
|
+
* once the worker is known to send them, a finished-message item arrived on `lk.history`), no transcript
|
|
198
|
+
* stream is still open, the agent reports `listening` (or never reports a state), and nothing
|
|
199
|
+
* new has arrived for a short settle window. `lk.agent.state` makes the window short; without
|
|
200
|
+
* it a longer quiet period has to stand in.
|
|
201
|
+
*/
|
|
202
|
+
async waitTurn(timeoutMs, from = this.events.length, messagesBefore = this.agentMessages) {
|
|
203
|
+
const deadline = Date.now() + timeoutMs;
|
|
204
|
+
while (Date.now() < deadline && !this.ended) {
|
|
205
|
+
const spoke = this.events.slice(from).some((e) => e.type === 'agent' || e.type === 'function');
|
|
206
|
+
const got = spoke && (!this.sawHistory || this.agentMessages > messagesBefore);
|
|
207
|
+
const idle = this.openStreams === 0 && (!this.sawState || this.agentState === 'listening');
|
|
208
|
+
const quiet = Date.now() - this.lastActivity >= (this.sawState ? 700 : 2500);
|
|
209
|
+
if (got && idle && quiet)
|
|
210
|
+
break;
|
|
211
|
+
await sleep(100);
|
|
212
|
+
}
|
|
213
|
+
return this.events.slice(from);
|
|
214
|
+
}
|
|
215
|
+
async say(text, timeoutMs = 60_000) {
|
|
216
|
+
if (this.ended)
|
|
217
|
+
return [{ type: 'ended', t: Date.now() }];
|
|
218
|
+
const from = this.events.length;
|
|
219
|
+
const messagesBefore = this.agentMessages;
|
|
220
|
+
this.events.push({ type: 'user', text, t: Date.now() });
|
|
221
|
+
this.lastSent = text.trim();
|
|
222
|
+
this.lastActivity = Date.now();
|
|
223
|
+
await this.room.localParticipant.sendText(text, { topic: 'lk.chat' });
|
|
224
|
+
// Give the agent a moment to leave `listening`, so a still-idle state is not read as done.
|
|
225
|
+
if (this.sawState) {
|
|
226
|
+
const until = Date.now() + 3000;
|
|
227
|
+
while (this.agentState === 'listening' && Date.now() < until && !this.ended)
|
|
228
|
+
await sleep(50);
|
|
229
|
+
}
|
|
230
|
+
return this.waitTurn(timeoutMs, from + 1, messagesBefore);
|
|
231
|
+
}
|
|
232
|
+
async close() {
|
|
233
|
+
try {
|
|
234
|
+
await this.room?.disconnect();
|
|
235
|
+
}
|
|
236
|
+
catch { /* already gone */ }
|
|
237
|
+
try {
|
|
238
|
+
exports.deps.rtc().dispose?.();
|
|
239
|
+
}
|
|
240
|
+
catch { /* best effort */ }
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
exports.LiveSession = LiveSession;
|
|
244
|
+
/** One line per event, numbered by turn, for people and for the judge. */
|
|
245
|
+
function formatTranscript(events) {
|
|
246
|
+
const lines = [];
|
|
247
|
+
let turn = 0;
|
|
248
|
+
for (const e of events) {
|
|
249
|
+
if (e.type === 'agent')
|
|
250
|
+
lines.push(`T${++turn} agent: ${e.text}`);
|
|
251
|
+
else if (e.type === 'user')
|
|
252
|
+
lines.push(`T${++turn} caller: ${e.text}`);
|
|
253
|
+
else if (e.type === 'function')
|
|
254
|
+
lines.push(` [function ${e.name} ${JSON.stringify(e.arguments ?? {})}]`);
|
|
255
|
+
else if (e.type === 'capability')
|
|
256
|
+
lines.push(` [node: ${e.capability}]`);
|
|
257
|
+
else if (e.type === 'ended')
|
|
258
|
+
lines.push(' [call ended]');
|
|
259
|
+
}
|
|
260
|
+
return lines.join('\n');
|
|
261
|
+
}
|
|
262
|
+
// --- LLM caller and judge -------------------------------------------------------------------
|
|
263
|
+
/** Every provider here speaks the OpenAI chat-completions shape, so one client covers them all. */
|
|
264
|
+
exports.LLM_PRESETS = {
|
|
265
|
+
openai: { url: 'https://api.openai.com/v1', env: 'OPENAI_API_KEY', model: 'gpt-4.1-mini' },
|
|
266
|
+
anthropic: { url: 'https://api.anthropic.com/v1', env: 'ANTHROPIC_API_KEY', model: 'claude-haiku-5-5' },
|
|
267
|
+
gemini: { url: 'https://generativelanguage.googleapis.com/v1beta/openai', env: 'GEMINI_API_KEY', model: 'gemini-2.5-flash' },
|
|
268
|
+
deepseek: { url: 'https://api.deepseek.com/v1', env: 'DEEPSEEK_API_KEY', model: 'deepseek-chat' },
|
|
269
|
+
groq: { url: 'https://api.groq.com/openai/v1', env: 'GROQ_API_KEY', model: 'llama-3.3-70b-versatile' },
|
|
270
|
+
openrouter: { url: 'https://openrouter.ai/api/v1', env: 'OPENROUTER_API_KEY', model: 'openai/gpt-4.1-mini' },
|
|
271
|
+
ollama: { url: 'http://localhost:11434/v1', model: 'llama3.1' },
|
|
272
|
+
};
|
|
273
|
+
/**
|
|
274
|
+
* Pick the provider: --llm, else --llm-url (custom, key in CKU_LLM_API_KEY), else the first
|
|
275
|
+
* preset whose key is in the environment. Keys only come from the environment, never a flag,
|
|
276
|
+
* so they stay out of shell history and process listings.
|
|
277
|
+
*/
|
|
278
|
+
function resolveLlm(opts) {
|
|
279
|
+
const names = Object.keys(exports.LLM_PRESETS);
|
|
280
|
+
let name = opts.llm;
|
|
281
|
+
if (!name && opts.llmUrl)
|
|
282
|
+
name = 'custom';
|
|
283
|
+
if (!name)
|
|
284
|
+
name = names.find((n) => exports.LLM_PRESETS[n].env && process.env[exports.LLM_PRESETS[n].env]);
|
|
285
|
+
if (!name) {
|
|
286
|
+
throw new Error(`No LLM for the simulated caller. Set one of ${names.filter((n) => exports.LLM_PRESETS[n].env).map((n) => exports.LLM_PRESETS[n].env).join(', ')}, `
|
|
287
|
+
+ 'or pass --llm ollama, or --llm-url <OpenAI-compatible base URL> with the key in CKU_LLM_API_KEY. '
|
|
288
|
+
+ 'To play the caller yourself instead, use: cku test start <agentId>');
|
|
289
|
+
}
|
|
290
|
+
if (name === 'custom') {
|
|
291
|
+
if (!opts.llmUrl)
|
|
292
|
+
throw new Error('--llm custom needs --llm-url <OpenAI-compatible base URL>.');
|
|
293
|
+
if (!opts.llmModel)
|
|
294
|
+
throw new Error('--llm-url needs --llm-model <model id>.');
|
|
295
|
+
return { name, url: opts.llmUrl.replace(/\/+$/, ''), key: process.env.CKU_LLM_API_KEY, model: opts.llmModel };
|
|
296
|
+
}
|
|
297
|
+
const p = exports.LLM_PRESETS[name];
|
|
298
|
+
if (!p)
|
|
299
|
+
throw new Error(`Unknown --llm "${name}". Use one of: ${names.join(', ')}, custom.`);
|
|
300
|
+
const key = process.env.CKU_LLM_API_KEY || (p.env ? process.env[p.env] : undefined);
|
|
301
|
+
if (p.env && !key)
|
|
302
|
+
throw new Error(`--llm ${name} needs ${p.env} (or CKU_LLM_API_KEY) set in the environment.`);
|
|
303
|
+
return { name, url: (opts.llmUrl || p.url).replace(/\/+$/, ''), key, model: opts.llmModel || p.model };
|
|
304
|
+
}
|
|
305
|
+
async function chatComplete(cfg, messages, temperature = 0.7) {
|
|
306
|
+
const res = await fetch(`${cfg.url}/chat/completions`, {
|
|
307
|
+
method: 'POST',
|
|
308
|
+
headers: { 'Content-Type': 'application/json', ...(cfg.key ? { Authorization: `Bearer ${cfg.key}` } : {}) },
|
|
309
|
+
body: JSON.stringify({ model: cfg.model, messages, temperature }),
|
|
310
|
+
signal: AbortSignal.timeout(120_000),
|
|
311
|
+
});
|
|
312
|
+
const body = await res.text();
|
|
313
|
+
if (!res.ok)
|
|
314
|
+
throw new Error(`${cfg.name} (${cfg.model}) returned HTTP ${res.status}: ${body.slice(0, 300)}`);
|
|
315
|
+
const content = JSON.parse(body)?.choices?.[0]?.message?.content;
|
|
316
|
+
return Array.isArray(content) ? content.map((c) => c.text ?? '').join('') : String(content ?? '');
|
|
317
|
+
}
|
|
318
|
+
exports.END_MARK = '[END]';
|
|
319
|
+
function callerSystemPrompt(s) {
|
|
320
|
+
return [
|
|
321
|
+
'You are role-playing a person on a phone call with a voice agent. Stay in character the whole call.',
|
|
322
|
+
s.persona ? `Who you are: ${s.persona}` : '',
|
|
323
|
+
`What happens in this call: ${s.prompt}`,
|
|
324
|
+
'Rules:',
|
|
325
|
+
'- Reply with only the words you say out loud: one short spoken turn, usually one or two sentences. No stage directions, no quotes, no labels.',
|
|
326
|
+
'- Speak the way this person would, in their language and register (Hindi, Hinglish, English, ...).',
|
|
327
|
+
'- Never say you are a test, a simulation or an AI.',
|
|
328
|
+
`- When the call is over for you (goal done, you give up, or the agent said goodbye), end your reply with ${exports.END_MARK}. Reply with only ${exports.END_MARK} if there is nothing left to say.`,
|
|
329
|
+
].filter(Boolean).join('\n');
|
|
330
|
+
}
|
|
331
|
+
/** The caller's next line, and whether it hangs up after it. */
|
|
332
|
+
function parseCallerLine(raw) {
|
|
333
|
+
const end = raw.includes(exports.END_MARK);
|
|
334
|
+
const text = raw.replace(exports.END_MARK, '').replace(/^\s*(caller|me|you)\s*:\s*/i, '').trim().replace(/^"(.*)"$/s, '$1');
|
|
335
|
+
return { text, end };
|
|
336
|
+
}
|
|
337
|
+
function judgeMessages(criteria, events) {
|
|
338
|
+
return [
|
|
339
|
+
{
|
|
340
|
+
role: 'system',
|
|
341
|
+
content: 'You grade a test call between a simulated caller and a voice agent. Judge only from the transcript. '
|
|
342
|
+
+ 'Split the success criteria into separate checks. A check passes only if the transcript shows it; anything the '
|
|
343
|
+
+ 'criteria forbid fails. Cite the turn (T3) or function line that decides each check. Answer with JSON only: '
|
|
344
|
+
+ '{"pass": boolean, "criteria": [{"criterion": string, "pass": boolean, "evidence": string, "reason": string}], "summary": string}. '
|
|
345
|
+
+ '"pass" is true only if every check passes.',
|
|
346
|
+
},
|
|
347
|
+
{ role: 'user', content: `Success criteria:\n${criteria}\n\nTranscript:\n${formatTranscript(events)}` },
|
|
348
|
+
];
|
|
349
|
+
}
|
|
350
|
+
/** The judge's JSON, tolerating a code fence or prose around it. A reply with no JSON is INVALID, not FAIL. */
|
|
351
|
+
function parseVerdict(raw) {
|
|
352
|
+
const m = raw.match(/\{[\s\S]*\}/);
|
|
353
|
+
try {
|
|
354
|
+
const v = JSON.parse(m ? m[0] : raw);
|
|
355
|
+
const criteria = Array.isArray(v.criteria) ? v.criteria : [];
|
|
356
|
+
return { pass: v.pass === true && criteria.every((c) => c.pass !== false), criteria, summary: String(v.summary ?? '') };
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
return { pass: false, criteria: [], summary: `Judge did not return JSON: ${raw.slice(0, 200)}`, invalid: true };
|
|
360
|
+
}
|
|
361
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "callkaro-cli-unofficial",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Unofficial fork of the CallKaro CLI — manage your voice AI agents from the terminal",
|
|
6
6
|
"bin": {
|
|
@@ -48,5 +48,8 @@
|
|
|
48
48
|
"cli",
|
|
49
49
|
"agents",
|
|
50
50
|
"telephony"
|
|
51
|
-
]
|
|
51
|
+
],
|
|
52
|
+
"optionalDependencies": {
|
|
53
|
+
"@livekit/rtc-node": "^1.2.2"
|
|
54
|
+
}
|
|
52
55
|
}
|