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.
@@ -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
+ )