voicecc 1.2.2 → 1.2.3
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 +9 -0
- 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
package/bin/voicecc.js
CHANGED
|
@@ -740,6 +740,15 @@ if (!commandExists("claude")) {
|
|
|
740
740
|
// Runs on every start but skips pip install if requirements.txt hasn't changed.
|
|
741
741
|
ensurePythonVenv();
|
|
742
742
|
|
|
743
|
+
// Hard check: verify the venv actually exists after setup
|
|
744
|
+
const expectedVenvPython = join(PKG_ROOT, "voice-server", ".venv", "bin", "python");
|
|
745
|
+
if (!existsSync(expectedVenvPython)) {
|
|
746
|
+
console.error(`ERROR: Python venv not found at ${expectedVenvPython}`);
|
|
747
|
+
console.error("The voice-server directory or its venv is missing from the installation.");
|
|
748
|
+
console.error("Try reinstalling: npm install -g voicecc");
|
|
749
|
+
process.exit(1);
|
|
750
|
+
}
|
|
751
|
+
|
|
743
752
|
// If already running, show info and exit
|
|
744
753
|
if (isRunning()) {
|
|
745
754
|
showInfo();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "voicecc",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.3",
|
|
4
4
|
"description": "Voice Agent Platform running on Claude Code -- create and deploy conversational voice agents with ElevenLabs STT/TTS and VAD",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"files": [
|
|
25
25
|
"bin/",
|
|
26
26
|
"server/",
|
|
27
|
+
"voice-server/",
|
|
27
28
|
"dashboard/dist/",
|
|
28
29
|
"dashboard/server.ts",
|
|
29
30
|
"dashboard/routes/",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom Pipecat LLMService wrapping the Python Claude Agent SDK (ClaudeSDKClient).
|
|
3
|
+
|
|
4
|
+
Uses ClaudeSDKClient for persistent multi-turn voice sessions with full tool use.
|
|
5
|
+
Does NOT use Pipecat's built-in context accumulation -- the Claude session maintains
|
|
6
|
+
its own conversation history internally.
|
|
7
|
+
|
|
8
|
+
Responsibilities:
|
|
9
|
+
- Override process_frame to handle LLM context frames from Pipecat aggregators
|
|
10
|
+
- Extract only the last user message from Pipecat context (SDK tracks history)
|
|
11
|
+
- Clear Pipecat context after each turn to prevent unbounded memory growth
|
|
12
|
+
- Support existing_client for heartbeat session handoff
|
|
13
|
+
- Support initial_prompt for agent-speaks-first flows
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import logging
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
from claude_agent_sdk import (
|
|
21
|
+
AssistantMessage,
|
|
22
|
+
ClaudeAgentOptions,
|
|
23
|
+
ClaudeSDKClient,
|
|
24
|
+
ResultMessage,
|
|
25
|
+
TextBlock,
|
|
26
|
+
ToolUseBlock,
|
|
27
|
+
)
|
|
28
|
+
from pipecat.frames.frames import (
|
|
29
|
+
CancelFrame,
|
|
30
|
+
EndFrame,
|
|
31
|
+
Frame,
|
|
32
|
+
FunctionCallsStartedFrame,
|
|
33
|
+
InterruptionFrame,
|
|
34
|
+
LLMContextFrame,
|
|
35
|
+
LLMFullResponseEndFrame,
|
|
36
|
+
LLMFullResponseStartFrame,
|
|
37
|
+
LLMMessagesFrame,
|
|
38
|
+
LLMTextFrame,
|
|
39
|
+
StartFrame,
|
|
40
|
+
TextFrame,
|
|
41
|
+
)
|
|
42
|
+
from pipecat.processors.aggregators.llm_context import LLMContext
|
|
43
|
+
from pipecat.processors.aggregators.openai_llm_context import (
|
|
44
|
+
OpenAILLMContext,
|
|
45
|
+
OpenAILLMContextFrame,
|
|
46
|
+
)
|
|
47
|
+
from pipecat.processors.frame_processor import FrameDirection
|
|
48
|
+
from pipecat.services.llm_service import LLMService
|
|
49
|
+
|
|
50
|
+
logger = logging.getLogger(__name__)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ============================================================================
|
|
54
|
+
# TYPES
|
|
55
|
+
# ============================================================================
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class ClaudeLLMServiceConfig:
|
|
59
|
+
"""Configuration for ClaudeLLMService.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
cwd: Working directory for the Claude Code session
|
|
63
|
+
system_prompt: System prompt for voice mode
|
|
64
|
+
allowed_tools: Tool allowlist (empty list = all tools allowed)
|
|
65
|
+
initial_prompt: Optional first message so the agent speaks first
|
|
66
|
+
existing_client: Pre-existing ClaudeSDKClient (e.g. from heartbeat handoff)
|
|
67
|
+
"""
|
|
68
|
+
cwd: str
|
|
69
|
+
system_prompt: str
|
|
70
|
+
allowed_tools: list[str] | None = None
|
|
71
|
+
initial_prompt: str | None = None
|
|
72
|
+
existing_client: ClaudeSDKClient | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ============================================================================
|
|
76
|
+
# MAIN HANDLERS
|
|
77
|
+
# ============================================================================
|
|
78
|
+
|
|
79
|
+
class ClaudeLLMService(LLMService):
|
|
80
|
+
"""Pipecat LLMService that wraps ClaudeSDKClient for voice conversations.
|
|
81
|
+
|
|
82
|
+
Intercepts LLM context frames from the user aggregator, extracts the last
|
|
83
|
+
user message, sends it to Claude via the SDK, and pushes text frames
|
|
84
|
+
downstream for TTS.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, config: ClaudeLLMServiceConfig, **kwargs):
|
|
88
|
+
super().__init__(**kwargs)
|
|
89
|
+
self._config = config
|
|
90
|
+
self._client: ClaudeSDKClient | None = config.existing_client
|
|
91
|
+
self._connected = config.existing_client is not None
|
|
92
|
+
self._initial_prompt_sent = False
|
|
93
|
+
self._processing = False
|
|
94
|
+
self._current_task: asyncio.Task | None = None
|
|
95
|
+
|
|
96
|
+
# Initialize LLMSettings fields — Claude SDK manages these internally,
|
|
97
|
+
# so we set them all to None (unsupported).
|
|
98
|
+
self._settings.model = None
|
|
99
|
+
self._settings.system_instruction = None
|
|
100
|
+
self._settings.temperature = None
|
|
101
|
+
self._settings.max_tokens = None
|
|
102
|
+
self._settings.top_p = None
|
|
103
|
+
self._settings.top_k = None
|
|
104
|
+
self._settings.frequency_penalty = None
|
|
105
|
+
self._settings.presence_penalty = None
|
|
106
|
+
self._settings.seed = None
|
|
107
|
+
self._settings.filter_incomplete_user_turns = None
|
|
108
|
+
self._settings.user_turn_completion_config = None
|
|
109
|
+
|
|
110
|
+
async def start(self, frame: StartFrame):
|
|
111
|
+
"""Handle pipeline start. Sends initial_prompt if configured."""
|
|
112
|
+
await super().start(frame)
|
|
113
|
+
if self._config.initial_prompt and not self._initial_prompt_sent:
|
|
114
|
+
self._initial_prompt_sent = True
|
|
115
|
+
await self._ensure_client()
|
|
116
|
+
await self._send_to_claude(self._config.initial_prompt)
|
|
117
|
+
|
|
118
|
+
async def stop(self, frame: EndFrame):
|
|
119
|
+
"""Handle pipeline stop. Disconnects the Claude session."""
|
|
120
|
+
await self.close()
|
|
121
|
+
await super().stop(frame)
|
|
122
|
+
|
|
123
|
+
async def cancel(self, frame: CancelFrame):
|
|
124
|
+
"""Handle pipeline cancel. Disconnects the Claude session."""
|
|
125
|
+
await self.close()
|
|
126
|
+
await super().cancel(frame)
|
|
127
|
+
|
|
128
|
+
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
|
129
|
+
"""Process incoming frames.
|
|
130
|
+
|
|
131
|
+
Handles context frames from Pipecat's aggregators by extracting the last
|
|
132
|
+
user message and sending it to Claude. All other frames pass through.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
frame: The incoming frame
|
|
136
|
+
direction: Frame direction (upstream/downstream)
|
|
137
|
+
"""
|
|
138
|
+
await super().process_frame(frame, direction)
|
|
139
|
+
|
|
140
|
+
context = None
|
|
141
|
+
if isinstance(frame, OpenAILLMContextFrame):
|
|
142
|
+
context = frame.context
|
|
143
|
+
elif isinstance(frame, LLMContextFrame):
|
|
144
|
+
context = frame.context
|
|
145
|
+
elif isinstance(frame, LLMMessagesFrame):
|
|
146
|
+
context = OpenAILLMContext.from_messages(frame.messages)
|
|
147
|
+
elif isinstance(frame, InterruptionFrame):
|
|
148
|
+
await self.interrupt()
|
|
149
|
+
await self.push_frame(frame, direction)
|
|
150
|
+
return
|
|
151
|
+
else:
|
|
152
|
+
await self.push_frame(frame, direction)
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
if context:
|
|
156
|
+
# Extract the last user message text from the Pipecat context
|
|
157
|
+
user_text = _extract_last_user_message(context)
|
|
158
|
+
if not user_text:
|
|
159
|
+
logger.warning("[claude-llm] No user message found in context")
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
# Clear Pipecat context to prevent unbounded growth
|
|
163
|
+
# (Claude SDK maintains its own conversation history)
|
|
164
|
+
if isinstance(context, OpenAILLMContext):
|
|
165
|
+
context.set_messages([])
|
|
166
|
+
elif isinstance(context, LLMContext):
|
|
167
|
+
context.messages.clear()
|
|
168
|
+
|
|
169
|
+
# Cancel any in-flight query before starting a new one
|
|
170
|
+
await self._cancel_current_task()
|
|
171
|
+
|
|
172
|
+
await self._ensure_client()
|
|
173
|
+
|
|
174
|
+
async def _run_query():
|
|
175
|
+
try:
|
|
176
|
+
await self.push_frame(LLMFullResponseStartFrame())
|
|
177
|
+
await self.start_processing_metrics()
|
|
178
|
+
await self._send_to_claude(user_text)
|
|
179
|
+
except asyncio.CancelledError:
|
|
180
|
+
logger.info("[claude-llm] Query cancelled by new input")
|
|
181
|
+
except Exception as e:
|
|
182
|
+
logger.error(f"[claude-llm] Error during Claude query: {e}")
|
|
183
|
+
await self.push_error(error_msg=f"Claude query error: {e}", exception=e)
|
|
184
|
+
finally:
|
|
185
|
+
await self.stop_processing_metrics()
|
|
186
|
+
await self.push_frame(LLMFullResponseEndFrame())
|
|
187
|
+
|
|
188
|
+
self._current_task = asyncio.create_task(_run_query())
|
|
189
|
+
await self._current_task
|
|
190
|
+
|
|
191
|
+
async def _cancel_current_task(self) -> None:
|
|
192
|
+
"""Cancel the in-flight query task if one is running."""
|
|
193
|
+
if self._current_task and not self._current_task.done():
|
|
194
|
+
self._current_task.cancel()
|
|
195
|
+
try:
|
|
196
|
+
await self._current_task
|
|
197
|
+
except (asyncio.CancelledError, Exception):
|
|
198
|
+
pass
|
|
199
|
+
self._current_task = None
|
|
200
|
+
|
|
201
|
+
async def interrupt(self) -> None:
|
|
202
|
+
"""Interrupt the current Claude response and cancel the query task."""
|
|
203
|
+
await self._cancel_current_task()
|
|
204
|
+
if self._client and self._connected:
|
|
205
|
+
try:
|
|
206
|
+
await self._client.interrupt()
|
|
207
|
+
except Exception as e:
|
|
208
|
+
logger.warning(f"[claude-llm] Interrupt error: {e}")
|
|
209
|
+
|
|
210
|
+
async def close(self) -> None:
|
|
211
|
+
"""Disconnect the Claude session."""
|
|
212
|
+
if self._client and self._connected:
|
|
213
|
+
try:
|
|
214
|
+
await self._client.disconnect()
|
|
215
|
+
except Exception as e:
|
|
216
|
+
logger.warning(f"[claude-llm] Disconnect error: {e}")
|
|
217
|
+
finally:
|
|
218
|
+
self._connected = False
|
|
219
|
+
self._client = None
|
|
220
|
+
|
|
221
|
+
# ============================================================================
|
|
222
|
+
# HELPER FUNCTIONS
|
|
223
|
+
# ============================================================================
|
|
224
|
+
|
|
225
|
+
async def _ensure_client(self) -> None:
|
|
226
|
+
"""Create and connect ClaudeSDKClient if not already connected.
|
|
227
|
+
|
|
228
|
+
Uses existing_client if provided in config, otherwise creates a new one.
|
|
229
|
+
"""
|
|
230
|
+
if self._client and self._connected:
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
if not self._client:
|
|
234
|
+
options = ClaudeAgentOptions(
|
|
235
|
+
system_prompt=self._config.system_prompt,
|
|
236
|
+
cwd=self._config.cwd,
|
|
237
|
+
allowed_tools=self._config.allowed_tools or [],
|
|
238
|
+
permission_mode="bypassPermissions",
|
|
239
|
+
include_partial_messages=True,
|
|
240
|
+
max_thinking_tokens=10000,
|
|
241
|
+
)
|
|
242
|
+
self._client = ClaudeSDKClient(options=options)
|
|
243
|
+
|
|
244
|
+
await self._client.connect()
|
|
245
|
+
self._connected = True
|
|
246
|
+
logger.info("[claude-llm] Claude session connected")
|
|
247
|
+
|
|
248
|
+
async def _send_to_claude(self, text: str) -> None:
|
|
249
|
+
"""Send a user message to Claude and push response text frames downstream.
|
|
250
|
+
|
|
251
|
+
Iterates over the streaming response, extracting text deltas and tool use
|
|
252
|
+
events. Text is pushed as LLMTextFrame for TTS. Tool starts are pushed as
|
|
253
|
+
FunctionCallsStartedFrame for the narration processor.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
text: The user message to send
|
|
257
|
+
"""
|
|
258
|
+
if not self._client:
|
|
259
|
+
raise RuntimeError("Claude client not connected")
|
|
260
|
+
|
|
261
|
+
self._processing = True
|
|
262
|
+
has_streamed = False
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
await self._client.query(text)
|
|
266
|
+
|
|
267
|
+
async for msg in self._client.receive_response():
|
|
268
|
+
if isinstance(msg, AssistantMessage):
|
|
269
|
+
# Process content blocks from the assistant message
|
|
270
|
+
for block in msg.content:
|
|
271
|
+
if isinstance(block, TextBlock) and block.text:
|
|
272
|
+
if not has_streamed:
|
|
273
|
+
has_streamed = True
|
|
274
|
+
await self.start_ttfb_metrics()
|
|
275
|
+
await self.stop_ttfb_metrics()
|
|
276
|
+
await self.push_frame(LLMTextFrame(block.text))
|
|
277
|
+
elif isinstance(block, ToolUseBlock):
|
|
278
|
+
logger.info(f"[claude-llm] Tool use: {block.name}")
|
|
279
|
+
# Push a text frame announcing tool use for narration
|
|
280
|
+
await self.push_frame(TextFrame(f"__tool_start:{block.name}"))
|
|
281
|
+
|
|
282
|
+
elif isinstance(msg, ResultMessage):
|
|
283
|
+
if msg.is_error:
|
|
284
|
+
logger.error(f"[claude-llm] Result error: {msg.subtype}")
|
|
285
|
+
else:
|
|
286
|
+
logger.info("[claude-llm] Turn complete")
|
|
287
|
+
break
|
|
288
|
+
|
|
289
|
+
finally:
|
|
290
|
+
self._processing = False
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _extract_last_user_message(context: OpenAILLMContext | LLMContext | object) -> str | None:
|
|
294
|
+
"""Extract the last user message text from a Pipecat LLM context.
|
|
295
|
+
|
|
296
|
+
The context contains OpenAI-format messages. We find the last message
|
|
297
|
+
with role="user" and extract its text content.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
context: Pipecat LLM context (OpenAILLMContext, LLMContext, or other)
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
The last user message text, or None if no user message found
|
|
304
|
+
"""
|
|
305
|
+
if isinstance(context, OpenAILLMContext):
|
|
306
|
+
messages = context.get_messages()
|
|
307
|
+
elif isinstance(context, LLMContext):
|
|
308
|
+
messages = context.messages
|
|
309
|
+
else:
|
|
310
|
+
messages = getattr(context, "messages", [])
|
|
311
|
+
|
|
312
|
+
if not messages:
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
# Walk backwards to find the last user message
|
|
316
|
+
for msg in reversed(messages):
|
|
317
|
+
msg_dict = msg if isinstance(msg, dict) else vars(msg) if hasattr(msg, "__dict__") else {}
|
|
318
|
+
if msg_dict.get("role") == "user":
|
|
319
|
+
content = msg_dict.get("content", "")
|
|
320
|
+
if isinstance(content, str):
|
|
321
|
+
return content.strip() or None
|
|
322
|
+
# Content might be a list of content blocks
|
|
323
|
+
if isinstance(content, list):
|
|
324
|
+
texts = []
|
|
325
|
+
for block in content:
|
|
326
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
327
|
+
texts.append(block.get("text", ""))
|
|
328
|
+
elif isinstance(block, str):
|
|
329
|
+
texts.append(block)
|
|
330
|
+
joined = " ".join(texts).strip()
|
|
331
|
+
return joined or None
|
|
332
|
+
|
|
333
|
+
return None
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Text chat session manager for the Python voice server.
|
|
3
|
+
|
|
4
|
+
Port of chat-server.ts + claude-session.ts. Manages ClaudeSDKClient lifecycle
|
|
5
|
+
for text chat: lazy creation on first message, multi-turn reuse, inactivity
|
|
6
|
+
cleanup after 10 minutes.
|
|
7
|
+
|
|
8
|
+
Responsibilities:
|
|
9
|
+
- Create and reuse ClaudeSDKClient sessions keyed by device token
|
|
10
|
+
- Stream Claude responses as ChatSseEvent async generators
|
|
11
|
+
- Enforce max concurrent sessions
|
|
12
|
+
- Auto-cleanup inactive sessions on a 60-second timer
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import logging
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
|
|
20
|
+
from claude_agent_sdk import (
|
|
21
|
+
AssistantMessage,
|
|
22
|
+
ClaudeAgentOptions,
|
|
23
|
+
ClaudeSDKClient,
|
|
24
|
+
ResultMessage,
|
|
25
|
+
TextBlock,
|
|
26
|
+
ToolUseBlock,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from config import build_system_prompt, load_config, DEFAULT_AGENTS_DIR
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
# ============================================================================
|
|
34
|
+
# CONSTANTS
|
|
35
|
+
# ============================================================================
|
|
36
|
+
|
|
37
|
+
INACTIVITY_TIMEOUT_SECONDS = 600 # 10 minutes
|
|
38
|
+
CLEANUP_INTERVAL_SECONDS = 60
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ============================================================================
|
|
42
|
+
# TYPES
|
|
43
|
+
# ============================================================================
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class ChatSseEvent:
|
|
47
|
+
"""SSE event sent to the client during text chat streaming.
|
|
48
|
+
|
|
49
|
+
Attributes:
|
|
50
|
+
type: Event type ("text_delta", "tool_start", "tool_end", "result", "error")
|
|
51
|
+
content: Text content or error message
|
|
52
|
+
tool_name: Tool name (only for tool_start events)
|
|
53
|
+
"""
|
|
54
|
+
type: str
|
|
55
|
+
content: str
|
|
56
|
+
tool_name: str | None = None
|
|
57
|
+
|
|
58
|
+
def to_dict(self) -> dict:
|
|
59
|
+
"""Serialize to a JSON-safe dict, omitting None fields."""
|
|
60
|
+
d: dict = {"type": self.type, "content": self.content}
|
|
61
|
+
if self.tool_name is not None:
|
|
62
|
+
d["toolName"] = self.tool_name
|
|
63
|
+
return d
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class ChatSession:
|
|
68
|
+
"""Tracks an active text chat session.
|
|
69
|
+
|
|
70
|
+
Attributes:
|
|
71
|
+
session_key: Device token used as the session key
|
|
72
|
+
client: Persistent ClaudeSDKClient for multi-turn chat
|
|
73
|
+
agent_id: Optional agent identifier for agent-specific prompts
|
|
74
|
+
streaming: Whether the session is currently streaming a response
|
|
75
|
+
last_activity: Unix timestamp of last activity (for inactivity timeout)
|
|
76
|
+
"""
|
|
77
|
+
session_key: str
|
|
78
|
+
client: ClaudeSDKClient
|
|
79
|
+
agent_id: str | None = None
|
|
80
|
+
streaming: bool = False
|
|
81
|
+
last_activity: float = field(default_factory=time.time)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ============================================================================
|
|
85
|
+
# STATE
|
|
86
|
+
# ============================================================================
|
|
87
|
+
|
|
88
|
+
_active_sessions: dict[str, ChatSession] = {}
|
|
89
|
+
_cleanup_task: asyncio.Task | None = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ============================================================================
|
|
93
|
+
# MAIN HANDLERS
|
|
94
|
+
# ============================================================================
|
|
95
|
+
|
|
96
|
+
async def get_or_create_session(session_key: str, agent_id: str | None = None) -> ChatSession:
|
|
97
|
+
"""Get an existing chat session or create a new one.
|
|
98
|
+
|
|
99
|
+
On first call for a session_key, creates a ClaudeSDKClient with the
|
|
100
|
+
appropriate system prompt. Subsequent calls return the existing session.
|
|
101
|
+
Enforces max concurrent sessions from config.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
session_key: Device token to key the session on
|
|
105
|
+
agent_id: Optional agent ID for agent-specific prompts
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
The active ChatSession
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
RuntimeError: If max concurrent sessions exceeded
|
|
112
|
+
"""
|
|
113
|
+
existing = _active_sessions.get(session_key)
|
|
114
|
+
if existing:
|
|
115
|
+
existing.last_activity = time.time()
|
|
116
|
+
return existing
|
|
117
|
+
|
|
118
|
+
config = load_config()
|
|
119
|
+
if len(_active_sessions) >= config.max_concurrent_sessions:
|
|
120
|
+
raise RuntimeError(
|
|
121
|
+
f"Max concurrent sessions ({config.max_concurrent_sessions}) reached"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
system_prompt = build_system_prompt(agent_id, "text")
|
|
125
|
+
|
|
126
|
+
# Determine working directory
|
|
127
|
+
import os
|
|
128
|
+
cwd = config.default_cwd
|
|
129
|
+
if agent_id:
|
|
130
|
+
agent_dir = os.path.join(DEFAULT_AGENTS_DIR, agent_id)
|
|
131
|
+
if os.path.isdir(agent_dir):
|
|
132
|
+
cwd = agent_dir
|
|
133
|
+
|
|
134
|
+
options = ClaudeAgentOptions(
|
|
135
|
+
system_prompt=system_prompt,
|
|
136
|
+
cwd=cwd,
|
|
137
|
+
allowed_tools=[],
|
|
138
|
+
permission_mode="bypassPermissions",
|
|
139
|
+
include_partial_messages=True,
|
|
140
|
+
max_thinking_tokens=10000,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
client = ClaudeSDKClient(options=options)
|
|
144
|
+
await client.connect()
|
|
145
|
+
|
|
146
|
+
session = ChatSession(
|
|
147
|
+
session_key=session_key,
|
|
148
|
+
client=client,
|
|
149
|
+
agent_id=agent_id,
|
|
150
|
+
)
|
|
151
|
+
_active_sessions[session_key] = session
|
|
152
|
+
logger.info(f"[chat] Session created, key: {session_key}")
|
|
153
|
+
|
|
154
|
+
return session
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def stream_message(session_key: str, text: str):
|
|
158
|
+
"""Send a user message and yield SSE events from Claude's response.
|
|
159
|
+
|
|
160
|
+
Guards against concurrent streaming on the same session. Yields
|
|
161
|
+
ChatSseEvent objects for each streaming event from Claude.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
session_key: Device token identifying the session
|
|
165
|
+
text: User message text
|
|
166
|
+
|
|
167
|
+
Yields:
|
|
168
|
+
ChatSseEvent objects for each streaming event
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
RuntimeError: If no active session or already streaming
|
|
172
|
+
"""
|
|
173
|
+
session = _active_sessions.get(session_key)
|
|
174
|
+
if not session:
|
|
175
|
+
raise RuntimeError("No active session")
|
|
176
|
+
|
|
177
|
+
if session.streaming:
|
|
178
|
+
raise RuntimeError("ALREADY_STREAMING")
|
|
179
|
+
|
|
180
|
+
session.last_activity = time.time()
|
|
181
|
+
session.streaming = True
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
await session.client.query(text)
|
|
185
|
+
|
|
186
|
+
async for msg in session.client.receive_response():
|
|
187
|
+
if isinstance(msg, AssistantMessage):
|
|
188
|
+
for block in msg.content:
|
|
189
|
+
if isinstance(block, TextBlock) and block.text:
|
|
190
|
+
yield ChatSseEvent(type="text_delta", content=block.text)
|
|
191
|
+
elif isinstance(block, ToolUseBlock):
|
|
192
|
+
yield ChatSseEvent(
|
|
193
|
+
type="tool_start", content="", tool_name=block.name
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
elif isinstance(msg, ResultMessage):
|
|
197
|
+
if msg.is_error:
|
|
198
|
+
yield ChatSseEvent(
|
|
199
|
+
type="error", content=msg.subtype or "Unknown error"
|
|
200
|
+
)
|
|
201
|
+
break
|
|
202
|
+
|
|
203
|
+
yield ChatSseEvent(type="result", content="")
|
|
204
|
+
|
|
205
|
+
except Exception as e:
|
|
206
|
+
logger.error(f"[chat] Stream error for {session_key}: {e}")
|
|
207
|
+
yield ChatSseEvent(type="error", content=str(e))
|
|
208
|
+
|
|
209
|
+
finally:
|
|
210
|
+
session.streaming = False
|
|
211
|
+
session.last_activity = time.time()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
async def close_session(session_key: str) -> None:
|
|
215
|
+
"""Close a chat session, disconnecting the Claude client.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
session_key: Device token identifying the session
|
|
219
|
+
"""
|
|
220
|
+
session = _active_sessions.pop(session_key, None)
|
|
221
|
+
if not session:
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
await session.client.disconnect()
|
|
226
|
+
except Exception as e:
|
|
227
|
+
logger.warning(f"[chat] Error disconnecting session {session_key}: {e}")
|
|
228
|
+
|
|
229
|
+
logger.info(f"[chat] Session closed, key: {session_key}")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
async def interrupt_session(session_key: str) -> bool:
|
|
233
|
+
"""Interrupt the current streaming response for a session.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
session_key: Device token identifying the session
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
True if a streaming session was interrupted, False otherwise
|
|
240
|
+
"""
|
|
241
|
+
session = _active_sessions.get(session_key)
|
|
242
|
+
if not session or not session.streaming:
|
|
243
|
+
return False
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
await session.client.interrupt()
|
|
247
|
+
except Exception as e:
|
|
248
|
+
logger.warning(f"[chat] Interrupt error for {session_key}: {e}")
|
|
249
|
+
|
|
250
|
+
session.streaming = False
|
|
251
|
+
session.last_activity = time.time()
|
|
252
|
+
logger.info(f"[chat] Session interrupted, key: {session_key}")
|
|
253
|
+
return True
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def has_session(session_key: str) -> bool:
|
|
257
|
+
"""Check if a session exists for the given key.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
session_key: Device token to check
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
True if a session exists
|
|
264
|
+
"""
|
|
265
|
+
return session_key in _active_sessions
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
async def cleanup_inactive() -> None:
|
|
269
|
+
"""Close sessions that have been inactive for 10+ minutes.
|
|
270
|
+
|
|
271
|
+
Called on a periodic timer. Safe to call concurrently.
|
|
272
|
+
"""
|
|
273
|
+
now = time.time()
|
|
274
|
+
stale_keys = [
|
|
275
|
+
key
|
|
276
|
+
for key, session in _active_sessions.items()
|
|
277
|
+
if now - session.last_activity > INACTIVITY_TIMEOUT_SECONDS
|
|
278
|
+
]
|
|
279
|
+
|
|
280
|
+
for key in stale_keys:
|
|
281
|
+
logger.info(f"[chat] Session timed out due to inactivity, key: {key}")
|
|
282
|
+
await close_session(key)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# ============================================================================
|
|
286
|
+
# HELPER FUNCTIONS
|
|
287
|
+
# ============================================================================
|
|
288
|
+
|
|
289
|
+
async def _cleanup_loop() -> None:
|
|
290
|
+
"""Background loop that runs cleanup_inactive every 60 seconds."""
|
|
291
|
+
while True:
|
|
292
|
+
await asyncio.sleep(CLEANUP_INTERVAL_SECONDS)
|
|
293
|
+
try:
|
|
294
|
+
await cleanup_inactive()
|
|
295
|
+
except Exception as e:
|
|
296
|
+
logger.error(f"[chat] Cleanup error: {e}")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def start_cleanup_timer() -> None:
|
|
300
|
+
"""Start the background cleanup timer. Call once at server startup."""
|
|
301
|
+
global _cleanup_task
|
|
302
|
+
if _cleanup_task is None:
|
|
303
|
+
_cleanup_task = asyncio.create_task(_cleanup_loop())
|
|
304
|
+
logger.info("[chat] Inactivity cleanup timer started")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def stop_cleanup_timer() -> None:
|
|
308
|
+
"""Stop the background cleanup timer."""
|
|
309
|
+
global _cleanup_task
|
|
310
|
+
if _cleanup_task is not None:
|
|
311
|
+
_cleanup_task.cancel()
|
|
312
|
+
_cleanup_task = None
|