vellum 0.2.0 → 0.2.1
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/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +28 -0
- package/src/__tests__/app-bundler.test.ts +12 -33
- package/src/__tests__/browser-skill-endstate.test.ts +1 -5
- package/src/__tests__/call-orchestrator.test.ts +328 -0
- package/src/__tests__/call-state.test.ts +133 -0
- package/src/__tests__/call-store.test.ts +476 -0
- package/src/__tests__/commit-message-enrichment-service.test.ts +409 -0
- package/src/__tests__/config-schema.test.ts +49 -0
- package/src/__tests__/doordash-session.test.ts +9 -0
- package/src/__tests__/ipc-snapshot.test.ts +34 -0
- package/src/__tests__/registry.test.ts +13 -8
- package/src/__tests__/run-orchestrator-assistant-events.test.ts +218 -0
- package/src/__tests__/run-orchestrator.test.ts +3 -3
- package/src/__tests__/runtime-attachment-metadata.test.ts +17 -19
- package/src/__tests__/runtime-runs-http.test.ts +1 -19
- package/src/__tests__/runtime-runs.test.ts +7 -7
- package/src/__tests__/session-queue.test.ts +50 -0
- package/src/__tests__/turn-commit.test.ts +56 -0
- package/src/__tests__/workspace-git-service.test.ts +217 -0
- package/src/__tests__/workspace-heartbeat-service.test.ts +129 -0
- package/src/bundler/app-bundler.ts +29 -12
- package/src/calls/call-constants.ts +10 -0
- package/src/calls/call-orchestrator.ts +364 -0
- package/src/calls/call-state.ts +64 -0
- package/src/calls/call-store.ts +229 -0
- package/src/calls/relay-server.ts +298 -0
- package/src/calls/twilio-config.ts +34 -0
- package/src/calls/twilio-provider.ts +169 -0
- package/src/calls/twilio-routes.ts +236 -0
- package/src/calls/types.ts +37 -0
- package/src/calls/voice-provider.ts +14 -0
- package/src/cli/doordash.ts +5 -24
- package/src/config/bundled-skills/doordash/SKILL.md +104 -0
- package/src/config/bundled-skills/image-studio/TOOLS.json +2 -2
- package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +1 -1
- package/src/config/defaults.ts +11 -0
- package/src/config/schema.ts +57 -0
- package/src/config/system-prompt.ts +50 -1
- package/src/config/types.ts +1 -0
- package/src/daemon/handlers/config.ts +30 -0
- package/src/daemon/handlers/index.ts +6 -0
- package/src/daemon/handlers/work-items.ts +142 -2
- package/src/daemon/ipc-contract-inventory.json +12 -0
- package/src/daemon/ipc-contract.ts +52 -0
- package/src/daemon/lifecycle.ts +27 -5
- package/src/daemon/server.ts +10 -12
- package/src/daemon/session-tool-setup.ts +6 -0
- package/src/daemon/session.ts +40 -1
- package/src/index.ts +2 -0
- package/src/media/gemini-image-service.ts +1 -1
- package/src/memory/db.ts +266 -0
- package/src/memory/schema.ts +42 -0
- package/src/runtime/http-server.ts +189 -25
- package/src/runtime/http-types.ts +0 -2
- package/src/runtime/routes/attachment-routes.ts +6 -6
- package/src/runtime/routes/channel-routes.ts +16 -18
- package/src/runtime/routes/conversation-routes.ts +5 -9
- package/src/runtime/routes/run-routes.ts +4 -8
- package/src/runtime/run-orchestrator.ts +32 -5
- package/src/tools/calls/call-end.ts +117 -0
- package/src/tools/calls/call-start.ts +134 -0
- package/src/tools/calls/call-status.ts +97 -0
- package/src/tools/credentials/vault.ts +1 -1
- package/src/tools/registry.ts +2 -4
- package/src/tools/tasks/index.ts +2 -0
- package/src/tools/tasks/task-delete.ts +49 -8
- package/src/tools/tasks/task-run.ts +9 -1
- package/src/tools/tasks/work-item-enqueue.ts +93 -3
- package/src/tools/tasks/work-item-list.ts +10 -25
- package/src/tools/tasks/work-item-remove.ts +112 -0
- package/src/tools/tasks/work-item-update.ts +186 -0
- package/src/tools/tool-manifest.ts +39 -31
- package/src/tools/ui-surface/definitions.ts +3 -0
- package/src/work-items/work-item-store.ts +209 -0
- package/src/workspace/commit-message-enrichment-service.ts +260 -0
- package/src/workspace/commit-message-provider.ts +95 -0
- package/src/workspace/git-service.ts +187 -32
- package/src/workspace/heartbeat-service.ts +70 -13
- package/src/workspace/turn-commit.ts +39 -49
|
@@ -11,7 +11,6 @@ import type { RunOrchestrator } from '../run-orchestrator.js';
|
|
|
11
11
|
const log = getLogger('runtime-http');
|
|
12
12
|
|
|
13
13
|
export async function handleCreateRun(
|
|
14
|
-
assistantId: string,
|
|
15
14
|
req: Request,
|
|
16
15
|
runOrchestrator: RunOrchestrator,
|
|
17
16
|
): Promise<Response> {
|
|
@@ -39,7 +38,7 @@ export async function handleCreateRun(
|
|
|
39
38
|
}
|
|
40
39
|
|
|
41
40
|
if (hasAttachments) {
|
|
42
|
-
const resolved = attachmentsStore.getAttachmentsByIds(
|
|
41
|
+
const resolved = attachmentsStore.getAttachmentsByIds("self", attachmentIds);
|
|
43
42
|
if (resolved.length !== attachmentIds.length) {
|
|
44
43
|
const resolvedIds = new Set(resolved.map((a) => a.id));
|
|
45
44
|
const missing = attachmentIds.filter((id) => !resolvedIds.has(id));
|
|
@@ -50,11 +49,10 @@ export async function handleCreateRun(
|
|
|
50
49
|
}
|
|
51
50
|
}
|
|
52
51
|
|
|
53
|
-
const mapping = getOrCreateConversation(
|
|
52
|
+
const mapping = getOrCreateConversation("self", conversationKey);
|
|
54
53
|
|
|
55
54
|
try {
|
|
56
55
|
const run = await runOrchestrator.startRun(
|
|
57
|
-
assistantId,
|
|
58
56
|
mapping.conversationId,
|
|
59
57
|
content ?? '',
|
|
60
58
|
hasAttachments ? attachmentIds : undefined,
|
|
@@ -77,12 +75,11 @@ export async function handleCreateRun(
|
|
|
77
75
|
}
|
|
78
76
|
|
|
79
77
|
export function handleGetRun(
|
|
80
|
-
assistantId: string,
|
|
81
78
|
runId: string,
|
|
82
79
|
runOrchestrator: RunOrchestrator,
|
|
83
80
|
): Response {
|
|
84
81
|
const run = runOrchestrator.getRun(runId);
|
|
85
|
-
if (!run
|
|
82
|
+
if (!run) {
|
|
86
83
|
return Response.json({ error: 'Run not found' }, { status: 404 });
|
|
87
84
|
}
|
|
88
85
|
|
|
@@ -98,13 +95,12 @@ export function handleGetRun(
|
|
|
98
95
|
}
|
|
99
96
|
|
|
100
97
|
export async function handleRunDecision(
|
|
101
|
-
assistantId: string,
|
|
102
98
|
runId: string,
|
|
103
99
|
req: Request,
|
|
104
100
|
runOrchestrator: RunOrchestrator,
|
|
105
101
|
): Promise<Response> {
|
|
106
102
|
const run = runOrchestrator.getRun(runId);
|
|
107
|
-
if (!run
|
|
103
|
+
if (!run) {
|
|
108
104
|
return Response.json({ error: 'Run not found' }, { status: 404 });
|
|
109
105
|
}
|
|
110
106
|
|
|
@@ -18,6 +18,8 @@ import type { UserDecision } from '../permissions/types.js';
|
|
|
18
18
|
import { checkIngressForSecrets } from '../security/secret-ingress.js';
|
|
19
19
|
import { IngressBlockedError } from '../util/errors.js';
|
|
20
20
|
import { getLogger } from '../util/logger.js';
|
|
21
|
+
import { assistantEventHub } from './assistant-event-hub.js';
|
|
22
|
+
import { buildAssistantEvent } from './assistant-event.js';
|
|
21
23
|
|
|
22
24
|
const log = getLogger('run-orchestrator');
|
|
23
25
|
|
|
@@ -32,7 +34,7 @@ interface PendingRunState {
|
|
|
32
34
|
|
|
33
35
|
export interface RunOrchestratorDeps {
|
|
34
36
|
getOrCreateSession: (conversationId: string) => Promise<Session>;
|
|
35
|
-
resolveAttachments: (
|
|
37
|
+
resolveAttachments: (attachmentIds: string[]) => Array<{
|
|
36
38
|
id: string;
|
|
37
39
|
filename: string;
|
|
38
40
|
mimeType: string;
|
|
@@ -64,7 +66,6 @@ export class RunOrchestrator {
|
|
|
64
66
|
* and fire the agent loop in the background.
|
|
65
67
|
*/
|
|
66
68
|
async startRun(
|
|
67
|
-
assistantId: string,
|
|
68
69
|
conversationId: string,
|
|
69
70
|
content: string,
|
|
70
71
|
attachmentIds?: string[],
|
|
@@ -82,15 +83,32 @@ export class RunOrchestrator {
|
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
const attachments = attachmentIds
|
|
85
|
-
? this.deps.resolveAttachments(
|
|
86
|
+
? this.deps.resolveAttachments(attachmentIds)
|
|
86
87
|
: [];
|
|
87
88
|
|
|
88
89
|
const requestId = crypto.randomUUID();
|
|
89
90
|
const messageId = session.persistUserMessage(content, attachments, requestId);
|
|
90
|
-
const run = runsStore.createRun(
|
|
91
|
+
const run = runsStore.createRun('self', conversationId, messageId);
|
|
91
92
|
|
|
92
93
|
// Set the assistant ID so attachments are scoped correctly.
|
|
93
|
-
session.setAssistantId(
|
|
94
|
+
session.setAssistantId('self');
|
|
95
|
+
|
|
96
|
+
// Serialized publish chain so hub subscribers observe events in order.
|
|
97
|
+
let hubChain: Promise<void> = Promise.resolve();
|
|
98
|
+
const publishToHub = (msg: ServerMessage): void => {
|
|
99
|
+
const msgRecord = msg as unknown as Record<string, unknown>;
|
|
100
|
+
const msgSessionId =
|
|
101
|
+
'sessionId' in msg && typeof msgRecord.sessionId === 'string'
|
|
102
|
+
? (msgRecord.sessionId as string)
|
|
103
|
+
: undefined;
|
|
104
|
+
const resolvedSessionId = msgSessionId ?? conversationId;
|
|
105
|
+
const event = buildAssistantEvent('self', msg, resolvedSessionId);
|
|
106
|
+
hubChain = hubChain
|
|
107
|
+
.then(() => assistantEventHub.publish(event))
|
|
108
|
+
.catch((err: unknown) => {
|
|
109
|
+
log.warn({ err }, 'assistant-events hub subscriber threw during HTTP run');
|
|
110
|
+
});
|
|
111
|
+
};
|
|
94
112
|
|
|
95
113
|
// Hook into session to intercept confirmation_request events.
|
|
96
114
|
// When the prompter sends a confirmation_request, we record it in the
|
|
@@ -118,6 +136,9 @@ export class RunOrchestrator {
|
|
|
118
136
|
session,
|
|
119
137
|
});
|
|
120
138
|
}
|
|
139
|
+
// Mirror every outbound message to the assistant-events hub so SSE
|
|
140
|
+
// subscribers receive the same payload parity as IPC clients.
|
|
141
|
+
publishToHub(msg);
|
|
121
142
|
});
|
|
122
143
|
|
|
123
144
|
// Fire-and-forget the agent loop
|
|
@@ -136,6 +157,12 @@ export class RunOrchestrator {
|
|
|
136
157
|
} else if (msg.type === 'session_error') {
|
|
137
158
|
lastError = msg.userMessage;
|
|
138
159
|
}
|
|
160
|
+
// Mirror agent-loop events (assistant_text_delta, message_complete,
|
|
161
|
+
// tool_use_start, tool_result, etc.) to the hub. These travel through
|
|
162
|
+
// the onEvent path, distinct from the updateClient path used by the
|
|
163
|
+
// prompter (confirmation_request). Both paths must publish so SSE
|
|
164
|
+
// consumers receive the full response stream.
|
|
165
|
+
publishToHub(msg);
|
|
139
166
|
}).then(() => {
|
|
140
167
|
if (lastError) {
|
|
141
168
|
log.error({ runId: run.id, error: lastError }, 'Run failed (error event from agent loop)');
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { RiskLevel } from '../../permissions/types.js';
|
|
2
|
+
import type { Tool, ToolContext, ToolExecutionResult } from '../types.js';
|
|
3
|
+
import type { ToolDefinition } from '../../providers/types.js';
|
|
4
|
+
import { registerTool } from '../registry.js';
|
|
5
|
+
import { getCallSession, updateCallSession } from '../../calls/call-store.js';
|
|
6
|
+
import { getCallOrchestrator, unregisterCallOrchestrator } from '../../calls/call-state.js';
|
|
7
|
+
import { activeRelayConnections } from '../../calls/relay-server.js';
|
|
8
|
+
import { TwilioConversationRelayProvider } from '../../calls/twilio-provider.js';
|
|
9
|
+
import { getLogger } from '../../util/logger.js';
|
|
10
|
+
|
|
11
|
+
const log = getLogger('call-end');
|
|
12
|
+
|
|
13
|
+
const definition: ToolDefinition = {
|
|
14
|
+
name: 'call_end',
|
|
15
|
+
description: 'End an active phone call',
|
|
16
|
+
input_schema: {
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: {
|
|
19
|
+
call_session_id: {
|
|
20
|
+
type: 'string',
|
|
21
|
+
description: 'The call session ID to end',
|
|
22
|
+
},
|
|
23
|
+
reason: {
|
|
24
|
+
type: 'string',
|
|
25
|
+
description: 'Reason for ending the call',
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
required: ['call_session_id'],
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
class CallEndTool implements Tool {
|
|
33
|
+
name = 'call_end';
|
|
34
|
+
description = definition.description;
|
|
35
|
+
category = 'communication';
|
|
36
|
+
defaultRiskLevel = RiskLevel.Medium;
|
|
37
|
+
|
|
38
|
+
getDefinition(): ToolDefinition {
|
|
39
|
+
return definition;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async execute(input: Record<string, unknown>, _context: ToolContext): Promise<ToolExecutionResult> {
|
|
43
|
+
const callSessionId = input.call_session_id as string | undefined;
|
|
44
|
+
if (!callSessionId || typeof callSessionId !== 'string') {
|
|
45
|
+
return { content: 'Error: call_session_id is required and must be a string', isError: true };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const reason = input.reason as string | undefined;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const session = getCallSession(callSessionId);
|
|
52
|
+
if (!session) {
|
|
53
|
+
return { content: `Error: no call session found with ID ${callSessionId}`, isError: true };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (session.status === 'completed' || session.status === 'failed') {
|
|
57
|
+
return {
|
|
58
|
+
content: `Call session ${callSessionId} has already ended with status: ${session.status}`,
|
|
59
|
+
isError: false,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
log.info({ callSessionId, reason }, 'Ending call');
|
|
64
|
+
|
|
65
|
+
// Terminate the call via the provider API so Twilio hangs up,
|
|
66
|
+
// even if the relay WebSocket is not connected.
|
|
67
|
+
if (session.providerCallSid) {
|
|
68
|
+
try {
|
|
69
|
+
const provider = new TwilioConversationRelayProvider();
|
|
70
|
+
await provider.endCall(session.providerCallSid);
|
|
71
|
+
} catch (endErr) {
|
|
72
|
+
log.warn({ err: endErr, callSessionId, callSid: session.providerCallSid }, 'Failed to terminate call via provider API — proceeding with cleanup');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// End the relay connection if active
|
|
77
|
+
const relayConnection = activeRelayConnections.get(callSessionId);
|
|
78
|
+
if (relayConnection) {
|
|
79
|
+
relayConnection.endSession(reason);
|
|
80
|
+
relayConnection.destroy();
|
|
81
|
+
activeRelayConnections.delete(callSessionId);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Clean up orchestrator
|
|
85
|
+
const orchestrator = getCallOrchestrator(callSessionId);
|
|
86
|
+
if (orchestrator) {
|
|
87
|
+
orchestrator.destroy();
|
|
88
|
+
unregisterCallOrchestrator(callSessionId);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Update session status
|
|
92
|
+
updateCallSession(callSessionId, {
|
|
93
|
+
status: 'completed',
|
|
94
|
+
endedAt: Date.now(),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
log.info({ callSessionId }, 'Call ended successfully');
|
|
98
|
+
|
|
99
|
+
const lines = [
|
|
100
|
+
'Call ended successfully.',
|
|
101
|
+
` Call Session ID: ${callSessionId}`,
|
|
102
|
+
` Status: completed`,
|
|
103
|
+
];
|
|
104
|
+
if (reason) {
|
|
105
|
+
lines.push(` Reason: ${reason}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { content: lines.join('\n'), isError: false };
|
|
109
|
+
} catch (err) {
|
|
110
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
111
|
+
log.error({ err, callSessionId }, 'Failed to end call');
|
|
112
|
+
return { content: `Error ending call: ${msg}`, isError: true };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
registerTool(new CallEndTool());
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { RiskLevel } from '../../permissions/types.js';
|
|
2
|
+
import type { Tool, ToolContext, ToolExecutionResult } from '../types.js';
|
|
3
|
+
import type { ToolDefinition } from '../../providers/types.js';
|
|
4
|
+
import { registerTool } from '../registry.js';
|
|
5
|
+
import { DENIED_NUMBERS } from '../../calls/call-constants.js';
|
|
6
|
+
import { createCallSession, updateCallSession } from '../../calls/call-store.js';
|
|
7
|
+
import { TwilioConversationRelayProvider } from '../../calls/twilio-provider.js';
|
|
8
|
+
import { getTwilioConfig } from '../../calls/twilio-config.js';
|
|
9
|
+
import { getLogger } from '../../util/logger.js';
|
|
10
|
+
|
|
11
|
+
const log = getLogger('call-start');
|
|
12
|
+
|
|
13
|
+
const E164_REGEX = /^\+\d+$/;
|
|
14
|
+
|
|
15
|
+
const definition: ToolDefinition = {
|
|
16
|
+
name: 'call_start',
|
|
17
|
+
description:
|
|
18
|
+
'Place an outbound phone call via AI voice. The assistant will converse with the callee on behalf of the user.',
|
|
19
|
+
input_schema: {
|
|
20
|
+
type: 'object',
|
|
21
|
+
properties: {
|
|
22
|
+
phone_number: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
description: 'E.164 formatted phone number (e.g. +14155551234)',
|
|
25
|
+
},
|
|
26
|
+
task: {
|
|
27
|
+
type: 'string',
|
|
28
|
+
description: 'What the call should accomplish',
|
|
29
|
+
},
|
|
30
|
+
context: {
|
|
31
|
+
type: 'string',
|
|
32
|
+
description: 'Additional context for the conversation',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
required: ['phone_number', 'task'],
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
class CallStartTool implements Tool {
|
|
40
|
+
name = 'call_start';
|
|
41
|
+
description = definition.description;
|
|
42
|
+
category = 'communication';
|
|
43
|
+
defaultRiskLevel = RiskLevel.High;
|
|
44
|
+
|
|
45
|
+
getDefinition(): ToolDefinition {
|
|
46
|
+
return definition;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async execute(input: Record<string, unknown>, context: ToolContext): Promise<ToolExecutionResult> {
|
|
50
|
+
const phoneNumber = input.phone_number as string | undefined;
|
|
51
|
+
if (!phoneNumber || typeof phoneNumber !== 'string') {
|
|
52
|
+
return { content: 'Error: phone_number is required and must be a string', isError: true };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!E164_REGEX.test(phoneNumber)) {
|
|
56
|
+
return {
|
|
57
|
+
content: 'Error: phone_number must be in E.164 format (starts with + followed by digits, e.g. +14155551234)',
|
|
58
|
+
isError: true,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const task = input.task as string | undefined;
|
|
63
|
+
if (!task || typeof task !== 'string' || task.trim().length === 0) {
|
|
64
|
+
return { content: 'Error: task is required and must be a non-empty string', isError: true };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (DENIED_NUMBERS.has(phoneNumber)) {
|
|
68
|
+
return { content: 'Error: this phone number is not allowed to be called', isError: true };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const callContext = input.context as string | undefined;
|
|
72
|
+
|
|
73
|
+
// Create session outside the try block so it's available in the catch block
|
|
74
|
+
// for marking as failed if the provider call fails.
|
|
75
|
+
let sessionId: string | null = null;
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const config = getTwilioConfig();
|
|
79
|
+
const provider = new TwilioConversationRelayProvider();
|
|
80
|
+
|
|
81
|
+
const session = createCallSession({
|
|
82
|
+
conversationId: context.conversationId,
|
|
83
|
+
provider: 'twilio',
|
|
84
|
+
fromNumber: config.phoneNumber,
|
|
85
|
+
toNumber: phoneNumber,
|
|
86
|
+
task: callContext ? `${task}\n\nContext: ${callContext}` : task,
|
|
87
|
+
});
|
|
88
|
+
sessionId = session.id;
|
|
89
|
+
|
|
90
|
+
log.info({ callSessionId: session.id, to: phoneNumber, task }, 'Initiating outbound call');
|
|
91
|
+
|
|
92
|
+
const baseUrl = config.webhookBaseUrl.replace(/\/$/, '');
|
|
93
|
+
const { callSid } = await provider.initiateCall({
|
|
94
|
+
from: config.phoneNumber,
|
|
95
|
+
to: phoneNumber,
|
|
96
|
+
webhookUrl: `${baseUrl}/v1/calls/twilio/voice-webhook?callSessionId=${session.id}`,
|
|
97
|
+
statusCallbackUrl: `${baseUrl}/v1/calls/twilio/status`,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
updateCallSession(session.id, { providerCallSid: callSid });
|
|
101
|
+
|
|
102
|
+
log.info({ callSessionId: session.id, callSid }, 'Call initiated successfully');
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
content: [
|
|
106
|
+
'Call initiated successfully.',
|
|
107
|
+
` Call Session ID: ${session.id}`,
|
|
108
|
+
` Call SID: ${callSid}`,
|
|
109
|
+
` To: ${phoneNumber}`,
|
|
110
|
+
` Status: initiated`,
|
|
111
|
+
'',
|
|
112
|
+
'The AI voice assistant is now placing the call. Use call_status to check progress.',
|
|
113
|
+
].join('\n'),
|
|
114
|
+
isError: false,
|
|
115
|
+
};
|
|
116
|
+
} catch (err) {
|
|
117
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
118
|
+
log.error({ err, phoneNumber }, 'Failed to initiate call');
|
|
119
|
+
|
|
120
|
+
// Mark the session as failed so it doesn't stay in 'initiated' state
|
|
121
|
+
if (sessionId) {
|
|
122
|
+
updateCallSession(sessionId, {
|
|
123
|
+
status: 'failed',
|
|
124
|
+
endedAt: Date.now(),
|
|
125
|
+
lastError: msg,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { content: `Error initiating call: ${msg}`, isError: true };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
registerTool(new CallStartTool());
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { RiskLevel } from '../../permissions/types.js';
|
|
2
|
+
import type { Tool, ToolContext, ToolExecutionResult } from '../types.js';
|
|
3
|
+
import type { ToolDefinition } from '../../providers/types.js';
|
|
4
|
+
import { registerTool } from '../registry.js';
|
|
5
|
+
import { getCallSession, getActiveCallSessionForConversation, getPendingQuestion } from '../../calls/call-store.js';
|
|
6
|
+
import { getLogger } from '../../util/logger.js';
|
|
7
|
+
|
|
8
|
+
const log = getLogger('call-status');
|
|
9
|
+
|
|
10
|
+
const definition: ToolDefinition = {
|
|
11
|
+
name: 'call_status',
|
|
12
|
+
description: 'Check the status of an active or recent phone call',
|
|
13
|
+
input_schema: {
|
|
14
|
+
type: 'object',
|
|
15
|
+
properties: {
|
|
16
|
+
call_session_id: {
|
|
17
|
+
type: 'string',
|
|
18
|
+
description: 'Specific call session ID to check. If omitted, checks for an active call in the current conversation.',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
required: [],
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
class CallStatusTool implements Tool {
|
|
26
|
+
name = 'call_status';
|
|
27
|
+
description = definition.description;
|
|
28
|
+
category = 'communication';
|
|
29
|
+
defaultRiskLevel = RiskLevel.Low;
|
|
30
|
+
|
|
31
|
+
getDefinition(): ToolDefinition {
|
|
32
|
+
return definition;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async execute(input: Record<string, unknown>, context: ToolContext): Promise<ToolExecutionResult> {
|
|
36
|
+
const callSessionId = input.call_session_id as string | undefined;
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
let session;
|
|
40
|
+
|
|
41
|
+
if (callSessionId) {
|
|
42
|
+
session = getCallSession(callSessionId);
|
|
43
|
+
if (!session) {
|
|
44
|
+
return { content: `Error: no call session found with ID ${callSessionId}`, isError: true };
|
|
45
|
+
}
|
|
46
|
+
} else {
|
|
47
|
+
session = getActiveCallSessionForConversation(context.conversationId);
|
|
48
|
+
if (!session) {
|
|
49
|
+
return { content: 'No active call found in the current conversation.', isError: false };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
log.info({ callSessionId: session.id, status: session.status }, 'Checking call status');
|
|
54
|
+
|
|
55
|
+
const lines = [
|
|
56
|
+
`Call Session: ${session.id}`,
|
|
57
|
+
` Status: ${session.status}`,
|
|
58
|
+
` To: ${session.toNumber}`,
|
|
59
|
+
` From: ${session.fromNumber}`,
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
if (session.providerCallSid) {
|
|
63
|
+
lines.push(` Call SID: ${session.providerCallSid}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (session.task) {
|
|
67
|
+
lines.push(` Task: ${session.task}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (session.startedAt) {
|
|
71
|
+
const durationMs = (session.endedAt ?? Date.now()) - session.startedAt;
|
|
72
|
+
const durationSec = Math.round(durationMs / 1000);
|
|
73
|
+
lines.push(` Duration: ${durationSec}s`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (session.lastError) {
|
|
77
|
+
lines.push(` Last Error: ${session.lastError}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Check for pending questions from the call
|
|
81
|
+
const pendingQuestion = getPendingQuestion(session.id);
|
|
82
|
+
if (pendingQuestion) {
|
|
83
|
+
lines.push('');
|
|
84
|
+
lines.push(` Pending Question: ${pendingQuestion.questionText}`);
|
|
85
|
+
lines.push(` Question ID: ${pendingQuestion.id}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { content: lines.join('\n'), isError: false };
|
|
89
|
+
} catch (err) {
|
|
90
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
91
|
+
log.error({ err, callSessionId }, 'Failed to check call status');
|
|
92
|
+
return { content: `Error checking call status: ${msg}`, isError: true };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
registerTool(new CallStatusTool());
|
|
@@ -113,7 +113,7 @@ class CredentialStoreTool implements Tool {
|
|
|
113
113
|
name = 'credential_store';
|
|
114
114
|
description = 'Store, list, delete, or prompt for credentials in the secure vault';
|
|
115
115
|
category = 'credentials';
|
|
116
|
-
defaultRiskLevel = RiskLevel.
|
|
116
|
+
defaultRiskLevel = RiskLevel.Low;
|
|
117
117
|
|
|
118
118
|
getDefinition(): ToolDefinition {
|
|
119
119
|
return {
|
package/src/tools/registry.ts
CHANGED
|
@@ -198,7 +198,7 @@ export function getAllToolDefinitions(): ToolDefinition[] {
|
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
export async function initializeTools(): Promise<void> {
|
|
201
|
-
const {
|
|
201
|
+
const { loadEagerModules, eagerModuleToolNames, explicitTools, lazyTools } = await import('./tool-manifest.js');
|
|
202
202
|
|
|
203
203
|
// Capture tool names already in the registry before any manifest
|
|
204
204
|
// registrations. In production this is empty; in tests a non-skill tool
|
|
@@ -206,9 +206,7 @@ export async function initializeTools(): Promise<void> {
|
|
|
206
206
|
const preExisting = new Set(tools.keys());
|
|
207
207
|
|
|
208
208
|
// Import tool modules to trigger registration side effects.
|
|
209
|
-
|
|
210
|
-
await import(modulePath);
|
|
211
|
-
}
|
|
209
|
+
await loadEagerModules();
|
|
212
210
|
|
|
213
211
|
// Explicit tool instances — no side-effect import required.
|
|
214
212
|
for (const tool of explicitTools) {
|
package/src/tools/tasks/index.ts
CHANGED
|
@@ -23,3 +23,5 @@ export { taskListTool } from './task-list.js';
|
|
|
23
23
|
export { taskDeleteTool } from './task-delete.js';
|
|
24
24
|
export { taskListShowTool } from './work-item-list.js';
|
|
25
25
|
export { taskListAddTool } from './work-item-enqueue.js';
|
|
26
|
+
export { taskListUpdateTool } from './work-item-update.js';
|
|
27
|
+
export { taskListRemoveTool } from './work-item-remove.js';
|
|
@@ -2,6 +2,10 @@ import { RiskLevel } from '../../permissions/types.js';
|
|
|
2
2
|
import type { Tool, ToolContext, ToolExecutionResult } from '../types.js';
|
|
3
3
|
import type { ToolDefinition } from '../../providers/types.js';
|
|
4
4
|
import { deleteTask, deleteTasks, getTask } from '../../tasks/task-store.js';
|
|
5
|
+
import { removeWorkItemFromQueue } from '../../work-items/work-item-store.js';
|
|
6
|
+
import { getLogger } from '../../util/logger.js';
|
|
7
|
+
|
|
8
|
+
const log = getLogger('task-delete');
|
|
5
9
|
|
|
6
10
|
const definition: ToolDefinition = {
|
|
7
11
|
name: 'task_delete',
|
|
@@ -44,23 +48,60 @@ class TaskDeleteTool implements Tool {
|
|
|
44
48
|
const task = getTask(ids[0]);
|
|
45
49
|
const deleted = deleteTask(ids[0]);
|
|
46
50
|
if (!deleted) {
|
|
47
|
-
|
|
51
|
+
// The LLM may pass a work item ID instead of a task template ID.
|
|
52
|
+
// Fall back to removing from the task queue so the user's intent succeeds.
|
|
53
|
+
const result = removeWorkItemFromQueue(ids[0]);
|
|
54
|
+
if (result.success) {
|
|
55
|
+
log.info({ inputId: ids[0], fallback: true, deletedCount: 1 }, 'deleted via work item fallback');
|
|
56
|
+
return { content: result.message, isError: false };
|
|
57
|
+
}
|
|
58
|
+
log.warn({ inputId: ids[0] }, 'no task or work item found for deletion');
|
|
59
|
+
return { content: `No task template or work item found with ID "${ids[0]}". Use task_list to see task templates or task_list_show to see work items in the queue.`, isError: true };
|
|
48
60
|
}
|
|
61
|
+
log.info({ taskId: ids[0], title: task?.title, deletedCount: 1 }, 'task deleted');
|
|
49
62
|
return { content: `Deleted task: ${task?.title ?? ids[0]}`, isError: false };
|
|
50
63
|
}
|
|
51
64
|
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
65
|
+
const taskIds: string[] = [];
|
|
66
|
+
const taskTitles: string[] = [];
|
|
67
|
+
const workItemTitles: string[] = [];
|
|
68
|
+
|
|
69
|
+
for (const id of ids) {
|
|
70
|
+
const task = getTask(id);
|
|
71
|
+
if (task) {
|
|
72
|
+
taskIds.push(id);
|
|
73
|
+
taskTitles.push(task.title);
|
|
74
|
+
} else {
|
|
75
|
+
const result = removeWorkItemFromQueue(id);
|
|
76
|
+
if (result.success) {
|
|
77
|
+
log.info({ inputId: id, fallback: true }, 'deleted work item in batch (fallback)');
|
|
78
|
+
workItemTitles.push(result.title);
|
|
79
|
+
} else {
|
|
80
|
+
log.warn({ inputId: id }, 'batch delete: no task or work item found');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const taskCount = taskIds.length > 0 ? deleteTasks(taskIds) : 0;
|
|
86
|
+
|
|
87
|
+
if (taskCount === 0 && workItemTitles.length === 0) {
|
|
88
|
+
log.warn({ inputIds: ids }, 'no matching tasks found to delete');
|
|
58
89
|
return { content: 'No matching tasks found to delete.', isError: true };
|
|
59
90
|
}
|
|
60
|
-
|
|
91
|
+
|
|
92
|
+
log.info({ deletedTasks: taskCount, deletedWorkItems: workItemTitles.length, totalInput: ids.length }, 'batch delete completed');
|
|
93
|
+
|
|
94
|
+
const lines: string[] = [];
|
|
95
|
+
if (taskCount > 0) {
|
|
96
|
+
lines.push(`Deleted ${taskCount} task(s):`, ...taskTitles.map((t) => `- ${t}`));
|
|
97
|
+
}
|
|
98
|
+
if (workItemTitles.length > 0) {
|
|
99
|
+
lines.push(`Removed ${workItemTitles.length} item(s) from the task queue:`, ...workItemTitles.map((t) => `- ${t}`));
|
|
100
|
+
}
|
|
61
101
|
return { content: lines.join('\n'), isError: false };
|
|
62
102
|
} catch (err) {
|
|
63
103
|
const msg = err instanceof Error ? err.message : String(err);
|
|
104
|
+
log.error({ inputIds: ids, error: msg }, 'delete failed');
|
|
64
105
|
return { content: `Error: ${msg}`, isError: true };
|
|
65
106
|
}
|
|
66
107
|
}
|
|
@@ -3,6 +3,7 @@ import type { Tool, ToolContext, ToolExecutionResult } from '../types.js';
|
|
|
3
3
|
import type { ToolDefinition } from '../../providers/types.js';
|
|
4
4
|
import { getTask, listTasks } from '../../tasks/task-store.js';
|
|
5
5
|
import { renderTemplate } from '../../tasks/task-runner.js';
|
|
6
|
+
import { identifyEntityById, buildWorkItemMismatchError } from '../../work-items/work-item-store.js';
|
|
6
7
|
|
|
7
8
|
const definition: ToolDefinition = {
|
|
8
9
|
name: 'task_run',
|
|
@@ -57,7 +58,14 @@ class TaskRunTool implements Tool {
|
|
|
57
58
|
if (taskId) {
|
|
58
59
|
task = getTask(taskId);
|
|
59
60
|
if (!task) {
|
|
60
|
-
|
|
61
|
+
const entity = identifyEntityById(taskId);
|
|
62
|
+
if (entity.type === 'work_item') {
|
|
63
|
+
return {
|
|
64
|
+
content: `Error: ${buildWorkItemMismatchError(taskId, entity.title!, 'task_list_show to view work items, or task_list_update to modify them')}`,
|
|
65
|
+
isError: true,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return { content: `Error: No task template found with ID "${taskId}". Use task_list to see available templates.`, isError: true };
|
|
61
69
|
}
|
|
62
70
|
} else if (taskName) {
|
|
63
71
|
const allTasks = listTasks();
|