multi-agent-platform 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.
- cli/__init__.py +0 -0
- cli/action_item_escalation.py +177 -0
- cli/agent_client.py +554 -0
- cli/bridge_state.py +43 -0
- cli/commands/__init__.py +13 -0
- cli/commands/action.py +142 -0
- cli/commands/agent.py +117 -0
- cli/commands/audit.py +68 -0
- cli/commands/docs.py +179 -0
- cli/commands/experiment.py +755 -0
- cli/commands/feedback.py +106 -0
- cli/commands/notification.py +213 -0
- cli/commands/persona.py +63 -0
- cli/commands/project.py +87 -0
- cli/commands/runtime.py +105 -0
- cli/commands/topic.py +361 -0
- cli/e2e_collab.py +602 -0
- cli/git_checkpoint.py +68 -0
- cli/host_worker_types.py +151 -0
- cli/main.py +1553 -0
- cli/map_command_client.py +497 -0
- cli/participant_worker.py +255 -0
- cli/reviewer_worker.py +263 -0
- cli/runtime/__init__.py +5 -0
- cli/runtime/run_lock.py +497 -0
- cli/runtime_chat.py +317 -0
- cli/session_wake_log.py +235 -0
- cli/simple_waker.py +950 -0
- cli/table_render.py +113 -0
- cli/wake_backend.py +236 -0
- cli/worker_cycle_log.py +36 -0
- map_client/__init__.py +37 -0
- map_client/bootstrap.py +193 -0
- map_client/client.py +1045 -0
- map_client/config.py +21 -0
- map_client/errors.py +283 -0
- map_client/exceptions.py +130 -0
- map_client/plan_evidence.py +159 -0
- map_client/project_config.py +153 -0
- map_client/result_template.py +167 -0
- map_client/testing.py +27 -0
- map_mcp/__init__.py +4 -0
- map_mcp/_utils.py +28 -0
- map_mcp/auth.py +34 -0
- map_mcp/config.py +50 -0
- map_mcp/context.py +39 -0
- map_mcp/main.py +75 -0
- map_mcp/server.py +573 -0
- map_mcp/session.py +79 -0
- map_sdk/__init__.py +29 -0
- map_sdk/evidence.py +68 -0
- map_types/__init__.py +203 -0
- map_types/enums.py +199 -0
- map_types/schemas.py +1351 -0
- multi_agent_platform-0.1.0.dist-info/METADATA +298 -0
- multi_agent_platform-0.1.0.dist-info/RECORD +144 -0
- multi_agent_platform-0.1.0.dist-info/WHEEL +5 -0
- multi_agent_platform-0.1.0.dist-info/entry_points.txt +6 -0
- multi_agent_platform-0.1.0.dist-info/licenses/LICENSE +21 -0
- multi_agent_platform-0.1.0.dist-info/top_level.txt +6 -0
- server/__init__.py +0 -0
- server/__version__.py +14 -0
- server/api/__init__.py +0 -0
- server/api/action_items.py +138 -0
- server/api/agents.py +412 -0
- server/api/audit.py +54 -0
- server/api/background_tasks.py +18 -0
- server/api/common.py +117 -0
- server/api/deps.py +30 -0
- server/api/experiments.py +858 -0
- server/api/feedback.py +75 -0
- server/api/notifications.py +22 -0
- server/api/projects.py +209 -0
- server/api/router.py +25 -0
- server/api/status.py +33 -0
- server/api/topics.py +302 -0
- server/api/webhooks.py +74 -0
- server/auth/__init__.py +8 -0
- server/auth/experiment_access.py +66 -0
- server/config.py +38 -0
- server/db/__init__.py +3 -0
- server/db/base.py +5 -0
- server/db/deadlock_retry.py +146 -0
- server/db/session.py +41 -0
- server/domain/__init__.py +3 -0
- server/domain/encrypted_types.py +63 -0
- server/domain/models.py +713 -0
- server/domain/schemas.py +3 -0
- server/domain/state_machine.py +79 -0
- server/domain/topic_ack_constants.py +9 -0
- server/main.py +148 -0
- server/scripts/__init__.py +0 -0
- server/scripts/migrate_notification_unique.py +231 -0
- server/scripts/purge_audit_pollution.py +116 -0
- server/services/__init__.py +0 -0
- server/services/_lookups.py +26 -0
- server/services/acceptance_service.py +90 -0
- server/services/action_item_migration_service.py +190 -0
- server/services/action_item_service.py +200 -0
- server/services/agent_work_service.py +405 -0
- server/services/archive_lint_service.py +156 -0
- server/services/audit_service.py +457 -0
- server/services/auth.py +66 -0
- server/services/comment_service.py +173 -0
- server/services/errors.py +65 -0
- server/services/escalation_resolver.py +248 -0
- server/services/evidence_service.py +88 -0
- server/services/experiment_capabilities_service.py +277 -0
- server/services/inbound_event_service.py +111 -0
- server/services/lock_service.py +273 -0
- server/services/log_service.py +202 -0
- server/services/mention_service.py +730 -0
- server/services/notification_service.py +939 -0
- server/services/notification_stream.py +138 -0
- server/services/permissions.py +147 -0
- server/services/persona_activity_service.py +108 -0
- server/services/phase_owner_resolver.py +95 -0
- server/services/phase_service.py +381 -0
- server/services/plan_marker_service.py +235 -0
- server/services/plan_service.py +186 -0
- server/services/platform_feedback_service.py +114 -0
- server/services/project_service.py +534 -0
- server/services/project_status_service.py +132 -0
- server/services/review_service.py +707 -0
- server/services/secret_encryption.py +97 -0
- server/services/similarity_service.py +119 -0
- server/services/sse_event_schemas.py +17 -0
- server/services/status_service.py +68 -0
- server/services/template_service.py +134 -0
- server/services/text_utils.py +19 -0
- server/services/thread_activity.py +180 -0
- server/services/todo_persona_filter.py +73 -0
- server/services/todo_service.py +604 -0
- server/services/topic_ack_service.py +312 -0
- server/services/topic_action_item_ops.py +538 -0
- server/services/topic_comment_kind.py +14 -0
- server/services/topic_comment_service.py +237 -0
- server/services/topic_helpers.py +32 -0
- server/services/topic_lifecycle_service.py +478 -0
- server/services/topic_progress_service.py +40 -0
- server/services/topic_resolve_service.py +234 -0
- server/services/topic_service.py +102 -0
- server/services/topic_work_item_service.py +570 -0
- server/services/webhook_service.py +273 -0
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
"""Unified per-agent topic work items (single source for todos + topic-progress)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
from map_types.enums import TopicCommentKind
|
|
10
|
+
from sqlalchemy import select
|
|
11
|
+
from sqlalchemy.orm import Session, joinedload
|
|
12
|
+
|
|
13
|
+
from server.domain.models import Agent, Mention, MentionSourceType, Topic, TopicComment, TopicReadCursor, TopicStatus
|
|
14
|
+
from server.domain.schemas import (
|
|
15
|
+
MentionTodoRead,
|
|
16
|
+
PendingRoundAckTodoRead,
|
|
17
|
+
PendingTopicReplyTodoRead,
|
|
18
|
+
TopicProgressCommentRead,
|
|
19
|
+
TopicProgressItemRead,
|
|
20
|
+
TopicWorkItemRead,
|
|
21
|
+
)
|
|
22
|
+
from server.services import mention_service, topic_ack_service
|
|
23
|
+
from server.services.text_utils import excerpt as _excerpt
|
|
24
|
+
from server.services.thread_activity import (
|
|
25
|
+
host_replied_after as _host_replied_after,
|
|
26
|
+
)
|
|
27
|
+
from server.services.thread_activity import (
|
|
28
|
+
thread_root_id,
|
|
29
|
+
topic_comment_order_clauses,
|
|
30
|
+
)
|
|
31
|
+
from server.services.topic_service import _agent_names_by_ids
|
|
32
|
+
|
|
33
|
+
_CLEAR_ACTION_BY_KIND = {
|
|
34
|
+
"pending_topic_reply": "comment",
|
|
35
|
+
"round_ack": "ack",
|
|
36
|
+
"mention": "dismiss",
|
|
37
|
+
"unread_change": "read",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class TopicWorkItem:
|
|
43
|
+
kind: str
|
|
44
|
+
priority: str
|
|
45
|
+
topic_id: uuid.UUID
|
|
46
|
+
topic_title: str
|
|
47
|
+
source_comment_id: uuid.UUID | None
|
|
48
|
+
thread_root_id: uuid.UUID | None
|
|
49
|
+
required_agent_id: uuid.UUID
|
|
50
|
+
reason: str
|
|
51
|
+
idempotency_key: str
|
|
52
|
+
clear_action: str
|
|
53
|
+
excerpt: str
|
|
54
|
+
created_at: datetime
|
|
55
|
+
discussion_round: str | None = None
|
|
56
|
+
|
|
57
|
+
def to_read(self) -> TopicWorkItemRead:
|
|
58
|
+
return TopicWorkItemRead(
|
|
59
|
+
kind=self.kind,
|
|
60
|
+
priority=self.priority,
|
|
61
|
+
topic_id=self.topic_id,
|
|
62
|
+
topic_title=self.topic_title,
|
|
63
|
+
source_comment_id=self.source_comment_id,
|
|
64
|
+
thread_root_id=self.thread_root_id,
|
|
65
|
+
required_agent_id=self.required_agent_id,
|
|
66
|
+
reason=self.reason,
|
|
67
|
+
idempotency_key=self.idempotency_key,
|
|
68
|
+
clear_action=self.clear_action,
|
|
69
|
+
excerpt=self.excerpt,
|
|
70
|
+
created_at=self.created_at,
|
|
71
|
+
discussion_round=self.discussion_round,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _topic_comments(db: Session, topic_id: uuid.UUID) -> list[TopicComment]:
|
|
76
|
+
return list(
|
|
77
|
+
db.scalars(
|
|
78
|
+
select(TopicComment)
|
|
79
|
+
.where(TopicComment.topic_id == topic_id)
|
|
80
|
+
.order_by(*topic_comment_order_clauses())
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _topic_comments_bulk(
|
|
86
|
+
db: Session, topic_ids: list[uuid.UUID]
|
|
87
|
+
) -> dict[uuid.UUID, list[TopicComment]]:
|
|
88
|
+
"""Single-query bulk loader for TopicComment rows across multiple topics.
|
|
89
|
+
|
|
90
|
+
perf experiment (193a5074) PR2 Layer 2: replaces per-topic ``_topic_comments``
|
|
91
|
+
inside the ``topic_work_items_bundle_for_agent`` loop. One ``WHERE topic_id
|
|
92
|
+
IN (...)`` instead of N+1 queries; caller groups by ``topic_id`` and
|
|
93
|
+
re-sorts each bucket with ``topic_comment_order_clauses()`` so downstream
|
|
94
|
+
consumers see the same ordering they used to.
|
|
95
|
+
"""
|
|
96
|
+
if not topic_ids:
|
|
97
|
+
return {}
|
|
98
|
+
rows = list(
|
|
99
|
+
db.scalars(
|
|
100
|
+
select(TopicComment)
|
|
101
|
+
.where(TopicComment.topic_id.in_(topic_ids))
|
|
102
|
+
.order_by(*topic_comment_order_clauses())
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
grouped: dict[uuid.UUID, list[TopicComment]] = {tid: [] for tid in topic_ids}
|
|
106
|
+
for row in rows:
|
|
107
|
+
grouped[row.topic_id].append(row)
|
|
108
|
+
return grouped
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _topic_suppressed_by_dismiss(topic: Topic) -> bool:
|
|
112
|
+
if topic.dismissed_at is None:
|
|
113
|
+
return False
|
|
114
|
+
updated = topic.updated_at
|
|
115
|
+
dismissed = topic.dismissed_at
|
|
116
|
+
if updated is None or dismissed is None:
|
|
117
|
+
return topic.dismissed_at is not None
|
|
118
|
+
return updated <= dismissed
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _agent_commented(comments: list[TopicComment], agent_id: uuid.UUID) -> bool:
|
|
122
|
+
return any(c.author_agent_id == agent_id for c in comments)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _pending_reply_items(
|
|
126
|
+
db: Session,
|
|
127
|
+
topic: Topic,
|
|
128
|
+
agent: Agent,
|
|
129
|
+
comments: list[TopicComment],
|
|
130
|
+
) -> list[TopicWorkItem]:
|
|
131
|
+
if topic.creator_agent_id != agent.id:
|
|
132
|
+
return []
|
|
133
|
+
by_id = {c.id: c for c in comments}
|
|
134
|
+
comment_order = [c.id for c in comments]
|
|
135
|
+
host_comment_ids = {c.id for c in comments if c.author_agent_id == agent.id}
|
|
136
|
+
items: list[TopicWorkItem] = []
|
|
137
|
+
for comment in comments:
|
|
138
|
+
if comment.author_agent_id == agent.id:
|
|
139
|
+
continue
|
|
140
|
+
# ack / round-summary protocol signals are system comments, not
|
|
141
|
+
# conversational threads the host must reply to (compare
|
|
142
|
+
# _unread_change_items, which already skips system comments).
|
|
143
|
+
# They drive the dedicated round_ack / pending_advance_rounds
|
|
144
|
+
# work items; counting them as pending_reply too leaves a stale
|
|
145
|
+
# host obligation after every participant ack.
|
|
146
|
+
if comment.kind == TopicCommentKind.system:
|
|
147
|
+
continue
|
|
148
|
+
root = thread_root_id(comment.id, by_id)
|
|
149
|
+
if _host_replied_after(comment, host_comment_ids, by_id, comment_order=comment_order):
|
|
150
|
+
continue
|
|
151
|
+
items.append(
|
|
152
|
+
TopicWorkItem(
|
|
153
|
+
kind="pending_topic_reply",
|
|
154
|
+
priority="obligation",
|
|
155
|
+
topic_id=topic.id,
|
|
156
|
+
topic_title=topic.title,
|
|
157
|
+
source_comment_id=comment.id,
|
|
158
|
+
thread_root_id=root,
|
|
159
|
+
required_agent_id=agent.id,
|
|
160
|
+
reason="thread_needs_reply",
|
|
161
|
+
idempotency_key=f"pending_topic_reply:{topic.id}:{comment.id}",
|
|
162
|
+
clear_action=_CLEAR_ACTION_BY_KIND["pending_topic_reply"],
|
|
163
|
+
excerpt=_excerpt(comment.body),
|
|
164
|
+
created_at=comment.created_at,
|
|
165
|
+
discussion_round=str(topic.discussion_round),
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
return items
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _round_ack_items(
|
|
172
|
+
db: Session,
|
|
173
|
+
topic: Topic,
|
|
174
|
+
agent: Agent,
|
|
175
|
+
comments: list[TopicComment] | None = None,
|
|
176
|
+
) -> list[TopicWorkItem]:
|
|
177
|
+
if comments is None:
|
|
178
|
+
comments = _topic_comments(db, topic.id)
|
|
179
|
+
if not topic_ack_service.agent_needs_round_ack(db, topic, agent.id, comments=comments):
|
|
180
|
+
return []
|
|
181
|
+
summary = topic_ack_service.latest_host_round_summary_comment(
|
|
182
|
+
comments,
|
|
183
|
+
host_agent_id=topic.creator_agent_id,
|
|
184
|
+
)
|
|
185
|
+
excerpt = _excerpt(summary.body) if summary is not None else ""
|
|
186
|
+
summary_id = summary.id if summary is not None else topic.id
|
|
187
|
+
return [
|
|
188
|
+
TopicWorkItem(
|
|
189
|
+
kind="round_ack",
|
|
190
|
+
priority="obligation",
|
|
191
|
+
topic_id=topic.id,
|
|
192
|
+
topic_title=topic.title,
|
|
193
|
+
source_comment_id=summary.id if summary is not None else None,
|
|
194
|
+
thread_root_id=summary.id if summary is not None else None,
|
|
195
|
+
required_agent_id=agent.id,
|
|
196
|
+
reason="round_summary_pending",
|
|
197
|
+
idempotency_key=f"round_ack:{topic.id}:{summary_id}",
|
|
198
|
+
clear_action=_CLEAR_ACTION_BY_KIND["round_ack"],
|
|
199
|
+
excerpt=excerpt,
|
|
200
|
+
created_at=summary.created_at if summary is not None else topic.updated_at,
|
|
201
|
+
discussion_round=str(topic.discussion_round),
|
|
202
|
+
)
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _mention_items(
|
|
207
|
+
db: Session,
|
|
208
|
+
topic: Topic,
|
|
209
|
+
agent: Agent,
|
|
210
|
+
mentions_for_agent: list[Mention] | None = None,
|
|
211
|
+
) -> list[TopicWorkItem]:
|
|
212
|
+
mentions = mentions_for_agent
|
|
213
|
+
if mentions is None:
|
|
214
|
+
mentions = mention_service.list_mentions_for_agent(db, agent.id, limit=200)
|
|
215
|
+
mentions = [
|
|
216
|
+
m
|
|
217
|
+
for m in mentions
|
|
218
|
+
if m.topic_id == topic.id and m.dismissed_at is None
|
|
219
|
+
]
|
|
220
|
+
items: list[TopicWorkItem] = []
|
|
221
|
+
for mention in mentions:
|
|
222
|
+
if mention_service.agent_replied_after_mention(
|
|
223
|
+
db, mention=mention, agent_id=agent.id
|
|
224
|
+
):
|
|
225
|
+
continue
|
|
226
|
+
items.append(
|
|
227
|
+
TopicWorkItem(
|
|
228
|
+
kind="mention",
|
|
229
|
+
priority="obligation",
|
|
230
|
+
topic_id=topic.id,
|
|
231
|
+
topic_title=topic.title,
|
|
232
|
+
source_comment_id=mention.source_id if mention.source_type == MentionSourceType.topic_comment else None,
|
|
233
|
+
thread_root_id=None,
|
|
234
|
+
required_agent_id=agent.id,
|
|
235
|
+
reason="mentioned",
|
|
236
|
+
idempotency_key=f"mention:{mention.id}",
|
|
237
|
+
clear_action=_CLEAR_ACTION_BY_KIND["mention"],
|
|
238
|
+
excerpt=mention.excerpt,
|
|
239
|
+
created_at=mention.created_at,
|
|
240
|
+
discussion_round=str(topic.discussion_round),
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
return items
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _agent_cursor_seq(db: Session, topic_id: uuid.UUID, agent_id: uuid.UUID) -> int:
|
|
247
|
+
cursor = db.scalar(
|
|
248
|
+
select(TopicReadCursor.last_read_comment_seq).where(
|
|
249
|
+
TopicReadCursor.topic_id == topic_id,
|
|
250
|
+
TopicReadCursor.agent_id == agent_id,
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
return int(cursor or 0)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _unread_change_items(
|
|
257
|
+
db: Session,
|
|
258
|
+
topic: Topic,
|
|
259
|
+
agent: Agent,
|
|
260
|
+
comments: list[TopicComment],
|
|
261
|
+
) -> list[TopicWorkItem]:
|
|
262
|
+
if not comments:
|
|
263
|
+
return []
|
|
264
|
+
cursor_seq = _agent_cursor_seq(db, topic.id, agent.id)
|
|
265
|
+
new_comments = [
|
|
266
|
+
c
|
|
267
|
+
for c in comments
|
|
268
|
+
if c.comment_seq > cursor_seq and c.author_agent_id != agent.id
|
|
269
|
+
]
|
|
270
|
+
if not new_comments:
|
|
271
|
+
return []
|
|
272
|
+
|
|
273
|
+
by_id = {c.id: c for c in comments}
|
|
274
|
+
items: list[TopicWorkItem] = []
|
|
275
|
+
for comment in new_comments:
|
|
276
|
+
if comment.kind == TopicCommentKind.system:
|
|
277
|
+
continue
|
|
278
|
+
items.append(
|
|
279
|
+
TopicWorkItem(
|
|
280
|
+
kind="unread_change",
|
|
281
|
+
priority="contextual",
|
|
282
|
+
topic_id=topic.id,
|
|
283
|
+
topic_title=topic.title,
|
|
284
|
+
source_comment_id=comment.id,
|
|
285
|
+
thread_root_id=thread_root_id(comment.id, by_id),
|
|
286
|
+
required_agent_id=agent.id,
|
|
287
|
+
reason="content_since_cursor",
|
|
288
|
+
idempotency_key=f"unread_change:{topic.id}:{comment.id}",
|
|
289
|
+
clear_action=_CLEAR_ACTION_BY_KIND["unread_change"],
|
|
290
|
+
excerpt=_excerpt(comment.body),
|
|
291
|
+
created_at=comment.created_at,
|
|
292
|
+
discussion_round=str(topic.discussion_round),
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
return items
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _include_topic_for_agent(
|
|
299
|
+
db: Session,
|
|
300
|
+
topic: Topic,
|
|
301
|
+
agent: Agent,
|
|
302
|
+
comments: list[TopicComment],
|
|
303
|
+
items: list[TopicWorkItem],
|
|
304
|
+
) -> bool:
|
|
305
|
+
if not items:
|
|
306
|
+
return False
|
|
307
|
+
if topic.creator_agent_id == agent.id and _topic_suppressed_by_dismiss(topic):
|
|
308
|
+
return False
|
|
309
|
+
if any(item.priority == "obligation" for item in items):
|
|
310
|
+
return True
|
|
311
|
+
if _agent_commented(comments, agent.id):
|
|
312
|
+
return True
|
|
313
|
+
if topic_ack_service.agent_needs_round_ack(db, topic, agent.id, comments=comments):
|
|
314
|
+
return True
|
|
315
|
+
# Cold-start: contextual-only for never-participated agents (reviewer filter).
|
|
316
|
+
return any(item.kind == "mention" for item in items)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def topic_work_items_for_topic(
|
|
320
|
+
db: Session,
|
|
321
|
+
topic: Topic,
|
|
322
|
+
agent: Agent,
|
|
323
|
+
*,
|
|
324
|
+
comments: list[TopicComment] | None = None,
|
|
325
|
+
mentions_for_agent: list[Mention] | None = None,
|
|
326
|
+
) -> list[TopicWorkItem]:
|
|
327
|
+
if comments is None:
|
|
328
|
+
comments = _topic_comments(db, topic.id)
|
|
329
|
+
items: list[TopicWorkItem] = []
|
|
330
|
+
items.extend(_pending_reply_items(db, topic, agent, comments))
|
|
331
|
+
items.extend(_round_ack_items(db, topic, agent, comments))
|
|
332
|
+
items.extend(_mention_items(db, topic, agent, mentions_for_agent))
|
|
333
|
+
items.extend(_unread_change_items(db, topic, agent, comments))
|
|
334
|
+
if not _include_topic_for_agent(db, topic, agent, comments, items):
|
|
335
|
+
return []
|
|
336
|
+
return items
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@dataclass(frozen=True)
|
|
340
|
+
class AgentTopicWorkItems:
|
|
341
|
+
items: list[TopicWorkItem]
|
|
342
|
+
comments_by_topic: dict[uuid.UUID, list[TopicComment]]
|
|
343
|
+
open_topics: list[Topic]
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def topic_work_items_for_agent(db: Session, agent: Agent) -> list[TopicWorkItem]:
|
|
347
|
+
return topic_work_items_bundle_for_agent(db, agent).items
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def topic_work_items_bundle_for_agent(db: Session, agent: Agent) -> AgentTopicWorkItems:
|
|
351
|
+
if agent.project_id is None:
|
|
352
|
+
return AgentTopicWorkItems(items=[], comments_by_topic={}, open_topics=[])
|
|
353
|
+
open_topics = list(
|
|
354
|
+
db.scalars(
|
|
355
|
+
select(Topic)
|
|
356
|
+
.where(
|
|
357
|
+
Topic.project_id == agent.project_id,
|
|
358
|
+
Topic.deleted_at.is_(None),
|
|
359
|
+
Topic.archived_at.is_(None),
|
|
360
|
+
Topic.status == TopicStatus.open,
|
|
361
|
+
)
|
|
362
|
+
.order_by(Topic.updated_at.desc())
|
|
363
|
+
)
|
|
364
|
+
)
|
|
365
|
+
mentions_for_agent = mention_service.list_mentions_for_agent(db, agent.id, limit=200)
|
|
366
|
+
items: list[TopicWorkItem] = []
|
|
367
|
+
comments_by_topic = _topic_comments_bulk(db, [t.id for t in open_topics])
|
|
368
|
+
for topic in open_topics:
|
|
369
|
+
comments = comments_by_topic.get(topic.id, [])
|
|
370
|
+
items.extend(
|
|
371
|
+
topic_work_items_for_topic(
|
|
372
|
+
db,
|
|
373
|
+
topic,
|
|
374
|
+
agent,
|
|
375
|
+
comments=comments,
|
|
376
|
+
mentions_for_agent=mentions_for_agent,
|
|
377
|
+
)
|
|
378
|
+
)
|
|
379
|
+
return AgentTopicWorkItems(
|
|
380
|
+
items=items,
|
|
381
|
+
comments_by_topic=comments_by_topic,
|
|
382
|
+
open_topics=open_topics,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def obligation_items_for_agent(db: Session, agent: Agent) -> list[TopicWorkItem]:
|
|
387
|
+
return [item for item in topic_work_items_for_agent(db, agent) if item.priority == "obligation"]
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def pending_topic_replies_from_work_items(
|
|
391
|
+
db: Session,
|
|
392
|
+
items: list[TopicWorkItem],
|
|
393
|
+
) -> list[PendingTopicReplyTodoRead]:
|
|
394
|
+
comment_ids = [
|
|
395
|
+
item.source_comment_id for item in items if item.kind == "pending_topic_reply" and item.source_comment_id
|
|
396
|
+
]
|
|
397
|
+
if not comment_ids:
|
|
398
|
+
return []
|
|
399
|
+
comments = {
|
|
400
|
+
c.id: c
|
|
401
|
+
for c in db.scalars(
|
|
402
|
+
select(TopicComment)
|
|
403
|
+
.where(TopicComment.id.in_(comment_ids))
|
|
404
|
+
.options(joinedload(TopicComment.author))
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
author_names = _agent_names_by_ids(
|
|
408
|
+
db,
|
|
409
|
+
{c.author_agent_id for c in comments.values()},
|
|
410
|
+
)
|
|
411
|
+
rows: list[PendingTopicReplyTodoRead] = []
|
|
412
|
+
for item in items:
|
|
413
|
+
if item.kind != "pending_topic_reply" or item.source_comment_id is None:
|
|
414
|
+
continue
|
|
415
|
+
if item.thread_root_id is None:
|
|
416
|
+
continue
|
|
417
|
+
comment = comments.get(item.source_comment_id)
|
|
418
|
+
if comment is None:
|
|
419
|
+
continue
|
|
420
|
+
parent_id = comment.parent_comment_id
|
|
421
|
+
rows.append(
|
|
422
|
+
PendingTopicReplyTodoRead(
|
|
423
|
+
topic_id=item.topic_id,
|
|
424
|
+
topic_title=item.topic_title,
|
|
425
|
+
comment_id=item.source_comment_id,
|
|
426
|
+
parent_comment_id=parent_id,
|
|
427
|
+
thread_root_id=item.thread_root_id,
|
|
428
|
+
author_agent_id=comment.author_agent_id,
|
|
429
|
+
author_name=author_names.get(comment.author_agent_id),
|
|
430
|
+
excerpt=item.excerpt,
|
|
431
|
+
created_at=item.created_at,
|
|
432
|
+
)
|
|
433
|
+
)
|
|
434
|
+
rows.sort(key=lambda p: p.created_at, reverse=True)
|
|
435
|
+
return rows
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def pending_round_acks_from_work_items(
|
|
439
|
+
db: Session,
|
|
440
|
+
items: list[TopicWorkItem],
|
|
441
|
+
) -> list[PendingRoundAckTodoRead]:
|
|
442
|
+
ack_items = [item for item in items if item.kind == "round_ack"]
|
|
443
|
+
if not ack_items:
|
|
444
|
+
return []
|
|
445
|
+
topic_ids = {item.topic_id for item in ack_items}
|
|
446
|
+
topics = {
|
|
447
|
+
t.id: t
|
|
448
|
+
for t in db.scalars(select(Topic).where(Topic.id.in_(topic_ids)))
|
|
449
|
+
}
|
|
450
|
+
rows: list[PendingRoundAckTodoRead] = []
|
|
451
|
+
for item in ack_items:
|
|
452
|
+
topic = topics.get(item.topic_id)
|
|
453
|
+
if topic is None:
|
|
454
|
+
continue
|
|
455
|
+
rows.append(
|
|
456
|
+
PendingRoundAckTodoRead(
|
|
457
|
+
topic_id=item.topic_id,
|
|
458
|
+
topic_title=item.topic_title,
|
|
459
|
+
discussion_round=topic.discussion_round,
|
|
460
|
+
round_summary_count=int(topic.round_summary_count or 0),
|
|
461
|
+
summary_comment_id=item.source_comment_id,
|
|
462
|
+
summary_excerpt=item.excerpt or None,
|
|
463
|
+
stale_since=topic.advance_round_pending_since,
|
|
464
|
+
updated_at=topic.updated_at,
|
|
465
|
+
)
|
|
466
|
+
)
|
|
467
|
+
rows.sort(key=lambda p: p.updated_at, reverse=True)
|
|
468
|
+
return rows
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def mentions_from_work_items(
|
|
472
|
+
db: Session,
|
|
473
|
+
agent_id: uuid.UUID,
|
|
474
|
+
items: list[TopicWorkItem],
|
|
475
|
+
) -> list[MentionTodoRead]:
|
|
476
|
+
mention_ids: list[uuid.UUID] = []
|
|
477
|
+
for item in items:
|
|
478
|
+
if item.kind != "mention":
|
|
479
|
+
continue
|
|
480
|
+
prefix = "mention:"
|
|
481
|
+
if not item.idempotency_key.startswith(prefix):
|
|
482
|
+
continue
|
|
483
|
+
mention_ids.append(uuid.UUID(item.idempotency_key[len(prefix) :]))
|
|
484
|
+
if not mention_ids:
|
|
485
|
+
return []
|
|
486
|
+
mentions = list(
|
|
487
|
+
db.scalars(
|
|
488
|
+
select(Mention).where(
|
|
489
|
+
Mention.id.in_(mention_ids),
|
|
490
|
+
Mention.mentioned_agent_id == agent_id,
|
|
491
|
+
Mention.dismissed_at.is_(None),
|
|
492
|
+
)
|
|
493
|
+
)
|
|
494
|
+
)
|
|
495
|
+
author_ids = {m.author_agent_id for m in mentions}
|
|
496
|
+
author_names = _agent_names_by_ids(db, author_ids)
|
|
497
|
+
return [
|
|
498
|
+
MentionTodoRead(
|
|
499
|
+
id=m.id,
|
|
500
|
+
mentioned_agent_id=m.mentioned_agent_id,
|
|
501
|
+
author_agent_id=m.author_agent_id,
|
|
502
|
+
author_name=author_names.get(m.author_agent_id),
|
|
503
|
+
source_type=m.source_type.value,
|
|
504
|
+
source_id=m.source_id,
|
|
505
|
+
project_id=m.project_id,
|
|
506
|
+
experiment_id=m.experiment_id,
|
|
507
|
+
topic_id=m.topic_id,
|
|
508
|
+
excerpt=m.excerpt,
|
|
509
|
+
created_at=m.created_at,
|
|
510
|
+
dismissed_at=m.dismissed_at,
|
|
511
|
+
)
|
|
512
|
+
for m in mentions
|
|
513
|
+
]
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def topic_progress_item_from_work_items(
|
|
517
|
+
db: Session,
|
|
518
|
+
topic: Topic,
|
|
519
|
+
agent: Agent,
|
|
520
|
+
items: list[TopicWorkItem],
|
|
521
|
+
*,
|
|
522
|
+
comments: list[TopicComment] | None = None,
|
|
523
|
+
) -> TopicProgressItemRead | None:
|
|
524
|
+
topic_items = [i for i in items if i.topic_id == topic.id]
|
|
525
|
+
if not topic_items:
|
|
526
|
+
return None
|
|
527
|
+
|
|
528
|
+
if comments is None:
|
|
529
|
+
comments = _topic_comments(db, topic.id)
|
|
530
|
+
last_comment = comments[-1] if comments else None
|
|
531
|
+
my_comments = [c for c in comments if c.author_agent_id == agent.id]
|
|
532
|
+
my_last = my_comments[-1] if my_comments else None
|
|
533
|
+
|
|
534
|
+
author_names = _agent_names_by_ids(
|
|
535
|
+
db,
|
|
536
|
+
({last_comment.author_agent_id} if last_comment is not None else set())
|
|
537
|
+
| {c.author_agent_id for c in comments if c.author_agent_id != agent.id},
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
unread = [i for i in topic_items if i.kind == "unread_change"]
|
|
541
|
+
new_comments = []
|
|
542
|
+
for item in unread:
|
|
543
|
+
if item.source_comment_id is None:
|
|
544
|
+
continue
|
|
545
|
+
comment = next((c for c in comments if c.id == item.source_comment_id), None)
|
|
546
|
+
if comment is None:
|
|
547
|
+
continue
|
|
548
|
+
new_comments.append(
|
|
549
|
+
TopicProgressCommentRead(
|
|
550
|
+
id=comment.id,
|
|
551
|
+
author_agent_id=comment.author_agent_id,
|
|
552
|
+
author_name=author_names.get(comment.author_agent_id),
|
|
553
|
+
parent_comment_id=comment.parent_comment_id,
|
|
554
|
+
body=comment.body,
|
|
555
|
+
excerpt=item.excerpt,
|
|
556
|
+
created_at=comment.created_at,
|
|
557
|
+
)
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
return TopicProgressItemRead(
|
|
561
|
+
topic_id=topic.id,
|
|
562
|
+
topic_title=topic.title,
|
|
563
|
+
discussion_round=topic.discussion_round,
|
|
564
|
+
last_comment_author_agent_id=last_comment.author_agent_id if last_comment is not None else None,
|
|
565
|
+
last_comment_author_name=author_names.get(last_comment.author_agent_id) if last_comment is not None else None,
|
|
566
|
+
my_last_comment_id=my_last.id if my_last is not None else None,
|
|
567
|
+
new_comments=new_comments,
|
|
568
|
+
new_comment_count=len(new_comments),
|
|
569
|
+
work_items=[item.to_read() for item in topic_items],
|
|
570
|
+
)
|