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
map_client/client.py ADDED
@@ -0,0 +1,1045 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from pathlib import Path
5
+ from typing import Any
6
+ from urllib.parse import urlparse
7
+
8
+ import httpx
9
+ from map_types import (
10
+ ActionItemCancel,
11
+ AgentCreateResponse,
12
+ AgentRead,
13
+ AgentRole,
14
+ AgentWorkRead,
15
+ AgentWorkSummaryRead,
16
+ AuditLogRead,
17
+ CommentAnchorType,
18
+ CommentCreate,
19
+ CommentRead,
20
+ CommentTreeNode,
21
+ CrossPersonaCallRecord,
22
+ DismissAllMentionsResultRead,
23
+ DismissMentionResultRead,
24
+ EscalationTargetRead,
25
+ ExperimentBundleRead,
26
+ ExperimentComplete,
27
+ ExperimentCreate,
28
+ ExperimentDetailRead,
29
+ ExperimentLockRead,
30
+ ExperimentLockStalledScanRead,
31
+ ExperimentLogCreate,
32
+ ExperimentLogRead,
33
+ ExperimentPhase,
34
+ ExperimentResultDecision,
35
+ ExperimentSummaryRead,
36
+ ExperimentUpdate,
37
+ FeedbackCategory,
38
+ FeedbackStatus,
39
+ GlobalStatusRead,
40
+ InboundEventCreate,
41
+ InboundEventRecordResult,
42
+ LogCreateResponse,
43
+ NotificationCategory,
44
+ NotificationListRead,
45
+ NotificationRead,
46
+ PlanRevise,
47
+ PlanVersionRead,
48
+ PlatformFeedbackCreate,
49
+ PlatformFeedbackRead,
50
+ PlatformFeedbackUpdate,
51
+ ProjectCreate,
52
+ ProjectRead,
53
+ ProjectStatusRead,
54
+ ProjectStatusRevise,
55
+ ProjectStatusVersionRead,
56
+ ProjectUpdate,
57
+ ReviewCreate,
58
+ ReviewItemRead,
59
+ ReviewItemStatus,
60
+ ReviewItemUpdate,
61
+ ReviewRead,
62
+ TodoRead,
63
+ TopicActionItemRead,
64
+ TopicActionItemStatus,
65
+ TopicAdvanceRound,
66
+ TopicCommentCreate,
67
+ TopicCommentRead,
68
+ TopicCommentTreeNode,
69
+ TopicCreate,
70
+ TopicDecisionRead,
71
+ TopicProgressListRead,
72
+ TopicRead,
73
+ TopicReadCursorRead,
74
+ TopicResolve,
75
+ TopicStatus,
76
+ TopicSummaryRead,
77
+ TopicUpdate,
78
+ WebhookCreate,
79
+ WebhookCreateResponse,
80
+ WebhookDeliveryRead,
81
+ WebhookRead,
82
+ )
83
+
84
+ from map_client.config import load_config
85
+ from map_client.exceptions import raise_for_status
86
+
87
+
88
+ _LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1", ""}
89
+
90
+
91
+ def _is_local_url(url: str) -> bool:
92
+ """判断 URL 的 host 是否指向本机。
93
+
94
+ localhost / 127.0.0.1 / ::1 / 未指定 host 时返回 True。此类场景应
95
+ 忽略环境变量代理,避免因 SOCKS 代理初始化失败等问题影响对本地 MAP
96
+ 服务的访问。
97
+ """
98
+ try:
99
+ host = (urlparse(url).hostname or "").lower()
100
+ except Exception:
101
+ return False
102
+ return host in _LOCAL_HOSTS
103
+
104
+
105
+ class MAPClient:
106
+ """HTTP client for the Multi-Agent Platform REST API."""
107
+
108
+ def __init__(
109
+ self,
110
+ base_url: str,
111
+ token: str,
112
+ *,
113
+ transport: httpx.BaseTransport | None = None,
114
+ timeout: float = 30.0,
115
+ ) -> None:
116
+ if not token:
117
+ raise ValueError("API token is required")
118
+ self.base_url = base_url.rstrip("/")
119
+ self.token = token
120
+ self._transport = transport
121
+ # 本地地址(localhost / 127.0.0.1 / ::1)忽略环境代理,避免因
122
+ # SOCKS 代理初始化失败等影响本地 MAP 服务访问;其他地址沿用
123
+ # 环境代理配置。transport 已显式指定时 trust_env 不生效。
124
+ trust_env = not _is_local_url(self.base_url) if transport is None else True
125
+ self._http = httpx.Client(
126
+ base_url=f"{self.base_url}/api/v1",
127
+ headers={"Authorization": f"Bearer {token}"},
128
+ timeout=timeout,
129
+ transport=transport,
130
+ trust_env=trust_env,
131
+ )
132
+
133
+ @classmethod
134
+ def from_env(cls, *, transport: httpx.BaseTransport | None = None) -> MAPClient:
135
+ cfg = load_config()
136
+ if not cfg.get("token"):
137
+ raise ValueError("MAP_TOKEN not set and no token in ~/.map/config.yaml")
138
+ return cls(cfg["api_url"], cfg["token"], transport=transport)
139
+
140
+ @classmethod
141
+ def from_config(
142
+ cls,
143
+ config_path: str | None = None,
144
+ *,
145
+ transport: httpx.BaseTransport | None = None,
146
+ ) -> MAPClient:
147
+ cfg = load_config(Path(config_path) if config_path else None)
148
+ if not cfg.get("token"):
149
+ raise ValueError("MAP_TOKEN not set and no token in config")
150
+ return cls(cfg["api_url"], cfg["token"], transport=transport)
151
+
152
+ def close(self) -> None:
153
+ if self._transport is None:
154
+ self._http.close()
155
+
156
+ def __enter__(self) -> MAPClient:
157
+ return self
158
+
159
+ def __exit__(self, *args: object) -> None:
160
+ self.close()
161
+
162
+ def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
163
+ response = self._http.request(method, path, **kwargs)
164
+ if response.status_code >= 400:
165
+ detail = response.text
166
+ error_code: str | None = None
167
+ hint: str | None = None
168
+ retryable: bool | None = None
169
+ if response.content:
170
+ try:
171
+ payload = response.json()
172
+ if isinstance(payload, dict):
173
+ if "detail" in payload:
174
+ detail = str(payload["detail"])
175
+ # I1(d): surface server-side subcodes so callers can
176
+ # branch on structured failure modes instead of
177
+ # pattern-matching the human-readable ``detail``.
178
+ if isinstance(payload.get("error_code"), str):
179
+ error_code = payload["error_code"]
180
+ if isinstance(payload.get("hint"), str):
181
+ hint = payload["hint"]
182
+ if isinstance(payload.get("retryable"), bool):
183
+ retryable = payload["retryable"]
184
+ except Exception:
185
+ detail = response.text
186
+ # P2 #2: 根据 status_code raise 具体子类(NotFound / Conflict 等),
187
+ # 调用方可 catch 子类写语义化处理,不再依赖 if status_code == 404。
188
+ raise_for_status(
189
+ response.status_code,
190
+ detail,
191
+ error_code=error_code,
192
+ hint=hint,
193
+ retryable=retryable,
194
+ )
195
+ return response
196
+
197
+ def _json(self, method: str, path: str, **kwargs: Any) -> Any:
198
+ response = self._request(method, path, **kwargs)
199
+ if response.status_code == 204:
200
+ return None
201
+ return response.json()
202
+
203
+ @staticmethod
204
+ def _total_count(response: httpx.Response) -> int:
205
+ raw = response.headers.get("X-Total-Count")
206
+ if raw is None:
207
+ return 0
208
+ try:
209
+ return int(raw)
210
+ except ValueError:
211
+ return 0
212
+
213
+ # --- agents ---
214
+
215
+ def list_agents(
216
+ self,
217
+ *,
218
+ project_id: uuid.UUID | None = None,
219
+ role: AgentRole | None = None,
220
+ ) -> list[AgentRead]:
221
+ params: dict[str, str] = {}
222
+ if project_id is not None:
223
+ params["project_id"] = str(project_id)
224
+ if role is not None:
225
+ params["role"] = role.value
226
+ data = self._json("GET", "/agents", params=params)
227
+ return [AgentRead.model_validate(item) for item in data]
228
+
229
+ def register_agent(
230
+ self,
231
+ name: str,
232
+ role: str = "agent",
233
+ *,
234
+ project_key: str | None = None,
235
+ project_id: uuid.UUID | None = None,
236
+ ) -> AgentCreateResponse:
237
+ # cleanup PR5: server expects a JSON body (AgentCreate), not query
238
+ # params. ``json=`` carries the same fields and matches the new
239
+ # contract.
240
+ body: dict[str, object] = {"name": name, "role": role}
241
+ if project_key is not None:
242
+ body["project_key"] = project_key
243
+ if project_id is not None:
244
+ body["project_id"] = str(project_id)
245
+ data = self._json("POST", "/agents", json=body)
246
+ return AgentCreateResponse.model_validate(data)
247
+
248
+ def get_me(self) -> AgentRead:
249
+ return AgentRead.model_validate(self._json("GET", "/agents/me"))
250
+
251
+ def get_escalation_target(
252
+ self, experiment_id: uuid.UUID | None = None
253
+ ) -> EscalationTargetRead:
254
+ """Resolve the escalation contact for a STATE_MACHINE.* error.
255
+
256
+ Calls ``GET /agents/me/escalation-target?experiment_id=...`` and
257
+ returns the chosen agent + the tier that picked it. Used by the
258
+ CLI on ``MAPHTTPError`` to append ``Escalation: @<name>`` to the
259
+ error output.
260
+
261
+ Args:
262
+ experiment_id: When provided, the experiment's
263
+ ``escalation_target_agent_id`` override takes precedence.
264
+ When None, the role-based fallback chain runs without it.
265
+ """
266
+ params: dict[str, Any] = {}
267
+ if experiment_id is not None:
268
+ params["experiment_id"] = str(experiment_id)
269
+ return EscalationTargetRead.model_validate(
270
+ self._json("GET", "/agents/me/escalation-target", params=params or None)
271
+ )
272
+
273
+ def resolve_project_id(
274
+ self,
275
+ project_id: uuid.UUID | None = None,
276
+ *,
277
+ project_key: str | None = None,
278
+ ) -> uuid.UUID:
279
+ if project_id is not None:
280
+ return project_id
281
+ if project_key is not None:
282
+ return self.get_project_by_key(project_key).id
283
+ me = self.get_me()
284
+ if me.project_id is not None:
285
+ return me.project_id
286
+ raise ValueError("project_id or project_key is required")
287
+
288
+ # --- projects ---
289
+
290
+ def create_project(
291
+ self,
292
+ project_key: str,
293
+ name: str,
294
+ workspace_path: str,
295
+ description: str | None = None,
296
+ ) -> ProjectRead:
297
+ payload = ProjectCreate(
298
+ project_key=project_key,
299
+ name=name,
300
+ workspace_path=workspace_path,
301
+ description=description,
302
+ )
303
+ data = self._json("POST", "/projects", json=payload.model_dump())
304
+ return ProjectRead.model_validate(data)
305
+
306
+ def list_projects(self, *, include_archived: bool = False) -> list[ProjectRead]:
307
+ data = self._json("GET", "/projects", params={"include_archived": include_archived})
308
+ return [ProjectRead.model_validate(item) for item in data]
309
+
310
+ def get_project(self, project_id: uuid.UUID) -> ProjectRead:
311
+ return ProjectRead.model_validate(self._json("GET", f"/projects/{project_id}"))
312
+
313
+ def get_project_by_key(self, project_key: str) -> ProjectRead:
314
+ return ProjectRead.model_validate(self._json("GET", f"/projects/by-key/{project_key}"))
315
+
316
+ def update_project(self, project_id: uuid.UUID, payload: ProjectUpdate) -> ProjectRead:
317
+ data = self._json("PATCH", f"/projects/{project_id}", json=payload.model_dump(exclude_unset=True))
318
+ return ProjectRead.model_validate(data)
319
+
320
+ def get_project_status(self, project_id: uuid.UUID) -> ProjectStatusRead:
321
+ return ProjectStatusRead.model_validate(self._json("GET", f"/projects/{project_id}/status"))
322
+
323
+ def list_project_decisions(self, project_id: uuid.UUID, *, limit: int = 20) -> list[TopicDecisionRead]:
324
+ data = self._json("GET", f"/projects/{project_id}/decisions", params={"limit": limit})
325
+ return [TopicDecisionRead.model_validate(item) for item in data]
326
+
327
+ def list_project_action_items(
328
+ self,
329
+ project_id: uuid.UUID,
330
+ *,
331
+ owner_agent_id: uuid.UUID | None = None,
332
+ status: TopicActionItemStatus | None = None,
333
+ limit: int = 100,
334
+ ) -> list[TopicActionItemRead]:
335
+ params: dict[str, Any] = {"limit": limit}
336
+ if owner_agent_id is not None:
337
+ params["owner_agent_id"] = str(owner_agent_id)
338
+ if status is not None:
339
+ params["status"] = status.value
340
+ data = self._json("GET", f"/projects/{project_id}/action-items", params=params)
341
+ return [TopicActionItemRead.model_validate(item) for item in data]
342
+
343
+ def complete_action_item(self, action_item_id: uuid.UUID) -> TopicActionItemRead:
344
+ data = self._json("POST", f"/action-items/{action_item_id}/complete")
345
+ return TopicActionItemRead.model_validate(data)
346
+
347
+ def deliver_action_item(self, action_item_id: uuid.UUID) -> TopicActionItemRead:
348
+ data = self._json("POST", f"/action-items/{action_item_id}/deliver")
349
+ return TopicActionItemRead.model_validate(data)
350
+
351
+ def cancel_action_item(
352
+ self,
353
+ action_item_id: uuid.UUID,
354
+ payload: ActionItemCancel,
355
+ ) -> TopicActionItemRead:
356
+ data = self._json(
357
+ "POST",
358
+ f"/action-items/{action_item_id}/cancel",
359
+ json=payload.model_dump(exclude_none=True),
360
+ )
361
+ return TopicActionItemRead.model_validate(data)
362
+
363
+ def link_action_item(
364
+ self,
365
+ action_item_id: uuid.UUID,
366
+ experiment_id: uuid.UUID,
367
+ ) -> TopicActionItemRead:
368
+ data = self._json(
369
+ "POST",
370
+ f"/action-items/{action_item_id}/link",
371
+ params={"experiment_id": str(experiment_id)},
372
+ )
373
+ return TopicActionItemRead.model_validate(data)
374
+
375
+ def mark_wake_sent(self, action_item_id: uuid.UUID) -> TopicActionItemRead:
376
+ """Bump ``wake_count`` + stamp ``last_woken_at`` + audit row.
377
+
378
+ Experiment B / I4 — called by the runtime-waker CLI process when
379
+ ``should_wake_action_item`` returns ``'wake'``. Owner or admin only.
380
+ """
381
+ data = self._json("POST", f"/action-items/{action_item_id}/mark-wake-sent")
382
+ return TopicActionItemRead.model_validate(data)
383
+
384
+ def mark_stale(self, action_item_id: uuid.UUID) -> TopicActionItemRead:
385
+ """Stamp ``stale_at`` + write the ``action_item.stale`` audit row.
386
+
387
+ Experiment B / I4 — called by the runtime-waker CLI process when
388
+ ``should_wake_action_item`` returns ``'stale'`` (4th unanswered wake).
389
+ Admin only.
390
+ """
391
+ data = self._json("POST", f"/action-items/{action_item_id}/mark-stale")
392
+ return TopicActionItemRead.model_validate(data)
393
+
394
+ def revise_project_status(
395
+ self,
396
+ project_id: uuid.UUID,
397
+ payload: ProjectStatusRevise,
398
+ ) -> ProjectStatusVersionRead:
399
+ data = self._json("POST", f"/projects/{project_id}/status/revisions", json=payload.model_dump())
400
+ return ProjectStatusVersionRead.model_validate(data)
401
+
402
+ def list_project_status_versions(self, project_id: uuid.UUID) -> list[ProjectStatusVersionRead]:
403
+ data = self._json("GET", f"/projects/{project_id}/status/versions")
404
+ return [ProjectStatusVersionRead.model_validate(item) for item in data]
405
+
406
+ def get_project_status_version(
407
+ self,
408
+ project_id: uuid.UUID,
409
+ version: int,
410
+ ) -> ProjectStatusVersionRead:
411
+ data = self._json("GET", f"/projects/{project_id}/status/versions/{version}")
412
+ return ProjectStatusVersionRead.model_validate(data)
413
+
414
+ # --- experiments ---
415
+
416
+ def create_experiment(self, project_id: uuid.UUID, payload: ExperimentCreate) -> ExperimentSummaryRead:
417
+ data = self._json("POST", f"/projects/{project_id}/experiments", json=payload.model_dump(mode="json"))
418
+ return ExperimentSummaryRead.model_validate(data)
419
+
420
+ def list_experiments(
421
+ self,
422
+ project_id: uuid.UUID,
423
+ *,
424
+ phase: ExperimentPhase | None = None,
425
+ creator_agent_id: uuid.UUID | None = None,
426
+ q: str | None = None,
427
+ page: int = 1,
428
+ page_size: int = 100,
429
+ include_archived: bool = False,
430
+ ) -> list[ExperimentSummaryRead]:
431
+ data, _total = self.list_experiments_page(
432
+ project_id,
433
+ phase=phase,
434
+ creator_agent_id=creator_agent_id,
435
+ q=q,
436
+ page=page,
437
+ page_size=page_size,
438
+ include_archived=include_archived,
439
+ )
440
+ return data
441
+
442
+ def list_experiments_page(
443
+ self,
444
+ project_id: uuid.UUID,
445
+ *,
446
+ phase: ExperimentPhase | None = None,
447
+ creator_agent_id: uuid.UUID | None = None,
448
+ q: str | None = None,
449
+ page: int = 1,
450
+ page_size: int = 100,
451
+ include_archived: bool = False,
452
+ ) -> tuple[list[ExperimentSummaryRead], int]:
453
+ params: dict[str, Any] = {
454
+ "page": page,
455
+ "page_size": page_size,
456
+ "include_archived": include_archived,
457
+ }
458
+ if phase is not None:
459
+ params["phase"] = phase.value
460
+ if creator_agent_id is not None:
461
+ params["creator_agent_id"] = str(creator_agent_id)
462
+ if q:
463
+ params["q"] = q
464
+ response = self._request("GET", f"/projects/{project_id}/experiments", params=params)
465
+ data = response.json()
466
+ return [ExperimentSummaryRead.model_validate(item) for item in data], self._total_count(response)
467
+
468
+ def get_experiment(self, experiment_id: uuid.UUID) -> ExperimentDetailRead:
469
+ return ExperimentDetailRead.model_validate(self._json("GET", f"/experiments/{experiment_id}"))
470
+
471
+ def get_experiment_bundle(self, experiment_id: uuid.UUID) -> ExperimentBundleRead:
472
+ return ExperimentBundleRead.model_validate(self._json("GET", f"/experiments/{experiment_id}/bundle"))
473
+
474
+ def delete_experiment(self, experiment_id: uuid.UUID) -> None:
475
+ self._request("DELETE", f"/experiments/{experiment_id}")
476
+
477
+ # --- phase transitions ---
478
+
479
+ def submit_for_review(self, experiment_id: uuid.UUID) -> ExperimentSummaryRead:
480
+ data = self._json("POST", f"/experiments/{experiment_id}/submit-review")
481
+ return ExperimentSummaryRead.model_validate(data)
482
+
483
+ def approve_experiment(self, experiment_id: uuid.UUID) -> ExperimentSummaryRead:
484
+ data = self._json("POST", f"/experiments/{experiment_id}/approve")
485
+ return ExperimentSummaryRead.model_validate(data)
486
+
487
+ def withdraw_from_review(self, experiment_id: uuid.UUID) -> ExperimentSummaryRead:
488
+ data = self._json("POST", f"/experiments/{experiment_id}/withdraw")
489
+ return ExperimentSummaryRead.model_validate(data)
490
+
491
+ def cancel_experiment(self, experiment_id: uuid.UUID) -> ExperimentSummaryRead:
492
+ data = self._json("POST", f"/experiments/{experiment_id}/cancel")
493
+ return ExperimentSummaryRead.model_validate(data)
494
+
495
+ def start_experiment(self, experiment_id: uuid.UUID) -> ExperimentSummaryRead:
496
+ data = self._json("POST", f"/experiments/{experiment_id}/start")
497
+ return ExperimentSummaryRead.model_validate(data)
498
+
499
+ def complete_experiment(self, experiment_id: uuid.UUID, payload: ExperimentComplete) -> ExperimentSummaryRead:
500
+ data = self._json(
501
+ "POST",
502
+ f"/experiments/{experiment_id}/complete",
503
+ json=payload.model_dump(mode="json"),
504
+ )
505
+ return ExperimentSummaryRead.model_validate(data)
506
+
507
+ def accept_experiment_result(
508
+ self,
509
+ experiment_id: uuid.UUID,
510
+ payload: ExperimentResultDecision,
511
+ ) -> ExperimentSummaryRead:
512
+ data = self._json(
513
+ "POST",
514
+ f"/experiments/{experiment_id}/accept-result",
515
+ json=payload.model_dump(mode="json"),
516
+ )
517
+ return ExperimentSummaryRead.model_validate(data)
518
+
519
+ def reject_experiment_result(
520
+ self,
521
+ experiment_id: uuid.UUID,
522
+ payload: ExperimentResultDecision,
523
+ ) -> ExperimentSummaryRead:
524
+ data = self._json(
525
+ "POST",
526
+ f"/experiments/{experiment_id}/reject-result",
527
+ json=payload.model_dump(mode="json"),
528
+ )
529
+ return ExperimentSummaryRead.model_validate(data)
530
+
531
+ # --- execution lock (CP-3) ---
532
+
533
+ def acquire_experiment_lock(
534
+ self, experiment_id: uuid.UUID, *, ttl_seconds: int
535
+ ) -> ExperimentLockRead:
536
+ data = self._json(
537
+ "POST",
538
+ f"/experiments/{experiment_id}/lock/acquire",
539
+ json={"ttl_seconds": ttl_seconds},
540
+ )
541
+ return ExperimentLockRead.model_validate(data)
542
+
543
+ def release_experiment_lock(self, experiment_id: uuid.UUID) -> ExperimentLockRead:
544
+ data = self._json("POST", f"/experiments/{experiment_id}/lock/release")
545
+ return ExperimentLockRead.model_validate(data)
546
+
547
+ def force_release_experiment_lock(
548
+ self,
549
+ experiment_id: uuid.UUID,
550
+ *,
551
+ reason: str,
552
+ actor: str | None = None,
553
+ ) -> ExperimentLockRead:
554
+ payload: dict[str, Any] = {"reason": reason}
555
+ if actor:
556
+ payload["actor"] = actor
557
+ data = self._json("POST", f"/experiments/{experiment_id}/lock/force-release", json=payload)
558
+ return ExperimentLockRead.model_validate(data)
559
+
560
+ def record_experiment_lock_skip(
561
+ self,
562
+ experiment_id: uuid.UUID,
563
+ *,
564
+ next_attempt_at: str,
565
+ ) -> ExperimentLockRead:
566
+ data = self._json(
567
+ "POST",
568
+ f"/experiments/{experiment_id}/lock/skip",
569
+ json={"next_attempt_at": next_attempt_at},
570
+ )
571
+ return ExperimentLockRead.model_validate(data)
572
+
573
+ def scan_stalled_experiment_locks(self) -> ExperimentLockStalledScanRead:
574
+ data = self._json("POST", "/experiments/lock/scan-stalled")
575
+ return ExperimentLockStalledScanRead.model_validate(data)
576
+
577
+ # --- plans ---
578
+
579
+ def list_plans(self, experiment_id: uuid.UUID) -> list[PlanVersionRead]:
580
+ data = self._json("GET", f"/experiments/{experiment_id}/plans")
581
+ return [PlanVersionRead.model_validate(item) for item in data]
582
+
583
+ def get_plan(self, experiment_id: uuid.UUID, version: int) -> PlanVersionRead:
584
+ return PlanVersionRead.model_validate(
585
+ self._json("GET", f"/experiments/{experiment_id}/plans/{version}")
586
+ )
587
+
588
+ def revise_plan(self, experiment_id: uuid.UUID, payload: PlanRevise) -> PlanVersionRead:
589
+ data = self._json("POST", f"/experiments/{experiment_id}/plans", json=payload.model_dump(mode="json"))
590
+ return PlanVersionRead.model_validate(data)
591
+
592
+ # --- reviews ---
593
+
594
+ def create_review(self, experiment_id: uuid.UUID, payload: ReviewCreate) -> ReviewRead:
595
+ data = self._json("POST", f"/experiments/{experiment_id}/reviews", json=payload.model_dump())
596
+ review = ReviewRead.model_validate(data)
597
+ return review
598
+
599
+ def list_reviews(
600
+ self,
601
+ experiment_id: uuid.UUID,
602
+ *,
603
+ include_archived: bool = False,
604
+ plan_version: int | None = None,
605
+ ) -> list[ReviewRead]:
606
+ params: dict[str, Any] = {}
607
+ if include_archived:
608
+ params["include_archived"] = "true"
609
+ if plan_version is not None:
610
+ params["plan_version"] = str(plan_version)
611
+ data = self._json(
612
+ "GET",
613
+ f"/experiments/{experiment_id}/reviews",
614
+ params=params or None,
615
+ )
616
+ return [ReviewRead.model_validate(item) for item in data]
617
+
618
+ def withdraw_review(self, experiment_id: uuid.UUID, review_id: uuid.UUID) -> None:
619
+ self._json("POST", f"/experiments/{experiment_id}/reviews/{review_id}/withdraw")
620
+
621
+ def update_review_item(self, item_id: uuid.UUID, status: ReviewItemStatus) -> ReviewItemRead:
622
+ payload = ReviewItemUpdate(status=status)
623
+ data = self._json(
624
+ "PATCH",
625
+ f"/review-items/{item_id}",
626
+ json=payload.model_dump(mode="json"),
627
+ )
628
+ return ReviewItemRead.model_validate(data)
629
+
630
+ # --- comments ---
631
+
632
+ def create_comment(self, experiment_id: uuid.UUID, payload: CommentCreate) -> CommentRead:
633
+ data = self._json(
634
+ "POST",
635
+ f"/experiments/{experiment_id}/comments",
636
+ json=payload.model_dump(mode="json"),
637
+ )
638
+ return CommentRead.model_validate(data)
639
+
640
+ def list_comments(
641
+ self,
642
+ experiment_id: uuid.UUID,
643
+ *,
644
+ tree: bool = False,
645
+ ) -> list[CommentRead] | list[CommentTreeNode]:
646
+ data = self._json("GET", f"/experiments/{experiment_id}/comments", params={"tree": tree})
647
+ if tree:
648
+ return [CommentTreeNode.model_validate(item) for item in data]
649
+ return [CommentRead.model_validate(item) for item in data]
650
+
651
+ # --- logs ---
652
+
653
+ def create_log(
654
+ self, experiment_id: uuid.UUID, payload: ExperimentLogCreate
655
+ ) -> LogCreateResponse:
656
+ data = self._json("POST", f"/experiments/{experiment_id}/logs", json=payload.model_dump())
657
+ return LogCreateResponse.model_validate(data)
658
+
659
+ def list_logs(self, experiment_id: uuid.UUID) -> list[ExperimentLogRead]:
660
+ data = self._json("GET", f"/experiments/{experiment_id}/logs")
661
+ return [ExperimentLogRead.model_validate(item) for item in data]
662
+
663
+ # 0db51e10 I2(5e): record a cross-persona acceptance_status compare call.
664
+ # Writes an audit_logs row with action="cross_persona_call" + the
665
+ # per-persona visibility diff so R6 metrics / admin audit list can
666
+ # aggregate later. Host persona only (the endpoint enforces
667
+ # ensure_experiment_access).
668
+ def record_cross_persona_call(
669
+ self,
670
+ experiment_id: uuid.UUID,
671
+ *,
672
+ visibility_diff: dict[str, Any] | None = None,
673
+ result_partition_count: int = 0,
674
+ diff_size: int = 0,
675
+ ) -> AuditLogRead:
676
+ payload = CrossPersonaCallRecord(
677
+ visibility_diff=visibility_diff or {},
678
+ result_partition_count=result_partition_count,
679
+ diff_size=diff_size,
680
+ )
681
+ data = self._json(
682
+ "POST",
683
+ f"/experiments/{experiment_id}/cross-persona-call",
684
+ json=payload.model_dump(mode="json"),
685
+ )
686
+ return AuditLogRead.model_validate(data)
687
+
688
+ # --- topics ---
689
+
690
+ def create_topic(self, project_id: uuid.UUID, payload: TopicCreate) -> TopicSummaryRead:
691
+ data = self._json("POST", f"/projects/{project_id}/topics", json=payload.model_dump())
692
+ return TopicSummaryRead.model_validate(data)
693
+
694
+ def list_topics(
695
+ self,
696
+ project_id: uuid.UUID,
697
+ *,
698
+ status: TopicStatus | None = None,
699
+ creator_agent_id: uuid.UUID | None = None,
700
+ q: str | None = None,
701
+ page: int = 1,
702
+ page_size: int = 100,
703
+ include_archived: bool = False,
704
+ ) -> list[TopicSummaryRead]:
705
+ data, _total = self.list_topics_page(
706
+ project_id,
707
+ status=status,
708
+ creator_agent_id=creator_agent_id,
709
+ q=q,
710
+ page=page,
711
+ page_size=page_size,
712
+ include_archived=include_archived,
713
+ )
714
+ return data
715
+
716
+ def list_topics_page(
717
+ self,
718
+ project_id: uuid.UUID,
719
+ *,
720
+ status: TopicStatus | None = None,
721
+ creator_agent_id: uuid.UUID | None = None,
722
+ q: str | None = None,
723
+ page: int = 1,
724
+ page_size: int = 100,
725
+ include_archived: bool = False,
726
+ ) -> tuple[list[TopicSummaryRead], int]:
727
+ params: dict[str, Any] = {
728
+ "page": page,
729
+ "page_size": page_size,
730
+ "include_archived": include_archived,
731
+ }
732
+ if status is not None:
733
+ params["status"] = status.value
734
+ if creator_agent_id is not None:
735
+ params["creator_agent_id"] = str(creator_agent_id)
736
+ if q:
737
+ params["q"] = q
738
+ response = self._request("GET", f"/projects/{project_id}/topics", params=params)
739
+ data = response.json()
740
+ return [TopicSummaryRead.model_validate(item) for item in data], self._total_count(response)
741
+
742
+ def get_topic(self, topic_id: uuid.UUID) -> TopicRead:
743
+ return TopicRead.model_validate(self._json("GET", f"/topics/{topic_id}"))
744
+
745
+ def resolve_topic(self, topic_id: uuid.UUID, payload: TopicResolve) -> TopicDecisionRead:
746
+ data = self._json("POST", f"/topics/{topic_id}/resolve", json=payload.model_dump(mode="json"))
747
+ return TopicDecisionRead.model_validate(data)
748
+
749
+ def advance_topic_round(self, topic_id: uuid.UUID, payload: TopicAdvanceRound | None = None) -> TopicSummaryRead:
750
+ body = (payload or TopicAdvanceRound()).model_dump(mode="json")
751
+ data = self._json("POST", f"/topics/{topic_id}/advance-round", json=body)
752
+ return TopicSummaryRead.model_validate(data)
753
+
754
+ def update_topic(self, topic_id: uuid.UUID, payload: TopicUpdate) -> TopicSummaryRead:
755
+ data = self._json("PATCH", f"/topics/{topic_id}", json=payload.model_dump(exclude_unset=True))
756
+ return TopicSummaryRead.model_validate(data)
757
+
758
+ def update_experiment(
759
+ self, experiment_id: uuid.UUID, payload: ExperimentUpdate
760
+ ) -> ExperimentSummaryRead:
761
+ data = self._json(
762
+ "PATCH",
763
+ f"/experiments/{experiment_id}",
764
+ json=payload.model_dump(exclude_unset=True),
765
+ )
766
+ return ExperimentSummaryRead.model_validate(data)
767
+
768
+ def delete_topic(self, topic_id: uuid.UUID) -> None:
769
+ self._request("DELETE", f"/topics/{topic_id}")
770
+
771
+ def close_topic(self, topic_id: uuid.UUID) -> TopicSummaryRead:
772
+ return TopicSummaryRead.model_validate(self._json("POST", f"/topics/{topic_id}/close"))
773
+
774
+ def reopen_topic(self, topic_id: uuid.UUID) -> TopicSummaryRead:
775
+ return TopicSummaryRead.model_validate(self._json("POST", f"/topics/{topic_id}/reopen"))
776
+
777
+ def dismiss_topic(self, topic_id: uuid.UUID) -> TopicSummaryRead:
778
+ return TopicSummaryRead.model_validate(self._json("POST", f"/topics/{topic_id}/dismiss"))
779
+
780
+ def mark_topic_read(self, topic_id: uuid.UUID) -> TopicReadCursorRead:
781
+ data = self._json("POST", f"/agents/me/topics/{topic_id}/read")
782
+ return TopicReadCursorRead.model_validate(data)
783
+
784
+ def create_topic_comment(
785
+ self,
786
+ topic_id: uuid.UUID,
787
+ payload: TopicCommentCreate,
788
+ ) -> TopicCommentRead:
789
+ data = self._json("POST", f"/topics/{topic_id}/comments", json=payload.model_dump(mode="json"))
790
+ return TopicCommentRead.model_validate(data)
791
+
792
+ def list_topic_comments(
793
+ self,
794
+ topic_id: uuid.UUID,
795
+ *,
796
+ tree: bool = False,
797
+ ) -> list[TopicCommentRead] | list[TopicCommentTreeNode]:
798
+ data = self._json("GET", f"/topics/{topic_id}/comments", params={"tree": tree})
799
+ if tree:
800
+ return [TopicCommentTreeNode.model_validate(item) for item in data]
801
+ return [TopicCommentRead.model_validate(item) for item in data]
802
+
803
+ # --- todos ---
804
+
805
+ def get_todos(self) -> TodoRead:
806
+ return TodoRead.model_validate(self._json("GET", "/agents/me/todos"))
807
+
808
+ def get_agent_work(
809
+ self,
810
+ *,
811
+ notification_limit: int = 50,
812
+ notification_category: NotificationCategory | str | None = "wakeable",
813
+ ) -> AgentWorkRead:
814
+ params: dict[str, Any] = {"notification_limit": notification_limit}
815
+ if notification_category is None or notification_category == "all":
816
+ params["notification_category"] = "all"
817
+ elif isinstance(notification_category, NotificationCategory):
818
+ params["notification_category"] = notification_category.value
819
+ else:
820
+ params["notification_category"] = notification_category
821
+ return AgentWorkRead.model_validate(self._json("GET", "/agents/me/work", params=params))
822
+
823
+ def get_agent_work_summary(
824
+ self,
825
+ *,
826
+ include_all_personas: bool = False,
827
+ topics_limit: int = 10,
828
+ experiments_limit: int = 5,
829
+ ) -> AgentWorkSummaryRead:
830
+ params: dict[str, Any] = {
831
+ "include_all_personas": str(include_all_personas).lower(),
832
+ "topics_limit": topics_limit,
833
+ "experiments_limit": experiments_limit,
834
+ }
835
+ return AgentWorkSummaryRead.model_validate(
836
+ self._json("GET", "/agents/me/work/summary", params=params)
837
+ )
838
+
839
+ def get_topic_progress(self) -> TopicProgressListRead:
840
+ return TopicProgressListRead.model_validate(self._json("GET", "/agents/me/topic-progress"))
841
+
842
+ def dismiss_mention(self, mention_id: uuid.UUID) -> DismissMentionResultRead:
843
+ data = self._json("POST", f"/agents/me/mentions/{mention_id}/dismiss")
844
+ return DismissMentionResultRead.model_validate(data)
845
+
846
+ def dismiss_all_mentions(self) -> DismissAllMentionsResultRead:
847
+ data = self._json("POST", "/agents/me/mentions/dismiss-all")
848
+ return DismissAllMentionsResultRead.model_validate(data)
849
+
850
+ # --- notifications ---
851
+
852
+ def list_notifications(
853
+ self,
854
+ *,
855
+ unread_only: bool = False,
856
+ category: NotificationCategory | str | None = None,
857
+ target_type: str | None = None,
858
+ limit: int = 50,
859
+ offset: int = 0,
860
+ ) -> NotificationListRead:
861
+ params: dict[str, Any] = {"unread_only": unread_only, "limit": limit, "offset": offset}
862
+ if category is not None:
863
+ params["category"] = category.value if isinstance(category, NotificationCategory) else category
864
+ if target_type is not None:
865
+ params["target_type"] = target_type
866
+ data = self._json(
867
+ "GET",
868
+ "/agents/me/notifications",
869
+ params=params,
870
+ )
871
+ return NotificationListRead.model_validate(data)
872
+
873
+ def mark_notification_read(self, notification_id: uuid.UUID) -> NotificationRead:
874
+ data = self._json("POST", f"/notifications/{notification_id}/read")
875
+ return NotificationRead.model_validate(data)
876
+
877
+ def mark_all_notifications_read(self) -> dict[str, int]:
878
+ return self._json("POST", "/agents/me/notifications/read-all")
879
+
880
+ # --- inbound events (runtime-waker dedup gate; D6) ---
881
+
882
+ def record_inbound_event(
883
+ self,
884
+ payload: InboundEventCreate,
885
+ ) -> InboundEventRecordResult:
886
+ """Record that the caller is about to act on ``event_id``.
887
+
888
+ Raises :class:`MAPHTTPError` with ``status_code == 409`` when the
889
+ fingerprint already exists (server gate). Callers should treat 409 as
890
+ "already woken" and skip the resume step (see plan D6 / A1 / A2).
891
+ """
892
+ data = self._json(
893
+ "POST",
894
+ "/agents/me/inbound-events",
895
+ json=payload.model_dump(mode="json"),
896
+ )
897
+ return InboundEventRecordResult.model_validate(data)
898
+
899
+ # --- webhooks (admin) ---
900
+
901
+ def create_webhook(self, payload: WebhookCreate) -> WebhookCreateResponse:
902
+ data = self._json("POST", "/webhooks", json=payload.model_dump(mode="json"))
903
+ return WebhookCreateResponse.model_validate(data)
904
+
905
+ def list_webhooks(self, project_id: uuid.UUID | None = None) -> list[WebhookRead]:
906
+ params = {"project_id": str(project_id)} if project_id else None
907
+ data = self._json("GET", "/webhooks", params=params)
908
+ return [WebhookRead.model_validate(w) for w in data]
909
+
910
+ def delete_webhook(self, webhook_id: uuid.UUID) -> None:
911
+ self._request("DELETE", f"/webhooks/{webhook_id}")
912
+
913
+ def list_webhook_deliveries(self, webhook_id: uuid.UUID) -> list[WebhookDeliveryRead]:
914
+ data = self._json("GET", f"/webhooks/{webhook_id}/deliveries")
915
+ return [WebhookDeliveryRead.model_validate(d) for d in data]
916
+
917
+ # --- audit ---
918
+
919
+ def list_audit_for_target(self, target_type: str, target_id: uuid.UUID) -> list[AuditLogRead]:
920
+ data = self._json(
921
+ "GET",
922
+ "/audit",
923
+ params={"target_type": target_type, "target_id": str(target_id)},
924
+ )
925
+ return [AuditLogRead.model_validate(a) for a in data]
926
+
927
+ def list_audit_global(
928
+ self,
929
+ *,
930
+ page: int = 1,
931
+ page_size: int = 50,
932
+ kind: str | None = None,
933
+ experiment_id: uuid.UUID | None = None,
934
+ ) -> tuple[list[AuditLogRead], int]:
935
+ """List global audit entries with optional admin filters.
936
+
937
+ ``kind`` matches ``AuditLog.action`` (e.g. ``"review_item.mutation"``).
938
+ ``experiment_id`` filters to events whose ``payload_json`` carries
939
+ the matching experiment id (used by ``map audit list --experiment <id>``).
940
+ Both filters compose so the CLI can scope down to a single
941
+ experiment × audit kind slice.
942
+ """
943
+ params: dict[str, str | int] = {"page": page, "page_size": page_size}
944
+ if kind is not None:
945
+ params["kind"] = kind
946
+ if experiment_id is not None:
947
+ params["experiment_id"] = str(experiment_id)
948
+ resp = self._request("GET", "/admin/audit", params=params)
949
+ items = [AuditLogRead.model_validate(a) for a in resp.json()]
950
+ return items, int(resp.headers.get("X-Total-Count", len(items)))
951
+
952
+ # --- status ---
953
+
954
+ def get_global_status(self, project_id: uuid.UUID | None = None) -> GlobalStatusRead:
955
+ params = {"project_id": str(project_id)} if project_id else None
956
+ return GlobalStatusRead.model_validate(self._json("GET", "/status", params=params))
957
+
958
+ # --- feedback ---
959
+
960
+ def submit_feedback(self, payload: PlatformFeedbackCreate) -> PlatformFeedbackRead:
961
+ data = self._json("POST", "/feedback", json=payload.model_dump(mode="json"))
962
+ return PlatformFeedbackRead.model_validate(data)
963
+
964
+ def list_feedback(
965
+ self,
966
+ *,
967
+ status: FeedbackStatus | None = None,
968
+ category: FeedbackCategory | None = None,
969
+ project_id: uuid.UUID | None = None,
970
+ page: int = 1,
971
+ page_size: int = 50,
972
+ include_archived: bool = False,
973
+ ) -> list[PlatformFeedbackRead]:
974
+ data, _total = self.list_feedback_page(
975
+ status=status,
976
+ category=category,
977
+ project_id=project_id,
978
+ page=page,
979
+ page_size=page_size,
980
+ include_archived=include_archived,
981
+ )
982
+ return data
983
+
984
+ def list_feedback_page(
985
+ self,
986
+ *,
987
+ status: FeedbackStatus | None = None,
988
+ category: FeedbackCategory | None = None,
989
+ project_id: uuid.UUID | None = None,
990
+ page: int = 1,
991
+ page_size: int = 50,
992
+ include_archived: bool = False,
993
+ ) -> tuple[list[PlatformFeedbackRead], int]:
994
+ params: dict[str, Any] = {
995
+ "page": page,
996
+ "page_size": page_size,
997
+ "include_archived": include_archived,
998
+ }
999
+ if status is not None:
1000
+ params["status"] = status.value
1001
+ if category is not None:
1002
+ params["category"] = category.value
1003
+ if project_id is not None:
1004
+ params["project_id"] = str(project_id)
1005
+ response = self._request("GET", "/feedback", params=params)
1006
+ data = response.json()
1007
+ return (
1008
+ [PlatformFeedbackRead.model_validate(item) for item in data],
1009
+ self._total_count(response),
1010
+ )
1011
+
1012
+ def get_feedback(self, feedback_id: uuid.UUID) -> PlatformFeedbackRead:
1013
+ data = self._json("GET", f"/feedback/{feedback_id}")
1014
+ return PlatformFeedbackRead.model_validate(data)
1015
+
1016
+ def update_feedback(
1017
+ self, feedback_id: uuid.UUID, payload: PlatformFeedbackUpdate
1018
+ ) -> PlatformFeedbackRead:
1019
+ data = self._json(
1020
+ "PATCH",
1021
+ f"/feedback/{feedback_id}",
1022
+ json=payload.model_dump(exclude_unset=True, mode="json"),
1023
+ )
1024
+ return PlatformFeedbackRead.model_validate(data)
1025
+
1026
+
1027
+ # Re-export types useful for SDK consumers
1028
+ __all__ = [
1029
+ "MAPClient",
1030
+ "CommentAnchorType",
1031
+ "ExperimentPhase",
1032
+ "ReviewItemStatus",
1033
+ "ExperimentCreate",
1034
+ "ExperimentComplete",
1035
+ "ExperimentResultDecision",
1036
+ "ExperimentLogCreate",
1037
+ "PlanRevise",
1038
+ "ReviewCreate",
1039
+ "CommentCreate",
1040
+ "ProjectUpdate",
1041
+ "TopicCreate",
1042
+ "TopicUpdate",
1043
+ "TopicCommentCreate",
1044
+ "TopicStatus",
1045
+ ]