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.
- super_agents/__init__.py +27 -0
- super_agents/__main__.py +4 -0
- super_agents/app_client_routines.py +199 -0
- super_agents/app_client_sessions.py +442 -0
- super_agents/app_client_transport.py +349 -0
- super_agents/app_environment.py +79 -0
- super_agents/app_formatting.py +142 -0
- super_agents/app_models.py +116 -0
- super_agents/app_permissions.py +107 -0
- super_agents/app_protocol.py +219 -0
- super_agents/app_queue.py +198 -0
- super_agents/app_routines.py +121 -0
- super_agents/app_server_client.py +890 -0
- super_agents/app_sessions.py +97 -0
- super_agents/app_time.py +67 -0
- super_agents/mcp_server.py +623 -0
- super_agents/state.py +352 -0
- super_agents-0.1.0.dist-info/METADATA +232 -0
- super_agents-0.1.0.dist-info/RECORD +22 -0
- super_agents-0.1.0.dist-info/WHEEL +4 -0
- super_agents-0.1.0.dist-info/entry_points.txt +2 -0
- super_agents-0.1.0.dist-info/licenses/LICENSE +18 -0
super_agents/__init__.py
ADDED
|
@@ -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
|
+
]
|
super_agents/__main__.py
ADDED
|
@@ -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
|