voicecc 1.2.2 → 1.2.4
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/bin/voicecc.js +92 -68
- package/package.json +2 -1
- package/voice-server/.python-version +1 -0
- package/voice-server/claude_llm_service.py +333 -0
- package/voice-server/claude_session.py +312 -0
- package/voice-server/config.py +340 -0
- package/voice-server/dev-server-start.sh +128 -0
- package/voice-server/heartbeat.py +505 -0
- package/voice-server/narration_processor.py +140 -0
- package/voice-server/requirements.txt +8 -0
- package/voice-server/server.py +335 -0
- package/voice-server/stop_phrase_processor.py +50 -0
- package/voice-server/twilio_pipeline.py +237 -0
- package/voice-server/voice_pipeline.py +147 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Start a Cloudflare quick tunnel and configure the Twilio phone number
|
|
4
|
+
# webhook to point at it, then start the voice pipeline server.
|
|
5
|
+
#
|
|
6
|
+
# Required env vars (from ~/.voicecc/.env or exported):
|
|
7
|
+
# TWILIO_ACCOUNT_SID - Twilio account SID
|
|
8
|
+
# TWILIO_AUTH_TOKEN - Twilio auth token
|
|
9
|
+
# TWILIO_PHONE_NUMBER - Twilio phone number (E.164, e.g. +15551234567)
|
|
10
|
+
# ELEVENLABS_API_KEY - ElevenLabs API key
|
|
11
|
+
#
|
|
12
|
+
# Usage:
|
|
13
|
+
# ./dev-server-start.sh
|
|
14
|
+
|
|
15
|
+
set -euo pipefail
|
|
16
|
+
|
|
17
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
18
|
+
|
|
19
|
+
# Create venv and install dependencies if needed
|
|
20
|
+
if [[ ! -d "$SCRIPT_DIR/.venv" ]]; then
|
|
21
|
+
echo "Creating virtual environment..."
|
|
22
|
+
python3 -m venv "$SCRIPT_DIR/.venv"
|
|
23
|
+
fi
|
|
24
|
+
source "$SCRIPT_DIR/.venv/bin/activate"
|
|
25
|
+
pip install -q -r "$SCRIPT_DIR/requirements.txt"
|
|
26
|
+
|
|
27
|
+
# Load ~/.voicecc/.env if present (same as config.py)
|
|
28
|
+
VOICECC_DIR="${VOICECC_DIR:-$HOME/.voicecc}"
|
|
29
|
+
if [[ -f "$VOICECC_DIR/.env" ]]; then
|
|
30
|
+
set -a
|
|
31
|
+
source "$VOICECC_DIR/.env"
|
|
32
|
+
set +a
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
API_PORT="${API_PORT:-7861}"
|
|
36
|
+
|
|
37
|
+
# Type check — catch type errors before starting
|
|
38
|
+
echo "Running type check..."
|
|
39
|
+
cd "$SCRIPT_DIR"
|
|
40
|
+
if ! python3 -m pyright .; then
|
|
41
|
+
echo "ERROR: Type check failed. Fix the errors above before starting." >&2
|
|
42
|
+
exit 1
|
|
43
|
+
fi
|
|
44
|
+
echo "Type check passed."
|
|
45
|
+
|
|
46
|
+
# Validate required credentials
|
|
47
|
+
for var in TWILIO_ACCOUNT_SID TWILIO_AUTH_TOKEN TWILIO_PHONE_NUMBER ELEVENLABS_API_KEY; do
|
|
48
|
+
if [[ -z "${!var:-}" ]]; then
|
|
49
|
+
echo "ERROR: $var is not set. Add it to ~/.voicecc/.env or export it." >&2
|
|
50
|
+
exit 1
|
|
51
|
+
fi
|
|
52
|
+
done
|
|
53
|
+
|
|
54
|
+
# Check dependencies
|
|
55
|
+
if ! command -v cloudflared &>/dev/null; then
|
|
56
|
+
echo "ERROR: cloudflared is not installed. brew install cloudflared" >&2
|
|
57
|
+
exit 1
|
|
58
|
+
fi
|
|
59
|
+
|
|
60
|
+
# Start cloudflared quick tunnel in background, capture the URL from its log
|
|
61
|
+
TUNNEL_LOG=$(mktemp)
|
|
62
|
+
cloudflared tunnel --url "http://localhost:$API_PORT" 2>"$TUNNEL_LOG" &
|
|
63
|
+
TUNNEL_PID=$!
|
|
64
|
+
|
|
65
|
+
cleanup() {
|
|
66
|
+
echo ""
|
|
67
|
+
echo "Shutting down tunnel (PID $TUNNEL_PID)..."
|
|
68
|
+
kill "$TUNNEL_PID" 2>/dev/null || true
|
|
69
|
+
rm -f "$TUNNEL_LOG"
|
|
70
|
+
}
|
|
71
|
+
trap cleanup EXIT
|
|
72
|
+
|
|
73
|
+
# Wait for the tunnel URL to appear in the log
|
|
74
|
+
echo "Starting Cloudflare quick tunnel on port $API_PORT..."
|
|
75
|
+
TUNNEL_URL=""
|
|
76
|
+
for i in $(seq 1 30); do
|
|
77
|
+
TUNNEL_URL=$(grep -oE 'https://[a-zA-Z0-9_-]+(-[a-zA-Z0-9_-]+)+\.trycloudflare\.com' "$TUNNEL_LOG" | head -1 || true)
|
|
78
|
+
if [[ -n "$TUNNEL_URL" ]]; then
|
|
79
|
+
break
|
|
80
|
+
fi
|
|
81
|
+
sleep 1
|
|
82
|
+
done
|
|
83
|
+
|
|
84
|
+
if [[ -z "$TUNNEL_URL" ]]; then
|
|
85
|
+
echo "ERROR: Could not get tunnel URL after 30s. cloudflared log:" >&2
|
|
86
|
+
cat "$TUNNEL_LOG" >&2
|
|
87
|
+
exit 1
|
|
88
|
+
fi
|
|
89
|
+
|
|
90
|
+
echo "Tunnel URL: $TUNNEL_URL"
|
|
91
|
+
|
|
92
|
+
# URL-encode the phone number (+ → %2B)
|
|
93
|
+
ENCODED_PHONE=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$TWILIO_PHONE_NUMBER', safe=''))")
|
|
94
|
+
|
|
95
|
+
# Look up the phone number SID
|
|
96
|
+
PHONE_SID=$(curl -s -X GET \
|
|
97
|
+
"https://api.twilio.com/2010-04-01/Accounts/$TWILIO_ACCOUNT_SID/IncomingPhoneNumbers.json?PhoneNumber=$ENCODED_PHONE" \
|
|
98
|
+
-u "$TWILIO_ACCOUNT_SID:$TWILIO_AUTH_TOKEN" \
|
|
99
|
+
| python3 -c "import sys,json; nums=json.load(sys.stdin).get('incoming_phone_numbers',[]); print(nums[0]['sid'] if nums else '')")
|
|
100
|
+
|
|
101
|
+
if [[ -z "$PHONE_SID" ]]; then
|
|
102
|
+
echo "ERROR: Could not find phone number $TWILIO_PHONE_NUMBER in your Twilio account." >&2
|
|
103
|
+
exit 1
|
|
104
|
+
fi
|
|
105
|
+
|
|
106
|
+
# Update the voice webhook URL
|
|
107
|
+
WEBHOOK_URL="$TUNNEL_URL/twilio/voice"
|
|
108
|
+
echo "Updating Twilio phone number $TWILIO_PHONE_NUMBER webhook to: $WEBHOOK_URL"
|
|
109
|
+
|
|
110
|
+
curl -s -X POST \
|
|
111
|
+
"https://api.twilio.com/2010-04-01/Accounts/$TWILIO_ACCOUNT_SID/IncomingPhoneNumbers/$PHONE_SID.json" \
|
|
112
|
+
-u "$TWILIO_ACCOUNT_SID:$TWILIO_AUTH_TOKEN" \
|
|
113
|
+
--data-urlencode "VoiceUrl=$WEBHOOK_URL" \
|
|
114
|
+
--data-urlencode "VoiceMethod=POST" \
|
|
115
|
+
> /dev/null
|
|
116
|
+
|
|
117
|
+
echo "Twilio webhook configured."
|
|
118
|
+
echo ""
|
|
119
|
+
echo "=== Ready ==="
|
|
120
|
+
echo " Tunnel: $TUNNEL_URL"
|
|
121
|
+
echo " Webhook: $WEBHOOK_URL"
|
|
122
|
+
echo " API: http://localhost:$API_PORT"
|
|
123
|
+
echo ""
|
|
124
|
+
|
|
125
|
+
# Start the voice server with TUNNEL_URL set
|
|
126
|
+
export TUNNEL_URL="$TUNNEL_URL"
|
|
127
|
+
cd "$SCRIPT_DIR"
|
|
128
|
+
exec python3 server.py
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Heartbeat scheduler for agent check-ins, ported from heartbeat.ts.
|
|
3
|
+
|
|
4
|
+
Creates a persistent Claude Code session per heartbeat check so the agent can
|
|
5
|
+
execute whatever HEARTBEAT.md instructs. When a heartbeat determines the user
|
|
6
|
+
should be contacted, initiates an outbound Twilio call and hands the live Claude
|
|
7
|
+
session to the voice pipeline so it retains full context.
|
|
8
|
+
|
|
9
|
+
Responsibilities:
|
|
10
|
+
- Start/stop a 60-second asyncio interval that checks all enabled agents
|
|
11
|
+
- Track per-agent check intervals and concurrent-check guards
|
|
12
|
+
- Create persistent Claude sessions with full tool access
|
|
13
|
+
- Parse JSON heartbeat responses and initiate outbound calls
|
|
14
|
+
- Store live Claude sessions in pending_calls for voice call handoff
|
|
15
|
+
- 60-second cleanup timer for unanswered calls
|
|
16
|
+
- Expose last heartbeat results for the API
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import time
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from uuid import uuid4
|
|
27
|
+
|
|
28
|
+
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient, AssistantMessage, ResultMessage, TextBlock
|
|
29
|
+
|
|
30
|
+
from config import (
|
|
31
|
+
Agent,
|
|
32
|
+
VoiceServerConfig,
|
|
33
|
+
build_system_prompt,
|
|
34
|
+
list_agents,
|
|
35
|
+
load_agent,
|
|
36
|
+
DEFAULT_AGENTS_DIR,
|
|
37
|
+
PROJECT_ROOT,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
# ============================================================================
|
|
43
|
+
# CONSTANTS
|
|
44
|
+
# ============================================================================
|
|
45
|
+
|
|
46
|
+
# Global check interval (60 seconds)
|
|
47
|
+
CHECK_INTERVAL_S = 60
|
|
48
|
+
|
|
49
|
+
# Default max time for a single heartbeat Claude session (5 minutes)
|
|
50
|
+
DEFAULT_HEARTBEAT_TIMEOUT_S = 5 * 60
|
|
51
|
+
|
|
52
|
+
# Cleanup timer for unanswered pending calls (60 seconds)
|
|
53
|
+
PENDING_CALL_TIMEOUT_S = 60
|
|
54
|
+
|
|
55
|
+
# Heartbeat prompt sent to the Claude session
|
|
56
|
+
_HEARTBEAT_PROMPT_PATH = os.path.join(PROJECT_ROOT, "init", "defaults", "system-heartbeat.md")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _load_heartbeat_prompt() -> str:
|
|
60
|
+
"""Read the heartbeat prompt from disk."""
|
|
61
|
+
with open(_HEARTBEAT_PROMPT_PATH, "r", encoding="utf-8") as f:
|
|
62
|
+
return f.read().strip()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ============================================================================
|
|
66
|
+
# TYPES
|
|
67
|
+
# ============================================================================
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class HeartbeatResult:
|
|
71
|
+
"""Result of a single agent heartbeat check."""
|
|
72
|
+
agent_id: str
|
|
73
|
+
should_call: bool
|
|
74
|
+
reason: str
|
|
75
|
+
timestamp: float
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class PendingCall:
|
|
80
|
+
"""A pending outbound call waiting for Twilio to connect."""
|
|
81
|
+
token: str
|
|
82
|
+
agent_id: str
|
|
83
|
+
client: ClaudeSDKClient
|
|
84
|
+
initial_prompt: str | None
|
|
85
|
+
created_at: float
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ============================================================================
|
|
89
|
+
# STATE
|
|
90
|
+
# ============================================================================
|
|
91
|
+
|
|
92
|
+
# Asyncio task for the interval loop
|
|
93
|
+
_interval_task: asyncio.Task | None = None
|
|
94
|
+
|
|
95
|
+
# Last heartbeat result per agent
|
|
96
|
+
_last_results: dict[str, HeartbeatResult] = {}
|
|
97
|
+
|
|
98
|
+
# Last check timestamp per agent (for interval tracking)
|
|
99
|
+
_last_check_times: dict[str, float] = {}
|
|
100
|
+
|
|
101
|
+
# Currently running agent IDs (concurrent guard)
|
|
102
|
+
_in_flight_checks: set[str] = set()
|
|
103
|
+
|
|
104
|
+
# Pending calls keyed by token, waiting for Twilio WebSocket connection
|
|
105
|
+
_pending_calls: dict[str, PendingCall] = {}
|
|
106
|
+
|
|
107
|
+
# Reference to config (set on start)
|
|
108
|
+
_config: VoiceServerConfig | None = None
|
|
109
|
+
|
|
110
|
+
# Getter for tunnel URL (set on start, imported from server module)
|
|
111
|
+
_get_tunnel_url = None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ============================================================================
|
|
115
|
+
# MAIN HANDLERS
|
|
116
|
+
# ============================================================================
|
|
117
|
+
|
|
118
|
+
def start_heartbeat(config: VoiceServerConfig, get_tunnel_url_fn) -> None:
|
|
119
|
+
"""Start the heartbeat scheduler.
|
|
120
|
+
|
|
121
|
+
Runs _check_all_agents every 60 seconds via an asyncio task.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
config: Voice server configuration with Twilio credentials
|
|
125
|
+
get_tunnel_url_fn: Callable that returns the current tunnel URL
|
|
126
|
+
"""
|
|
127
|
+
global _interval_task, _config, _get_tunnel_url
|
|
128
|
+
|
|
129
|
+
if _interval_task is not None:
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
_config = config
|
|
133
|
+
_get_tunnel_url = get_tunnel_url_fn
|
|
134
|
+
|
|
135
|
+
_interval_task = asyncio.create_task(_interval_loop())
|
|
136
|
+
logger.info("[heartbeat] scheduler started (60s interval)")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def stop_heartbeat() -> None:
|
|
140
|
+
"""Stop the heartbeat scheduler. Cancels the asyncio interval task."""
|
|
141
|
+
global _interval_task
|
|
142
|
+
|
|
143
|
+
if _interval_task is not None:
|
|
144
|
+
_interval_task.cancel()
|
|
145
|
+
_interval_task = None
|
|
146
|
+
logger.info("[heartbeat] scheduler stopped")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def get_heartbeat_status() -> dict[str, dict]:
|
|
150
|
+
"""Get the last heartbeat result per agent.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Dict of agent_id -> serialized HeartbeatResult
|
|
154
|
+
"""
|
|
155
|
+
return {
|
|
156
|
+
agent_id: {
|
|
157
|
+
"agent_id": r.agent_id,
|
|
158
|
+
"should_call": r.should_call,
|
|
159
|
+
"reason": r.reason,
|
|
160
|
+
"timestamp": r.timestamp,
|
|
161
|
+
}
|
|
162
|
+
for agent_id, r in _last_results.items()
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def get_pending_client(token: str) -> PendingCall | None:
|
|
167
|
+
"""Retrieve and remove a pending call by token.
|
|
168
|
+
|
|
169
|
+
Called when the Twilio WebSocket connects to hand off the live Claude session.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
token: The call token
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
PendingCall if found, None otherwise
|
|
176
|
+
"""
|
|
177
|
+
return _pending_calls.pop(token, None)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def register_pending_call(token: str, agent_id: str, initial_prompt: str | None = None) -> None:
|
|
181
|
+
"""Register a pending call without a pre-existing Claude client.
|
|
182
|
+
|
|
183
|
+
Used by the /register-call endpoint for API-initiated calls (e.g. "Call Me").
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
token: Unique call token
|
|
187
|
+
agent_id: Agent identifier
|
|
188
|
+
initial_prompt: Optional initial prompt for the agent
|
|
189
|
+
"""
|
|
190
|
+
# No Claude client -- the pipeline will create a fresh one
|
|
191
|
+
_pending_calls[token] = PendingCall(
|
|
192
|
+
token=token,
|
|
193
|
+
agent_id=agent_id,
|
|
194
|
+
client=None, # type: ignore[arg-type]
|
|
195
|
+
initial_prompt=initial_prompt,
|
|
196
|
+
created_at=time.time(),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ============================================================================
|
|
201
|
+
# HELPER FUNCTIONS
|
|
202
|
+
# ============================================================================
|
|
203
|
+
|
|
204
|
+
async def _interval_loop() -> None:
|
|
205
|
+
"""Asyncio loop that runs _check_all_agents every CHECK_INTERVAL_S."""
|
|
206
|
+
while True:
|
|
207
|
+
try:
|
|
208
|
+
await _check_all_agents()
|
|
209
|
+
except Exception as e:
|
|
210
|
+
logger.error(f"[heartbeat] check_all_agents error: {e}")
|
|
211
|
+
await asyncio.sleep(CHECK_INTERVAL_S)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
async def _check_all_agents() -> None:
|
|
215
|
+
"""Check all enabled agents and spawn heartbeat sessions for those that are due."""
|
|
216
|
+
if not _config:
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
agents = list_agents(_config.agents_dir)
|
|
220
|
+
if not agents:
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
now = time.time()
|
|
224
|
+
|
|
225
|
+
for agent in agents:
|
|
226
|
+
# Skip if interval has not elapsed
|
|
227
|
+
last_check = _last_check_times.get(agent.id, 0)
|
|
228
|
+
interval_s = agent.config.heartbeat_interval_minutes * 60
|
|
229
|
+
if now - last_check < interval_s:
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
# Skip if already checking this agent
|
|
233
|
+
if agent.id in _in_flight_checks:
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
# Fire-and-forget the check
|
|
237
|
+
asyncio.create_task(_check_single_agent_wrapper(agent))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
async def _check_single_agent_wrapper(agent: Agent) -> None:
|
|
241
|
+
"""Wrapper for check_single_agent that handles errors and in-flight tracking."""
|
|
242
|
+
try:
|
|
243
|
+
await check_single_agent(agent)
|
|
244
|
+
except Exception as e:
|
|
245
|
+
logger.error(f'[heartbeat] check failed for agent "{agent.id}": {e}')
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
async def check_single_agent(agent: Agent) -> HeartbeatResult:
|
|
249
|
+
"""Run a heartbeat check for a single agent using a persistent Claude session.
|
|
250
|
+
|
|
251
|
+
If the check determines shouldCall, keeps the session alive and passes it
|
|
252
|
+
to the outbound call so the voice session continues with full context.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
agent: Full agent data with SOUL.md, MEMORY.md, HEARTBEAT.md
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
HeartbeatResult with the check outcome
|
|
259
|
+
"""
|
|
260
|
+
_in_flight_checks.add(agent.id)
|
|
261
|
+
_last_check_times[agent.id] = time.time()
|
|
262
|
+
|
|
263
|
+
client: ClaudeSDKClient | None = None
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
timeout_s = (agent.config.heartbeat_timeout_minutes or 5) * 60 or DEFAULT_HEARTBEAT_TIMEOUT_S
|
|
267
|
+
result, client = await _run_heartbeat_session(agent, timeout_s)
|
|
268
|
+
_last_results[agent.id] = result
|
|
269
|
+
|
|
270
|
+
logger.info(
|
|
271
|
+
f'[heartbeat] agent "{agent.id}": shouldCall={result.should_call}, '
|
|
272
|
+
f'reason="{result.reason}"'
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
if result.should_call:
|
|
276
|
+
try:
|
|
277
|
+
await initiate_agent_call(agent, client)
|
|
278
|
+
client = None # Don't close -- voice session owns it now
|
|
279
|
+
except Exception as e:
|
|
280
|
+
logger.error(f'[heartbeat] failed to call agent "{agent.id}": {e}')
|
|
281
|
+
|
|
282
|
+
return result
|
|
283
|
+
finally:
|
|
284
|
+
# Close the session if we still own it
|
|
285
|
+
if client:
|
|
286
|
+
try:
|
|
287
|
+
await client.disconnect()
|
|
288
|
+
except Exception:
|
|
289
|
+
pass
|
|
290
|
+
_in_flight_checks.discard(agent.id)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
async def _run_heartbeat_session(
|
|
294
|
+
agent: Agent, timeout_s: float
|
|
295
|
+
) -> tuple[HeartbeatResult, ClaudeSDKClient]:
|
|
296
|
+
"""Run a heartbeat check using a persistent Claude session.
|
|
297
|
+
|
|
298
|
+
Creates the session with the agent's full context, sends the heartbeat prompt,
|
|
299
|
+
and parses the JSON response.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
agent: Full agent data
|
|
303
|
+
timeout_s: Maximum time for the session in seconds
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
Tuple of (HeartbeatResult, live ClaudeSDKClient)
|
|
307
|
+
"""
|
|
308
|
+
agent_dir = os.path.join(DEFAULT_AGENTS_DIR, agent.id)
|
|
309
|
+
system_prompt = build_system_prompt(agent.id, "voice")
|
|
310
|
+
|
|
311
|
+
options = ClaudeAgentOptions(
|
|
312
|
+
system_prompt=system_prompt,
|
|
313
|
+
cwd=agent_dir,
|
|
314
|
+
allowed_tools=[],
|
|
315
|
+
permission_mode="bypassPermissions",
|
|
316
|
+
include_partial_messages=True,
|
|
317
|
+
max_thinking_tokens=10000,
|
|
318
|
+
)
|
|
319
|
+
client = ClaudeSDKClient(options=options)
|
|
320
|
+
await client.connect()
|
|
321
|
+
|
|
322
|
+
timed_out = False
|
|
323
|
+
|
|
324
|
+
async def _timeout_guard():
|
|
325
|
+
nonlocal timed_out
|
|
326
|
+
await asyncio.sleep(timeout_s)
|
|
327
|
+
timed_out = True
|
|
328
|
+
try:
|
|
329
|
+
await client.interrupt()
|
|
330
|
+
except Exception:
|
|
331
|
+
pass
|
|
332
|
+
|
|
333
|
+
timeout_task = asyncio.create_task(_timeout_guard())
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
heartbeat_prompt = _load_heartbeat_prompt()
|
|
337
|
+
response_text = ""
|
|
338
|
+
|
|
339
|
+
await client.query(heartbeat_prompt)
|
|
340
|
+
|
|
341
|
+
async for msg in client.receive_response():
|
|
342
|
+
if isinstance(msg, AssistantMessage):
|
|
343
|
+
for block in msg.content:
|
|
344
|
+
if isinstance(block, TextBlock) and block.text:
|
|
345
|
+
response_text += block.text
|
|
346
|
+
elif isinstance(msg, ResultMessage):
|
|
347
|
+
break
|
|
348
|
+
|
|
349
|
+
if timed_out:
|
|
350
|
+
logger.error(f'[heartbeat] session timed out for agent "{agent.id}"')
|
|
351
|
+
return _fail_safe_result(agent.id), client
|
|
352
|
+
|
|
353
|
+
if not response_text:
|
|
354
|
+
logger.error(f'[heartbeat] no response text for agent "{agent.id}"')
|
|
355
|
+
return _fail_safe_result(agent.id), client
|
|
356
|
+
|
|
357
|
+
result = _parse_heartbeat_response(agent.id, response_text)
|
|
358
|
+
return result, client
|
|
359
|
+
|
|
360
|
+
except Exception as e:
|
|
361
|
+
if timed_out:
|
|
362
|
+
logger.error(f'[heartbeat] session timed out for agent "{agent.id}"')
|
|
363
|
+
else:
|
|
364
|
+
logger.error(f'[heartbeat] session error for agent "{agent.id}": {e}')
|
|
365
|
+
return _fail_safe_result(agent.id), client
|
|
366
|
+
|
|
367
|
+
finally:
|
|
368
|
+
timeout_task.cancel()
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
async def initiate_agent_call(agent: Agent, client: ClaudeSDKClient) -> str:
|
|
372
|
+
"""Initiate an outbound Twilio call for an agent.
|
|
373
|
+
|
|
374
|
+
Stores the live client in pending_calls, places the outbound call via Twilio
|
|
375
|
+
REST API, and sets a 60-second cleanup timer.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
agent: Full agent data
|
|
379
|
+
client: Live Claude session to hand off to the voice pipeline
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
The Twilio call SID
|
|
383
|
+
"""
|
|
384
|
+
if not _config:
|
|
385
|
+
raise RuntimeError("Heartbeat not started -- no config")
|
|
386
|
+
|
|
387
|
+
tunnel_url = _get_tunnel_url() if _get_tunnel_url else None
|
|
388
|
+
if not tunnel_url:
|
|
389
|
+
raise RuntimeError("Tunnel is not running. Cannot place outbound call.")
|
|
390
|
+
|
|
391
|
+
if not _config.twilio_account_sid or not _config.twilio_auth_token:
|
|
392
|
+
raise RuntimeError("TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN must be set")
|
|
393
|
+
|
|
394
|
+
if not _config.user_phone_number:
|
|
395
|
+
raise RuntimeError("USER_PHONE_NUMBER must be set in Settings > General")
|
|
396
|
+
|
|
397
|
+
token = str(uuid4())
|
|
398
|
+
|
|
399
|
+
# Store the pending call with the live client
|
|
400
|
+
pending = PendingCall(
|
|
401
|
+
token=token,
|
|
402
|
+
agent_id=agent.id,
|
|
403
|
+
client=client,
|
|
404
|
+
initial_prompt="The user just answered your call. Greet them and briefly explain why you're calling.",
|
|
405
|
+
created_at=time.time(),
|
|
406
|
+
)
|
|
407
|
+
_pending_calls[token] = pending
|
|
408
|
+
|
|
409
|
+
# Schedule cleanup for unanswered calls
|
|
410
|
+
asyncio.create_task(_cleanup_pending_call(token))
|
|
411
|
+
|
|
412
|
+
# Get from number via Twilio API
|
|
413
|
+
from twilio.rest import Client as TwilioClient
|
|
414
|
+
|
|
415
|
+
twilio_client = TwilioClient(_config.twilio_account_sid, _config.twilio_auth_token)
|
|
416
|
+
numbers = twilio_client.incoming_phone_numbers.list(limit=1)
|
|
417
|
+
if not numbers:
|
|
418
|
+
raise RuntimeError("No Twilio phone numbers found on the account")
|
|
419
|
+
from_number: str = numbers[0].phone_number or ""
|
|
420
|
+
|
|
421
|
+
# Build TwiML with WebSocket stream URL
|
|
422
|
+
tunnel_host = tunnel_url.replace("https://", "").replace("http://", "")
|
|
423
|
+
twiml = (
|
|
424
|
+
f'<Response><Connect>'
|
|
425
|
+
f'<Stream url="wss://{tunnel_host}/media/{token}?agentId={agent.id}" />'
|
|
426
|
+
f'</Connect></Response>'
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
call = twilio_client.calls.create(
|
|
430
|
+
to=_config.user_phone_number,
|
|
431
|
+
from_=from_number,
|
|
432
|
+
twiml=twiml,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
logger.info(
|
|
436
|
+
f"[heartbeat] outbound call placed to {_config.user_phone_number} "
|
|
437
|
+
f"(callSid={call.sid})"
|
|
438
|
+
)
|
|
439
|
+
return call.sid or ""
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
async def _cleanup_pending_call(token: str) -> None:
|
|
443
|
+
"""Clean up a pending call after PENDING_CALL_TIMEOUT_S if not claimed.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
token: The pending call token
|
|
447
|
+
"""
|
|
448
|
+
await asyncio.sleep(PENDING_CALL_TIMEOUT_S)
|
|
449
|
+
|
|
450
|
+
pending = _pending_calls.pop(token, None)
|
|
451
|
+
if pending and pending.client:
|
|
452
|
+
logger.info(f"[heartbeat] cleaning up unanswered pending call: {token}")
|
|
453
|
+
try:
|
|
454
|
+
await pending.client.disconnect()
|
|
455
|
+
except Exception:
|
|
456
|
+
pass
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _parse_heartbeat_response(agent_id: str, text: str) -> HeartbeatResult:
|
|
460
|
+
"""Parse a heartbeat JSON response string into a HeartbeatResult.
|
|
461
|
+
|
|
462
|
+
Expects a JSON object with shouldCall (boolean) and reason (string).
|
|
463
|
+
|
|
464
|
+
Args:
|
|
465
|
+
agent_id: Agent identifier
|
|
466
|
+
text: Raw text from the assistant response
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
Parsed HeartbeatResult
|
|
470
|
+
"""
|
|
471
|
+
try:
|
|
472
|
+
match = re.search(r'\{[\s\S]*"shouldCall"[\s\S]*\}', text)
|
|
473
|
+
if not match:
|
|
474
|
+
logger.error(
|
|
475
|
+
f'[heartbeat] no JSON found in response for agent "{agent_id}": {text}'
|
|
476
|
+
)
|
|
477
|
+
return _fail_safe_result(agent_id)
|
|
478
|
+
|
|
479
|
+
parsed = json.loads(match.group(0))
|
|
480
|
+
return HeartbeatResult(
|
|
481
|
+
agent_id=agent_id,
|
|
482
|
+
should_call=bool(parsed.get("shouldCall", False)),
|
|
483
|
+
reason=str(parsed.get("reason", "")),
|
|
484
|
+
timestamp=time.time(),
|
|
485
|
+
)
|
|
486
|
+
except (json.JSONDecodeError, Exception) as e:
|
|
487
|
+
logger.error(f'[heartbeat] JSON parse error for agent "{agent_id}": {e}')
|
|
488
|
+
return _fail_safe_result(agent_id)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _fail_safe_result(agent_id: str) -> HeartbeatResult:
|
|
492
|
+
"""Create a fail-safe HeartbeatResult that does not trigger a call.
|
|
493
|
+
|
|
494
|
+
Args:
|
|
495
|
+
agent_id: Agent identifier
|
|
496
|
+
|
|
497
|
+
Returns:
|
|
498
|
+
HeartbeatResult with should_call=False
|
|
499
|
+
"""
|
|
500
|
+
return HeartbeatResult(
|
|
501
|
+
agent_id=agent_id,
|
|
502
|
+
should_call=False,
|
|
503
|
+
reason="heartbeat check failed or timed out",
|
|
504
|
+
timestamp=time.time(),
|
|
505
|
+
)
|