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.
Files changed (144) hide show
  1. cli/__init__.py +0 -0
  2. cli/action_item_escalation.py +177 -0
  3. cli/agent_client.py +554 -0
  4. cli/bridge_state.py +43 -0
  5. cli/commands/__init__.py +13 -0
  6. cli/commands/action.py +142 -0
  7. cli/commands/agent.py +117 -0
  8. cli/commands/audit.py +68 -0
  9. cli/commands/docs.py +179 -0
  10. cli/commands/experiment.py +755 -0
  11. cli/commands/feedback.py +106 -0
  12. cli/commands/notification.py +213 -0
  13. cli/commands/persona.py +63 -0
  14. cli/commands/project.py +87 -0
  15. cli/commands/runtime.py +105 -0
  16. cli/commands/topic.py +361 -0
  17. cli/e2e_collab.py +602 -0
  18. cli/git_checkpoint.py +68 -0
  19. cli/host_worker_types.py +151 -0
  20. cli/main.py +1553 -0
  21. cli/map_command_client.py +497 -0
  22. cli/participant_worker.py +255 -0
  23. cli/reviewer_worker.py +263 -0
  24. cli/runtime/__init__.py +5 -0
  25. cli/runtime/run_lock.py +497 -0
  26. cli/runtime_chat.py +317 -0
  27. cli/session_wake_log.py +235 -0
  28. cli/simple_waker.py +950 -0
  29. cli/table_render.py +113 -0
  30. cli/wake_backend.py +236 -0
  31. cli/worker_cycle_log.py +36 -0
  32. map_client/__init__.py +37 -0
  33. map_client/bootstrap.py +193 -0
  34. map_client/client.py +1045 -0
  35. map_client/config.py +21 -0
  36. map_client/errors.py +283 -0
  37. map_client/exceptions.py +130 -0
  38. map_client/plan_evidence.py +159 -0
  39. map_client/project_config.py +153 -0
  40. map_client/result_template.py +167 -0
  41. map_client/testing.py +27 -0
  42. map_mcp/__init__.py +4 -0
  43. map_mcp/_utils.py +28 -0
  44. map_mcp/auth.py +34 -0
  45. map_mcp/config.py +50 -0
  46. map_mcp/context.py +39 -0
  47. map_mcp/main.py +75 -0
  48. map_mcp/server.py +573 -0
  49. map_mcp/session.py +79 -0
  50. map_sdk/__init__.py +29 -0
  51. map_sdk/evidence.py +68 -0
  52. map_types/__init__.py +203 -0
  53. map_types/enums.py +199 -0
  54. map_types/schemas.py +1351 -0
  55. multi_agent_platform-0.1.0.dist-info/METADATA +298 -0
  56. multi_agent_platform-0.1.0.dist-info/RECORD +144 -0
  57. multi_agent_platform-0.1.0.dist-info/WHEEL +5 -0
  58. multi_agent_platform-0.1.0.dist-info/entry_points.txt +6 -0
  59. multi_agent_platform-0.1.0.dist-info/licenses/LICENSE +21 -0
  60. multi_agent_platform-0.1.0.dist-info/top_level.txt +6 -0
  61. server/__init__.py +0 -0
  62. server/__version__.py +14 -0
  63. server/api/__init__.py +0 -0
  64. server/api/action_items.py +138 -0
  65. server/api/agents.py +412 -0
  66. server/api/audit.py +54 -0
  67. server/api/background_tasks.py +18 -0
  68. server/api/common.py +117 -0
  69. server/api/deps.py +30 -0
  70. server/api/experiments.py +858 -0
  71. server/api/feedback.py +75 -0
  72. server/api/notifications.py +22 -0
  73. server/api/projects.py +209 -0
  74. server/api/router.py +25 -0
  75. server/api/status.py +33 -0
  76. server/api/topics.py +302 -0
  77. server/api/webhooks.py +74 -0
  78. server/auth/__init__.py +8 -0
  79. server/auth/experiment_access.py +66 -0
  80. server/config.py +38 -0
  81. server/db/__init__.py +3 -0
  82. server/db/base.py +5 -0
  83. server/db/deadlock_retry.py +146 -0
  84. server/db/session.py +41 -0
  85. server/domain/__init__.py +3 -0
  86. server/domain/encrypted_types.py +63 -0
  87. server/domain/models.py +713 -0
  88. server/domain/schemas.py +3 -0
  89. server/domain/state_machine.py +79 -0
  90. server/domain/topic_ack_constants.py +9 -0
  91. server/main.py +148 -0
  92. server/scripts/__init__.py +0 -0
  93. server/scripts/migrate_notification_unique.py +231 -0
  94. server/scripts/purge_audit_pollution.py +116 -0
  95. server/services/__init__.py +0 -0
  96. server/services/_lookups.py +26 -0
  97. server/services/acceptance_service.py +90 -0
  98. server/services/action_item_migration_service.py +190 -0
  99. server/services/action_item_service.py +200 -0
  100. server/services/agent_work_service.py +405 -0
  101. server/services/archive_lint_service.py +156 -0
  102. server/services/audit_service.py +457 -0
  103. server/services/auth.py +66 -0
  104. server/services/comment_service.py +173 -0
  105. server/services/errors.py +65 -0
  106. server/services/escalation_resolver.py +248 -0
  107. server/services/evidence_service.py +88 -0
  108. server/services/experiment_capabilities_service.py +277 -0
  109. server/services/inbound_event_service.py +111 -0
  110. server/services/lock_service.py +273 -0
  111. server/services/log_service.py +202 -0
  112. server/services/mention_service.py +730 -0
  113. server/services/notification_service.py +939 -0
  114. server/services/notification_stream.py +138 -0
  115. server/services/permissions.py +147 -0
  116. server/services/persona_activity_service.py +108 -0
  117. server/services/phase_owner_resolver.py +95 -0
  118. server/services/phase_service.py +381 -0
  119. server/services/plan_marker_service.py +235 -0
  120. server/services/plan_service.py +186 -0
  121. server/services/platform_feedback_service.py +114 -0
  122. server/services/project_service.py +534 -0
  123. server/services/project_status_service.py +132 -0
  124. server/services/review_service.py +707 -0
  125. server/services/secret_encryption.py +97 -0
  126. server/services/similarity_service.py +119 -0
  127. server/services/sse_event_schemas.py +17 -0
  128. server/services/status_service.py +68 -0
  129. server/services/template_service.py +134 -0
  130. server/services/text_utils.py +19 -0
  131. server/services/thread_activity.py +180 -0
  132. server/services/todo_persona_filter.py +73 -0
  133. server/services/todo_service.py +604 -0
  134. server/services/topic_ack_service.py +312 -0
  135. server/services/topic_action_item_ops.py +538 -0
  136. server/services/topic_comment_kind.py +14 -0
  137. server/services/topic_comment_service.py +237 -0
  138. server/services/topic_helpers.py +32 -0
  139. server/services/topic_lifecycle_service.py +478 -0
  140. server/services/topic_progress_service.py +40 -0
  141. server/services/topic_resolve_service.py +234 -0
  142. server/services/topic_service.py +102 -0
  143. server/services/topic_work_item_service.py +570 -0
  144. 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
+ )