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,730 @@
1
+ import re
2
+ import uuid
3
+ from datetime import UTC, datetime
4
+ from typing import Any
5
+
6
+ from sqlalchemy import select
7
+ from sqlalchemy.orm import Session
8
+
9
+ from server.domain.models import Agent, Comment, Mention, MentionSourceType, Topic, TopicComment
10
+ from server.services import notification_service, thread_activity
11
+ from server.services.text_utils import excerpt as _excerpt
12
+
13
+ MENTION_PATTERN = re.compile(r"@([a-zA-Z][a-zA-Z0-9_-]*)")
14
+
15
+
16
+ def _iter_text_outside_markdown_code(body: str):
17
+ """Yield substrings of *body* that lie outside inline/fenced Markdown code."""
18
+ i = 0
19
+ n = len(body)
20
+ while i < n:
21
+ if body.startswith("```", i) or body.startswith("~~~", i):
22
+ fence = body[i : i + 3]
23
+ i += 3
24
+ while i < n and body[i] != "\n":
25
+ i += 1
26
+ if i < n:
27
+ i += 1
28
+ while i < n:
29
+ if body.startswith(fence, i):
30
+ i += 3
31
+ break
32
+ i += 1
33
+ continue
34
+ if body[i] == "`":
35
+ j = i
36
+ while j < n and body[j] == "`":
37
+ j += 1
38
+ tick_count = j - i
39
+ i = j
40
+ while i < n:
41
+ if body[i] == "`":
42
+ k = i
43
+ while k < n and body[k] == "`":
44
+ k += 1
45
+ if k - i >= tick_count:
46
+ i = k
47
+ break
48
+ i += 1
49
+ continue
50
+ start = i
51
+ while (
52
+ i < n
53
+ and body[i] != "`"
54
+ and not body.startswith("```", i)
55
+ and not body.startswith("~~~", i)
56
+ ):
57
+ i += 1
58
+ if start < i:
59
+ yield body[start:i]
60
+
61
+
62
+ def extract_mention_names(body: str) -> list[str]:
63
+ seen: set[str] = set()
64
+ names: list[str] = []
65
+ for segment in _iter_text_outside_markdown_code(body):
66
+ for match in MENTION_PATTERN.finditer(segment):
67
+ name = match.group(1)
68
+ if name not in seen:
69
+ seen.add(name)
70
+ names.append(name)
71
+ return names
72
+
73
+
74
+ def resolve_mentioned_agents(db: Session, names: list[str]) -> list[Agent]:
75
+ if not names:
76
+ return []
77
+ return list(db.scalars(select(Agent).where(Agent.name.in_(names))))
78
+
79
+
80
+ def partition_mention_names(
81
+ db: Session, names: list[str]
82
+ ) -> tuple[list[Agent], list[str]]:
83
+ """Split @tokens into resolved MAP agents and names with no matching Agent."""
84
+ if not names:
85
+ return [], []
86
+ agents = resolve_mentioned_agents(db, names)
87
+ resolved_names = {agent.name for agent in agents}
88
+ unresolved = [name for name in names if name not in resolved_names]
89
+ return agents, unresolved
90
+
91
+
92
+ def unresolved_mention_names(db: Session, body: str) -> list[str]:
93
+ _, unresolved = partition_mention_names(db, extract_mention_names(body))
94
+ return unresolved
95
+
96
+
97
+ def notify_unresolved_mentions(
98
+ db: Session,
99
+ *,
100
+ author: Agent,
101
+ project_id: uuid.UUID,
102
+ unresolved: list[str],
103
+ target_type: str,
104
+ target_id: uuid.UUID,
105
+ context_label: str,
106
+ payload: dict[str, str],
107
+ commit: bool = True,
108
+ ) -> None:
109
+ """Soft-fail feedback: comment is kept, author is told which @names did not match."""
110
+ if not unresolved:
111
+ return
112
+ labels = ", ".join(f"@{name}" for name in unresolved)
113
+ notification_service.enqueue_for_agents(
114
+ db,
115
+ recipient_agent_ids=[author.id],
116
+ project_id=project_id,
117
+ actor_id=author.id,
118
+ event="mention.unresolved",
119
+ summary=(
120
+ f"评论中的 {labels} 未匹配到 MAP Agent({context_label})。"
121
+ "请运行 `map persona list` 查看 agent_name,@ 时使用全名而非 persona 短名。"
122
+ ),
123
+ target_type=target_type,
124
+ target_id=target_id,
125
+ payload={
126
+ **payload,
127
+ "unresolved_mentions": unresolved,
128
+ "hint": "Run `map persona list` and @ the exact agent_name field.",
129
+ },
130
+ exclude_actor=False,
131
+ commit=commit,
132
+ )
133
+
134
+
135
+ def process_experiment_comment_mentions(
136
+ db: Session,
137
+ *,
138
+ comment: Comment,
139
+ author: Agent,
140
+ project_id: uuid.UUID,
141
+ experiment_title: str,
142
+ commit: bool = True,
143
+ ) -> list[str]:
144
+ names = extract_mention_names(comment.body)
145
+ agents, unresolved = partition_mention_names(db, names)
146
+ if agents:
147
+ excerpt = _excerpt(comment.body)
148
+ recipient_ids: list[uuid.UUID] = []
149
+ for agent in agents:
150
+ if agent.id == author.id:
151
+ continue
152
+ mention = Mention(
153
+ mentioned_agent_id=agent.id,
154
+ author_agent_id=author.id,
155
+ source_type=MentionSourceType.experiment_comment,
156
+ source_id=comment.id,
157
+ project_id=project_id,
158
+ experiment_id=comment.experiment_id,
159
+ topic_id=None,
160
+ excerpt=excerpt,
161
+ )
162
+ db.add(mention)
163
+ recipient_ids.append(agent.id)
164
+
165
+ if recipient_ids:
166
+ if commit:
167
+ db.commit()
168
+ notification_service.enqueue_for_agents(
169
+ db,
170
+ recipient_agent_ids=recipient_ids,
171
+ project_id=project_id,
172
+ actor_id=author.id,
173
+ event="agent.mentioned",
174
+ summary=f"{author.name} 在实验「{experiment_title}」中提及了你",
175
+ target_type="comment",
176
+ target_id=comment.id,
177
+ payload={
178
+ "experiment_id": str(comment.experiment_id),
179
+ "comment_id": str(comment.id),
180
+ "author_name": author.name,
181
+ "excerpt": excerpt,
182
+ },
183
+ commit=commit,
184
+ )
185
+
186
+ if unresolved:
187
+ notify_unresolved_mentions(
188
+ db,
189
+ author=author,
190
+ project_id=project_id,
191
+ unresolved=unresolved,
192
+ target_type="comment",
193
+ target_id=comment.id,
194
+ context_label=f"实验「{experiment_title}」",
195
+ payload={
196
+ "experiment_id": str(comment.experiment_id),
197
+ "comment_id": str(comment.id),
198
+ },
199
+ commit=commit,
200
+ )
201
+ return unresolved
202
+
203
+
204
+ def process_topic_comment_mentions(
205
+ db: Session,
206
+ *,
207
+ comment: TopicComment,
208
+ author: Agent,
209
+ topic: Topic,
210
+ commit: bool = True,
211
+ ) -> list[str]:
212
+ names = extract_mention_names(comment.body)
213
+ agents, unresolved = partition_mention_names(db, names)
214
+ if agents:
215
+ excerpt = _excerpt(comment.body)
216
+ recipient_ids: list[uuid.UUID] = []
217
+ for agent in agents:
218
+ if agent.id == author.id:
219
+ continue
220
+ mention = Mention(
221
+ mentioned_agent_id=agent.id,
222
+ author_agent_id=author.id,
223
+ source_type=MentionSourceType.topic_comment,
224
+ source_id=comment.id,
225
+ project_id=topic.project_id,
226
+ experiment_id=None,
227
+ topic_id=topic.id,
228
+ excerpt=excerpt,
229
+ )
230
+ db.add(mention)
231
+ recipient_ids.append(agent.id)
232
+
233
+ if recipient_ids:
234
+ if commit:
235
+ db.commit()
236
+ notification_service.enqueue_for_agents(
237
+ db,
238
+ recipient_agent_ids=recipient_ids,
239
+ project_id=topic.project_id,
240
+ actor_id=author.id,
241
+ event="agent.mentioned",
242
+ summary=f"{author.name} 在话题「{topic.title}」中提及了你",
243
+ target_type="topic_comment",
244
+ target_id=comment.id,
245
+ payload={
246
+ "topic_id": str(topic.id),
247
+ "comment_id": str(comment.id),
248
+ "author_name": author.name,
249
+ "excerpt": excerpt,
250
+ },
251
+ commit=commit,
252
+ )
253
+
254
+ if unresolved:
255
+ notify_unresolved_mentions(
256
+ db,
257
+ author=author,
258
+ project_id=topic.project_id,
259
+ unresolved=unresolved,
260
+ target_type="topic_comment",
261
+ target_id=comment.id,
262
+ context_label=f"话题「{topic.title}」",
263
+ payload={
264
+ "topic_id": str(topic.id),
265
+ "comment_id": str(comment.id),
266
+ },
267
+ commit=commit,
268
+ )
269
+ return unresolved
270
+
271
+
272
+ def process_topic_mentions(
273
+ db: Session,
274
+ *,
275
+ topic: Topic,
276
+ author: Agent,
277
+ commit: bool = True,
278
+ ) -> list[str]:
279
+ """Create mention todos/notifications from a topic's initial description."""
280
+ body = topic.description or ""
281
+ names = extract_mention_names(body)
282
+ agents, unresolved = partition_mention_names(db, names)
283
+ if agents:
284
+ excerpt = _excerpt(body)
285
+ recipient_ids: list[uuid.UUID] = []
286
+ for agent in agents:
287
+ if agent.id == author.id:
288
+ continue
289
+ mention = Mention(
290
+ mentioned_agent_id=agent.id,
291
+ author_agent_id=author.id,
292
+ source_type=MentionSourceType.topic,
293
+ source_id=topic.id,
294
+ project_id=topic.project_id,
295
+ experiment_id=None,
296
+ topic_id=topic.id,
297
+ excerpt=excerpt,
298
+ )
299
+ db.add(mention)
300
+ recipient_ids.append(agent.id)
301
+
302
+ if recipient_ids:
303
+ if commit:
304
+ db.commit()
305
+ notification_service.enqueue_for_agents(
306
+ db,
307
+ recipient_agent_ids=recipient_ids,
308
+ project_id=topic.project_id,
309
+ actor_id=author.id,
310
+ event="agent.mentioned",
311
+ summary=f"{author.name} 在话题「{topic.title}」中提及了你",
312
+ target_type="topic",
313
+ target_id=topic.id,
314
+ payload={
315
+ "topic_id": str(topic.id),
316
+ "author_name": author.name,
317
+ "excerpt": excerpt,
318
+ },
319
+ commit=commit,
320
+ )
321
+
322
+ if unresolved:
323
+ notify_unresolved_mentions(
324
+ db,
325
+ author=author,
326
+ project_id=topic.project_id,
327
+ unresolved=unresolved,
328
+ target_type="topic",
329
+ target_id=topic.id,
330
+ context_label=f"话题「{topic.title}」",
331
+ payload={"topic_id": str(topic.id)},
332
+ commit=commit,
333
+ )
334
+ return unresolved
335
+
336
+
337
+ def list_mentions_for_agent(
338
+ db: Session,
339
+ agent_id: uuid.UUID,
340
+ *,
341
+ limit: int = 50,
342
+ include_dismissed: bool = False,
343
+ ) -> list[Mention]:
344
+ stmt = select(Mention).where(Mention.mentioned_agent_id == agent_id)
345
+ if not include_dismissed:
346
+ stmt = stmt.where(Mention.dismissed_at.is_(None))
347
+ stmt = stmt.order_by(Mention.created_at.desc()).limit(min(limit, 200))
348
+ return list(db.scalars(stmt))
349
+
350
+
351
+ def _apply_mention_dismiss_cascade(
352
+ db: Session,
353
+ *,
354
+ actor_agent_id: uuid.UUID,
355
+ mentions: list[Mention],
356
+ now: datetime | None = None,
357
+ audit_action: str = "mention.dismiss",
358
+ audit_payload_extra: dict[str, Any] | None = None,
359
+ ) -> int:
360
+ """Dismiss mentions, cascade-read matching notifications, write audit rows."""
361
+ from server.services import audit_service, notification_service
362
+
363
+ if not mentions:
364
+ return 0
365
+ now = now or datetime.now(UTC)
366
+ touched: list[Mention] = []
367
+ for mention in mentions:
368
+ if mention.dismissed_at is not None:
369
+ continue
370
+ mention.dismissed_at = now
371
+ touched.append(mention)
372
+ payload = {
373
+ "mention_id": str(mention.id),
374
+ "source_comment_id": str(mention.source_id),
375
+ "actor_agent_id": str(actor_agent_id),
376
+ }
377
+ if audit_payload_extra:
378
+ payload.update(audit_payload_extra)
379
+ audit_service.log_no_commit(
380
+ db,
381
+ action=audit_action,
382
+ target_type="mention",
383
+ agent_id=actor_agent_id,
384
+ project_id=mention.project_id,
385
+ target_id=mention.id,
386
+ summary="Mention dismissed",
387
+ payload=payload,
388
+ )
389
+ if not touched:
390
+ return 0
391
+ notification_service.mark_agent_mentioned_notifications_read_no_commit(
392
+ db, mentions=touched, now=now
393
+ )
394
+ return len(touched)
395
+
396
+
397
+ def dismiss_mention(db: Session, *, agent: Agent, mention_id: uuid.UUID) -> Mention | None:
398
+ """Mark a mention as dismissed for the mentioned agent. Idempotent."""
399
+ mention = db.get(Mention, mention_id)
400
+ if mention is None or mention.mentioned_agent_id != agent.id:
401
+ return None
402
+ if mention.dismissed_at is None:
403
+ _apply_mention_dismiss_cascade(db, actor_agent_id=agent.id, mentions=[mention])
404
+ db.commit()
405
+ db.refresh(mention)
406
+ return mention
407
+
408
+
409
+ def dismiss_all_for_agent(db: Session, agent: Agent) -> int:
410
+ """Dismiss every open mention addressed to this agent. Returns rows touched."""
411
+ mentions = list(
412
+ db.scalars(
413
+ select(Mention).where(
414
+ Mention.mentioned_agent_id == agent.id,
415
+ Mention.dismissed_at.is_(None),
416
+ )
417
+ )
418
+ )
419
+ count = _apply_mention_dismiss_cascade(db, actor_agent_id=agent.id, mentions=mentions)
420
+ if count:
421
+ db.commit()
422
+ return count
423
+
424
+
425
+ def _experiment_thread_comment_ids(
426
+ db: Session, *, comment_id: uuid.UUID, experiment_id: uuid.UUID
427
+ ) -> list[uuid.UUID]:
428
+ """All comment ids in the same thread (same root via parent_comment_id) within one experiment."""
429
+ rows = db.execute(
430
+ select(Comment.id, Comment.parent_comment_id).where(
431
+ Comment.experiment_id == experiment_id
432
+ )
433
+ ).all()
434
+ if not rows:
435
+ return []
436
+ by_id: dict[uuid.UUID, uuid.UUID | None] = {row[0]: row[1] for row in rows}
437
+ if comment_id not in by_id:
438
+ return []
439
+ cur = comment_id
440
+ while True:
441
+ parent = by_id[cur]
442
+ if parent is None:
443
+ break
444
+ cur = parent
445
+ if cur not in by_id:
446
+ return []
447
+ root_id = cur
448
+ return [cid for cid in by_id if _walk_root(cid, by_id) == root_id]
449
+
450
+
451
+ def _topic_thread_comment_ids(
452
+ db: Session, *, comment_id: uuid.UUID, topic_id: uuid.UUID
453
+ ) -> list[uuid.UUID]:
454
+ rows = db.execute(
455
+ select(TopicComment.id, TopicComment.parent_comment_id).where(
456
+ TopicComment.topic_id == topic_id
457
+ )
458
+ ).all()
459
+ if not rows:
460
+ return []
461
+ by_id: dict[uuid.UUID, uuid.UUID | None] = {row[0]: row[1] for row in rows}
462
+ if comment_id not in by_id:
463
+ return []
464
+ cur = comment_id
465
+ while True:
466
+ parent = by_id[cur]
467
+ if parent is None:
468
+ break
469
+ cur = parent
470
+ if cur not in by_id:
471
+ return []
472
+ root_id = cur
473
+ return [cid for cid in by_id if _walk_root(cid, by_id) == root_id]
474
+
475
+
476
+ def _walk_root(
477
+ cid: uuid.UUID, by_id: dict[uuid.UUID, uuid.UUID | None]
478
+ ) -> uuid.UUID:
479
+ cur = cid
480
+ seen: set[uuid.UUID] = set()
481
+ while True:
482
+ parent = by_id.get(cur)
483
+ if parent is None:
484
+ break
485
+ if cur in seen:
486
+ break
487
+ seen.add(cur)
488
+ cur = parent
489
+ return cur
490
+
491
+
492
+ def auto_dismiss_mentions_after_comment(
493
+ db: Session,
494
+ *,
495
+ new_comment_author: Agent,
496
+ experiment_id: uuid.UUID | None,
497
+ topic_id: uuid.UUID | None,
498
+ new_comment_id: uuid.UUID,
499
+ comment_body: str | None = None,
500
+ commit: bool = True,
501
+ ) -> int:
502
+ """Auto-dismiss open mentions after an agent participates in a topic/experiment."""
503
+ if topic_id is not None:
504
+ from server.services import topic_ack_service
505
+
506
+ body = comment_body
507
+ if body is None:
508
+ comment = db.get(TopicComment, new_comment_id)
509
+ body = comment.body if comment is not None else None
510
+ if body is not None and topic_ack_service._ack_kind(body) is not None:
511
+ # Round-ack dismiss uses mention.auto_dismissed in record_participant_round_ack.
512
+ return 0
513
+
514
+ count = auto_dismiss_mentions_for_author_in_thread(
515
+ db,
516
+ new_comment_author=new_comment_author,
517
+ experiment_id=experiment_id,
518
+ topic_id=topic_id,
519
+ new_comment_id=new_comment_id,
520
+ commit=False,
521
+ )
522
+ count += dismiss_mentions_answered_by_participation(
523
+ db,
524
+ agent_id=new_comment_author.id,
525
+ topic_id=topic_id,
526
+ experiment_id=experiment_id,
527
+ commit=False,
528
+ )
529
+ if commit:
530
+ db.commit()
531
+ return count
532
+
533
+
534
+ def dismiss_mentions_answered_by_participation(
535
+ db: Session,
536
+ *,
537
+ agent_id: uuid.UUID,
538
+ topic_id: uuid.UUID | None = None,
539
+ experiment_id: uuid.UUID | None = None,
540
+ commit: bool = True,
541
+ ) -> int:
542
+ """Dismiss open mentions when the agent already replied per thread/container rules."""
543
+ filters = [
544
+ Mention.mentioned_agent_id == agent_id,
545
+ Mention.dismissed_at.is_(None),
546
+ ]
547
+ if topic_id is not None:
548
+ filters.extend(
549
+ [
550
+ Mention.topic_id == topic_id,
551
+ Mention.source_type.in_(
552
+ [MentionSourceType.topic, MentionSourceType.topic_comment]
553
+ ),
554
+ ]
555
+ )
556
+ elif experiment_id is not None:
557
+ filters.extend(
558
+ [
559
+ Mention.experiment_id == experiment_id,
560
+ Mention.source_type == MentionSourceType.experiment_comment,
561
+ ]
562
+ )
563
+ else:
564
+ return 0
565
+
566
+ open_mentions = list(db.scalars(select(Mention).where(*filters)))
567
+ to_dismiss_objs = [
568
+ mention
569
+ for mention in open_mentions
570
+ if thread_activity.comment_after(db, mention=mention, agent_id=agent_id)
571
+ ]
572
+ if not to_dismiss_objs:
573
+ return 0
574
+ count = _apply_mention_dismiss_cascade(
575
+ db, actor_agent_id=agent_id, mentions=to_dismiss_objs
576
+ )
577
+ if commit and count:
578
+ db.commit()
579
+ return count
580
+
581
+
582
+ def _agent_replied_after_mention(
583
+ db: Session,
584
+ *,
585
+ mention: Mention,
586
+ agent_id: uuid.UUID,
587
+ ) -> bool:
588
+ return thread_activity.comment_after(db, mention=mention, agent_id=agent_id)
589
+
590
+
591
+ def agent_replied_after_mention(
592
+ db: Session,
593
+ *,
594
+ mention: Mention,
595
+ agent_id: uuid.UUID,
596
+ ) -> bool:
597
+ """Public wrapper: True when agent posted in container after the mention source."""
598
+ return thread_activity.replied_after(db, mention=mention, agent_id=agent_id)
599
+
600
+
601
+ def auto_dismiss_mentions_for_author_in_thread(
602
+ db: Session,
603
+ *,
604
+ new_comment_author: Agent,
605
+ experiment_id: uuid.UUID | None,
606
+ topic_id: uuid.UUID | None,
607
+ new_comment_id: uuid.UUID,
608
+ commit: bool = True,
609
+ ) -> int:
610
+ """When an agent posts a reply in a thread, any @mention rows pointing at him
611
+ inside that thread (i.e. whose source_id is one of the thread comment ids)
612
+ are dismissed — he has obviously seen them.
613
+ """
614
+ if experiment_id is not None:
615
+ thread_ids = _experiment_thread_comment_ids(
616
+ db, comment_id=new_comment_id, experiment_id=experiment_id
617
+ )
618
+ source_type = MentionSourceType.experiment_comment
619
+ elif topic_id is not None:
620
+ thread_ids = _topic_thread_comment_ids(
621
+ db, comment_id=new_comment_id, topic_id=topic_id
622
+ )
623
+ source_type = MentionSourceType.topic_comment
624
+ else:
625
+ return 0
626
+ if not thread_ids:
627
+ return 0
628
+ mentions = list(
629
+ db.scalars(
630
+ select(Mention).where(
631
+ Mention.mentioned_agent_id == new_comment_author.id,
632
+ Mention.source_type == source_type,
633
+ Mention.source_id.in_(thread_ids),
634
+ Mention.dismissed_at.is_(None),
635
+ )
636
+ )
637
+ )
638
+ count = _apply_mention_dismiss_cascade(
639
+ db, actor_agent_id=new_comment_author.id, mentions=mentions
640
+ )
641
+ if topic_id is not None:
642
+ topic_mentions = list(
643
+ db.scalars(
644
+ select(Mention).where(
645
+ Mention.mentioned_agent_id == new_comment_author.id,
646
+ Mention.source_type == MentionSourceType.topic,
647
+ Mention.topic_id == topic_id,
648
+ Mention.dismissed_at.is_(None),
649
+ )
650
+ )
651
+ )
652
+ count += _apply_mention_dismiss_cascade(
653
+ db, actor_agent_id=new_comment_author.id, mentions=topic_mentions
654
+ )
655
+ if commit and count:
656
+ db.commit()
657
+ return count
658
+
659
+
660
+ def auto_dismiss_mentions_for_round_ack(
661
+ db: Session,
662
+ *,
663
+ topic: Topic,
664
+ agent: Agent,
665
+ ack_kind: str,
666
+ commit: bool = True,
667
+ ) -> int:
668
+ """Dismiss open mentions in the latest Round Summary subtree after accept/dismiss ack."""
669
+ import logging
670
+
671
+ from server.services import topic_ack_service
672
+
673
+ if ack_kind == "reject":
674
+ return 0
675
+ if ack_kind not in {"accept", "dismiss"}:
676
+ return 0
677
+
678
+ comments = topic_ack_service._topic_comments(db, topic.id)
679
+ summary = topic_ack_service.latest_host_round_summary_comment(
680
+ comments, host_agent_id=topic.creator_agent_id
681
+ )
682
+ if summary is None:
683
+ logging.getLogger(__name__).debug(
684
+ "round_ack auto_dismiss skip topic=%s agent=%s reason=no_summary",
685
+ topic.id,
686
+ agent.id,
687
+ )
688
+ return 0
689
+
690
+ thread_ids = _topic_thread_comment_ids(
691
+ db, comment_id=summary.id, topic_id=topic.id
692
+ )
693
+ if not thread_ids:
694
+ return 0
695
+
696
+ mentions = list(
697
+ db.scalars(
698
+ select(Mention).where(
699
+ Mention.topic_id == topic.id,
700
+ Mention.mentioned_agent_id == agent.id,
701
+ Mention.source_type == MentionSourceType.topic_comment,
702
+ Mention.source_id.in_(thread_ids),
703
+ Mention.dismissed_at.is_(None),
704
+ )
705
+ )
706
+ )
707
+ if not mentions:
708
+ logging.getLogger(__name__).debug(
709
+ "round_ack auto_dismiss skip topic=%s agent=%s reason=no_open_mentions",
710
+ topic.id,
711
+ agent.id,
712
+ )
713
+ return 0
714
+
715
+ audit_extra = {
716
+ "triggered_by": f"round_ack:{ack_kind}",
717
+ "summary_comment_id": str(summary.id),
718
+ "dismissing_agent_id": str(agent.id),
719
+ "round_id": topic.discussion_round.value,
720
+ }
721
+ count = _apply_mention_dismiss_cascade(
722
+ db,
723
+ actor_agent_id=agent.id,
724
+ mentions=mentions,
725
+ audit_action="mention.auto_dismissed",
726
+ audit_payload_extra=audit_extra,
727
+ )
728
+ if commit and count:
729
+ db.commit()
730
+ return count