@vellumai/assistant 0.3.3 → 0.3.5

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.
Files changed (163) hide show
  1. package/Dockerfile +2 -0
  2. package/README.md +45 -18
  3. package/package.json +1 -1
  4. package/scripts/ipc/generate-swift.ts +13 -0
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +100 -0
  6. package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
  7. package/src/__tests__/approval-message-composer.test.ts +253 -0
  8. package/src/__tests__/call-domain.test.ts +12 -2
  9. package/src/__tests__/call-orchestrator.test.ts +391 -1
  10. package/src/__tests__/call-routes-http.test.ts +27 -2
  11. package/src/__tests__/channel-approval-routes.test.ts +397 -135
  12. package/src/__tests__/channel-approvals.test.ts +99 -3
  13. package/src/__tests__/channel-delivery-store.test.ts +30 -4
  14. package/src/__tests__/channel-guardian.test.ts +261 -22
  15. package/src/__tests__/channel-readiness-service.test.ts +257 -0
  16. package/src/__tests__/config-schema.test.ts +2 -1
  17. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  18. package/src/__tests__/daemon-lifecycle.test.ts +636 -0
  19. package/src/__tests__/dictation-mode-detection.test.ts +63 -0
  20. package/src/__tests__/entity-search.test.ts +615 -0
  21. package/src/__tests__/gateway-only-enforcement.test.ts +19 -13
  22. package/src/__tests__/handlers-twilio-config.test.ts +480 -0
  23. package/src/__tests__/ipc-snapshot.test.ts +63 -0
  24. package/src/__tests__/messaging-send-tool.test.ts +65 -0
  25. package/src/__tests__/run-orchestrator-assistant-events.test.ts +4 -0
  26. package/src/__tests__/run-orchestrator.test.ts +22 -0
  27. package/src/__tests__/secret-scanner.test.ts +223 -0
  28. package/src/__tests__/session-runtime-assembly.test.ts +85 -1
  29. package/src/__tests__/shell-parser-property.test.ts +357 -2
  30. package/src/__tests__/sms-messaging-provider.test.ts +125 -0
  31. package/src/__tests__/system-prompt.test.ts +25 -1
  32. package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
  33. package/src/__tests__/twilio-routes.test.ts +39 -3
  34. package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
  35. package/src/__tests__/user-reference.test.ts +68 -0
  36. package/src/__tests__/web-search.test.ts +1 -1
  37. package/src/__tests__/work-item-output.test.ts +110 -0
  38. package/src/calls/call-domain.ts +8 -5
  39. package/src/calls/call-orchestrator.ts +85 -22
  40. package/src/calls/twilio-config.ts +17 -11
  41. package/src/calls/twilio-rest.ts +276 -0
  42. package/src/calls/twilio-routes.ts +39 -1
  43. package/src/cli/map.ts +6 -0
  44. package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
  45. package/src/commands/cc-command-registry.ts +14 -1
  46. package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
  47. package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
  48. package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
  49. package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
  50. package/src/config/bundled-skills/media-processing/SKILL.md +199 -0
  51. package/src/config/bundled-skills/media-processing/TOOLS.json +320 -0
  52. package/src/config/bundled-skills/media-processing/services/capability-registry.ts +137 -0
  53. package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +280 -0
  54. package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +144 -0
  55. package/src/config/bundled-skills/media-processing/services/feedback-store.ts +136 -0
  56. package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +261 -0
  57. package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +95 -0
  58. package/src/config/bundled-skills/media-processing/services/timeline-service.ts +267 -0
  59. package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +301 -0
  60. package/src/config/bundled-skills/media-processing/tools/detect-events.ts +110 -0
  61. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +190 -0
  62. package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
  63. package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
  64. package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +166 -0
  65. package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
  66. package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +300 -0
  67. package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +235 -0
  68. package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +142 -0
  69. package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +150 -0
  70. package/src/config/bundled-skills/messaging/SKILL.md +24 -5
  71. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  72. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  73. package/src/config/bundled-skills/twitter/SKILL.md +19 -3
  74. package/src/config/defaults.ts +2 -1
  75. package/src/config/schema.ts +9 -3
  76. package/src/config/skills.ts +5 -32
  77. package/src/config/system-prompt.ts +40 -0
  78. package/src/config/templates/IDENTITY.md +2 -2
  79. package/src/config/user-reference.ts +29 -0
  80. package/src/config/vellum-skills/catalog.json +58 -0
  81. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +3 -3
  82. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +3 -3
  83. package/src/config/vellum-skills/sms-setup/SKILL.md +118 -0
  84. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
  85. package/src/config/vellum-skills/twilio-setup/SKILL.md +76 -6
  86. package/src/daemon/auth-manager.ts +103 -0
  87. package/src/daemon/computer-use-session.ts +8 -1
  88. package/src/daemon/config-watcher.ts +253 -0
  89. package/src/daemon/handlers/config.ts +819 -22
  90. package/src/daemon/handlers/dictation.ts +182 -0
  91. package/src/daemon/handlers/identity.ts +14 -23
  92. package/src/daemon/handlers/index.ts +2 -0
  93. package/src/daemon/handlers/sessions.ts +2 -0
  94. package/src/daemon/handlers/shared.ts +3 -0
  95. package/src/daemon/handlers/skills.ts +6 -7
  96. package/src/daemon/handlers/work-items.ts +15 -7
  97. package/src/daemon/ipc-contract-inventory.json +10 -0
  98. package/src/daemon/ipc-contract.ts +114 -4
  99. package/src/daemon/ipc-handler.ts +87 -0
  100. package/src/daemon/lifecycle.ts +18 -4
  101. package/src/daemon/ride-shotgun-handler.ts +11 -1
  102. package/src/daemon/server.ts +111 -504
  103. package/src/daemon/session-agent-loop.ts +10 -15
  104. package/src/daemon/session-runtime-assembly.ts +115 -44
  105. package/src/daemon/session-tool-setup.ts +2 -0
  106. package/src/daemon/session.ts +19 -2
  107. package/src/inbound/public-ingress-urls.ts +3 -3
  108. package/src/memory/channel-guardian-store.ts +2 -1
  109. package/src/memory/db-connection.ts +28 -0
  110. package/src/memory/db-init.ts +1163 -0
  111. package/src/memory/db.ts +2 -2007
  112. package/src/memory/embedding-backend.ts +79 -11
  113. package/src/memory/indexer.ts +2 -0
  114. package/src/memory/job-handlers/media-processing.ts +100 -0
  115. package/src/memory/job-utils.ts +64 -4
  116. package/src/memory/jobs-store.ts +2 -1
  117. package/src/memory/jobs-worker.ts +11 -1
  118. package/src/memory/media-store.ts +759 -0
  119. package/src/memory/recall-cache.ts +107 -0
  120. package/src/memory/retriever.ts +36 -2
  121. package/src/memory/schema-migration.ts +984 -0
  122. package/src/memory/schema.ts +99 -0
  123. package/src/memory/search/entity.ts +208 -25
  124. package/src/memory/search/ranking.ts +6 -1
  125. package/src/memory/search/types.ts +26 -0
  126. package/src/messaging/provider-types.ts +2 -0
  127. package/src/messaging/providers/sms/adapter.ts +204 -0
  128. package/src/messaging/providers/sms/client.ts +93 -0
  129. package/src/messaging/providers/sms/types.ts +7 -0
  130. package/src/permissions/checker.ts +16 -2
  131. package/src/permissions/prompter.ts +14 -3
  132. package/src/permissions/trust-store.ts +7 -0
  133. package/src/runtime/approval-message-composer.ts +143 -0
  134. package/src/runtime/channel-approvals.ts +29 -7
  135. package/src/runtime/channel-guardian-service.ts +44 -18
  136. package/src/runtime/channel-readiness-service.ts +292 -0
  137. package/src/runtime/channel-readiness-types.ts +29 -0
  138. package/src/runtime/gateway-client.ts +2 -1
  139. package/src/runtime/http-server.ts +65 -28
  140. package/src/runtime/http-types.ts +3 -0
  141. package/src/runtime/routes/call-routes.ts +2 -1
  142. package/src/runtime/routes/channel-routes.ts +237 -103
  143. package/src/runtime/routes/run-routes.ts +7 -1
  144. package/src/runtime/run-orchestrator.ts +43 -3
  145. package/src/security/secret-scanner.ts +218 -0
  146. package/src/skills/frontmatter.ts +63 -0
  147. package/src/skills/slash-commands.ts +23 -0
  148. package/src/skills/vellum-catalog-remote.ts +107 -0
  149. package/src/tools/assets/materialize.ts +2 -2
  150. package/src/tools/browser/auto-navigate.ts +132 -24
  151. package/src/tools/browser/browser-manager.ts +67 -61
  152. package/src/tools/calls/call-start.ts +1 -0
  153. package/src/tools/claude-code/claude-code.ts +55 -3
  154. package/src/tools/credentials/vault.ts +1 -1
  155. package/src/tools/execution-target.ts +11 -1
  156. package/src/tools/executor.ts +10 -2
  157. package/src/tools/network/web-search.ts +1 -1
  158. package/src/tools/skills/vellum-catalog.ts +61 -156
  159. package/src/tools/terminal/parser.ts +21 -5
  160. package/src/tools/types.ts +2 -0
  161. package/src/twitter/router.ts +1 -1
  162. package/src/util/platform.ts +43 -1
  163. package/src/util/retry.ts +4 -4
@@ -0,0 +1,68 @@
1
+ import { describe, test, expect, mock, beforeEach } from 'bun:test';
2
+ import { join } from 'node:path';
3
+
4
+ const TEST_DIR = '/tmp/vellum-user-ref-test';
5
+
6
+ mock.module('../util/platform.js', () => ({
7
+ getWorkspacePromptPath: (file: string) => join(TEST_DIR, file),
8
+ }));
9
+
10
+ // Mutable state the tests control
11
+ let mockFileExists = false;
12
+ let mockFileContent = '';
13
+
14
+ mock.module('node:fs', () => ({
15
+ existsSync: (path: string) => {
16
+ if (path === join(TEST_DIR, 'USER.md')) return mockFileExists;
17
+ return false;
18
+ },
19
+ readFileSync: (path: string, _encoding: string) => {
20
+ if (path === join(TEST_DIR, 'USER.md') && mockFileExists) return mockFileContent;
21
+ throw new Error(`ENOENT: no such file: ${path}`);
22
+ },
23
+ }));
24
+
25
+ // Import after mocks are in place
26
+ const { resolveUserReference } = await import('../config/user-reference.js');
27
+
28
+ describe('resolveUserReference', () => {
29
+ beforeEach(() => {
30
+ mockFileExists = false;
31
+ mockFileContent = '';
32
+ });
33
+
34
+ test('returns "my human" when USER.md does not exist', () => {
35
+ mockFileExists = false;
36
+ expect(resolveUserReference()).toBe('my human');
37
+ });
38
+
39
+ test('returns "my human" when preferred name field is empty', () => {
40
+ mockFileExists = true;
41
+ mockFileContent = [
42
+ '## Onboarding Snapshot',
43
+ '',
44
+ '- Preferred name/reference:',
45
+ '- Goals:',
46
+ '- Locale:',
47
+ ].join('\n');
48
+ expect(resolveUserReference()).toBe('my human');
49
+ });
50
+
51
+ test('returns the configured name when it is set', () => {
52
+ mockFileExists = true;
53
+ mockFileContent = [
54
+ '## Onboarding Snapshot',
55
+ '',
56
+ '- Preferred name/reference: John',
57
+ '- Goals: ship fast',
58
+ '- Locale: en-US',
59
+ ].join('\n');
60
+ expect(resolveUserReference()).toBe('John');
61
+ });
62
+
63
+ test('trims whitespace around the configured name', () => {
64
+ mockFileExists = true;
65
+ mockFileContent = '- Preferred name/reference: Alice \n';
66
+ expect(resolveUserReference()).toBe('Alice');
67
+ });
68
+ });
@@ -414,7 +414,7 @@ async function executeWebSearch(
414
414
 
415
415
  if (!apiKey) {
416
416
  return {
417
- content: 'Error: No web search API key configured. Set PERPLEXITY_API_KEY or BRAVE_API_KEY environment variable, or configure a key in settings.',
417
+ content: 'Error: No web search API key configured. Provide a PERPLEXITY_API_KEY or BRAVE_API_KEY here in the chat using the secure credential prompt, or set it from the Settings page.',
418
418
  isError: true,
419
419
  };
420
420
  }
@@ -0,0 +1,110 @@
1
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import type { Database } from 'bun:sqlite';
4
+ import * as net from 'node:net';
5
+ import { join } from 'node:path';
6
+ import { tmpdir } from 'node:os';
7
+
8
+ const testDir = mkdtempSync(join(tmpdir(), 'work-item-output-test-'));
9
+
10
+ mock.module('../util/platform.js', () => ({
11
+ getDataDir: () => testDir,
12
+ isMacOS: () => process.platform === 'darwin',
13
+ isLinux: () => process.platform === 'linux',
14
+ isWindows: () => process.platform === 'win32',
15
+ getSocketPath: () => join(testDir, 'test.sock'),
16
+ getPidPath: () => join(testDir, 'test.pid'),
17
+ getDbPath: () => join(testDir, 'test.db'),
18
+ getLogPath: () => join(testDir, 'test.log'),
19
+ ensureDataDir: () => {},
20
+ migrateToDataLayout: () => {},
21
+ migrateToWorkspaceLayout: () => {},
22
+ }));
23
+
24
+ mock.module('../util/logger.js', () => ({
25
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
26
+ get: () => () => {},
27
+ }),
28
+ }));
29
+
30
+ mock.module('../config/loader.js', () => ({
31
+ getConfig: () => ({ memory: {} }),
32
+ }));
33
+
34
+ mock.module('./indexer.js', () => ({
35
+ indexMessageNow: () => {},
36
+ }));
37
+
38
+ import { addMessage, createConversation } from '../memory/conversation-store.js';
39
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
40
+ import { createTask, createTaskRun, updateTaskRun } from '../tasks/task-store.js';
41
+ import { createWorkItem, updateWorkItem } from '../work-items/work-item-store.js';
42
+ import { handleWorkItemOutput } from '../daemon/handlers/work-items.js';
43
+ import type { HandlerContext } from '../daemon/handlers/shared.js';
44
+
45
+ initializeDb();
46
+
47
+ afterAll(() => {
48
+ resetDb();
49
+ try { rmSync(testDir, { recursive: true, force: true }); } catch { /* best effort */ }
50
+ });
51
+
52
+ function getRawDb(): Database {
53
+ return (getDb() as unknown as { $client: Database }).$client;
54
+ }
55
+
56
+ describe('handleWorkItemOutput', () => {
57
+ beforeEach(() => {
58
+ const raw = getRawDb();
59
+ raw.run('DELETE FROM task_runs');
60
+ raw.run('DELETE FROM work_items');
61
+ raw.run('DELETE FROM tasks');
62
+ raw.run('DELETE FROM messages');
63
+ raw.run('DELETE FROM conversations');
64
+ });
65
+
66
+ test('uses only the latest assistant text block for summary output', () => {
67
+ const task = createTask({
68
+ title: 'Delete weather report',
69
+ template: 'Delete weather_report_task.txt',
70
+ });
71
+ const run = createTaskRun(task.id);
72
+ const item = createWorkItem({
73
+ taskId: task.id,
74
+ title: 'Delete weather_report_task.txt',
75
+ });
76
+ const conversation = createConversation('Task output test');
77
+
78
+ updateTaskRun(run.id, {
79
+ status: 'completed',
80
+ conversationId: conversation.id,
81
+ finishedAt: Date.now(),
82
+ });
83
+ updateWorkItem(item.id, {
84
+ status: 'awaiting_review',
85
+ lastRunId: run.id,
86
+ lastRunConversationId: conversation.id,
87
+ lastRunStatus: 'completed',
88
+ });
89
+
90
+ addMessage(conversation.id, 'assistant', JSON.stringify([
91
+ { type: 'text', text: "I'll need to delete the weather report file from your Documents folder. This will permanently remove it." },
92
+ { type: 'text', text: "Looks like that file has already been deleted — it's no longer there. I'll mark this task as done." },
93
+ { type: 'text', text: 'The file is already deleted, so the task is complete.' },
94
+ ]));
95
+
96
+ const sent: Array<{ type: string; [key: string]: unknown }> = [];
97
+ const socket = {} as net.Socket;
98
+ const ctx = {
99
+ send: (_socket: net.Socket, msg: { type: string; [key: string]: unknown }) => sent.push(msg),
100
+ } as unknown as HandlerContext;
101
+
102
+ handleWorkItemOutput({ type: 'work_item_output', id: item.id }, socket, ctx);
103
+
104
+ expect(sent).toHaveLength(1);
105
+ const response = sent[0];
106
+ expect(response.type).toBe('work_item_output_response');
107
+ expect(response.success).toBe(true);
108
+ expect((response.output as { summary: string }).summary).toBe('The file is already deleted, so the task is complete.');
109
+ });
110
+ });
@@ -51,6 +51,7 @@ export type StartCallInput = {
51
51
  task: string;
52
52
  context?: string;
53
53
  conversationId: string;
54
+ assistantId?: string;
54
55
  callerIdentityMode?: 'assistant_number' | 'user_number';
55
56
  };
56
57
 
@@ -87,7 +88,8 @@ export type CallerIdentityResult =
87
88
  * - If `requestedMode` is provided but overrides are disabled, return an error.
88
89
  * - Otherwise, always use `assistant_number` (implicit default).
89
90
  *
90
- * For `assistant_number`: uses the Twilio phone number from `getTwilioConfig()`.
91
+ * For `assistant_number`: uses the Twilio phone number from
92
+ * `getTwilioConfig(assistantId)` so multi-assistant mappings are honored.
91
93
  * No eligibility check is performed — this is a fast path.
92
94
  * For `user_number`: uses `config.calls.callerIdentity.userNumber` or the
93
95
  * secure key `credential:twilio:user_phone_number`, then validates that the
@@ -96,6 +98,7 @@ export type CallerIdentityResult =
96
98
  export async function resolveCallerIdentity(
97
99
  config: AssistantConfig,
98
100
  requestedMode?: 'assistant_number' | 'user_number',
101
+ assistantId?: string,
99
102
  ): Promise<CallerIdentityResult> {
100
103
  const identityConfig = config.calls.callerIdentity;
101
104
  let mode: 'assistant_number' | 'user_number';
@@ -118,8 +121,8 @@ export async function resolveCallerIdentity(
118
121
  }
119
122
 
120
123
  if (mode === 'assistant_number') {
121
- const twilioConfig = getTwilioConfig();
122
- log.info({ mode, source, fromNumber: twilioConfig.phoneNumber }, 'Resolved caller identity');
124
+ const twilioConfig = getTwilioConfig(assistantId);
125
+ log.info({ mode, source, fromNumber: twilioConfig.phoneNumber, assistantId }, 'Resolved caller identity');
123
126
  return { ok: true, mode, fromNumber: twilioConfig.phoneNumber, source };
124
127
  }
125
128
 
@@ -175,7 +178,7 @@ export async function resolveCallerIdentity(
175
178
  * Initiate a new outbound call.
176
179
  */
177
180
  export async function startCall(input: StartCallInput): Promise<StartCallResult | CallError> {
178
- const { phoneNumber, task, context: callContext, conversationId, callerIdentityMode } = input;
181
+ const { phoneNumber, task, context: callContext, conversationId, callerIdentityMode, assistantId = 'self' } = input;
179
182
 
180
183
  if (!phoneNumber || typeof phoneNumber !== 'string') {
181
184
  return { ok: false, error: 'phone_number is required and must be a string', status: 400 };
@@ -204,7 +207,7 @@ export async function startCall(input: StartCallInput): Promise<StartCallResult
204
207
  const provider = new TwilioConversationRelayProvider();
205
208
 
206
209
  // Resolve which phone number to use as caller ID
207
- const identityResult = await resolveCallerIdentity(ingressConfig, callerIdentityMode);
210
+ const identityResult = await resolveCallerIdentity(ingressConfig, callerIdentityMode, assistantId);
208
211
  if (!identityResult.ok) {
209
212
  return { ok: false, error: identityResult.error, status: 400 };
210
213
  }
@@ -8,6 +8,7 @@
8
8
 
9
9
  import Anthropic from '@anthropic-ai/sdk';
10
10
  import { getConfig } from '../config/loader.js';
11
+ import { resolveUserReference } from '../config/user-reference.js';
11
12
  import { getLogger } from '../util/logger.js';
12
13
  import {
13
14
  getCallSession,
@@ -25,9 +26,21 @@ const log = getLogger('call-orchestrator');
25
26
 
26
27
  type OrchestratorState = 'idle' | 'processing' | 'waiting_on_user' | 'speaking';
27
28
 
28
- const ASK_USER_REGEX = /\[ASK_USER:\s*(.+?)\]/;
29
+ const ASK_USER_CAPTURE_REGEX = /\[ASK_USER:\s*(.+?)\]/;
30
+ const ASK_USER_MARKER_REGEX = /\[ASK_USER:\s*.+?\]/g;
31
+ const USER_ANSWERED_MARKER_REGEX = /\[USER_ANSWERED:\s*.+?\]/g;
32
+ const USER_INSTRUCTION_MARKER_REGEX = /\[USER_INSTRUCTION:\s*.+?\]/g;
33
+ const END_CALL_MARKER_REGEX = /\[END_CALL\]/g;
29
34
  const END_CALL_MARKER = '[END_CALL]';
30
35
 
36
+ function stripInternalSpeechMarkers(text: string): string {
37
+ return text
38
+ .replace(ASK_USER_MARKER_REGEX, '')
39
+ .replace(USER_ANSWERED_MARKER_REGEX, '')
40
+ .replace(USER_INSTRUCTION_MARKER_REGEX, '')
41
+ .replace(END_CALL_MARKER_REGEX, '');
42
+ }
43
+
31
44
  export class CallOrchestrator {
32
45
  private callSessionId: string;
33
46
  private relay: RelayConnection;
@@ -43,6 +56,8 @@ export class CallOrchestrator {
43
56
  private task: string | null;
44
57
  /** Instructions queued while an LLM turn is in-flight or during waiting_on_user */
45
58
  private pendingInstructions: string[] = [];
59
+ /** Monotonic run id used to suppress stale turn side effects after interruption. */
60
+ private llmRunVersion = 0;
46
61
 
47
62
  constructor(callSessionId: string, relay: RelayConnection, task: string | null) {
48
63
  this.callSessionId = callSessionId;
@@ -64,20 +79,30 @@ export class CallOrchestrator {
64
79
  * Handle a final caller utterance from the ConversationRelay.
65
80
  */
66
81
  async handleCallerUtterance(transcript: string, speaker?: PromptSpeakerContext): Promise<void> {
82
+ const interruptedInFlight = this.state === 'processing' || this.state === 'speaking';
67
83
  // If we're already processing or speaking, abort the in-flight generation
68
- if (this.state === 'processing' || this.state === 'speaking') {
84
+ if (interruptedInFlight) {
69
85
  this.abortController.abort();
70
86
  this.abortController = new AbortController();
71
87
  }
72
88
 
73
89
  this.state = 'processing';
74
90
  this.resetSilenceTimer();
75
-
76
- // Append caller utterance
77
- this.conversationHistory.push({
78
- role: 'user',
79
- content: this.formatCallerUtterance(transcript, speaker),
80
- });
91
+ const callerContent = this.formatCallerUtterance(transcript, speaker);
92
+
93
+ // Preserve strict role alternation for Anthropic. If the last message
94
+ // is already user-role (e.g. interrupted run never appended assistant,
95
+ // or a second caller prompt arrives before assistant completion), merge
96
+ // this utterance into that same user turn.
97
+ const lastMessage = this.conversationHistory[this.conversationHistory.length - 1];
98
+ if (lastMessage?.role === 'user') {
99
+ lastMessage.content = `${lastMessage.content}\n${callerContent}`;
100
+ } else {
101
+ this.conversationHistory.push({
102
+ role: 'user',
103
+ content: callerContent,
104
+ });
105
+ }
81
106
 
82
107
  await this.runLlm();
83
108
  }
@@ -168,8 +193,15 @@ export class CallOrchestrator {
168
193
  * Handle caller interrupting the assistant's speech.
169
194
  */
170
195
  handleInterrupt(): void {
196
+ const wasSpeaking = this.state === 'speaking';
171
197
  this.abortController.abort();
172
198
  this.abortController = new AbortController();
199
+ this.llmRunVersion++;
200
+ // Explicitly terminate the in-progress TTS turn so the relay can
201
+ // immediately hand control back to the caller after barge-in.
202
+ if (wasSpeaking) {
203
+ this.relay.sendTextToken('', true);
204
+ }
173
205
  this.state = 'idle';
174
206
  }
175
207
 
@@ -196,13 +228,14 @@ export class CallOrchestrator {
196
228
  : '1. Begin the conversation naturally.';
197
229
 
198
230
  return [
199
- 'You are on a live phone call on behalf of your user.',
231
+ `You are on a live phone call on behalf of ${resolveUserReference()}.`,
200
232
  this.task ? `Task: ${this.task}` : '',
201
233
  '',
202
234
  'You are speaking directly to the person who answered the phone.',
203
235
  'Respond naturally and conversationally — speak as you would in a real phone conversation.',
204
236
  '',
205
237
  'IMPORTANT RULES:',
238
+ '0. When introducing yourself, refer to yourself as an assistant. Avoid the phrase "AI assistant" unless directly asked.',
206
239
  disclosureRule,
207
240
  '2. Be concise — phone conversations should be brief and natural.',
208
241
  '3. If the callee asks something you don\'t know, include [ASK_USER: your question here] in your response along with a hold message like "Let me check on that for you."',
@@ -239,6 +272,8 @@ export class CallOrchestrator {
239
272
  }
240
273
 
241
274
  const client = new Anthropic({ apiKey });
275
+ const runVersion = ++this.llmRunVersion;
276
+ const runSignal = this.abortController.signal;
242
277
 
243
278
  try {
244
279
  this.state = 'speaking';
@@ -255,7 +290,7 @@ export class CallOrchestrator {
255
290
  content: m.content,
256
291
  })),
257
292
  },
258
- { signal: this.abortController.signal },
293
+ { signal: runSignal },
259
294
  );
260
295
 
261
296
  // Buffer incoming tokens so we can strip control markers ([ASK_USER:...], [END_CALL])
@@ -264,6 +299,7 @@ export class CallOrchestrator {
264
299
  let ttsBuffer = '';
265
300
 
266
301
  const flushSafeText = (_force: boolean): void => {
302
+ if (!this.isCurrentRun(runVersion)) return;
267
303
  if (ttsBuffer.length === 0) return;
268
304
  const bracketIdx = ttsBuffer.indexOf('[');
269
305
  if (bracketIdx === -1) {
@@ -290,8 +326,12 @@ export class CallOrchestrator {
290
326
  const afterBracket = ttsBuffer;
291
327
  const couldBeControl =
292
328
  '[ASK_USER:'.startsWith(afterBracket) ||
329
+ '[USER_ANSWERED:'.startsWith(afterBracket) ||
330
+ '[USER_INSTRUCTION:'.startsWith(afterBracket) ||
293
331
  '[END_CALL]'.startsWith(afterBracket) ||
294
332
  afterBracket.startsWith('[ASK_USER:') ||
333
+ afterBracket.startsWith('[USER_ANSWERED:') ||
334
+ afterBracket.startsWith('[USER_INSTRUCTION:') ||
295
335
  afterBracket === '[END_CALL' ||
296
336
  afterBracket.startsWith('[END_CALL]');
297
337
 
@@ -312,23 +352,20 @@ export class CallOrchestrator {
312
352
  };
313
353
 
314
354
  stream.on('text', (text) => {
355
+ if (!this.isCurrentRun(runVersion)) return;
315
356
  ttsBuffer += text;
316
357
 
317
- // If the buffer contains a complete control marker, strip it
318
- if (ASK_USER_REGEX.test(ttsBuffer)) {
319
- ttsBuffer = ttsBuffer.replace(ASK_USER_REGEX, '');
320
- }
321
- if (ttsBuffer.includes(END_CALL_MARKER)) {
322
- ttsBuffer = ttsBuffer.replace(END_CALL_MARKER, '');
323
- }
358
+ // Remove complete control markers before text reaches TTS.
359
+ ttsBuffer = stripInternalSpeechMarkers(ttsBuffer);
324
360
 
325
361
  flushSafeText(false);
326
362
  });
327
363
 
328
364
  const finalMessage = await stream.finalMessage();
365
+ if (!this.isCurrentRun(runVersion)) return;
329
366
 
330
367
  // Final sweep: strip any remaining control markers from the buffer
331
- ttsBuffer = ttsBuffer.replace(ASK_USER_REGEX, '').replace(END_CALL_MARKER, '');
368
+ ttsBuffer = stripInternalSpeechMarkers(ttsBuffer);
332
369
  if (ttsBuffer.length > 0) {
333
370
  this.relay.sendTextToken(ttsBuffer, false);
334
371
  }
@@ -345,7 +382,7 @@ export class CallOrchestrator {
345
382
  // Record the assistant response
346
383
  this.conversationHistory.push({ role: 'assistant', content: responseText });
347
384
  recordCallEvent(this.callSessionId, 'assistant_spoke', { text: responseText });
348
- const spokenText = responseText.replace(ASK_USER_REGEX, '').replace(END_CALL_MARKER, '').trim();
385
+ const spokenText = stripInternalSpeechMarkers(responseText).trim();
349
386
  if (spokenText.length > 0) {
350
387
  const session = getCallSession(this.callSessionId);
351
388
  if (session) {
@@ -354,7 +391,7 @@ export class CallOrchestrator {
354
391
  }
355
392
 
356
393
  // Check for ASK_USER pattern
357
- const askMatch = responseText.match(ASK_USER_REGEX);
394
+ const askMatch = responseText.match(ASK_USER_CAPTURE_REGEX);
358
395
  if (askMatch) {
359
396
  const questionText = askMatch[1];
360
397
  createPendingQuestion(this.callSessionId, questionText);
@@ -412,8 +449,25 @@ export class CallOrchestrator {
412
449
  this.flushPendingInstructions();
413
450
  } catch (err: unknown) {
414
451
  // Aborted requests are expected (interruptions, rapid utterances)
415
- if (err instanceof Error && err.name === 'AbortError') {
416
- log.debug({ callSessionId: this.callSessionId }, 'LLM request aborted');
452
+ if (this.isExpectedAbortError(err) || runSignal.aborted) {
453
+ log.debug(
454
+ {
455
+ callSessionId: this.callSessionId,
456
+ errName: err instanceof Error ? err.name : typeof err,
457
+ stale: !this.isCurrentRun(runVersion),
458
+ },
459
+ 'LLM request aborted',
460
+ );
461
+ if (this.isCurrentRun(runVersion)) {
462
+ this.state = 'idle';
463
+ }
464
+ return;
465
+ }
466
+ if (!this.isCurrentRun(runVersion)) {
467
+ log.debug(
468
+ { callSessionId: this.callSessionId, errName: err instanceof Error ? err.name : typeof err },
469
+ 'Ignoring stale LLM streaming error from superseded turn',
470
+ );
417
471
  return;
418
472
  }
419
473
  log.error({ err, callSessionId: this.callSessionId }, 'LLM streaming error');
@@ -423,6 +477,15 @@ export class CallOrchestrator {
423
477
  }
424
478
  }
425
479
 
480
+ private isExpectedAbortError(err: unknown): boolean {
481
+ if (!(err instanceof Error)) return false;
482
+ return err.name === 'AbortError' || err.name === 'APIUserAbortError';
483
+ }
484
+
485
+ private isCurrentRun(runVersion: number): boolean {
486
+ return runVersion === this.llmRunVersion;
487
+ }
488
+
426
489
  /**
427
490
  * Drain any instructions that were queued while the LLM was active.
428
491
  * Each instruction is appended as a user message (now correctly after
@@ -13,20 +13,26 @@ export interface TwilioConfig {
13
13
  wssBaseUrl: string;
14
14
  }
15
15
 
16
- export function getTwilioConfig(): TwilioConfig {
17
- const accountSid = getSecureKey('credential:twilio:account_sid');
18
- const authToken = getSecureKey('credential:twilio:auth_token');
19
- const config = loadConfig();
16
+ function resolveTwilioPhoneNumber(assistantId: string | undefined, config: ReturnType<typeof loadConfig>): string {
17
+ if (assistantId) {
18
+ const assistantPhone = config.sms?.assistantPhoneNumbers?.[assistantId];
19
+ if (assistantPhone) {
20
+ return assistantPhone;
21
+ }
22
+ }
20
23
 
21
- // Phone number resolution priority:
24
+ // Global fallback order:
22
25
  // 1. TWILIO_PHONE_NUMBER env var (explicit override)
23
26
  // 2. config file sms.phoneNumber (primary storage)
24
27
  // 3. credential:twilio:phone_number secure key (backward-compat fallback)
25
- const phoneNumber =
26
- process.env.TWILIO_PHONE_NUMBER ||
27
- config.sms?.phoneNumber ||
28
- getSecureKey('credential:twilio:phone_number') ||
29
- '';
28
+ return process.env.TWILIO_PHONE_NUMBER || config.sms?.phoneNumber || getSecureKey('credential:twilio:phone_number') || '';
29
+ }
30
+
31
+ export function getTwilioConfig(assistantId?: string): TwilioConfig {
32
+ const accountSid = getSecureKey('credential:twilio:account_sid');
33
+ const authToken = getSecureKey('credential:twilio:auth_token');
34
+ const config = loadConfig();
35
+ const phoneNumber = resolveTwilioPhoneNumber(assistantId, config);
30
36
  const webhookBaseUrl = getPublicBaseUrl(config);
31
37
 
32
38
  // Always use the centralized relay URL derived from the public ingress base URL.
@@ -45,7 +51,7 @@ export function getTwilioConfig(): TwilioConfig {
45
51
  throw new Error('Twilio credentials not configured. Set credential:twilio:account_sid and credential:twilio:auth_token via the credential_store tool.');
46
52
  }
47
53
  if (!phoneNumber) {
48
- throw new Error('TWILIO_PHONE_NUMBER not configured.');
54
+ throw new Error('Twilio phone number not configured.');
49
55
  }
50
56
 
51
57
  log.debug('Twilio config loaded successfully');