usage-cli 0.29.32__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.
- adapters/__init__.py +5 -0
- adapters/agy.py +68 -0
- adapters/claude.py +215 -0
- adapters/codex.py +209 -0
- adapters/rate_limits.py +76 -0
- adapters/registry.py +17 -0
- adapters/types.py +139 -0
- agy_disk_cache.py +135 -0
- agy_loader.py +416 -0
- agy_quota_probe.py +748 -0
- agy_window_keeper.py +185 -0
- analyzer/__init__.py +5 -0
- analyzer/aggregator.py +139 -0
- analyzer/blocks.py +80 -0
- analyzer/diagnoser.py +638 -0
- analyzer/insights.py +277 -0
- analyzer/persona_loader.py +199 -0
- analyzer/reporter.py +989 -0
- analyzer/subscription.py +108 -0
- burn_rate.py +75 -0
- cache_quarantine.py +50 -0
- codex_disk_cache.py +227 -0
- codex_events.py +136 -0
- codex_fork_replay.py +111 -0
- codex_loader.py +1426 -0
- codex_paths.py +20 -0
- critter_frames.py +26 -0
- discussion_bridge.py +1196 -0
- discussion_cli.py +844 -0
- discussion_session.py +622 -0
- discussion_usage.py +13 -0
- discussion_window.py +955 -0
- disk_cache_common.py +132 -0
- disk_cache_lifecycle.py +39 -0
- doctor.py +452 -0
- fsevents_watch.py +207 -0
- history_disk_cache.py +110 -0
- history_loader.py +416 -0
- i18n.py +88 -0
- jsonl_limits.py +17 -0
- jsonl_utils.py +40 -0
- login_item.py +154 -0
- main.py +387 -0
- menubar.py +1201 -0
- menubar_actions.py +204 -0
- menubar_agy.py +193 -0
- menubar_chrome.py +156 -0
- menubar_menu.py +169 -0
- menubar_notify.py +102 -0
- menubar_popover.py +233 -0
- menubar_prefs.py +118 -0
- menubar_refresh.py +285 -0
- menubar_state.py +1200 -0
- menubar_title.py +157 -0
- menubar_update.py +123 -0
- panel_window.py +78 -0
- panel_window_state.py +159 -0
- panels/__init__.py +186 -0
- panels/base.py +83 -0
- panels/dynamic_height.py +140 -0
- panels/payload.py +178 -0
- panels/web_panel.py +513 -0
- panels/window_drag.py +56 -0
- prefs.py +44 -0
- pricing.py +452 -0
- project_resolver.py +112 -0
- service_status.py +383 -0
- session_hooks.py +1154 -0
- setup_app.py +171 -0
- setup_hook.py +1011 -0
- statusline_settings.py +160 -0
- talent_market_bridge.py +243 -0
- time_utils.py +24 -0
- tui.py +288 -0
- tui_sprite.py +206 -0
- ui/__init__.py +5 -0
- ui/html_report.py +923 -0
- ui/report_scripts.py +251 -0
- ui/report_styles.py +370 -0
- ui/tables.py +888 -0
- update_checker.py +156 -0
- update_gate.py +66 -0
- update_release_notes.py +49 -0
- usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
- usage_cli-0.29.32.dist-info/METADATA +223 -0
- usage_cli-0.29.32.dist-info/RECORD +109 -0
- usage_cli-0.29.32.dist-info/WHEEL +5 -0
- usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
- usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
- usage_cli-0.29.32.dist-info/top_level.txt +80 -0
- usage_cli.py +827 -0
- usage_client.py +487 -0
- usage_diagnosis_snapshot.py +143 -0
- usage_dir_sweeper.py +100 -0
- usage_lang.py +79 -0
- usage_logging.py +75 -0
- usage_notifications.py +96 -0
- usage_rate.py +97 -0
- usage_session_resume.py +913 -0
- usage_statusline.py +810 -0
- usage_statusline_agy.py +397 -0
- usage_statusline_forwarder.py +88 -0
- usage_terse_mode.py +223 -0
- usage_terse_reminder.py +151 -0
- win_login_item.py +53 -0
- window_keeper.py +264 -0
- windows_watch.py +443 -0
- wintray.py +2014 -0
- wintray_menu.py +136 -0
discussion_bridge.py
ADDED
|
@@ -0,0 +1,1196 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
3
|
+
#
|
|
4
|
+
# Part of "usage". Free software licensed under the GNU Affero General Public
|
|
5
|
+
# License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
|
|
6
|
+
|
|
7
|
+
"""PyObjC-free orchestration for one AI council discussion at a time."""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from collections import deque
|
|
19
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
20
|
+
from dataclasses import asdict, dataclass, field, replace
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Literal
|
|
24
|
+
|
|
25
|
+
from discussion_cli import (
|
|
26
|
+
DEFAULT_TIMEOUT_SECONDS,
|
|
27
|
+
AgyAdapter,
|
|
28
|
+
ClaudeAdapter,
|
|
29
|
+
CLIAdapter,
|
|
30
|
+
CodexAdapter,
|
|
31
|
+
DetectionResult,
|
|
32
|
+
Invocation,
|
|
33
|
+
StreamError,
|
|
34
|
+
StreamFailureReason,
|
|
35
|
+
build_argv_invocation,
|
|
36
|
+
build_login_shell_invocation,
|
|
37
|
+
resolve_neutral_working_directory,
|
|
38
|
+
run_streaming,
|
|
39
|
+
validate_project_working_directory,
|
|
40
|
+
)
|
|
41
|
+
from discussion_session import (
|
|
42
|
+
ConsensusCount,
|
|
43
|
+
DebateStyle,
|
|
44
|
+
DiscussionEvent,
|
|
45
|
+
DiscussionSession,
|
|
46
|
+
Participant,
|
|
47
|
+
SessionStatus,
|
|
48
|
+
build_moderator_prompt,
|
|
49
|
+
build_round1_prompt,
|
|
50
|
+
build_round2_prompt,
|
|
51
|
+
truncate_guidance,
|
|
52
|
+
)
|
|
53
|
+
from discussion_usage import TurnUsage
|
|
54
|
+
from i18n import _t
|
|
55
|
+
from usage_lang import detect_lang
|
|
56
|
+
|
|
57
|
+
logger = logging.getLogger(__name__)
|
|
58
|
+
|
|
59
|
+
MAX_CONCURRENT_PROCESSES = 4
|
|
60
|
+
DELTA_FLUSH_CHARS = 128
|
|
61
|
+
DELTA_FLUSH_SECONDS = 0.05
|
|
62
|
+
GUIDANCE_TIMEOUT_SECONDS = 300.0
|
|
63
|
+
DISCUSSIONS_DIRECTORY = Path("~/.usage/discussions").expanduser()
|
|
64
|
+
|
|
65
|
+
ParticipantSource = Literal["builtin", "argv", "login_shell"]
|
|
66
|
+
AdapterFactory = Callable[["ParticipantSpec"], CLIAdapter]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class DiscussionBusyError(RuntimeError):
|
|
70
|
+
"""Raised when start is called while the current session is still running."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class ParticipantSpec:
|
|
75
|
+
id: str
|
|
76
|
+
label: str
|
|
77
|
+
adapter_id: str
|
|
78
|
+
model: str | None = None
|
|
79
|
+
source: ParticipantSource = "builtin"
|
|
80
|
+
executable: str | None = None
|
|
81
|
+
args_before_prompt: tuple[str, ...] = ()
|
|
82
|
+
args_after_prompt: tuple[str, ...] = ()
|
|
83
|
+
login_shell_script: str | None = None
|
|
84
|
+
login_shell_opt_in: bool = False
|
|
85
|
+
cwd: str | None = None
|
|
86
|
+
read_only: bool = False
|
|
87
|
+
extra_read_dirs: tuple[str, ...] = ()
|
|
88
|
+
env_overrides: Mapping[str, str] = field(default_factory=dict)
|
|
89
|
+
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS
|
|
90
|
+
supports_token_stream: bool = False
|
|
91
|
+
persona_prompt: str | None = None
|
|
92
|
+
persona_label: str | None = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class _ResolvedParticipant:
|
|
97
|
+
spec: ParticipantSpec
|
|
98
|
+
adapter: CLIAdapter | None
|
|
99
|
+
detection: DetectionResult
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class _TurnResult:
|
|
104
|
+
participant: _ResolvedParticipant
|
|
105
|
+
turn_id: str | None
|
|
106
|
+
success: bool
|
|
107
|
+
text: str
|
|
108
|
+
error: str | None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class _DeltaAccumulator:
|
|
112
|
+
def __init__(self) -> None:
|
|
113
|
+
self._parts: list[str] = []
|
|
114
|
+
self._length = 0
|
|
115
|
+
self._last_flush = time.monotonic()
|
|
116
|
+
self._lock = threading.Lock()
|
|
117
|
+
|
|
118
|
+
def add(self, text: str) -> str:
|
|
119
|
+
with self._lock:
|
|
120
|
+
self._parts.append(text)
|
|
121
|
+
self._length += len(text)
|
|
122
|
+
now = time.monotonic()
|
|
123
|
+
if (
|
|
124
|
+
self._length < DELTA_FLUSH_CHARS
|
|
125
|
+
and now - self._last_flush < DELTA_FLUSH_SECONDS
|
|
126
|
+
):
|
|
127
|
+
return ""
|
|
128
|
+
return self._take_locked(now)
|
|
129
|
+
|
|
130
|
+
def flush(self) -> str:
|
|
131
|
+
with self._lock:
|
|
132
|
+
return self._take_locked(time.monotonic())
|
|
133
|
+
|
|
134
|
+
def _take_locked(self, now: float) -> str:
|
|
135
|
+
if not self._parts:
|
|
136
|
+
return ""
|
|
137
|
+
value = "".join(self._parts)
|
|
138
|
+
self._parts.clear()
|
|
139
|
+
self._length = 0
|
|
140
|
+
self._last_flush = now
|
|
141
|
+
return value
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class _CustomLineAdapter:
|
|
145
|
+
def __init__(self, spec: ParticipantSpec) -> None:
|
|
146
|
+
self.adapter_id = spec.adapter_id
|
|
147
|
+
self.supports_token_stream = spec.supports_token_stream
|
|
148
|
+
self._spec = spec
|
|
149
|
+
|
|
150
|
+
def detect(self) -> DetectionResult:
|
|
151
|
+
if self._spec.source == "login_shell":
|
|
152
|
+
shell = Path("/bin/zsh")
|
|
153
|
+
available = shell.is_file() and os.access(shell, os.X_OK)
|
|
154
|
+
return DetectionResult(
|
|
155
|
+
self.adapter_id,
|
|
156
|
+
available,
|
|
157
|
+
str(shell) if available else None,
|
|
158
|
+
"user_configured" if available else "not_found",
|
|
159
|
+
None if available else "/bin/zsh is missing or not executable",
|
|
160
|
+
)
|
|
161
|
+
executable = self._spec.executable
|
|
162
|
+
if not executable:
|
|
163
|
+
return DetectionResult(
|
|
164
|
+
self.adapter_id,
|
|
165
|
+
False,
|
|
166
|
+
None,
|
|
167
|
+
"not_found",
|
|
168
|
+
"custom executable is required",
|
|
169
|
+
)
|
|
170
|
+
path = Path(executable)
|
|
171
|
+
if path.is_absolute():
|
|
172
|
+
available = path.is_file() and os.access(path, os.X_OK)
|
|
173
|
+
return DetectionResult(
|
|
174
|
+
self.adapter_id,
|
|
175
|
+
available,
|
|
176
|
+
str(path),
|
|
177
|
+
"user_configured",
|
|
178
|
+
None if available else "custom executable is missing or not executable",
|
|
179
|
+
)
|
|
180
|
+
found = shutil.which(executable)
|
|
181
|
+
return DetectionResult(
|
|
182
|
+
self.adapter_id,
|
|
183
|
+
found is not None,
|
|
184
|
+
found,
|
|
185
|
+
"which" if found is not None else "not_found",
|
|
186
|
+
None if found is not None else f"custom executable not found: {executable}",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def build_invocation(self, prompt: str, model: str | None) -> Invocation:
|
|
190
|
+
if self._spec.read_only:
|
|
191
|
+
raise ValueError("read-only project mode is unavailable for custom participants")
|
|
192
|
+
cwd = self._spec.cwd or resolve_neutral_working_directory()
|
|
193
|
+
if self._spec.source == "login_shell":
|
|
194
|
+
script = self._spec.login_shell_script
|
|
195
|
+
if script is None:
|
|
196
|
+
raise ValueError("login-shell command requires login_shell_script")
|
|
197
|
+
return build_login_shell_invocation(
|
|
198
|
+
script,
|
|
199
|
+
prompt,
|
|
200
|
+
opt_in=self._spec.login_shell_opt_in,
|
|
201
|
+
cwd=cwd,
|
|
202
|
+
env_overrides=self._spec.env_overrides,
|
|
203
|
+
timeout_seconds=self._spec.timeout_seconds,
|
|
204
|
+
)
|
|
205
|
+
detection = self.detect()
|
|
206
|
+
if not detection.available or detection.path is None:
|
|
207
|
+
raise ValueError(detection.error or "custom executable is unavailable")
|
|
208
|
+
return build_argv_invocation(
|
|
209
|
+
detection.path,
|
|
210
|
+
self._spec.args_before_prompt,
|
|
211
|
+
self._spec.args_after_prompt,
|
|
212
|
+
prompt,
|
|
213
|
+
cwd=cwd,
|
|
214
|
+
env_overrides=self._spec.env_overrides,
|
|
215
|
+
timeout_seconds=self._spec.timeout_seconds,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def parse_stdout_line(self, line: str) -> tuple[str | None, bool]:
|
|
219
|
+
return (line, False) if line else (None, False)
|
|
220
|
+
|
|
221
|
+
def take_final_text(self) -> str | None:
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
def take_usage(self) -> TurnUsage | None:
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class DiscussionBridge:
|
|
229
|
+
def __init__(self, adapter_factory: AdapterFactory | None = None) -> None:
|
|
230
|
+
self._adapter_factory = adapter_factory or _default_adapter_factory
|
|
231
|
+
self._session: DiscussionSession | None = None
|
|
232
|
+
self._worker: threading.Thread | None = None
|
|
233
|
+
self._cancel_event: threading.Event | None = None
|
|
234
|
+
self._guidance_event = threading.Event()
|
|
235
|
+
self._guidance_lock = threading.Lock()
|
|
236
|
+
self._guidance_text: str | None = None
|
|
237
|
+
self._state_lock = threading.Lock()
|
|
238
|
+
self._event_order_lock = threading.RLock()
|
|
239
|
+
self._event_lock = threading.Lock()
|
|
240
|
+
self._events: deque[DiscussionEvent] = deque()
|
|
241
|
+
self._event_listener: Callable[[], None] | None = None
|
|
242
|
+
self._callbacks_enabled = True
|
|
243
|
+
self._working_directory: str | None = None
|
|
244
|
+
|
|
245
|
+
def detect_participants(self) -> list[DetectionResult]:
|
|
246
|
+
return [
|
|
247
|
+
ClaudeAdapter().detect(),
|
|
248
|
+
CodexAdapter().detect(),
|
|
249
|
+
AgyAdapter().detect(),
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
def start(
|
|
253
|
+
self,
|
|
254
|
+
topic: str,
|
|
255
|
+
participants: Sequence[ParticipantSpec],
|
|
256
|
+
moderator_id: str | None = None,
|
|
257
|
+
working_directory: str | None = None,
|
|
258
|
+
attachments: Sequence[str] | None = None,
|
|
259
|
+
total_rounds: int = 2,
|
|
260
|
+
include_summary: bool = True,
|
|
261
|
+
end_on_consensus: bool = False,
|
|
262
|
+
guidance_between_rounds: bool = False,
|
|
263
|
+
debate_style: DebateStyle = DebateStyle.CONSTRUCTIVE,
|
|
264
|
+
) -> str:
|
|
265
|
+
normalized_topic = topic.strip()
|
|
266
|
+
if not normalized_topic:
|
|
267
|
+
raise ValueError("topic must not be blank")
|
|
268
|
+
specs = tuple(participants)
|
|
269
|
+
if not specs:
|
|
270
|
+
raise ValueError("at least one participant is required")
|
|
271
|
+
project_cwd = (
|
|
272
|
+
validate_project_working_directory(working_directory)
|
|
273
|
+
if working_directory is not None
|
|
274
|
+
else None
|
|
275
|
+
)
|
|
276
|
+
if project_cwd is not None:
|
|
277
|
+
specs = tuple(
|
|
278
|
+
replace(spec, cwd=project_cwd, read_only=True) for spec in specs
|
|
279
|
+
)
|
|
280
|
+
# Grant each CLI read access to the folders holding the attached
|
|
281
|
+
# images, so non-interactive mode can open files outside the working
|
|
282
|
+
# directory without an approval prompt. Only the attachment folders
|
|
283
|
+
# themselves — never the home directory or wider paths.
|
|
284
|
+
attachment_dirs = _attachment_dirs(attachments or ())
|
|
285
|
+
if attachment_dirs:
|
|
286
|
+
specs = tuple(
|
|
287
|
+
replace(spec, extra_read_dirs=attachment_dirs) for spec in specs
|
|
288
|
+
)
|
|
289
|
+
participant_models = [
|
|
290
|
+
Participant(
|
|
291
|
+
id=spec.id,
|
|
292
|
+
label=spec.label,
|
|
293
|
+
adapter_id=spec.adapter_id,
|
|
294
|
+
model=spec.model,
|
|
295
|
+
is_moderator=spec.id == moderator_id,
|
|
296
|
+
persona_label=spec.persona_label,
|
|
297
|
+
)
|
|
298
|
+
for spec in specs
|
|
299
|
+
]
|
|
300
|
+
rounds = min(5, max(1, total_rounds))
|
|
301
|
+
session = DiscussionSession(
|
|
302
|
+
normalized_topic, participant_models, total_rounds=rounds
|
|
303
|
+
)
|
|
304
|
+
# The session keeps the user's original topic for display; only the
|
|
305
|
+
# prompt handed to each CLI carries the appended image paths.
|
|
306
|
+
effective_topic = normalized_topic + build_attachment_block(
|
|
307
|
+
attachments or (), detect_lang()
|
|
308
|
+
)
|
|
309
|
+
cancel_event = threading.Event()
|
|
310
|
+
with self._guidance_lock:
|
|
311
|
+
self._guidance_text = None
|
|
312
|
+
self._guidance_event.clear()
|
|
313
|
+
with self._state_lock:
|
|
314
|
+
if self._worker is not None and self._worker.is_alive():
|
|
315
|
+
raise DiscussionBusyError("a discussion session is already running")
|
|
316
|
+
self._session = session
|
|
317
|
+
self._working_directory = project_cwd
|
|
318
|
+
self._cancel_event = cancel_event
|
|
319
|
+
self._callbacks_enabled = True
|
|
320
|
+
with self._event_lock:
|
|
321
|
+
self._events.clear()
|
|
322
|
+
session.transition(SessionStatus.PREPARING)
|
|
323
|
+
worker = threading.Thread(
|
|
324
|
+
target=self._run_session,
|
|
325
|
+
args=(
|
|
326
|
+
session,
|
|
327
|
+
specs,
|
|
328
|
+
moderator_id,
|
|
329
|
+
effective_topic,
|
|
330
|
+
rounds,
|
|
331
|
+
include_summary,
|
|
332
|
+
end_on_consensus,
|
|
333
|
+
guidance_between_rounds,
|
|
334
|
+
debate_style,
|
|
335
|
+
cancel_event,
|
|
336
|
+
),
|
|
337
|
+
name=f"discussion-session-{session.session_id}",
|
|
338
|
+
daemon=True,
|
|
339
|
+
)
|
|
340
|
+
self._worker = worker
|
|
341
|
+
worker.start()
|
|
342
|
+
return session.session_id
|
|
343
|
+
|
|
344
|
+
def stop(self) -> None:
|
|
345
|
+
with self._state_lock:
|
|
346
|
+
session = self._session
|
|
347
|
+
cancel_event = self._cancel_event
|
|
348
|
+
if session is None or cancel_event is None:
|
|
349
|
+
return
|
|
350
|
+
with self._event_order_lock:
|
|
351
|
+
if session.status not in {
|
|
352
|
+
SessionStatus.PREPARING,
|
|
353
|
+
SessionStatus.ROUND1_RUNNING,
|
|
354
|
+
SessionStatus.AWAITING_GUIDANCE,
|
|
355
|
+
SessionStatus.ROUND2_RUNNING,
|
|
356
|
+
SessionStatus.SUMMARIZING,
|
|
357
|
+
}:
|
|
358
|
+
return
|
|
359
|
+
cancel_event.set()
|
|
360
|
+
self._guidance_event.set()
|
|
361
|
+
session.transition(SessionStatus.CANCELLING)
|
|
362
|
+
for cancelled in session.cancel_incomplete_turns():
|
|
363
|
+
self._enqueue_event_locked(cancelled)
|
|
364
|
+
event = session.transition(SessionStatus.CANCELLED)
|
|
365
|
+
if event is not None:
|
|
366
|
+
self._enqueue_event_locked(event)
|
|
367
|
+
|
|
368
|
+
def clear(self) -> dict[str, str]:
|
|
369
|
+
"""Drop the finished discussion so a new round can start fresh.
|
|
370
|
+
|
|
371
|
+
Refuses while the worker thread is still alive (the UI must stop
|
|
372
|
+
first); returns ``{"status": "busy"}`` rather than raising so a stray
|
|
373
|
+
call never crashes the window. On success returns ``{"status": "ok"}``
|
|
374
|
+
and ``snapshot()`` is empty afterwards. Never touches attachment files.
|
|
375
|
+
"""
|
|
376
|
+
with self._state_lock:
|
|
377
|
+
if self._worker is not None and self._worker.is_alive():
|
|
378
|
+
return {"status": "busy"}
|
|
379
|
+
self._session = None
|
|
380
|
+
self._worker = None
|
|
381
|
+
self._cancel_event = None
|
|
382
|
+
self._working_directory = None
|
|
383
|
+
with self._guidance_lock:
|
|
384
|
+
self._guidance_text = None
|
|
385
|
+
self._guidance_event.clear()
|
|
386
|
+
with self._event_lock:
|
|
387
|
+
self._events.clear()
|
|
388
|
+
return {"status": "ok"}
|
|
389
|
+
|
|
390
|
+
def submit_guidance(self, text: str) -> None:
|
|
391
|
+
with self._guidance_lock:
|
|
392
|
+
session = self._session
|
|
393
|
+
if session is None or session.status is not SessionStatus.AWAITING_GUIDANCE:
|
|
394
|
+
return
|
|
395
|
+
self._guidance_text = truncate_guidance(text)
|
|
396
|
+
self._guidance_event.set()
|
|
397
|
+
|
|
398
|
+
def snapshot(self) -> dict[str, object]:
|
|
399
|
+
with self._state_lock:
|
|
400
|
+
session = self._session
|
|
401
|
+
working_directory = self._working_directory
|
|
402
|
+
if session is None:
|
|
403
|
+
return {}
|
|
404
|
+
snapshot = session.snapshot()
|
|
405
|
+
snapshot["working_directory"] = working_directory
|
|
406
|
+
return snapshot
|
|
407
|
+
|
|
408
|
+
def drain_events(self, max_count: int = 50) -> list[dict[str, object]]:
|
|
409
|
+
if max_count <= 0:
|
|
410
|
+
return []
|
|
411
|
+
drained: list[dict[str, object]] = []
|
|
412
|
+
with self._event_lock:
|
|
413
|
+
for _ in range(min(max_count, len(self._events))):
|
|
414
|
+
drained.append(asdict(self._events.popleft()))
|
|
415
|
+
return drained
|
|
416
|
+
|
|
417
|
+
def set_event_listener(self, callback: Callable[[], None] | None) -> None:
|
|
418
|
+
with self._event_lock:
|
|
419
|
+
self._event_listener = callback
|
|
420
|
+
|
|
421
|
+
def shutdown(self, timeout_seconds: float = 5.0) -> None:
|
|
422
|
+
self.stop()
|
|
423
|
+
with self._state_lock:
|
|
424
|
+
worker = self._worker
|
|
425
|
+
if worker is not None:
|
|
426
|
+
worker.join(timeout=max(0.0, timeout_seconds))
|
|
427
|
+
with self._event_order_lock, self._event_lock:
|
|
428
|
+
self._callbacks_enabled = False
|
|
429
|
+
self._event_listener = None
|
|
430
|
+
|
|
431
|
+
def _run_session(
|
|
432
|
+
self,
|
|
433
|
+
session: DiscussionSession,
|
|
434
|
+
specs: tuple[ParticipantSpec, ...],
|
|
435
|
+
moderator_id: str | None,
|
|
436
|
+
effective_topic: str,
|
|
437
|
+
total_rounds: int,
|
|
438
|
+
include_summary: bool,
|
|
439
|
+
end_on_consensus: bool,
|
|
440
|
+
guidance_between_rounds: bool,
|
|
441
|
+
debate_style: DebateStyle,
|
|
442
|
+
cancel_event: threading.Event,
|
|
443
|
+
) -> None:
|
|
444
|
+
try:
|
|
445
|
+
anonymous_labels = {
|
|
446
|
+
participant.id: _anonymous_participant_label(index)
|
|
447
|
+
for index, participant in enumerate(session.participants)
|
|
448
|
+
}
|
|
449
|
+
resolved = self._resolve_participants(specs)
|
|
450
|
+
if cancel_event.is_set():
|
|
451
|
+
return
|
|
452
|
+
self._transition(session, cancel_event, SessionStatus.ROUND1_RUNNING)
|
|
453
|
+
round1 = self._run_round(
|
|
454
|
+
session,
|
|
455
|
+
resolved,
|
|
456
|
+
1,
|
|
457
|
+
lambda participant: build_round1_prompt(
|
|
458
|
+
effective_topic,
|
|
459
|
+
persona=participant.spec.persona_prompt,
|
|
460
|
+
),
|
|
461
|
+
cancel_event,
|
|
462
|
+
)
|
|
463
|
+
if cancel_event.is_set():
|
|
464
|
+
return
|
|
465
|
+
round1_survivors = [result for result in round1 if result.success]
|
|
466
|
+
if not round1_survivors:
|
|
467
|
+
self._transition(
|
|
468
|
+
session,
|
|
469
|
+
cancel_event,
|
|
470
|
+
SessionStatus.FAILED,
|
|
471
|
+
error="all participants failed in round 1",
|
|
472
|
+
)
|
|
473
|
+
return
|
|
474
|
+
if len(round1_survivors) < 2:
|
|
475
|
+
self._transition(session, cancel_event, SessionStatus.COMPLETED)
|
|
476
|
+
return
|
|
477
|
+
survivors = round1_survivors
|
|
478
|
+
for round_index in range(2, total_rounds + 1):
|
|
479
|
+
if len(survivors) < 2:
|
|
480
|
+
break
|
|
481
|
+
guidance: str | None = None
|
|
482
|
+
if guidance_between_rounds:
|
|
483
|
+
guidance = self._await_guidance(
|
|
484
|
+
session,
|
|
485
|
+
round_index,
|
|
486
|
+
cancel_event,
|
|
487
|
+
)
|
|
488
|
+
if cancel_event.is_set():
|
|
489
|
+
return
|
|
490
|
+
else:
|
|
491
|
+
self._transition(
|
|
492
|
+
session,
|
|
493
|
+
cancel_event,
|
|
494
|
+
SessionStatus.ROUND2_RUNNING,
|
|
495
|
+
round_index=round_index,
|
|
496
|
+
)
|
|
497
|
+
answers = [
|
|
498
|
+
(
|
|
499
|
+
anonymous_labels[result.participant.spec.id],
|
|
500
|
+
result.text,
|
|
501
|
+
)
|
|
502
|
+
for result in survivors
|
|
503
|
+
]
|
|
504
|
+
|
|
505
|
+
def round_prompt(
|
|
506
|
+
participant: _ResolvedParticipant,
|
|
507
|
+
answers: list[tuple[str, str]] = answers,
|
|
508
|
+
prior_round: int = round_index - 1,
|
|
509
|
+
guidance: str | None = guidance,
|
|
510
|
+
) -> str:
|
|
511
|
+
return build_round2_prompt(
|
|
512
|
+
effective_topic,
|
|
513
|
+
answers,
|
|
514
|
+
prior_round=prior_round,
|
|
515
|
+
persona=participant.spec.persona_prompt,
|
|
516
|
+
style=debate_style,
|
|
517
|
+
guidance=guidance,
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
round_results = self._run_round(
|
|
521
|
+
session,
|
|
522
|
+
[result.participant for result in survivors],
|
|
523
|
+
round_index,
|
|
524
|
+
round_prompt,
|
|
525
|
+
cancel_event,
|
|
526
|
+
)
|
|
527
|
+
consensus = self._publish_consensus_count(session, cancel_event)
|
|
528
|
+
survivors = [
|
|
529
|
+
result
|
|
530
|
+
for result in round_results
|
|
531
|
+
if result.success
|
|
532
|
+
]
|
|
533
|
+
if end_on_consensus and _is_unanimous_consensus(
|
|
534
|
+
consensus, len(round_results)
|
|
535
|
+
):
|
|
536
|
+
self._mark_consensus_reached(
|
|
537
|
+
session,
|
|
538
|
+
round_index,
|
|
539
|
+
cancel_event,
|
|
540
|
+
)
|
|
541
|
+
break
|
|
542
|
+
if cancel_event.is_set() or not survivors:
|
|
543
|
+
break
|
|
544
|
+
if cancel_event.is_set() or not survivors or not include_summary:
|
|
545
|
+
self._transition(session, cancel_event, SessionStatus.COMPLETED)
|
|
546
|
+
return
|
|
547
|
+
|
|
548
|
+
moderator = _select_moderator(survivors, moderator_id)
|
|
549
|
+
if moderator is None:
|
|
550
|
+
self._transition(session, cancel_event, SessionStatus.COMPLETED)
|
|
551
|
+
return
|
|
552
|
+
self._transition(session, cancel_event, SessionStatus.SUMMARIZING)
|
|
553
|
+
transcript = _build_transcript(session, anonymous_labels)
|
|
554
|
+
self._run_turn(
|
|
555
|
+
session,
|
|
556
|
+
moderator.participant,
|
|
557
|
+
total_rounds + 1,
|
|
558
|
+
build_moderator_prompt(transcript),
|
|
559
|
+
cancel_event,
|
|
560
|
+
text_transform=lambda text: _restore_participant_labels(
|
|
561
|
+
text,
|
|
562
|
+
session,
|
|
563
|
+
anonymous_labels,
|
|
564
|
+
),
|
|
565
|
+
)
|
|
566
|
+
if cancel_event.is_set():
|
|
567
|
+
return
|
|
568
|
+
self._transition(session, cancel_event, SessionStatus.COMPLETED)
|
|
569
|
+
except Exception as exc:
|
|
570
|
+
if cancel_event.is_set():
|
|
571
|
+
return
|
|
572
|
+
try:
|
|
573
|
+
self._transition(
|
|
574
|
+
session,
|
|
575
|
+
cancel_event,
|
|
576
|
+
SessionStatus.FAILED,
|
|
577
|
+
error=str(exc),
|
|
578
|
+
)
|
|
579
|
+
except Exception:
|
|
580
|
+
return
|
|
581
|
+
|
|
582
|
+
def _await_guidance(
|
|
583
|
+
self,
|
|
584
|
+
session: DiscussionSession,
|
|
585
|
+
round_index: int,
|
|
586
|
+
cancel_event: threading.Event,
|
|
587
|
+
) -> str | None:
|
|
588
|
+
with self._guidance_lock:
|
|
589
|
+
self._guidance_text = None
|
|
590
|
+
self._guidance_event.clear()
|
|
591
|
+
self._transition(
|
|
592
|
+
session,
|
|
593
|
+
cancel_event,
|
|
594
|
+
SessionStatus.AWAITING_GUIDANCE,
|
|
595
|
+
round_index=round_index,
|
|
596
|
+
)
|
|
597
|
+
self._guidance_event.wait(GUIDANCE_TIMEOUT_SECONDS)
|
|
598
|
+
with self._guidance_lock:
|
|
599
|
+
if cancel_event.is_set():
|
|
600
|
+
return None
|
|
601
|
+
guidance = self._guidance_text
|
|
602
|
+
self._transition(
|
|
603
|
+
session,
|
|
604
|
+
cancel_event,
|
|
605
|
+
SessionStatus.ROUND2_RUNNING,
|
|
606
|
+
round_index=round_index,
|
|
607
|
+
)
|
|
608
|
+
return guidance
|
|
609
|
+
|
|
610
|
+
def _resolve_participants(
|
|
611
|
+
self,
|
|
612
|
+
specs: tuple[ParticipantSpec, ...],
|
|
613
|
+
) -> list[_ResolvedParticipant]:
|
|
614
|
+
resolved: list[_ResolvedParticipant] = []
|
|
615
|
+
for spec in specs:
|
|
616
|
+
try:
|
|
617
|
+
adapter = self._adapter_factory(spec)
|
|
618
|
+
detection = adapter.detect()
|
|
619
|
+
except Exception as exc:
|
|
620
|
+
adapter = None
|
|
621
|
+
detection = DetectionResult(
|
|
622
|
+
spec.adapter_id,
|
|
623
|
+
False,
|
|
624
|
+
None,
|
|
625
|
+
"not_found",
|
|
626
|
+
str(exc),
|
|
627
|
+
)
|
|
628
|
+
resolved.append(_ResolvedParticipant(spec, adapter, detection))
|
|
629
|
+
return resolved
|
|
630
|
+
|
|
631
|
+
def _run_round(
|
|
632
|
+
self,
|
|
633
|
+
session: DiscussionSession,
|
|
634
|
+
participants: Sequence[_ResolvedParticipant],
|
|
635
|
+
round_index: int,
|
|
636
|
+
prompt_factory: Callable[[_ResolvedParticipant], str],
|
|
637
|
+
cancel_event: threading.Event,
|
|
638
|
+
) -> list[_TurnResult]:
|
|
639
|
+
results: list[_TurnResult | None] = [None] * len(participants)
|
|
640
|
+
results_lock = threading.Lock()
|
|
641
|
+
semaphore = threading.Semaphore(MAX_CONCURRENT_PROCESSES)
|
|
642
|
+
turn_ids = [
|
|
643
|
+
self._begin_turn(
|
|
644
|
+
session,
|
|
645
|
+
participant.spec.id,
|
|
646
|
+
round_index,
|
|
647
|
+
(
|
|
648
|
+
participant.adapter.supports_token_stream
|
|
649
|
+
if participant.adapter is not None
|
|
650
|
+
else participant.spec.supports_token_stream
|
|
651
|
+
),
|
|
652
|
+
cancel_event,
|
|
653
|
+
)
|
|
654
|
+
for participant in participants
|
|
655
|
+
]
|
|
656
|
+
|
|
657
|
+
def run_one(
|
|
658
|
+
index: int,
|
|
659
|
+
participant: _ResolvedParticipant,
|
|
660
|
+
turn_id: str | None,
|
|
661
|
+
) -> None:
|
|
662
|
+
with semaphore:
|
|
663
|
+
if cancel_event.is_set():
|
|
664
|
+
return
|
|
665
|
+
result = self._run_turn(
|
|
666
|
+
session,
|
|
667
|
+
participant,
|
|
668
|
+
round_index,
|
|
669
|
+
prompt_factory(participant),
|
|
670
|
+
cancel_event,
|
|
671
|
+
turn_id=turn_id,
|
|
672
|
+
)
|
|
673
|
+
with results_lock:
|
|
674
|
+
results[index] = result
|
|
675
|
+
|
|
676
|
+
threads = [
|
|
677
|
+
threading.Thread(
|
|
678
|
+
target=run_one,
|
|
679
|
+
args=(index, participant, turn_ids[index]),
|
|
680
|
+
name=f"discussion-turn-r{round_index}-{participant.spec.id}",
|
|
681
|
+
daemon=True,
|
|
682
|
+
)
|
|
683
|
+
for index, participant in enumerate(participants)
|
|
684
|
+
]
|
|
685
|
+
for thread in threads:
|
|
686
|
+
thread.start()
|
|
687
|
+
for thread in threads:
|
|
688
|
+
thread.join()
|
|
689
|
+
return [result for result in results if result is not None]
|
|
690
|
+
|
|
691
|
+
def _run_turn(
|
|
692
|
+
self,
|
|
693
|
+
session: DiscussionSession,
|
|
694
|
+
participant: _ResolvedParticipant,
|
|
695
|
+
round_index: int,
|
|
696
|
+
prompt: str,
|
|
697
|
+
cancel_event: threading.Event,
|
|
698
|
+
*,
|
|
699
|
+
turn_id: str | None = None,
|
|
700
|
+
text_transform: Callable[[str], str] | None = None,
|
|
701
|
+
) -> _TurnResult:
|
|
702
|
+
adapter = participant.adapter
|
|
703
|
+
supports_token_stream = (
|
|
704
|
+
adapter.supports_token_stream
|
|
705
|
+
if adapter is not None
|
|
706
|
+
else participant.spec.supports_token_stream
|
|
707
|
+
)
|
|
708
|
+
if turn_id is None:
|
|
709
|
+
turn_id = self._begin_turn(
|
|
710
|
+
session,
|
|
711
|
+
participant.spec.id,
|
|
712
|
+
round_index,
|
|
713
|
+
supports_token_stream,
|
|
714
|
+
cancel_event,
|
|
715
|
+
)
|
|
716
|
+
if turn_id is None:
|
|
717
|
+
return _TurnResult(participant, None, False, "", "cancelled")
|
|
718
|
+
if (
|
|
719
|
+
adapter is None
|
|
720
|
+
or not participant.detection.available
|
|
721
|
+
or participant.detection.path is None
|
|
722
|
+
):
|
|
723
|
+
error = participant.detection.error or f"{participant.spec.adapter_id} is unavailable"
|
|
724
|
+
self._fail_turn(session, turn_id, error, _DeltaAccumulator(), cancel_event)
|
|
725
|
+
return _TurnResult(participant, turn_id, False, "", error)
|
|
726
|
+
|
|
727
|
+
result_success = False
|
|
728
|
+
result_error: str | None = None
|
|
729
|
+
for attempt in range(2):
|
|
730
|
+
accumulator = _DeltaAccumulator()
|
|
731
|
+
terminal = threading.Event()
|
|
732
|
+
attempt_done = False
|
|
733
|
+
attempt_error: str | None = None
|
|
734
|
+
attempt_reason: str | None = None
|
|
735
|
+
attempt_text: list[str] = []
|
|
736
|
+
final_text: str | None = None
|
|
737
|
+
|
|
738
|
+
def on_delta(
|
|
739
|
+
text: str,
|
|
740
|
+
accumulator: _DeltaAccumulator = accumulator,
|
|
741
|
+
attempt_text: list[str] = attempt_text,
|
|
742
|
+
) -> None:
|
|
743
|
+
if cancel_event.is_set():
|
|
744
|
+
return
|
|
745
|
+
attempt_text.append(text)
|
|
746
|
+
combined = accumulator.add(text)
|
|
747
|
+
if combined:
|
|
748
|
+
self._append_delta(session, turn_id, combined, cancel_event)
|
|
749
|
+
|
|
750
|
+
def on_done(terminal: threading.Event = terminal) -> None:
|
|
751
|
+
nonlocal attempt_done
|
|
752
|
+
attempt_done = True
|
|
753
|
+
terminal.set()
|
|
754
|
+
|
|
755
|
+
def on_final_text(text: str) -> None:
|
|
756
|
+
nonlocal final_text
|
|
757
|
+
final_text = text
|
|
758
|
+
self._replace_text(session, turn_id, text, cancel_event)
|
|
759
|
+
|
|
760
|
+
def on_usage(usage: TurnUsage) -> None:
|
|
761
|
+
self._set_turn_usage(session, turn_id, usage, cancel_event)
|
|
762
|
+
|
|
763
|
+
def on_error(
|
|
764
|
+
message: str,
|
|
765
|
+
terminal: threading.Event = terminal,
|
|
766
|
+
) -> None:
|
|
767
|
+
nonlocal attempt_error, attempt_reason
|
|
768
|
+
attempt_error = str(message)
|
|
769
|
+
if isinstance(message, StreamError):
|
|
770
|
+
attempt_reason = message.reason
|
|
771
|
+
terminal.set()
|
|
772
|
+
|
|
773
|
+
def on_cancelled(terminal: threading.Event = terminal) -> None:
|
|
774
|
+
terminal.set()
|
|
775
|
+
|
|
776
|
+
try:
|
|
777
|
+
# A failed agy invocation can retain its final response. Clear
|
|
778
|
+
# it before every attempt so a retry cannot inherit stale text.
|
|
779
|
+
adapter.take_final_text()
|
|
780
|
+
invocation = adapter.build_invocation(prompt, participant.spec.model)
|
|
781
|
+
run_streaming(
|
|
782
|
+
adapter,
|
|
783
|
+
invocation,
|
|
784
|
+
on_delta,
|
|
785
|
+
on_done,
|
|
786
|
+
on_error,
|
|
787
|
+
on_cancelled,
|
|
788
|
+
on_final_text=on_final_text,
|
|
789
|
+
on_usage=on_usage,
|
|
790
|
+
cancel_event=cancel_event,
|
|
791
|
+
)
|
|
792
|
+
except OSError as exc:
|
|
793
|
+
attempt_error = str(exc)
|
|
794
|
+
attempt_reason = StreamFailureReason.LAUNCH
|
|
795
|
+
terminal.set()
|
|
796
|
+
except Exception as exc:
|
|
797
|
+
attempt_error = str(exc)
|
|
798
|
+
terminal.set()
|
|
799
|
+
|
|
800
|
+
if cancel_event.is_set():
|
|
801
|
+
break
|
|
802
|
+
if not terminal.is_set():
|
|
803
|
+
attempt_error = "stream runner returned without a terminal callback"
|
|
804
|
+
produced_text = final_text if final_text is not None else "".join(attempt_text)
|
|
805
|
+
retryable = attempt_reason == StreamFailureReason.NONZERO_EXIT
|
|
806
|
+
if attempt_done and attempt_error is None:
|
|
807
|
+
if produced_text.strip():
|
|
808
|
+
if text_transform is not None:
|
|
809
|
+
self._replace_text(
|
|
810
|
+
session,
|
|
811
|
+
turn_id,
|
|
812
|
+
text_transform(produced_text),
|
|
813
|
+
cancel_event,
|
|
814
|
+
)
|
|
815
|
+
accumulator = _DeltaAccumulator()
|
|
816
|
+
if final_text is not None:
|
|
817
|
+
accumulator = _DeltaAccumulator()
|
|
818
|
+
result_success = self._complete_turn(
|
|
819
|
+
session, turn_id, accumulator, cancel_event
|
|
820
|
+
)
|
|
821
|
+
break
|
|
822
|
+
attempt_error = "CLI exited with empty output"
|
|
823
|
+
retryable = True
|
|
824
|
+
if retryable and attempt == 0:
|
|
825
|
+
self._replace_text(session, turn_id, "", cancel_event)
|
|
826
|
+
continue
|
|
827
|
+
result_error = attempt_error or "stream runner returned without a terminal callback"
|
|
828
|
+
self._fail_turn(session, turn_id, result_error, accumulator, cancel_event)
|
|
829
|
+
break
|
|
830
|
+
return _TurnResult(
|
|
831
|
+
participant,
|
|
832
|
+
turn_id,
|
|
833
|
+
result_success,
|
|
834
|
+
_turn_text(session, turn_id),
|
|
835
|
+
result_error,
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
def _begin_turn(
|
|
839
|
+
self,
|
|
840
|
+
session: DiscussionSession,
|
|
841
|
+
participant_id: str,
|
|
842
|
+
round_index: int,
|
|
843
|
+
supports_token_stream: bool,
|
|
844
|
+
cancel_event: threading.Event,
|
|
845
|
+
) -> str | None:
|
|
846
|
+
with self._event_order_lock:
|
|
847
|
+
if cancel_event.is_set():
|
|
848
|
+
return None
|
|
849
|
+
turn = session.add_turn(
|
|
850
|
+
participant_id,
|
|
851
|
+
round_index,
|
|
852
|
+
supports_token_stream=supports_token_stream,
|
|
853
|
+
)
|
|
854
|
+
event = session.start_turn(turn.id)
|
|
855
|
+
self._enqueue_event_locked(event)
|
|
856
|
+
return turn.id
|
|
857
|
+
|
|
858
|
+
def _append_delta(
|
|
859
|
+
self,
|
|
860
|
+
session: DiscussionSession,
|
|
861
|
+
turn_id: str,
|
|
862
|
+
text: str,
|
|
863
|
+
cancel_event: threading.Event,
|
|
864
|
+
) -> bool:
|
|
865
|
+
with self._event_order_lock:
|
|
866
|
+
if cancel_event.is_set():
|
|
867
|
+
return False
|
|
868
|
+
event = session.append_delta(turn_id, text)
|
|
869
|
+
self._enqueue_event_locked(event)
|
|
870
|
+
return True
|
|
871
|
+
|
|
872
|
+
def _replace_text(
|
|
873
|
+
self,
|
|
874
|
+
session: DiscussionSession,
|
|
875
|
+
turn_id: str,
|
|
876
|
+
text: str,
|
|
877
|
+
cancel_event: threading.Event,
|
|
878
|
+
) -> bool:
|
|
879
|
+
with self._event_order_lock:
|
|
880
|
+
if cancel_event.is_set():
|
|
881
|
+
return False
|
|
882
|
+
event = session.replace_text(turn_id, text)
|
|
883
|
+
self._enqueue_event_locked(event)
|
|
884
|
+
return True
|
|
885
|
+
|
|
886
|
+
def _set_turn_usage(
|
|
887
|
+
self,
|
|
888
|
+
session: DiscussionSession,
|
|
889
|
+
turn_id: str,
|
|
890
|
+
usage: TurnUsage,
|
|
891
|
+
cancel_event: threading.Event,
|
|
892
|
+
) -> bool:
|
|
893
|
+
with self._event_order_lock:
|
|
894
|
+
if cancel_event.is_set():
|
|
895
|
+
return False
|
|
896
|
+
self._enqueue_event_locked(session.set_turn_usage(turn_id, usage))
|
|
897
|
+
return True
|
|
898
|
+
|
|
899
|
+
def _complete_turn(
|
|
900
|
+
self,
|
|
901
|
+
session: DiscussionSession,
|
|
902
|
+
turn_id: str,
|
|
903
|
+
accumulator: _DeltaAccumulator,
|
|
904
|
+
cancel_event: threading.Event,
|
|
905
|
+
) -> bool:
|
|
906
|
+
with self._event_order_lock:
|
|
907
|
+
if cancel_event.is_set():
|
|
908
|
+
return False
|
|
909
|
+
remaining = accumulator.flush()
|
|
910
|
+
if remaining:
|
|
911
|
+
self._enqueue_event_locked(session.append_delta(turn_id, remaining))
|
|
912
|
+
self._enqueue_event_locked(session.complete_turn(turn_id))
|
|
913
|
+
return True
|
|
914
|
+
|
|
915
|
+
def _fail_turn(
|
|
916
|
+
self,
|
|
917
|
+
session: DiscussionSession,
|
|
918
|
+
turn_id: str,
|
|
919
|
+
error: str,
|
|
920
|
+
accumulator: _DeltaAccumulator,
|
|
921
|
+
cancel_event: threading.Event,
|
|
922
|
+
) -> bool:
|
|
923
|
+
with self._event_order_lock:
|
|
924
|
+
if cancel_event.is_set():
|
|
925
|
+
return False
|
|
926
|
+
remaining = accumulator.flush()
|
|
927
|
+
if remaining:
|
|
928
|
+
self._enqueue_event_locked(session.append_delta(turn_id, remaining))
|
|
929
|
+
self._enqueue_event_locked(session.fail_turn(turn_id, error))
|
|
930
|
+
return True
|
|
931
|
+
|
|
932
|
+
def _publish_consensus_count(
|
|
933
|
+
self,
|
|
934
|
+
session: DiscussionSession,
|
|
935
|
+
cancel_event: threading.Event,
|
|
936
|
+
) -> ConsensusCount:
|
|
937
|
+
with self._event_order_lock:
|
|
938
|
+
if cancel_event.is_set():
|
|
939
|
+
return ConsensusCount()
|
|
940
|
+
event = session.count_consensus()
|
|
941
|
+
self._enqueue_event_locked(event)
|
|
942
|
+
return ConsensusCount(**event.payload)
|
|
943
|
+
|
|
944
|
+
def _mark_consensus_reached(
|
|
945
|
+
self,
|
|
946
|
+
session: DiscussionSession,
|
|
947
|
+
round_index: int,
|
|
948
|
+
cancel_event: threading.Event,
|
|
949
|
+
) -> bool:
|
|
950
|
+
with self._event_order_lock:
|
|
951
|
+
if cancel_event.is_set():
|
|
952
|
+
return False
|
|
953
|
+
self._enqueue_event_locked(session.mark_consensus_reached(round_index))
|
|
954
|
+
return True
|
|
955
|
+
|
|
956
|
+
def _transition(
|
|
957
|
+
self,
|
|
958
|
+
session: DiscussionSession,
|
|
959
|
+
cancel_event: threading.Event,
|
|
960
|
+
status: SessionStatus,
|
|
961
|
+
*,
|
|
962
|
+
error: str | None = None,
|
|
963
|
+
round_index: int | None = None,
|
|
964
|
+
) -> bool:
|
|
965
|
+
with self._event_order_lock:
|
|
966
|
+
if cancel_event.is_set():
|
|
967
|
+
return False
|
|
968
|
+
if status is SessionStatus.COMPLETED:
|
|
969
|
+
snapshot = session.snapshot()
|
|
970
|
+
snapshot["status"] = status.value
|
|
971
|
+
with self._state_lock:
|
|
972
|
+
snapshot["working_directory"] = self._working_directory
|
|
973
|
+
self._archive_completed_session(session.session_id, snapshot)
|
|
974
|
+
event = session.transition(status, error=error, round_index=round_index)
|
|
975
|
+
if event is not None:
|
|
976
|
+
self._enqueue_event_locked(event)
|
|
977
|
+
return True
|
|
978
|
+
|
|
979
|
+
def _archive_completed_session(
|
|
980
|
+
self,
|
|
981
|
+
session_id: str,
|
|
982
|
+
snapshot: dict[str, object],
|
|
983
|
+
) -> None:
|
|
984
|
+
try:
|
|
985
|
+
# Transcripts carry full conversation text and project paths; keep them
|
|
986
|
+
# owner-only like the neutral workspace in discussion_cli.
|
|
987
|
+
DISCUSSIONS_DIRECTORY.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
988
|
+
base = DISCUSSIONS_DIRECTORY / session_id
|
|
989
|
+
for suffix, payload in (
|
|
990
|
+
(".json", json.dumps(snapshot, ensure_ascii=False, indent=2)),
|
|
991
|
+
(".md", _render_discussion_markdown(snapshot)),
|
|
992
|
+
):
|
|
993
|
+
path = base.with_suffix(suffix)
|
|
994
|
+
path.write_text(payload, encoding="utf-8")
|
|
995
|
+
path.chmod(0o600)
|
|
996
|
+
except Exception:
|
|
997
|
+
if os.environ.get("USAGE_DEBUG") == "1":
|
|
998
|
+
logger.warning("failed to archive discussion %s", session_id, exc_info=True)
|
|
999
|
+
return
|
|
1000
|
+
|
|
1001
|
+
def _enqueue_event_locked(self, event: DiscussionEvent) -> None:
|
|
1002
|
+
listener: Callable[[], None] | None = None
|
|
1003
|
+
with self._event_lock:
|
|
1004
|
+
if not self._callbacks_enabled:
|
|
1005
|
+
return
|
|
1006
|
+
was_empty = not self._events
|
|
1007
|
+
self._events.append(event)
|
|
1008
|
+
if was_empty:
|
|
1009
|
+
listener = self._event_listener
|
|
1010
|
+
if listener is not None:
|
|
1011
|
+
try:
|
|
1012
|
+
listener()
|
|
1013
|
+
except Exception:
|
|
1014
|
+
return
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def _default_adapter_factory(spec: ParticipantSpec) -> CLIAdapter:
|
|
1018
|
+
if spec.source == "argv" or spec.source == "login_shell":
|
|
1019
|
+
return _CustomLineAdapter(spec)
|
|
1020
|
+
if spec.adapter_id == "claude":
|
|
1021
|
+
return ClaudeAdapter(
|
|
1022
|
+
cwd=spec.cwd,
|
|
1023
|
+
read_only=spec.read_only,
|
|
1024
|
+
extra_read_dirs=spec.extra_read_dirs,
|
|
1025
|
+
env_overrides=spec.env_overrides,
|
|
1026
|
+
timeout_seconds=spec.timeout_seconds,
|
|
1027
|
+
)
|
|
1028
|
+
if spec.adapter_id == "codex":
|
|
1029
|
+
return CodexAdapter(
|
|
1030
|
+
cwd=spec.cwd,
|
|
1031
|
+
read_only=spec.read_only,
|
|
1032
|
+
extra_read_dirs=spec.extra_read_dirs,
|
|
1033
|
+
env_overrides=spec.env_overrides,
|
|
1034
|
+
timeout_seconds=spec.timeout_seconds,
|
|
1035
|
+
)
|
|
1036
|
+
if spec.adapter_id == "agy":
|
|
1037
|
+
return AgyAdapter(
|
|
1038
|
+
cwd=spec.cwd,
|
|
1039
|
+
read_only=spec.read_only,
|
|
1040
|
+
extra_read_dirs=spec.extra_read_dirs,
|
|
1041
|
+
env_overrides=spec.env_overrides,
|
|
1042
|
+
timeout_seconds=spec.timeout_seconds,
|
|
1043
|
+
)
|
|
1044
|
+
raise ValueError(f"unknown built-in adapter: {spec.adapter_id}")
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def _select_moderator(
|
|
1048
|
+
survivors: Sequence[_TurnResult],
|
|
1049
|
+
moderator_id: str | None,
|
|
1050
|
+
) -> _TurnResult | None:
|
|
1051
|
+
if moderator_id is not None:
|
|
1052
|
+
for survivor in survivors:
|
|
1053
|
+
if survivor.participant.spec.id == moderator_id:
|
|
1054
|
+
return survivor
|
|
1055
|
+
return survivors[0] if survivors else None
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def _turn_text(session: DiscussionSession, turn_id: str) -> str:
|
|
1059
|
+
snapshot = session.snapshot()
|
|
1060
|
+
for turn in snapshot["turns"]:
|
|
1061
|
+
if turn["id"] == turn_id:
|
|
1062
|
+
return str(turn["text"])
|
|
1063
|
+
return ""
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def _anonymous_participant_label(index: int) -> str:
|
|
1067
|
+
"""Return a stable A, B, … label for a zero-based participant index."""
|
|
1068
|
+
return f"參與者 {chr(ord('A') + index)}" if index < 26 else f"參與者 {index + 1}"
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
def _is_unanimous_consensus(
|
|
1072
|
+
consensus: ConsensusCount, expected_participants: int
|
|
1073
|
+
) -> bool:
|
|
1074
|
+
return (
|
|
1075
|
+
consensus.agree
|
|
1076
|
+
+ consensus.disagree
|
|
1077
|
+
+ consensus.alternative
|
|
1078
|
+
+ consensus.unparsed
|
|
1079
|
+
== expected_participants
|
|
1080
|
+
and consensus.agree >= 2
|
|
1081
|
+
and consensus.disagree == 0
|
|
1082
|
+
and consensus.alternative == 0
|
|
1083
|
+
and consensus.unparsed == 0
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def _render_discussion_markdown(snapshot: dict[str, object]) -> str:
|
|
1088
|
+
participants = snapshot["participants"]
|
|
1089
|
+
turns = snapshot["turns"]
|
|
1090
|
+
assert isinstance(participants, list)
|
|
1091
|
+
assert isinstance(turns, list)
|
|
1092
|
+
total_rounds = snapshot["total_rounds"]
|
|
1093
|
+
assert isinstance(total_rounds, int)
|
|
1094
|
+
labels = {
|
|
1095
|
+
str(participant["id"]): str(participant["label"])
|
|
1096
|
+
for participant in participants
|
|
1097
|
+
if isinstance(participant, dict)
|
|
1098
|
+
}
|
|
1099
|
+
lines = [
|
|
1100
|
+
f"# {snapshot['topic']}",
|
|
1101
|
+
datetime.now().astimezone().isoformat(),
|
|
1102
|
+
"參與者:" + "、".join(labels.values()),
|
|
1103
|
+
]
|
|
1104
|
+
for turn in turns:
|
|
1105
|
+
if not isinstance(turn, dict):
|
|
1106
|
+
continue
|
|
1107
|
+
text = str(turn["text"])
|
|
1108
|
+
participant = labels.get(str(turn["participant_id"]), str(turn["participant_id"]))
|
|
1109
|
+
if int(turn["round_index"]) > total_rounds:
|
|
1110
|
+
lines.extend(("## 主持人總結", text))
|
|
1111
|
+
else:
|
|
1112
|
+
lines.extend((f"## 第 {turn['round_index']} 輪 · {participant}", text))
|
|
1113
|
+
return "\n\n".join(lines) + "\n"
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def _build_transcript(
|
|
1117
|
+
session: DiscussionSession,
|
|
1118
|
+
anonymous_labels: Mapping[str, str],
|
|
1119
|
+
) -> str:
|
|
1120
|
+
snapshot = session.snapshot()
|
|
1121
|
+
sections: list[str] = []
|
|
1122
|
+
for turn in snapshot["turns"]:
|
|
1123
|
+
participant_id = str(turn["participant_id"])
|
|
1124
|
+
error = turn["error"]
|
|
1125
|
+
body = str(turn["text"])
|
|
1126
|
+
if error:
|
|
1127
|
+
body = f"{body}\n[此發言未完成]" if body else "[此發言未完成]"
|
|
1128
|
+
sections.append(
|
|
1129
|
+
f"<<<TURN participant={anonymous_labels.get(participant_id, participant_id)!r} "
|
|
1130
|
+
f"round={turn['round_index']} status={turn['status']}>>>\n"
|
|
1131
|
+
f"{body}\n<<<TURN_END>>>"
|
|
1132
|
+
)
|
|
1133
|
+
return "\n\n".join(sections)
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def _restore_participant_labels(
|
|
1137
|
+
text: str,
|
|
1138
|
+
session: DiscussionSession,
|
|
1139
|
+
anonymous_labels: Mapping[str, str],
|
|
1140
|
+
) -> str:
|
|
1141
|
+
labels = {
|
|
1142
|
+
anonymous_labels[participant.id]: participant.label
|
|
1143
|
+
for participant in session.participants
|
|
1144
|
+
}
|
|
1145
|
+
return _replace_labels(text, labels)
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def _replace_labels(
|
|
1149
|
+
text: str,
|
|
1150
|
+
replacements: Mapping[str, str],
|
|
1151
|
+
) -> str:
|
|
1152
|
+
labels = sorted((label for label in replacements if label), key=len, reverse=True)
|
|
1153
|
+
if not labels:
|
|
1154
|
+
return text
|
|
1155
|
+
pattern = re.compile(
|
|
1156
|
+
f"(?:{'|'.join(re.escape(label) for label in labels)})(?![A-Za-z0-9])"
|
|
1157
|
+
)
|
|
1158
|
+
return pattern.sub(lambda match: replacements[match.group(0)], text)
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def _attachment_dirs(attachments: Sequence[str]) -> tuple[str, ...]:
|
|
1162
|
+
"""Distinct absolute directories holding the existing attachment files.
|
|
1163
|
+
|
|
1164
|
+
Missing paths are ignored (mirrors ``build_attachment_block``); only the
|
|
1165
|
+
folder each surviving file lives in is returned, deduplicated and in
|
|
1166
|
+
stable first-seen order.
|
|
1167
|
+
"""
|
|
1168
|
+
seen: set[str] = set()
|
|
1169
|
+
dirs: list[str] = []
|
|
1170
|
+
for raw in attachments:
|
|
1171
|
+
path = Path(str(raw))
|
|
1172
|
+
if not path.is_file():
|
|
1173
|
+
continue
|
|
1174
|
+
directory = str(path.resolve().parent)
|
|
1175
|
+
if directory not in seen:
|
|
1176
|
+
seen.add(directory)
|
|
1177
|
+
dirs.append(directory)
|
|
1178
|
+
return tuple(dirs)
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def build_attachment_block(attachments: Sequence[str], language: str) -> str:
|
|
1182
|
+
"""Append-only image section for the prompt sent to each CLI.
|
|
1183
|
+
|
|
1184
|
+
Returns ``""`` when none of the paths point at an existing file, so the
|
|
1185
|
+
discussion proceeds unchanged. Existing-file paths are resolved to absolute
|
|
1186
|
+
form; missing paths are skipped rather than aborting the whole session.
|
|
1187
|
+
"""
|
|
1188
|
+
resolved: list[str] = []
|
|
1189
|
+
for raw in attachments:
|
|
1190
|
+
path = Path(str(raw))
|
|
1191
|
+
if path.is_file():
|
|
1192
|
+
resolved.append(str(path.resolve()))
|
|
1193
|
+
if not resolved:
|
|
1194
|
+
return ""
|
|
1195
|
+
header = _t(language, "discussion_prompt_attachment_header")
|
|
1196
|
+
return "\n\n" + header + "\n" + "\n".join(resolved)
|