cc-discussion 1.0.0__py3-none-any.whl

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,461 @@
1
+ """
2
+ Discussion Orchestrator
3
+ =======================
4
+
5
+ Orchestrates multi-Claude discussions using subprocess-based agents.
6
+ Each participant agent runs in a separate process to avoid client reuse issues.
7
+ """
8
+
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ import os
13
+ import subprocess
14
+ import sys
15
+ import tempfile
16
+ from pathlib import Path
17
+ from typing import AsyncGenerator, Optional, List, Callable
18
+
19
+ from .history_reader import (
20
+ load_session_history,
21
+ format_context_for_injection,
22
+ decode_project_id,
23
+ get_original_path_from_dir,
24
+ )
25
+ from ..models.database import (
26
+ DiscussionRoom,
27
+ RoomParticipant,
28
+ DiscussionMessage,
29
+ RoomStatus,
30
+ )
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ # Path to the participant agent script
35
+ PARTICIPANT_AGENT_PATH = Path(__file__).parent / "participant_agent.py"
36
+
37
+
38
+ class ParticipantClient:
39
+ """
40
+ Manages a single Claude participant in the discussion.
41
+
42
+ Uses subprocess-based execution to avoid client reuse issues.
43
+ Each call to respond() spawns a fresh process with a new ClaudeSDKClient.
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ participant: RoomParticipant,
49
+ context_text: str,
50
+ room_topic: str,
51
+ ):
52
+ self.participant = participant
53
+ self.context_text = context_text
54
+ self.room_topic = room_topic
55
+ self.cwd: Optional[str] = None
56
+ self._process: Optional[subprocess.Popen] = None
57
+
58
+ def resolve_cwd(self) -> None:
59
+ """Resolve the working directory from participant's project context."""
60
+ if self.participant.context_project_dir:
61
+ try:
62
+ # Decode the Base64 project ID to get internal directory path
63
+ internal_dir = Path(decode_project_id(self.participant.context_project_dir))
64
+ # Get the actual project path from session files
65
+ actual_path = get_original_path_from_dir(internal_dir)
66
+ if actual_path and Path(actual_path).exists():
67
+ self.cwd = actual_path
68
+ logger.info(f"Resolved cwd for {self.participant.name}: {self.cwd}")
69
+ except Exception as e:
70
+ logger.warning(f"Failed to resolve cwd for {self.participant.name}: {e}")
71
+
72
+ async def respond(
73
+ self,
74
+ conversation_history: str,
75
+ mode: str = "speak",
76
+ preparation_notes: str = "",
77
+ ) -> AsyncGenerator[dict, None]:
78
+ """
79
+ Generate a response by spawning a subprocess.
80
+
81
+ Args:
82
+ conversation_history: Current conversation history
83
+ mode: "speak" for generating response, "prepare" for preparation
84
+ preparation_notes: Notes from preparation phase (if any)
85
+
86
+ Yields:
87
+ Dict with type and content (text chunks, tool use, completion, etc.)
88
+ """
89
+ # Create temporary file with context data
90
+ data = {
91
+ "room_topic": self.room_topic,
92
+ "context_text": self.context_text,
93
+ "conversation_history": conversation_history,
94
+ "preparation_notes": preparation_notes,
95
+ }
96
+
97
+ # Write context to temp file
98
+ fd, data_file = tempfile.mkstemp(suffix=".json", prefix="participant_")
99
+ try:
100
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
101
+ json.dump(data, f, ensure_ascii=False)
102
+
103
+ # Build command
104
+ cmd = [
105
+ sys.executable, "-u",
106
+ str(PARTICIPANT_AGENT_PATH),
107
+ "--participant-id", str(self.participant.id),
108
+ "--participant-name", self.participant.name,
109
+ "--participant-role", self.participant.role or "",
110
+ "--data-file", data_file,
111
+ "--mode", mode,
112
+ ]
113
+
114
+ if self.cwd:
115
+ cmd.extend(["--cwd", self.cwd])
116
+
117
+ logger.info(f"Spawning participant agent: {self.participant.name} (mode={mode})")
118
+
119
+ # Spawn subprocess
120
+ env = os.environ.copy()
121
+ env["PYTHONUNBUFFERED"] = "1"
122
+
123
+ self._process = subprocess.Popen(
124
+ cmd,
125
+ stdout=subprocess.PIPE,
126
+ stderr=subprocess.PIPE,
127
+ text=True,
128
+ encoding="utf-8",
129
+ errors="replace",
130
+ env=env,
131
+ cwd=str(Path(__file__).parent.parent.parent), # Project root
132
+ )
133
+
134
+ # Read stdout line by line (JSON Lines format)
135
+ full_response = ""
136
+
137
+ async def read_output():
138
+ nonlocal full_response
139
+ loop = asyncio.get_event_loop()
140
+
141
+ while True:
142
+ # Read line in executor to not block event loop
143
+ line = await loop.run_in_executor(
144
+ None, self._process.stdout.readline
145
+ )
146
+
147
+ if not line:
148
+ break
149
+
150
+ line = line.strip()
151
+ if not line:
152
+ continue
153
+
154
+ try:
155
+ msg = json.loads(line)
156
+ yield msg
157
+
158
+ # Track full response
159
+ if msg.get("type") == "text":
160
+ full_response += msg.get("content", "")
161
+ elif msg.get("type") == "response_complete":
162
+ full_response = msg.get("full_content", full_response)
163
+
164
+ except json.JSONDecodeError:
165
+ logger.warning(f"Non-JSON output from agent: {line}")
166
+
167
+ async for msg in read_output():
168
+ yield msg
169
+
170
+ # Wait for process to complete
171
+ await asyncio.get_event_loop().run_in_executor(
172
+ None, self._process.wait
173
+ )
174
+
175
+ # Check for errors
176
+ if self._process.returncode != 0:
177
+ stderr = self._process.stderr.read()
178
+ logger.error(f"Agent process failed: {stderr}")
179
+ if not full_response:
180
+ yield {
181
+ "type": "error",
182
+ "content": f"Agent process failed: {stderr[:500]}"
183
+ }
184
+
185
+ # Ensure response_complete is yielded if not already
186
+ # (in case the subprocess didn't output it)
187
+
188
+ except Exception as e:
189
+ logger.error(f"Error running participant agent: {e}")
190
+ yield {"type": "error", "content": str(e)}
191
+
192
+ finally:
193
+ # Clean up temp file
194
+ try:
195
+ Path(data_file).unlink(missing_ok=True)
196
+ except Exception:
197
+ pass
198
+
199
+ # Clean up process
200
+ if self._process:
201
+ try:
202
+ self._process.kill()
203
+ except Exception:
204
+ pass
205
+ self._process = None
206
+
207
+ def stop(self):
208
+ """Stop the running process if any."""
209
+ if self._process:
210
+ try:
211
+ self._process.terminate()
212
+ self._process.wait(timeout=5)
213
+ except Exception:
214
+ try:
215
+ self._process.kill()
216
+ except Exception:
217
+ pass
218
+ self._process = None
219
+
220
+
221
+ class DiscussionOrchestrator:
222
+ """Orchestrates a multi-Claude discussion."""
223
+
224
+ def __init__(
225
+ self,
226
+ room: DiscussionRoom,
227
+ db_session,
228
+ on_event: Optional[Callable] = None,
229
+ ):
230
+ self.room = room
231
+ self.db = db_session
232
+ self.participants: List[ParticipantClient] = []
233
+ self.current_speaker_idx = 0
234
+ self._running = False
235
+ self._paused = False
236
+ self.on_event = on_event
237
+
238
+ async def initialize_participants(self):
239
+ """Initialize all participant clients with their contexts."""
240
+ for participant in self.room.participants:
241
+ # Load context from ClaudeCode history if specified
242
+ context_text = ""
243
+ if participant.context_project_dir and participant.context_session_id:
244
+ try:
245
+ messages = load_session_history(
246
+ participant.context_project_dir,
247
+ participant.context_session_id
248
+ )
249
+ context_text = format_context_for_injection(messages, max_chars=50000)
250
+ logger.info(
251
+ f"Loaded context for {participant.name}: "
252
+ f"{len(messages)} messages, {len(context_text)} chars"
253
+ )
254
+ except Exception as e:
255
+ logger.warning(
256
+ f"Failed to load context for {participant.name}: {e}"
257
+ )
258
+ context_text = participant.context_summary or ""
259
+ elif participant.context_summary:
260
+ context_text = participant.context_summary
261
+
262
+ client = ParticipantClient(
263
+ participant=participant,
264
+ context_text=context_text,
265
+ room_topic=self.room.topic or "General discussion",
266
+ )
267
+ # Resolve working directory
268
+ client.resolve_cwd()
269
+ self.participants.append(client)
270
+
271
+ logger.info(f"Initialized {len(self.participants)} participants")
272
+
273
+ def _build_conversation_history(self) -> str:
274
+ """Build conversation history string from all messages."""
275
+ messages = self.db.query(DiscussionMessage).filter(
276
+ DiscussionMessage.room_id == self.room.id
277
+ ).order_by(DiscussionMessage.created_at).all()
278
+
279
+ lines = []
280
+ for msg in messages:
281
+ if msg.role == "system":
282
+ lines.append(f"[System]: {msg.content}\n")
283
+ elif msg.role == "moderator":
284
+ lines.append(f"[Moderator]: {msg.content}\n")
285
+ else:
286
+ # Find participant name
287
+ participant = next(
288
+ (p for p in self.room.participants if p.id == msg.participant_id),
289
+ None
290
+ )
291
+ name = participant.name if participant else "Unknown"
292
+ lines.append(f"[{name}]: {msg.content}\n")
293
+
294
+ return "\n".join(lines)
295
+
296
+ async def run_turn(self) -> AsyncGenerator[dict, None]:
297
+ """Run a single turn of discussion."""
298
+ if not self.participants:
299
+ yield {"type": "error", "content": "No participants initialized"}
300
+ return
301
+
302
+ # Get current speaker
303
+ speaker = self.participants[self.current_speaker_idx]
304
+ participant = speaker.participant
305
+
306
+ yield {
307
+ "type": "turn_start",
308
+ "participant_id": participant.id,
309
+ "participant_name": participant.name,
310
+ "turn_number": self.room.current_turn + 1,
311
+ }
312
+
313
+ # Mark as speaking
314
+ participant.is_speaking = True
315
+ self.db.commit()
316
+
317
+ # Build history and get response
318
+ history = self._build_conversation_history()
319
+ full_content = ""
320
+
321
+ async for chunk in speaker.respond(history):
322
+ if chunk["type"] == "text":
323
+ yield {
324
+ "type": "text",
325
+ "content": chunk["content"],
326
+ "participant_id": participant.id,
327
+ }
328
+ elif chunk["type"] == "tool_use":
329
+ yield {
330
+ "type": "tool_use",
331
+ "tool": chunk.get("tool"),
332
+ "input": chunk.get("input"),
333
+ "participant_id": participant.id,
334
+ }
335
+ elif chunk["type"] == "response_complete":
336
+ full_content = chunk["full_content"]
337
+ elif chunk["type"] == "error":
338
+ yield {
339
+ "type": "error",
340
+ "content": chunk["content"],
341
+ "participant_id": participant.id,
342
+ }
343
+
344
+ # Save message to database
345
+ message = DiscussionMessage(
346
+ room_id=self.room.id,
347
+ participant_id=participant.id,
348
+ role="participant",
349
+ content=full_content,
350
+ turn_number=self.room.current_turn + 1,
351
+ )
352
+ self.db.add(message)
353
+
354
+ # Update state
355
+ participant.is_speaking = False
356
+ participant.message_count += 1
357
+ self.room.current_turn += 1
358
+ self.current_speaker_idx = (
359
+ self.current_speaker_idx + 1
360
+ ) % len(self.participants)
361
+ self.db.commit()
362
+
363
+ yield {
364
+ "type": "turn_complete",
365
+ "participant_id": participant.id,
366
+ "message_id": message.id,
367
+ "turn_number": self.room.current_turn,
368
+ }
369
+
370
+ async def run_discussion(self) -> AsyncGenerator[dict, None]:
371
+ """Run the full discussion until max_turns or completion."""
372
+ self._running = True
373
+ self._paused = False
374
+ self.room.status = RoomStatus.ACTIVE
375
+ self.db.commit()
376
+
377
+ yield {
378
+ "type": "discussion_start",
379
+ "room_id": self.room.id,
380
+ "max_turns": self.room.max_turns,
381
+ }
382
+
383
+ while (
384
+ self._running
385
+ and not self._paused
386
+ and self.room.current_turn < self.room.max_turns
387
+ ):
388
+ async for chunk in self.run_turn():
389
+ yield chunk
390
+
391
+ # Small delay between turns for readability
392
+ await asyncio.sleep(1)
393
+
394
+ # Refresh room state from database (for pause/stop from outside)
395
+ self.db.refresh(self.room)
396
+ if self.room.status == RoomStatus.PAUSED:
397
+ self._paused = True
398
+ yield {"type": "discussion_paused", "turn": self.room.current_turn}
399
+ break
400
+
401
+ if not self._paused:
402
+ self.room.status = RoomStatus.COMPLETED
403
+ self.db.commit()
404
+ yield {
405
+ "type": "discussion_complete",
406
+ "total_turns": self.room.current_turn,
407
+ }
408
+
409
+ def pause(self):
410
+ """Pause the discussion."""
411
+ self._paused = True
412
+ self.room.status = RoomStatus.PAUSED
413
+ self.db.commit()
414
+
415
+ # Stop any running participant process
416
+ for p in self.participants:
417
+ p.stop()
418
+
419
+ def stop(self):
420
+ """Stop the discussion."""
421
+ self._running = False
422
+ self.room.status = RoomStatus.COMPLETED
423
+ self.db.commit()
424
+
425
+ # Stop any running participant process
426
+ for p in self.participants:
427
+ p.stop()
428
+
429
+ async def cleanup(self):
430
+ """Clean up all participant clients."""
431
+ for client in self.participants:
432
+ client.stop()
433
+ self.participants.clear()
434
+ logger.info("Cleaned up all participant clients")
435
+
436
+
437
+ # Global registry of active orchestrators
438
+ _active_orchestrators: dict[int, DiscussionOrchestrator] = {}
439
+
440
+
441
+ def get_orchestrator(room_id: int) -> Optional[DiscussionOrchestrator]:
442
+ """Get an active orchestrator by room ID."""
443
+ return _active_orchestrators.get(room_id)
444
+
445
+
446
+ def register_orchestrator(room_id: int, orchestrator: DiscussionOrchestrator):
447
+ """Register an active orchestrator."""
448
+ _active_orchestrators[room_id] = orchestrator
449
+
450
+
451
+ def unregister_orchestrator(room_id: int):
452
+ """Unregister an orchestrator."""
453
+ if room_id in _active_orchestrators:
454
+ del _active_orchestrators[room_id]
455
+
456
+
457
+ async def cleanup_all_orchestrators():
458
+ """Clean up all active orchestrators."""
459
+ for room_id, orchestrator in list(_active_orchestrators.items()):
460
+ await orchestrator.cleanup()
461
+ _active_orchestrators.clear()