deeptutor-server 1.7.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.
- deeptutor/api/__init__.py +0 -0
- deeptutor/api/contracts/__init__.py +21 -0
- deeptutor/api/contracts/export.py +110 -0
- deeptutor/api/contracts/turn_protocol.py +313 -0
- deeptutor/api/main.py +547 -0
- deeptutor/api/module_routes.py +42 -0
- deeptutor/api/router_index.json +5752 -0
- deeptutor/api/router_loader.py +139 -0
- deeptutor/api/routers/__init__.py +1 -0
- deeptutor/api/routers/agent_config.py +59 -0
- deeptutor/api/routers/attachments.py +79 -0
- deeptutor/api/routers/auth.py +1937 -0
- deeptutor/api/routers/capabilities.py +44 -0
- deeptutor/api/routers/capabilities_settings.py +38 -0
- deeptutor/api/routers/dashboard.py +292 -0
- deeptutor/api/routers/file_library.py +169 -0
- deeptutor/api/routers/file_preview.py +205 -0
- deeptutor/api/routers/imports.py +146 -0
- deeptutor/api/routers/learning_journal.py +18 -0
- deeptutor/api/routers/mcp_settings.py +133 -0
- deeptutor/api/routers/modules.py +12 -0
- deeptutor/api/routers/multi_user.py +943 -0
- deeptutor/api/routers/outputs.py +97 -0
- deeptutor/api/routers/personas.py +154 -0
- deeptutor/api/routers/plugins.py +416 -0
- deeptutor/api/routers/quiz_judge.py +464 -0
- deeptutor/api/routers/sessions.py +801 -0
- deeptutor/api/routers/settings.py +2503 -0
- deeptutor/api/routers/skills.py +315 -0
- deeptutor/api/routers/space_cli_apps.py +222 -0
- deeptutor/api/routers/space_mcp.py +460 -0
- deeptutor/api/routers/subagents.py +306 -0
- deeptutor/api/routers/system.py +633 -0
- deeptutor/api/routers/task_board.py +139 -0
- deeptutor/api/routers/tools.py +268 -0
- deeptutor/api/routers/unified_ws.py +385 -0
- deeptutor/api/routers/workspace.py +371 -0
- deeptutor/api/run_server.py +99 -0
- deeptutor/api/security_guard.py +91 -0
- deeptutor/api/utils/http_headers.py +27 -0
- deeptutor/api/utils/progress_broadcaster.py +73 -0
- deeptutor/api/utils/task_id_manager.py +120 -0
- deeptutor/api/utils/task_log_stream.py +362 -0
- deeptutor/api/utils/tool_options.py +133 -0
- deeptutor_server-1.7.0.dist-info/METADATA +14 -0
- deeptutor_server-1.7.0.dist-info/RECORD +48 -0
- deeptutor_server-1.7.0.dist-info/WHEEL +5 -0
- deeptutor_server-1.7.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""Unified WebSocket adapter for turn execution and replayable streaming.
|
|
2
|
+
|
|
3
|
+
All mutating operations are commands on :class:`TurnApplicationService`.
|
|
4
|
+
Subscriptions and active-turn checks are deliberately read-only, including
|
|
5
|
+
when the owner worker has disappeared and leader recovery is still pending.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
16
|
+
from pydantic import TypeAdapter, ValidationError
|
|
17
|
+
|
|
18
|
+
from deeptutor.api.contracts.turn_protocol import (
|
|
19
|
+
PROTOCOL_VERSION,
|
|
20
|
+
ClientCommand,
|
|
21
|
+
StartTurnCommand,
|
|
22
|
+
)
|
|
23
|
+
from deeptutor.services.workspace.models import WorkspaceError
|
|
24
|
+
|
|
25
|
+
router = APIRouter()
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
_CLIENT_COMMAND_ADAPTER = TypeAdapter(ClientCommand)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _clean_answers(value: Any) -> list[dict[str, Any]] | None:
|
|
31
|
+
if not isinstance(value, list):
|
|
32
|
+
return None
|
|
33
|
+
cleaned: list[dict[str, Any]] = []
|
|
34
|
+
for entry in value:
|
|
35
|
+
if not isinstance(entry, dict):
|
|
36
|
+
continue
|
|
37
|
+
question_id = str(entry.get("questionId") or entry.get("id") or "").strip()
|
|
38
|
+
if question_id:
|
|
39
|
+
cleaned.append({"questionId": question_id, "text": str(entry.get("text") or "")})
|
|
40
|
+
return cleaned or None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@router.websocket("/ws")
|
|
44
|
+
async def unified_websocket(ws: WebSocket) -> None:
|
|
45
|
+
from deeptutor.api.routers.auth import ws_auth_failed, ws_require_auth
|
|
46
|
+
from deeptutor.app.container import get_application_container
|
|
47
|
+
from deeptutor.multi_user.context import reset_current_user
|
|
48
|
+
|
|
49
|
+
user_token = await ws_require_auth(ws)
|
|
50
|
+
if user_token is ws_auth_failed:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
await ws.accept()
|
|
54
|
+
closed = False
|
|
55
|
+
subscription_tasks: dict[str, asyncio.Task[None]] = {}
|
|
56
|
+
|
|
57
|
+
# Resolve once after authentication. Context variables are copied into
|
|
58
|
+
# subscription tasks, so the socket remains in one stable StoreScope.
|
|
59
|
+
container = getattr(ws.app.state, "application_container", None)
|
|
60
|
+
if container is None:
|
|
61
|
+
container = get_application_container()
|
|
62
|
+
await container.start()
|
|
63
|
+
turns = container.turns
|
|
64
|
+
|
|
65
|
+
async def safe_send(data: dict[str, Any]) -> None:
|
|
66
|
+
nonlocal closed
|
|
67
|
+
if closed:
|
|
68
|
+
return
|
|
69
|
+
try:
|
|
70
|
+
payload = {**data, "protocol_version": PROTOCOL_VERSION}
|
|
71
|
+
await ws.send_text(json.dumps(payload, ensure_ascii=False, default=str))
|
|
72
|
+
except Exception:
|
|
73
|
+
closed = True
|
|
74
|
+
|
|
75
|
+
async def send_protocol_error(
|
|
76
|
+
message: str,
|
|
77
|
+
*,
|
|
78
|
+
error_code: str,
|
|
79
|
+
session_id: str = "",
|
|
80
|
+
turn_id: str = "",
|
|
81
|
+
retryable: bool = False,
|
|
82
|
+
) -> None:
|
|
83
|
+
await safe_send(
|
|
84
|
+
{
|
|
85
|
+
"type": "protocol_error",
|
|
86
|
+
"error_code": error_code,
|
|
87
|
+
"message": message,
|
|
88
|
+
"retryable": retryable,
|
|
89
|
+
"session_id": session_id,
|
|
90
|
+
"turn_id": turn_id,
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
async def send_error(
|
|
95
|
+
content: str,
|
|
96
|
+
*,
|
|
97
|
+
error_code: str,
|
|
98
|
+
session_id: str = "",
|
|
99
|
+
turn_id: str = "",
|
|
100
|
+
retryable: bool = False,
|
|
101
|
+
terminal: bool = False,
|
|
102
|
+
) -> None:
|
|
103
|
+
await send_protocol_error(
|
|
104
|
+
content,
|
|
105
|
+
error_code=error_code,
|
|
106
|
+
session_id=session_id,
|
|
107
|
+
turn_id=turn_id,
|
|
108
|
+
retryable=retryable or terminal,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
async def send_command_ack(
|
|
112
|
+
msg: dict[str, Any],
|
|
113
|
+
*,
|
|
114
|
+
accepted: bool,
|
|
115
|
+
error_code: str = "",
|
|
116
|
+
message: str = "",
|
|
117
|
+
) -> None:
|
|
118
|
+
await safe_send(
|
|
119
|
+
{
|
|
120
|
+
"type": "command_ack",
|
|
121
|
+
"command_id": str(msg["command_id"]),
|
|
122
|
+
"command_type": str(msg["type"]),
|
|
123
|
+
"accepted": accepted,
|
|
124
|
+
"turn_id": str(msg.get("turn_id") or ""),
|
|
125
|
+
"error_code": error_code,
|
|
126
|
+
"message": message,
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
async def stop_subscription(key: str) -> None:
|
|
131
|
+
task = subscription_tasks.pop(key, None)
|
|
132
|
+
if task is None:
|
|
133
|
+
return
|
|
134
|
+
task.cancel()
|
|
135
|
+
try:
|
|
136
|
+
await task
|
|
137
|
+
except asyncio.CancelledError:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
async def subscribe_turn(turn_id: str, after_seq: int = 0) -> None:
|
|
141
|
+
async def _forward() -> None:
|
|
142
|
+
try:
|
|
143
|
+
async for event in turns.subscribe_turn(turn_id, after_seq=after_seq):
|
|
144
|
+
await safe_send(event)
|
|
145
|
+
except asyncio.CancelledError:
|
|
146
|
+
raise
|
|
147
|
+
except Exception as exc:
|
|
148
|
+
logger.exception("Turn subscription failed: %s", turn_id)
|
|
149
|
+
await send_error(
|
|
150
|
+
str(exc),
|
|
151
|
+
error_code="subscription_failed",
|
|
152
|
+
turn_id=turn_id,
|
|
153
|
+
retryable=True,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
await stop_subscription(turn_id)
|
|
157
|
+
subscription_tasks[turn_id] = asyncio.create_task(_forward())
|
|
158
|
+
|
|
159
|
+
async def subscribe_session(session_id: str, after_seq: int = 0) -> None:
|
|
160
|
+
async def _forward() -> None:
|
|
161
|
+
try:
|
|
162
|
+
async for event in turns.subscribe_session(session_id, after_seq=after_seq):
|
|
163
|
+
await safe_send(event)
|
|
164
|
+
except asyncio.CancelledError:
|
|
165
|
+
raise
|
|
166
|
+
except Exception as exc:
|
|
167
|
+
logger.exception("Session subscription failed: %s", session_id)
|
|
168
|
+
await send_error(
|
|
169
|
+
str(exc),
|
|
170
|
+
error_code="subscription_failed",
|
|
171
|
+
session_id=session_id,
|
|
172
|
+
retryable=True,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
key = f"session:{session_id}"
|
|
176
|
+
await stop_subscription(key)
|
|
177
|
+
subscription_tasks[key] = asyncio.create_task(_forward())
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
while not closed:
|
|
181
|
+
raw = await ws.receive_text()
|
|
182
|
+
try:
|
|
183
|
+
decoded = json.loads(raw)
|
|
184
|
+
except json.JSONDecodeError:
|
|
185
|
+
await send_protocol_error("Invalid JSON.", error_code="invalid_json")
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
if not isinstance(decoded, dict):
|
|
189
|
+
await send_protocol_error(
|
|
190
|
+
"WebSocket commands must be JSON objects.",
|
|
191
|
+
error_code="invalid_command",
|
|
192
|
+
)
|
|
193
|
+
continue
|
|
194
|
+
if decoded.get("protocol_version") != PROTOCOL_VERSION:
|
|
195
|
+
await send_protocol_error(
|
|
196
|
+
f"Unsupported or missing protocol_version; expected {PROTOCOL_VERSION}.",
|
|
197
|
+
error_code="unsupported_protocol_version",
|
|
198
|
+
)
|
|
199
|
+
continue
|
|
200
|
+
try:
|
|
201
|
+
command = _CLIENT_COMMAND_ADAPTER.validate_python(decoded)
|
|
202
|
+
except ValidationError:
|
|
203
|
+
await send_protocol_error(
|
|
204
|
+
"Command does not match the turn protocol.",
|
|
205
|
+
error_code="invalid_command",
|
|
206
|
+
)
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
# A continuing turn omits workspace_id to inherit its binding.
|
|
210
|
+
# Filling defaults here would turn that omission into an explicit
|
|
211
|
+
# null (and also turn an omitted parent into a root-branch edit).
|
|
212
|
+
msg = (
|
|
213
|
+
command.to_payload()
|
|
214
|
+
if isinstance(command, StartTurnCommand)
|
|
215
|
+
else command.model_dump(mode="python")
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
msg_type = msg.get("type")
|
|
219
|
+
|
|
220
|
+
if msg_type in {"message", "start_turn"}:
|
|
221
|
+
try:
|
|
222
|
+
_, turn = await turns.start_turn(
|
|
223
|
+
{
|
|
224
|
+
key: value
|
|
225
|
+
for key, value in msg.items()
|
|
226
|
+
if key not in {"type", "protocol_version"}
|
|
227
|
+
}
|
|
228
|
+
)
|
|
229
|
+
except (RuntimeError, WorkspaceError) as exc:
|
|
230
|
+
await send_error(
|
|
231
|
+
str(exc),
|
|
232
|
+
error_code="start_turn_rejected",
|
|
233
|
+
session_id=str(msg.get("session_id") or ""),
|
|
234
|
+
terminal=True,
|
|
235
|
+
)
|
|
236
|
+
continue
|
|
237
|
+
await subscribe_turn(turn["id"])
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
if msg_type == "ping":
|
|
241
|
+
await safe_send({"type": "pong"})
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
if msg_type in {"subscribe_turn", "resume_from"}:
|
|
245
|
+
turn_id = str(msg.get("turn_id") or "").strip()
|
|
246
|
+
if not turn_id:
|
|
247
|
+
await send_error("Missing turn_id.", error_code="missing_turn_id")
|
|
248
|
+
continue
|
|
249
|
+
after_seq = int(
|
|
250
|
+
(msg.get("seq") if msg_type == "resume_from" else msg.get("after_seq")) or 0
|
|
251
|
+
)
|
|
252
|
+
await subscribe_turn(turn_id, after_seq=after_seq)
|
|
253
|
+
continue
|
|
254
|
+
|
|
255
|
+
if msg_type == "subscribe_session":
|
|
256
|
+
session_id = str(msg.get("session_id") or "").strip()
|
|
257
|
+
if not session_id:
|
|
258
|
+
await send_error("Missing session_id.", error_code="missing_session_id")
|
|
259
|
+
continue
|
|
260
|
+
await subscribe_session(session_id, after_seq=int(msg.get("after_seq") or 0))
|
|
261
|
+
continue
|
|
262
|
+
|
|
263
|
+
if msg_type == "check_active_turn":
|
|
264
|
+
session_id = str(msg.get("session_id") or "").strip()
|
|
265
|
+
if not session_id:
|
|
266
|
+
await send_error("Missing session_id.", error_code="missing_session_id")
|
|
267
|
+
continue
|
|
268
|
+
active = await turns.check_active_turn(session_id)
|
|
269
|
+
await safe_send(
|
|
270
|
+
{
|
|
271
|
+
"type": "active_turn_info",
|
|
272
|
+
"turn_id": str((active or {}).get("turn_id") or ""),
|
|
273
|
+
"status": str((active or {}).get("status") or "none"),
|
|
274
|
+
"owner_id": str((active or {}).get("owner_id") or ""),
|
|
275
|
+
}
|
|
276
|
+
)
|
|
277
|
+
continue
|
|
278
|
+
|
|
279
|
+
if msg_type == "unsubscribe":
|
|
280
|
+
turn_id = str(msg.get("turn_id") or "").strip()
|
|
281
|
+
session_id = str(msg.get("session_id") or "").strip()
|
|
282
|
+
if turn_id:
|
|
283
|
+
await stop_subscription(turn_id)
|
|
284
|
+
if session_id:
|
|
285
|
+
await stop_subscription(f"session:{session_id}")
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
if msg_type == "cancel_turn":
|
|
289
|
+
turn_id = str(msg.get("turn_id") or "").strip()
|
|
290
|
+
if not turn_id:
|
|
291
|
+
await send_error("Missing turn_id.", error_code="missing_turn_id")
|
|
292
|
+
continue
|
|
293
|
+
accepted = await turns.cancel_turn(turn_id, command_id=str(msg["command_id"]))
|
|
294
|
+
if not accepted:
|
|
295
|
+
await send_command_ack(
|
|
296
|
+
msg,
|
|
297
|
+
accepted=False,
|
|
298
|
+
error_code="turn_not_active",
|
|
299
|
+
message=f"Turn is not active or recoverable: {turn_id}",
|
|
300
|
+
)
|
|
301
|
+
else:
|
|
302
|
+
await send_command_ack(msg, accepted=True)
|
|
303
|
+
continue
|
|
304
|
+
|
|
305
|
+
if msg_type == "submit_user_reply":
|
|
306
|
+
turn_id = str(msg.get("turn_id") or "").strip()
|
|
307
|
+
if not turn_id:
|
|
308
|
+
await send_error("Missing turn_id.", error_code="missing_turn_id")
|
|
309
|
+
continue
|
|
310
|
+
text = msg.get("text")
|
|
311
|
+
accepted = await turns.submit_user_reply(
|
|
312
|
+
turn_id,
|
|
313
|
+
text=str(text) if text is not None else None,
|
|
314
|
+
answers=_clean_answers(msg.get("answers")),
|
|
315
|
+
command_id=str(msg["command_id"]),
|
|
316
|
+
)
|
|
317
|
+
if not accepted:
|
|
318
|
+
await send_command_ack(
|
|
319
|
+
msg,
|
|
320
|
+
accepted=False,
|
|
321
|
+
error_code="turn_not_waiting_input",
|
|
322
|
+
message=f"Turn {turn_id} is not awaiting a user reply.",
|
|
323
|
+
)
|
|
324
|
+
else:
|
|
325
|
+
await send_command_ack(msg, accepted=True)
|
|
326
|
+
continue
|
|
327
|
+
|
|
328
|
+
if msg_type == "regenerate":
|
|
329
|
+
session_id = str(msg.get("session_id") or "").strip()
|
|
330
|
+
if not session_id:
|
|
331
|
+
await send_error("Missing session_id.", error_code="missing_session_id")
|
|
332
|
+
continue
|
|
333
|
+
overrides = msg.get("overrides") if isinstance(msg.get("overrides"), dict) else None
|
|
334
|
+
try:
|
|
335
|
+
_, turn = await turns.regenerate_last_turn(session_id, overrides=overrides)
|
|
336
|
+
except (RuntimeError, WorkspaceError) as exc:
|
|
337
|
+
await send_error(
|
|
338
|
+
str(exc),
|
|
339
|
+
error_code="regenerate_rejected",
|
|
340
|
+
session_id=session_id,
|
|
341
|
+
terminal=True,
|
|
342
|
+
)
|
|
343
|
+
continue
|
|
344
|
+
await subscribe_turn(turn["id"])
|
|
345
|
+
continue
|
|
346
|
+
|
|
347
|
+
if msg_type == "user_input":
|
|
348
|
+
turn_id = str(msg.get("turn_id") or "").strip()
|
|
349
|
+
if not turn_id:
|
|
350
|
+
await send_error(
|
|
351
|
+
"Missing turn_id for user_input.", error_code="missing_turn_id"
|
|
352
|
+
)
|
|
353
|
+
continue
|
|
354
|
+
accepted = await turns.submit_user_input(
|
|
355
|
+
turn_id,
|
|
356
|
+
str(msg.get("content") or ""),
|
|
357
|
+
command_id=str(msg["command_id"]),
|
|
358
|
+
withdraw=str(msg.get("withdraw") or "") or None,
|
|
359
|
+
)
|
|
360
|
+
if not accepted:
|
|
361
|
+
await send_command_ack(
|
|
362
|
+
msg,
|
|
363
|
+
accepted=False,
|
|
364
|
+
error_code="turn_not_active",
|
|
365
|
+
message=f"Turn is not active: {turn_id}",
|
|
366
|
+
)
|
|
367
|
+
else:
|
|
368
|
+
await send_command_ack(msg, accepted=True)
|
|
369
|
+
continue
|
|
370
|
+
|
|
371
|
+
await send_protocol_error(
|
|
372
|
+
f"Unknown type: {msg_type}", error_code="unknown_message_type"
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
except WebSocketDisconnect:
|
|
376
|
+
logger.debug("Client disconnected from /ws")
|
|
377
|
+
except Exception as exc:
|
|
378
|
+
logger.error("Unified WS error: %s", exc, exc_info=True)
|
|
379
|
+
await send_error(str(exc), error_code="internal_error", retryable=True)
|
|
380
|
+
finally:
|
|
381
|
+
closed = True
|
|
382
|
+
for key in list(subscription_tasks):
|
|
383
|
+
await stop_subscription(key)
|
|
384
|
+
if user_token is not None:
|
|
385
|
+
reset_current_user(user_token)
|