super-agents 0.1.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,27 @@
1
+ """Python implementation of the Super Agents MCP server."""
2
+
3
+ from .app_server_client import (
4
+ CodexAppServerClient,
5
+ LabelQueryInput,
6
+ PendingServerRequest,
7
+ PermissionRequestCallback,
8
+ TurnState,
9
+ shared_permission_requests,
10
+ write_shared_permission_decision,
11
+ is_permission_request,
12
+ )
13
+ from .state import SessionRecord, StateFile, TurnSummary
14
+
15
+ __all__ = [
16
+ "CodexAppServerClient",
17
+ "LabelQueryInput",
18
+ "PendingServerRequest",
19
+ "PermissionRequestCallback",
20
+ "SessionRecord",
21
+ "StateFile",
22
+ "TurnState",
23
+ "TurnSummary",
24
+ "shared_permission_requests",
25
+ "write_shared_permission_decision",
26
+ "is_permission_request",
27
+ ]
@@ -0,0 +1,4 @@
1
+ from .mcp_server import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,199 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ from .app_formatting import without_none
6
+ from .app_models import LabelQueryInput
7
+ from .app_protocol import extract_thread_id
8
+ from .app_routines import (
9
+ DEFAULT_ROUTINE_TIMEZONE,
10
+ routine_from_patch,
11
+ routine_is_due,
12
+ routine_local_date,
13
+ routine_next_run_sort_key,
14
+ routine_next_run_summary,
15
+ routine_turn_input,
16
+ routine_with_next_run,
17
+ )
18
+ from .app_time import iso_now
19
+ from .state import JsonObject, RoutineRecord, StateFile, get_string, update_state_file
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class RoutineClientMixin:
25
+ async def save_routine(self, input_data: JsonObject) -> JsonObject:
26
+ name = str(input_data["name"])
27
+ async with self._state_lock:
28
+ def update(state: StateFile) -> JsonObject:
29
+ now = iso_now()
30
+ current = state.routines.get(name)
31
+ raw = current.to_json() if current else {"name": name, "createdAt": now}
32
+ for key, value in input_data.items():
33
+ if value is not None:
34
+ raw[key] = value
35
+ raw["name"] = name
36
+ raw["updatedAt"] = now
37
+ raw.setdefault("createdAt", now)
38
+ raw.setdefault("enabled", True)
39
+ raw.setdefault("timezone", DEFAULT_ROUTINE_TIMEZONE)
40
+ state.routines[name] = routine_from_patch(raw)
41
+ return state.routines[name].to_json()
42
+
43
+ routine = update_state_file(self.state_file, update)
44
+ return {
45
+ "routine": routine,
46
+ "nativeSupport": False,
47
+ "scheduler": "super-agents-local-wrapper",
48
+ }
49
+
50
+ async def list_routines(self) -> JsonObject:
51
+ state = await self.read_state()
52
+ routines = sorted(state.routines.values(), key=lambda routine: routine.updated_at, reverse=True)
53
+ return {
54
+ "count": len(routines),
55
+ "routines": [routine_with_next_run(routine) for routine in routines],
56
+ "nativeSupport": False,
57
+ "scheduler": "super-agents-local-wrapper",
58
+ }
59
+
60
+ async def read_routine(self, name: str) -> JsonObject:
61
+ state = await self.read_state()
62
+ routine = state.routines.get(name)
63
+ if routine is None:
64
+ raise ValueError(f"No Super Agents routine found for name {name}.")
65
+ return {
66
+ "routine": routine_with_next_run(routine),
67
+ "nativeSupport": False,
68
+ "scheduler": "super-agents-local-wrapper",
69
+ }
70
+
71
+ async def delete_routine(self, name: str) -> JsonObject:
72
+ async with self._state_lock:
73
+ def update(state: StateFile) -> JsonObject:
74
+ if name not in state.routines:
75
+ raise ValueError(f"No Super Agents routine found for name {name}.")
76
+ removed = state.routines.pop(name)
77
+ return removed.to_json()
78
+
79
+ removed = update_state_file(self.state_file, update)
80
+ return {"deleted": True, "routine": removed}
81
+
82
+ async def routine_status_summary(self) -> JsonObject:
83
+ state = await self.read_state()
84
+ enabled = [routine for routine in state.routines.values() if routine.enabled]
85
+ return {
86
+ "count": len(state.routines),
87
+ "enabledCount": len(enabled),
88
+ "nextRuns": [
89
+ routine_next_run_summary(routine) for routine in sorted(enabled, key=routine_next_run_sort_key)[:5]
90
+ ],
91
+ "nativeSupport": False,
92
+ "scheduler": "super-agents-local-wrapper",
93
+ }
94
+
95
+ async def run_due_routines(self, name: str | None = None, force: bool = False) -> JsonObject:
96
+ candidates = await self.reserve_due_routines(name=name, force=force)
97
+ results = []
98
+ for routine in sorted(candidates, key=lambda item: item.name):
99
+ results.append(await self.run_routine(routine, force=force))
100
+ return {
101
+ "count": len(results),
102
+ "results": results,
103
+ "nativeSupport": False,
104
+ "scheduler": "super-agents-local-wrapper",
105
+ }
106
+
107
+ async def reserve_due_routines(self, name: str | None = None, force: bool = False) -> list[RoutineRecord]:
108
+ async with self._state_lock:
109
+ def update(state: StateFile) -> list[RoutineRecord]:
110
+ candidates = [
111
+ routine
112
+ for routine in state.routines.values()
113
+ if (not name or routine.name == name) and (force or routine_is_due(routine))
114
+ ]
115
+ if name and not candidates and name not in state.routines:
116
+ raise ValueError(f"No Super Agents routine found for name {name}.")
117
+ reserved: list[RoutineRecord] = []
118
+ now = iso_now()
119
+ for routine in candidates:
120
+ run_date = routine_local_date(routine)
121
+ merged = {
122
+ **routine.to_json(),
123
+ "lastRunDate": run_date,
124
+ "lastStartedAt": now,
125
+ "lastStatus": "starting",
126
+ "lastError": None,
127
+ "updatedAt": now,
128
+ }
129
+ reserved_routine = routine_from_patch(merged)
130
+ state.routines[routine.name] = reserved_routine
131
+ reserved.append(reserved_routine)
132
+ return reserved
133
+
134
+ return update_state_file(self.state_file, update)
135
+
136
+ async def run_routine(self, routine: RoutineRecord, *, force: bool = False) -> JsonObject:
137
+ if not routine.enabled and not force:
138
+ return {"name": routine.name, "skipped": True, "reason": "disabled"}
139
+ run_date = routine_local_date(routine)
140
+ try:
141
+ if routine.thread_id:
142
+ target = await self.resolve_queue_target(LabelQueryInput(thread_id=routine.thread_id, cwd=routine.cwd))
143
+ result = await self.start_or_queue_turn(target, routine_turn_input(routine))
144
+ elif routine.target_name:
145
+ target = await self.resolve_queue_target(
146
+ LabelQueryInput(label=routine.target_name, cwd=routine.cwd, prefer="latest_any")
147
+ )
148
+ result = await self.start_or_queue_turn(target, routine_turn_input(routine))
149
+ else:
150
+ thread_result = await self.start_thread(
151
+ without_none(
152
+ {
153
+ "name": routine.name,
154
+ "cwd": routine.cwd,
155
+ "developerInstructions": routine.developer_instructions,
156
+ }
157
+ )
158
+ )
159
+ thread_id = extract_thread_id(thread_result)
160
+ if not thread_id:
161
+ raise RuntimeError(f"Could not start thread for routine {routine.name}.")
162
+ result = await self.start_turn(
163
+ {**routine_turn_input(routine), "threadId": thread_id, "label": routine.name}
164
+ )
165
+ await self.record_routine_run(
166
+ routine.name,
167
+ {
168
+ "lastRunDate": run_date,
169
+ "lastStartedAt": iso_now(),
170
+ "lastThreadId": get_string(result, "threadId"),
171
+ "lastTurnId": get_string(result, "turnId"),
172
+ "lastStatus": "queued" if result.get("queued") else "started",
173
+ "lastError": None,
174
+ },
175
+ )
176
+ return {"name": routine.name, "ran": True, **result}
177
+ except Exception as exc:
178
+ await self.record_routine_run(
179
+ routine.name,
180
+ {
181
+ "lastRunDate": run_date,
182
+ "lastStartedAt": iso_now(),
183
+ "lastStatus": "failed",
184
+ "lastError": str(exc),
185
+ },
186
+ )
187
+ logger.exception("Failed to run Super Agents routine name=%s", routine.name)
188
+ return {"name": routine.name, "ran": False, "error": str(exc)}
189
+
190
+ async def record_routine_run(self, name: str, patch: JsonObject) -> None:
191
+ async with self._state_lock:
192
+ def update(state: StateFile) -> None:
193
+ routine = state.routines.get(name)
194
+ if routine is None:
195
+ return
196
+ merged = {**routine.to_json(), **patch, "updatedAt": iso_now()}
197
+ state.routines[name] = routine_from_patch(merged)
198
+
199
+ update_state_file(self.state_file, update)
@@ -0,0 +1,442 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import time
7
+ from dataclasses import replace
8
+
9
+ from .app_formatting import (
10
+ apply_field_selection,
11
+ preview_text,
12
+ text_preview,
13
+ without_none,
14
+ )
15
+ from .app_models import LabelQueryInput, PendingServerRequest, ResolvedSession, TurnState
16
+ from .app_queue import (
17
+ complete_queued_turn,
18
+ queued_turn_summaries,
19
+ release_queued_turn,
20
+ reserve_next_queued_turn,
21
+ )
22
+ from .app_protocol import (
23
+ extract_thread_cwd,
24
+ extract_thread_id,
25
+ extract_thread_name,
26
+ extract_threads,
27
+ find_latest_turn,
28
+ is_active_status,
29
+ is_likely_stale,
30
+ normalize_thread_status,
31
+ to_tracked_turn_status,
32
+ )
33
+ from .app_sessions import merge_turns, required_label, session_from_patch, session_from_thread, session_recency
34
+ from .app_time import age_ms, iso_from_thread_time, iso_now, parse_iso_ms, thread_recency, turn_key
35
+ from .state import (
36
+ JsonObject,
37
+ SessionRecord,
38
+ StateFile,
39
+ StoredStatus,
40
+ get_string,
41
+ read_state_file_locked,
42
+ update_state_file,
43
+ )
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+
48
+ class SessionClientMixin:
49
+ async def filtered_sessions(self, input_data: LabelQueryInput) -> list[SessionRecord]:
50
+ state = await self.read_state()
51
+ sessions = list(state.sessions.values())
52
+ sessions = [session for session in sessions if not input_data.label or session.label == input_data.label]
53
+ sessions = [session for session in sessions if not input_data.cwd or session.cwd == input_data.cwd]
54
+ sessions = [session for session in sessions if not input_data.group or session.group == input_data.group]
55
+ sessions = [
56
+ session
57
+ for session in sessions
58
+ if not input_data.status or self.session_status(session) == input_data.status
59
+ ]
60
+ return sorted(sessions, key=session_recency, reverse=True)
61
+
62
+ async def resolve_session(self, label: str, input_data: LabelQueryInput) -> ResolvedSession:
63
+ prefer = input_data.prefer or "latest_active"
64
+ native_thread = await self.resolve_thread_name(label, input_data)
65
+ native_thread_id = extract_thread_id(native_thread)
66
+ if native_thread_id:
67
+ session = await self.get_session(native_thread_id) or session_from_thread(native_thread, label)
68
+ status = self.session_status(session)
69
+ if prefer == "latest_active" and not is_active_status(status):
70
+ raise ValueError(
71
+ f"No active Super Agents session found for name {label}. Recent match: {json.dumps(self.session_view(session))}"
72
+ )
73
+ return ResolvedSession(
74
+ session=session,
75
+ turn_id=input_data.turn_id or session.active_turn_id or session.last_turn_id,
76
+ status=status,
77
+ )
78
+
79
+ candidates = await self.filtered_sessions(replace(input_data, label=label))
80
+ if not candidates:
81
+ raise ValueError(f"No Super Agents session found for name {label}.")
82
+ active_candidates = [session for session in candidates if is_active_status(self.session_status(session))]
83
+ scoped_candidates = candidates if prefer == "latest_any" else active_candidates
84
+ if not scoped_candidates:
85
+ recent = [self.session_view(session) for session in candidates[:5]]
86
+ raise ValueError(
87
+ f"No active Super Agents session found for name {label}. Recent inactive candidates: {json.dumps(recent)}"
88
+ )
89
+ first = scoped_candidates[0]
90
+ if len(scoped_candidates) > 1 and session_recency(first) == session_recency(scoped_candidates[1]):
91
+ candidates_json = json.dumps([self.session_view(session) for session in scoped_candidates[:5]])
92
+ raise ValueError(f"Ambiguous Super Agents name {label}. Candidates: {candidates_json}")
93
+ return ResolvedSession(
94
+ session=first,
95
+ turn_id=input_data.turn_id or first.active_turn_id or first.last_turn_id,
96
+ status=self.session_status(first),
97
+ )
98
+
99
+ async def resolve_queue_target(self, input_data: LabelQueryInput) -> ResolvedSession:
100
+ if input_data.thread_id:
101
+ session = await self.get_session(input_data.thread_id)
102
+ if session is None:
103
+ session = SessionRecord(
104
+ label=input_data.label,
105
+ thread_id=input_data.thread_id,
106
+ cwd=input_data.cwd,
107
+ updated_at=iso_now(),
108
+ )
109
+ return ResolvedSession(
110
+ session=session,
111
+ turn_id=session.active_turn_id or session.last_turn_id,
112
+ status=self.session_status(session),
113
+ )
114
+ return await self.resolve_session(
115
+ required_label(input_data),
116
+ replace(input_data, prefer=input_data.prefer or "latest_any"),
117
+ )
118
+
119
+ def queued_turn_summary(self) -> list[JsonObject]:
120
+ return queued_turn_summaries(self.queue_dir)
121
+
122
+ def schedule_queue_drain(self, thread_id: str) -> None:
123
+ existing = self._queue_tasks.get(thread_id)
124
+ if existing and not existing.done():
125
+ return
126
+ self._queue_tasks[thread_id] = asyncio.create_task(self._drain_queue(thread_id))
127
+
128
+ async def _drain_queue(self, thread_id: str) -> None:
129
+ await asyncio.sleep(0)
130
+ while True:
131
+ queued = reserve_next_queued_turn(
132
+ self.queue_dir,
133
+ thread_id,
134
+ lambda: self.thread_has_active_turn(thread_id),
135
+ )
136
+ if queued is None:
137
+ return
138
+ try:
139
+ await self.start_turn(
140
+ {
141
+ **queued.input_data,
142
+ "threadId": thread_id,
143
+ "label": queued.label,
144
+ "agentName": queued.agent_name,
145
+ }
146
+ )
147
+ complete_queued_turn(self.queue_dir, queued)
148
+ except Exception as exc:
149
+ release_queued_turn(self.queue_dir, queued, error=exc)
150
+ logger.exception("Failed to start queued Super Agents turn for thread_id=%s", thread_id)
151
+ return
152
+
153
+ def thread_has_active_turn(self, thread_id: str) -> bool:
154
+ for turn in self._turns.values():
155
+ if turn.thread_id == thread_id and self.tracked_turn_is_active(turn):
156
+ return True
157
+ session = self.session_from_memory(thread_id)
158
+ return bool(session and is_active_status(self.session_status(session)))
159
+
160
+ def tracked_turn_is_active(self, turn: TurnState) -> bool:
161
+ return is_active_status(turn.status) and not turn.finished_at
162
+
163
+ async def resolve_thread_name(self, name: str, input_data: LabelQueryInput) -> JsonObject:
164
+ try:
165
+ response = await self.list_threads(True, name, input_data.cwd, input_data.limit or 50)
166
+ except Exception:
167
+ return {}
168
+ threads = extract_threads(response)
169
+ matches = [thread for thread in threads if extract_thread_name(thread) == name]
170
+ if not matches:
171
+ return {}
172
+ matches.sort(key=thread_recency, reverse=True)
173
+ return matches[0]
174
+
175
+ async def recent_items(self, input_data: LabelQueryInput) -> list[JsonObject]:
176
+ if input_data.thread_id:
177
+ session = await self.get_session(input_data.thread_id)
178
+ if session:
179
+ return [self.session_view(session)]
180
+ return []
181
+ try:
182
+ response = await self.list_threads(
183
+ True,
184
+ input_data.label,
185
+ input_data.cwd,
186
+ input_data.limit or 50,
187
+ )
188
+ except Exception:
189
+ response = {}
190
+ threads = extract_threads(response)
191
+ if not threads:
192
+ sessions = await self.filtered_sessions(input_data)
193
+ return [self.session_view(session) for session in sessions]
194
+ items = [
195
+ self.thread_view(thread)
196
+ for thread in threads
197
+ if not input_data.label or extract_thread_name(thread) == input_data.label
198
+ ]
199
+ if input_data.status:
200
+ items = [item for item in items if item.get("status") == input_data.status]
201
+ return sorted(items, key=lambda item: parse_iso_ms(str(item.get("updatedAt") or "")), reverse=True)
202
+
203
+ def thread_view(self, thread: JsonObject) -> JsonObject:
204
+ thread_id = extract_thread_id(thread)
205
+ session = self.session_from_memory(thread_id) if thread_id else None
206
+ status = self.session_status(session) if session else normalize_thread_status(thread) or "unknown"
207
+ running_turn_id = (
208
+ session.active_turn_id or session.last_turn_id if session and is_active_status(status) else None
209
+ )
210
+ updated_at = iso_from_thread_time(thread)
211
+ last_event_at = session.last_event_at if session else None
212
+ return without_none(
213
+ {
214
+ "name": extract_thread_name(thread),
215
+ "agentName": session.agent_name if session else None,
216
+ "cwd": extract_thread_cwd(thread),
217
+ "threadId": thread_id,
218
+ "runningTurnId": running_turn_id,
219
+ "lastTurnId": session.last_turn_id if session else None,
220
+ "reasoningEffort": self.session_turn_reasoning_effort(
221
+ session, running_turn_id or (session.last_turn_id if session else None)
222
+ ),
223
+ "status": status,
224
+ "ageMs": int(time.time() * 1000) - thread_recency(thread),
225
+ "updatedAt": updated_at,
226
+ "lastEventAt": last_event_at,
227
+ "lastEventAgeMs": age_ms(last_event_at),
228
+ "ageSinceUpdateMs": age_ms(updated_at),
229
+ "isLikelyStale": is_likely_stale(status, last_event_at or updated_at),
230
+ "preview": get_string(thread, "preview"),
231
+ "lastUsefulMessage": session.last_useful_message if session else None,
232
+ "pendingRequestCount": self.pending_request_count(thread_id, running_turn_id) if thread_id else None,
233
+ }
234
+ )
235
+
236
+ def session_view(self, session: SessionRecord) -> JsonObject:
237
+ status = self.session_status(session)
238
+ running_turn_id = session.active_turn_id or session.last_turn_id if is_active_status(status) else None
239
+ started_at = session.last_started_at or session.updated_at
240
+ return without_none(
241
+ {
242
+ "label": session.label,
243
+ "agentName": session.agent_name,
244
+ "group": session.group,
245
+ "cwd": session.cwd,
246
+ "threadId": session.thread_id,
247
+ "runningTurnId": running_turn_id,
248
+ "lastTurnId": session.last_turn_id,
249
+ "reasoningEffort": self.session_turn_reasoning_effort(session, running_turn_id or session.last_turn_id),
250
+ "status": status,
251
+ "ageMs": int(time.time() * 1000) - parse_iso_ms(started_at),
252
+ "updatedAt": session.updated_at,
253
+ "lastEventAt": session.last_event_at,
254
+ "lastEventAgeMs": age_ms(session.last_event_at),
255
+ "ageSinceUpdateMs": age_ms(session.updated_at),
256
+ "isLikelyStale": is_likely_stale(status, session.last_event_at or session.updated_at),
257
+ "lastUsefulMessage": session.last_useful_message,
258
+ "pendingRequestCount": self.pending_request_count(session.thread_id, running_turn_id),
259
+ }
260
+ )
261
+
262
+ def compact_agent_item(self, item: JsonObject, query: LabelQueryInput) -> JsonObject:
263
+ result = dict(item)
264
+ include_preview = query.include_preview if query.include_preview is not None else True
265
+ preview_length = query.preview_length or 160
266
+ if not include_preview:
267
+ result.pop("preview", None)
268
+ result.pop("lastUsefulMessage", None)
269
+ else:
270
+ if isinstance(result.get("preview"), str):
271
+ result["preview"] = preview_text(str(result["preview"]), preview_length)
272
+ if isinstance(result.get("lastUsefulMessage"), str):
273
+ result["lastUsefulMessage"] = preview_text(str(result["lastUsefulMessage"]), preview_length)
274
+ return apply_field_selection(result, query.fields)
275
+
276
+ def status_item(self, item: JsonObject) -> JsonObject:
277
+ return without_none(
278
+ {
279
+ "name": item.get("name") or item.get("label"),
280
+ "threadId": item.get("threadId"),
281
+ "turnId": item.get("runningTurnId") or item.get("lastTurnId"),
282
+ "reasoningEffort": item.get("reasoningEffort"),
283
+ "status": item.get("status"),
284
+ "lastEventAt": item.get("lastEventAt"),
285
+ "updatedAt": item.get("updatedAt"),
286
+ "lastEventAgeMs": item.get("lastEventAgeMs"),
287
+ "ageSinceUpdateMs": item.get("ageSinceUpdateMs"),
288
+ "isLikelyStale": item.get("isLikelyStale"),
289
+ "pendingRequestCount": item.get("pendingRequestCount"),
290
+ "cwd": item.get("cwd"),
291
+ }
292
+ )
293
+
294
+ def compact_tracked_turn(self, turn: TurnState | None) -> JsonObject:
295
+ if turn is None:
296
+ return {}
297
+ last_event_at = get_string(turn.events[-1], "receivedAt") if turn.events else turn.started_at
298
+ return without_none(
299
+ {
300
+ "threadId": turn.thread_id,
301
+ "turnId": turn.turn_id,
302
+ "status": turn.status,
303
+ "reasoningEffort": turn.reasoning_effort,
304
+ "startedAt": turn.started_at,
305
+ "finishedAt": turn.finished_at,
306
+ "lastEventAt": last_event_at,
307
+ "lastEventAgeMs": age_ms(last_event_at),
308
+ "isLikelyStale": is_likely_stale(turn.status, last_event_at),
309
+ "eventCount": len(turn.events),
310
+ "pendingRequestCount": len(turn.pending_requests),
311
+ "pendingRequestIds": [request.id for request in turn.pending_requests],
312
+ }
313
+ )
314
+
315
+ def session_turn_reasoning_effort(self, session: SessionRecord | None, turn_id: str | None) -> str | None:
316
+ if not session or not turn_id:
317
+ return None
318
+ runtime_turn = self._turns.get(turn_key(session.thread_id, turn_id))
319
+ if runtime_turn and runtime_turn.reasoning_effort:
320
+ return runtime_turn.reasoning_effort
321
+ turn_summary = (session.turns or {}).get(turn_id)
322
+ return turn_summary.reasoning_effort if turn_summary else None
323
+
324
+ def session_status(self, session: SessionRecord) -> StoredStatus:
325
+ turn_id = session.active_turn_id or session.last_turn_id
326
+ runtime_turn = self._turns.get(turn_key(session.thread_id, turn_id)) if turn_id else None
327
+ if runtime_turn:
328
+ if is_active_status(runtime_turn.status) and runtime_turn.finished_at:
329
+ if session.last_status and not is_active_status(session.last_status):
330
+ return session.last_status
331
+ return "unknown"
332
+ return runtime_turn.status
333
+ return session.last_status or "unknown"
334
+
335
+ def session_from_memory(self, thread_id: str | None) -> SessionRecord | None:
336
+ if not thread_id:
337
+ return None
338
+ return read_state_file_locked(self.state_file).sessions.get(thread_id)
339
+
340
+ def pending_request_count(self, thread_id: str, turn_id: str | None = None) -> int:
341
+ return len(
342
+ [
343
+ request
344
+ for request in self._pending_server_requests.values()
345
+ if request.params.get("threadId") == thread_id
346
+ and (not turn_id or request.params.get("turnId") == turn_id)
347
+ ]
348
+ )
349
+
350
+ async def record_turn_progress(
351
+ self,
352
+ thread_id: str,
353
+ turn_id: str,
354
+ status: str,
355
+ persisted_turn: JsonObject | None,
356
+ pending_requests: list[PendingServerRequest],
357
+ ) -> None:
358
+ tracked_status: StoredStatus = "unknown" if status == "unknown" else to_tracked_turn_status(status)
359
+ tracked_turn = self._turns.get(turn_key(thread_id, turn_id))
360
+ state_session = await self.get_session(thread_id)
361
+ reasoning_effort = (
362
+ tracked_turn.reasoning_effort if tracked_turn else None
363
+ ) or self.session_turn_reasoning_effort(
364
+ state_session,
365
+ turn_id,
366
+ )
367
+ finished_at = (
368
+ (tracked_turn.finished_at if tracked_turn else None) or iso_now()
369
+ if tracked_status in {"completed", "failed", "cancelled"}
370
+ else None
371
+ )
372
+ await self.merge_session(
373
+ thread_id,
374
+ {
375
+ "threadId": thread_id,
376
+ "activeTurnId": turn_id if is_active_status(tracked_status) else None,
377
+ "lastTurnId": turn_id,
378
+ "lastStatus": tracked_status,
379
+ "lastFinishedAt": finished_at,
380
+ "lastUsefulMessage": text_preview(persisted_turn),
381
+ "turns": {
382
+ turn_id: {
383
+ "turnId": turn_id,
384
+ "status": "running" if tracked_status == "unknown" else tracked_status,
385
+ "reasoningEffort": reasoning_effort,
386
+ "startedAt": tracked_turn.started_at if tracked_turn else iso_now(),
387
+ "updatedAt": iso_now(),
388
+ "finishedAt": finished_at,
389
+ "lastUsefulMessage": text_preview(persisted_turn),
390
+ "pendingRequestIds": [request.id for request in pending_requests],
391
+ "eventCount": len(tracked_turn.events) if tracked_turn else 0,
392
+ }
393
+ },
394
+ },
395
+ clear_fields=[] if is_active_status(tracked_status) else ["activeTurnId"],
396
+ )
397
+ if not is_active_status(tracked_status):
398
+ self.schedule_queue_drain(thread_id)
399
+
400
+ async def remember_session(self, thread_id: str, patch: JsonObject) -> None:
401
+ async with self._state_lock:
402
+ def update(state: StateFile) -> None:
403
+ now = iso_now()
404
+ state.sessions[thread_id] = session_from_patch(
405
+ {"threadId": thread_id, "createdAt": now, **patch, "updatedAt": now}
406
+ )
407
+
408
+ update_state_file(self.state_file, update)
409
+
410
+ async def merge_session(
411
+ self,
412
+ thread_id: str,
413
+ patch: JsonObject,
414
+ clear_fields: list[str] | None = None,
415
+ ) -> None:
416
+ async with self._state_lock:
417
+ def update(state: StateFile) -> None:
418
+ now = iso_now()
419
+ current = state.sessions.get(thread_id) or SessionRecord(
420
+ thread_id=thread_id, created_at=now, updated_at=now
421
+ )
422
+ merged_json = {**current.to_json(), **without_none(patch), "threadId": thread_id, "updatedAt": now}
423
+ merged_json["createdAt"] = current.created_at or now
424
+ merged_json["turns"] = merge_turns(current.turns, patch.get("turns"))
425
+ for field_name in clear_fields or []:
426
+ merged_json.pop(field_name, None)
427
+ state.sessions[thread_id] = session_from_patch(merged_json)
428
+
429
+ update_state_file(self.state_file, update)
430
+
431
+ async def get_session(self, thread_id: str) -> SessionRecord | None:
432
+ state = await self.read_state()
433
+ return state.sessions.get(thread_id)
434
+
435
+ async def read_state(self) -> StateFile:
436
+ async with self._state_lock:
437
+ return read_state_file_locked(self.state_file)
438
+
439
+ async def latest_turn_id(self, thread_id: str) -> str | None:
440
+ thread = await self.read_thread(thread_id, True)
441
+ turn = find_latest_turn(thread, active_only=True) or find_latest_turn(thread, active_only=False)
442
+ return get_string(turn, "id") if turn else None