callkaro-cli-unofficial 0.9.4 → 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/agentMemory.js +72 -0
- package/dist/commands/agentVersions.js +3 -1
- package/dist/commands/agents.js +46 -22
- package/dist/commands/liveTest.js +330 -0
- package/dist/commands/simulations.js +25 -2
- package/dist/endpoints.json +0 -0
- package/dist/index.js +73 -0
- package/dist/lib/agentMemory.js +177 -0
- package/dist/lib/agentUpdate.js +35 -5
- package/dist/lib/liveTest.js +361 -0
- package/dist/lib/liveVoiceCheck.js +55 -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,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.agentsMemoryCommand = agentsMemoryCommand;
|
|
4
|
+
exports.agentsVoiceRotationCommand = agentsVoiceRotationCommand;
|
|
5
|
+
const fs_1 = require("fs");
|
|
6
|
+
const session_1 = require("../lib/session");
|
|
7
|
+
const ui_1 = require("../lib/ui");
|
|
8
|
+
const versions_1 = require("../lib/versions");
|
|
9
|
+
const looseJson_1 = require("../lib/looseJson");
|
|
10
|
+
const liveVoiceCheck_1 = require("../lib/liveVoiceCheck");
|
|
11
|
+
const agentMemory_1 = require("../lib/agentMemory");
|
|
12
|
+
async function run(agentId, opts, flags, change, describe, what, verify) {
|
|
13
|
+
const api = (0, session_1.requireApi)();
|
|
14
|
+
try {
|
|
15
|
+
const versionId = (0, versions_1.firstVersion)(opts.versions);
|
|
16
|
+
if (!versionId)
|
|
17
|
+
throw new Error('--versions <id> is required (find it with `cku agents versions <agentId>`)');
|
|
18
|
+
const { agent } = await api.getAgent(agentId, versionId);
|
|
19
|
+
const current = agent.agentMemory;
|
|
20
|
+
if (!(0, agentMemory_1.hasChanges)(flags)) {
|
|
21
|
+
if (opts.json)
|
|
22
|
+
console.log(JSON.stringify(current ?? {}, null, 2));
|
|
23
|
+
else
|
|
24
|
+
(0, ui_1.keyValue)(describe(current));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const next = change(current);
|
|
28
|
+
if (verify)
|
|
29
|
+
await verify(api, next, agent);
|
|
30
|
+
const body = { agentMemory: next };
|
|
31
|
+
if (opts.commit)
|
|
32
|
+
body.commitMessage = opts.commit;
|
|
33
|
+
const result = await api.updateAgent(agentId, versionId, body);
|
|
34
|
+
if (opts.json)
|
|
35
|
+
console.log(JSON.stringify(next, null, 2));
|
|
36
|
+
else {
|
|
37
|
+
(0, ui_1.success)(result.msg || `${what} updated.`);
|
|
38
|
+
(0, ui_1.keyValue)(describe(next));
|
|
39
|
+
(0, ui_1.info)(' Takes effect on the next call. Publish the version if it is not live yet.');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
(0, ui_1.handleError)(error);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function agentsMemoryCommand(agentId, opts) {
|
|
47
|
+
const { versions, commit, json, ...flags } = opts;
|
|
48
|
+
return run(agentId, opts, flags, (memory) => (0, agentMemory_1.applyMemoryFlags)(memory, flags), agentMemory_1.describeMemory, 'Call memory');
|
|
49
|
+
}
|
|
50
|
+
function agentsVoiceRotationCommand(agentId, opts) {
|
|
51
|
+
const { versions, commit, json, voices, ...rest } = opts;
|
|
52
|
+
const flags = rest;
|
|
53
|
+
if (voices !== undefined) {
|
|
54
|
+
const file = String(voices).startsWith('@') ? String(voices).slice(1) : String(voices);
|
|
55
|
+
if (!(0, fs_1.existsSync)(file)) {
|
|
56
|
+
(0, ui_1.handleError)(new Error(`File not found: ${file}`));
|
|
57
|
+
return Promise.resolve();
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
flags.voices = JSON.parse((0, looseJson_1.readJsonFile)(file));
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
(0, ui_1.handleError)(new Error(`--voices is not valid JSON: ${error.message}`));
|
|
64
|
+
return Promise.resolve();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const verify = flags.voices === undefined ? undefined : async (api, memory, version) => {
|
|
68
|
+
const entries = Object.fromEntries((memory.voice_rotation_config?.voices || []).map((voice, index) => [`voices[${index}]`, voice]));
|
|
69
|
+
await (0, liveVoiceCheck_1.checkVoiceConfigs)(api, entries, async () => (0, liveVoiceCheck_1.savedVoices)(version));
|
|
70
|
+
};
|
|
71
|
+
return run(agentId, opts, flags, (memory) => (0, agentMemory_1.applyRotationFlags)(memory, flags), agentMemory_1.describeRotation, 'Voice rotation', verify);
|
|
72
|
+
}
|
|
@@ -19,6 +19,7 @@ const dashboardApi_1 = require("../lib/dashboardApi");
|
|
|
19
19
|
const ui_1 = require("../lib/ui");
|
|
20
20
|
const versions_1 = require("../lib/versions");
|
|
21
21
|
const agentUpdate_1 = require("../lib/agentUpdate");
|
|
22
|
+
const liveVoiceCheck_1 = require("../lib/liveVoiceCheck");
|
|
22
23
|
const setJson_1 = require("./setJson");
|
|
23
24
|
/** Keys a clone may override. Anything else is rejected before the request. */
|
|
24
25
|
const CLONE_OVERRIDE_KEYS = new Set([
|
|
@@ -60,7 +61,8 @@ async function agentsCloneVersionCommand(agentId, opts) {
|
|
|
60
61
|
if (unsupported.length) {
|
|
61
62
|
return (0, ui_1.handleError)(new Error(`Unsupported clone override(s): ${unsupported.join(', ')}\n allowed: ${[...CLONE_OVERRIDE_KEYS].join(', ')}`));
|
|
62
63
|
}
|
|
63
|
-
overrides = (0, agentUpdate_1.preparePatch)(parsed).patch;
|
|
64
|
+
overrides = (0, agentUpdate_1.preparePatch)(parsed, { skipVoices: true }).patch;
|
|
65
|
+
await (0, liveVoiceCheck_1.checkVoiceConfigs)(api, { voice_configuration: overrides.voice_configuration, secondary_voice_configuration: overrides.secondary_voice_configuration }, sourceVersionId ? async () => (0, liveVoiceCheck_1.savedVoices)((await api.getAgent(agentId, sourceVersionId)).agent) : undefined);
|
|
64
66
|
}
|
|
65
67
|
const res = await api.cloneAgentVersion(agentId, sourceVersionId, {
|
|
66
68
|
...overrides,
|
package/dist/commands/agents.js
CHANGED
|
@@ -53,6 +53,7 @@ const ui_1 = require("../lib/ui");
|
|
|
53
53
|
const versions_1 = require("../lib/versions");
|
|
54
54
|
const agentUpdate_1 = require("../lib/agentUpdate");
|
|
55
55
|
Object.defineProperty(exports, "preparePatch", { enumerable: true, get: function () { return agentUpdate_1.preparePatch; } });
|
|
56
|
+
const liveVoiceCheck_1 = require("../lib/liveVoiceCheck");
|
|
56
57
|
const dashboardAuth_1 = require("../lib/dashboardAuth");
|
|
57
58
|
const agentImport_1 = require("../lib/agentImport");
|
|
58
59
|
const dbFields_1 = require("../lib/dbFields");
|
|
@@ -400,7 +401,7 @@ async function agentsUpdateCommand(agentId, opts) {
|
|
|
400
401
|
const dbf = (0, dbFields_1.dbManagedFields)(obj);
|
|
401
402
|
if (dbf.length)
|
|
402
403
|
throw new Error((0, dbFields_1.dbManagedFieldsError)(dbf, '--set object'));
|
|
403
|
-
const prepared = (0, agentUpdate_1.preparePatch)(obj
|
|
404
|
+
const prepared = (0, agentUpdate_1.preparePatch)(obj, { skipVoices: true });
|
|
404
405
|
patch = prepared.patch;
|
|
405
406
|
versionKeys = prepared.versionKeys;
|
|
406
407
|
wholesaleKeys = prepared.wholesaleKeys;
|
|
@@ -421,37 +422,60 @@ async function agentsUpdateCommand(agentId, opts) {
|
|
|
421
422
|
' Run the command once per version id.'));
|
|
422
423
|
}
|
|
423
424
|
try {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
// version. Wholesale keys are version-level, so --versions is set here.
|
|
430
|
-
// `functions` is a whole array on the backend: merge by name unless the
|
|
431
|
-
// caller asked to replace the list.
|
|
425
|
+
const vid = (0, versions_1.firstVersion)(opts.versions);
|
|
426
|
+
await (0, liveVoiceCheck_1.checkVoiceConfigs)(api, { voice_configuration: patch.voice_configuration, secondary_voice_configuration: patch.secondary_voice_configuration }, vid ? async () => (0, liveVoiceCheck_1.savedVoices)((await api.getAgent(agentId, vid)).agent) : undefined);
|
|
427
|
+
let versionTarget = vid;
|
|
428
|
+
if (opts.commit && !versionTarget)
|
|
429
|
+
versionTarget = (await api.getAgent(agentId)).agent.versionId;
|
|
432
430
|
const mergeFns = Array.isArray(patch.functions) && !opts.replaceFunctions;
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
431
|
+
let current;
|
|
432
|
+
try {
|
|
433
|
+
current = (await api.getAgent(agentId, versionTarget)).agent;
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
if (mergeFns)
|
|
437
|
+
throw error;
|
|
438
|
+
(0, ui_1.info)(' (could not read the current values; sending the patch as given)');
|
|
439
|
+
}
|
|
440
|
+
let effectivePatch = patch;
|
|
441
|
+
if (current) {
|
|
442
|
+
if (wholesaleKeys.length)
|
|
443
|
+
effectivePatch = (0, agentUpdate_1.mergeWholesaleKeys)(effectivePatch, current);
|
|
436
444
|
if (mergeFns)
|
|
437
|
-
|
|
445
|
+
effectivePatch.functions = (0, agentUpdate_1.mergeFunctions)(effectivePatch.functions, current.functions);
|
|
446
|
+
}
|
|
447
|
+
let { send, changed, unchanged } = current
|
|
448
|
+
? (0, agentUpdate_1.planUpdate)(current, effectivePatch)
|
|
449
|
+
: { send: effectivePatch, changed: Object.keys(effectivePatch), unchanged: [] };
|
|
450
|
+
if (opts.dryRun) {
|
|
451
|
+
if (opts.json)
|
|
452
|
+
console.log(JSON.stringify({ dryRun: true, changing: changed, unchanged }, null, 2));
|
|
453
|
+
else {
|
|
454
|
+
(0, ui_1.info)(` would change: ${changed.join(', ') || '(nothing)'}`);
|
|
455
|
+
if (unchanged.length)
|
|
456
|
+
(0, ui_1.info)(` already set, skipped: ${unchanged.join(', ')}`);
|
|
457
|
+
(0, ui_1.info)(' Nothing saved (--dry-run).');
|
|
458
|
+
}
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (!changed.length) {
|
|
462
|
+
(0, ui_1.info)(`Nothing to change: ${unchanged.join(', ')} already hold the requested value(s).`);
|
|
463
|
+
return;
|
|
438
464
|
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
let versionTarget = (0, versions_1.firstVersion)(opts.versions);
|
|
443
|
-
if (opts.commit && !versionTarget) {
|
|
444
|
-
const { agent } = await api.getAgent(agentId);
|
|
445
|
-
versionTarget = agent.versionId;
|
|
465
|
+
if (changed.length > agentUpdate_1.MAX_FIELDS_WITHOUT_YES && !opts.yes) {
|
|
466
|
+
return (0, ui_1.handleError)(new Error(`This would change ${changed.length} fields (${changed.join(', ')}).\n` +
|
|
467
|
+
' Send only the fields you mean to change. If this is intended, re-run with --yes (or preview with --dry-run).'));
|
|
446
468
|
}
|
|
447
|
-
const body = { ...
|
|
469
|
+
const body = { ...send };
|
|
448
470
|
if (opts.commit)
|
|
449
471
|
body.commitMessage = opts.commit;
|
|
450
472
|
const res = await api.updateAgent(agentId, versionTarget, body);
|
|
451
473
|
if (opts.json)
|
|
452
474
|
return void console.log(JSON.stringify(res, null, 2));
|
|
453
475
|
(0, ui_1.success)(res.msg || 'Agent updated.');
|
|
454
|
-
(0, ui_1.info)(` updated: ${
|
|
476
|
+
(0, ui_1.info)(` updated: ${changed.join(', ')}`);
|
|
477
|
+
if (unchanged.length)
|
|
478
|
+
(0, ui_1.info)(` already set, skipped: ${unchanged.join(', ')}`);
|
|
455
479
|
if (opts.commit)
|
|
456
480
|
(0, ui_1.info)(` commit: ${opts.commit}`);
|
|
457
481
|
}
|
|
@@ -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/endpoints.json
CHANGED
|
File without changes
|