multi-agent-platform 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli/__init__.py +0 -0
- cli/action_item_escalation.py +177 -0
- cli/agent_client.py +554 -0
- cli/bridge_state.py +43 -0
- cli/commands/__init__.py +13 -0
- cli/commands/action.py +142 -0
- cli/commands/agent.py +117 -0
- cli/commands/audit.py +68 -0
- cli/commands/docs.py +179 -0
- cli/commands/experiment.py +755 -0
- cli/commands/feedback.py +106 -0
- cli/commands/notification.py +213 -0
- cli/commands/persona.py +63 -0
- cli/commands/project.py +87 -0
- cli/commands/runtime.py +105 -0
- cli/commands/topic.py +361 -0
- cli/e2e_collab.py +602 -0
- cli/git_checkpoint.py +68 -0
- cli/host_worker_types.py +151 -0
- cli/main.py +1553 -0
- cli/map_command_client.py +497 -0
- cli/participant_worker.py +255 -0
- cli/reviewer_worker.py +263 -0
- cli/runtime/__init__.py +5 -0
- cli/runtime/run_lock.py +497 -0
- cli/runtime_chat.py +317 -0
- cli/session_wake_log.py +235 -0
- cli/simple_waker.py +950 -0
- cli/table_render.py +113 -0
- cli/wake_backend.py +236 -0
- cli/worker_cycle_log.py +36 -0
- map_client/__init__.py +37 -0
- map_client/bootstrap.py +193 -0
- map_client/client.py +1045 -0
- map_client/config.py +21 -0
- map_client/errors.py +283 -0
- map_client/exceptions.py +130 -0
- map_client/plan_evidence.py +159 -0
- map_client/project_config.py +153 -0
- map_client/result_template.py +167 -0
- map_client/testing.py +27 -0
- map_mcp/__init__.py +4 -0
- map_mcp/_utils.py +28 -0
- map_mcp/auth.py +34 -0
- map_mcp/config.py +50 -0
- map_mcp/context.py +39 -0
- map_mcp/main.py +75 -0
- map_mcp/server.py +573 -0
- map_mcp/session.py +79 -0
- map_sdk/__init__.py +29 -0
- map_sdk/evidence.py +68 -0
- map_types/__init__.py +203 -0
- map_types/enums.py +199 -0
- map_types/schemas.py +1351 -0
- multi_agent_platform-0.1.0.dist-info/METADATA +298 -0
- multi_agent_platform-0.1.0.dist-info/RECORD +144 -0
- multi_agent_platform-0.1.0.dist-info/WHEEL +5 -0
- multi_agent_platform-0.1.0.dist-info/entry_points.txt +6 -0
- multi_agent_platform-0.1.0.dist-info/licenses/LICENSE +21 -0
- multi_agent_platform-0.1.0.dist-info/top_level.txt +6 -0
- server/__init__.py +0 -0
- server/__version__.py +14 -0
- server/api/__init__.py +0 -0
- server/api/action_items.py +138 -0
- server/api/agents.py +412 -0
- server/api/audit.py +54 -0
- server/api/background_tasks.py +18 -0
- server/api/common.py +117 -0
- server/api/deps.py +30 -0
- server/api/experiments.py +858 -0
- server/api/feedback.py +75 -0
- server/api/notifications.py +22 -0
- server/api/projects.py +209 -0
- server/api/router.py +25 -0
- server/api/status.py +33 -0
- server/api/topics.py +302 -0
- server/api/webhooks.py +74 -0
- server/auth/__init__.py +8 -0
- server/auth/experiment_access.py +66 -0
- server/config.py +38 -0
- server/db/__init__.py +3 -0
- server/db/base.py +5 -0
- server/db/deadlock_retry.py +146 -0
- server/db/session.py +41 -0
- server/domain/__init__.py +3 -0
- server/domain/encrypted_types.py +63 -0
- server/domain/models.py +713 -0
- server/domain/schemas.py +3 -0
- server/domain/state_machine.py +79 -0
- server/domain/topic_ack_constants.py +9 -0
- server/main.py +148 -0
- server/scripts/__init__.py +0 -0
- server/scripts/migrate_notification_unique.py +231 -0
- server/scripts/purge_audit_pollution.py +116 -0
- server/services/__init__.py +0 -0
- server/services/_lookups.py +26 -0
- server/services/acceptance_service.py +90 -0
- server/services/action_item_migration_service.py +190 -0
- server/services/action_item_service.py +200 -0
- server/services/agent_work_service.py +405 -0
- server/services/archive_lint_service.py +156 -0
- server/services/audit_service.py +457 -0
- server/services/auth.py +66 -0
- server/services/comment_service.py +173 -0
- server/services/errors.py +65 -0
- server/services/escalation_resolver.py +248 -0
- server/services/evidence_service.py +88 -0
- server/services/experiment_capabilities_service.py +277 -0
- server/services/inbound_event_service.py +111 -0
- server/services/lock_service.py +273 -0
- server/services/log_service.py +202 -0
- server/services/mention_service.py +730 -0
- server/services/notification_service.py +939 -0
- server/services/notification_stream.py +138 -0
- server/services/permissions.py +147 -0
- server/services/persona_activity_service.py +108 -0
- server/services/phase_owner_resolver.py +95 -0
- server/services/phase_service.py +381 -0
- server/services/plan_marker_service.py +235 -0
- server/services/plan_service.py +186 -0
- server/services/platform_feedback_service.py +114 -0
- server/services/project_service.py +534 -0
- server/services/project_status_service.py +132 -0
- server/services/review_service.py +707 -0
- server/services/secret_encryption.py +97 -0
- server/services/similarity_service.py +119 -0
- server/services/sse_event_schemas.py +17 -0
- server/services/status_service.py +68 -0
- server/services/template_service.py +134 -0
- server/services/text_utils.py +19 -0
- server/services/thread_activity.py +180 -0
- server/services/todo_persona_filter.py +73 -0
- server/services/todo_service.py +604 -0
- server/services/topic_ack_service.py +312 -0
- server/services/topic_action_item_ops.py +538 -0
- server/services/topic_comment_kind.py +14 -0
- server/services/topic_comment_service.py +237 -0
- server/services/topic_helpers.py +32 -0
- server/services/topic_lifecycle_service.py +478 -0
- server/services/topic_progress_service.py +40 -0
- server/services/topic_resolve_service.py +234 -0
- server/services/topic_service.py +102 -0
- server/services/topic_work_item_service.py +570 -0
- server/services/webhook_service.py +273 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
class NotFoundError(Exception):
|
|
2
|
+
pass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ConflictError(Exception):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BadRequestError(Exception):
|
|
10
|
+
"""Caller-supplied payload is well-formed (syntactically valid JSON /
|
|
11
|
+
body) but semantically rejected — e.g. a missing cross-reference, an
|
|
12
|
+
out-of-range enum value, or a field combination that does not
|
|
13
|
+
satisfy a domain precondition. Maps to HTTP 400.
|
|
14
|
+
|
|
15
|
+
Distinct from pydantic's 422 (which fires on shape-level schema
|
|
16
|
+
failures before the handler runs).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UnauthorizedError(Exception):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ForbiddenError(Exception):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class StateTransitionError(Exception):
|
|
29
|
+
"""A request was structurally valid but the domain state machine refuses
|
|
30
|
+
the requested transition.
|
|
31
|
+
|
|
32
|
+
Optional attributes are surfaced to the HTTP layer by
|
|
33
|
+
:func:`server.main.register_domain_exception_handlers` so callers can
|
|
34
|
+
programmatically route on the failure mode instead of pattern-matching
|
|
35
|
+
the human-readable ``detail`` string:
|
|
36
|
+
|
|
37
|
+
- ``error_code`` — stable machine identifier (default ``"state_machine_error"``).
|
|
38
|
+
I1(d) introduces the ``REVIEW_REJECT_RESULT_MISUSE`` subcode so the
|
|
39
|
+
client can distinguish "wrong CLI command for this intent" from
|
|
40
|
+
generic state-machine refusals.
|
|
41
|
+
- ``hint`` — short remediation hint surfaced to operators and CLI users.
|
|
42
|
+
- ``retryable`` — whether the caller can succeed by retrying with the
|
|
43
|
+
same input after fixing state out-of-band. Always ``False`` for misuse
|
|
44
|
+
subcodes; preserved as a structured field for future retryable errors.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
error_code: str = "state_machine_error"
|
|
48
|
+
hint: str | None = None
|
|
49
|
+
retryable: bool = False
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
message: str,
|
|
54
|
+
*,
|
|
55
|
+
error_code: str | None = None,
|
|
56
|
+
hint: str | None = None,
|
|
57
|
+
retryable: bool | None = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
super().__init__(message)
|
|
60
|
+
if error_code is not None:
|
|
61
|
+
self.error_code = error_code
|
|
62
|
+
if hint is not None:
|
|
63
|
+
self.hint = hint
|
|
64
|
+
if retryable is not None:
|
|
65
|
+
self.retryable = retryable
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""STATE_MACHINE.* error escalation contact resolver (experiment 156172e9 I1(b)).
|
|
2
|
+
|
|
3
|
+
Resolves which agent should be surfaced as the recovery contact when a
|
|
4
|
+
STATE_MACHINE.* error fires. The lookup is two-tier:
|
|
5
|
+
|
|
6
|
+
1. ``Experiment.escalation_target_agent_id`` override (set by host creator
|
|
7
|
+
on a per-experiment basis; NULL by default).
|
|
8
|
+
2. Role-based fallback chain documented in plan (b):
|
|
9
|
+
a. Current caller agent (avoid cross-role mis-routing; preferred
|
|
10
|
+
since the caller usually has the most context).
|
|
11
|
+
b. Same-role + same-project active agent (any log / topic-comment /
|
|
12
|
+
review action in the last 7 days).
|
|
13
|
+
c. Admin role (last-resort).
|
|
14
|
+
|
|
15
|
+
The function never raises — callers use it in error-handling paths
|
|
16
|
+
where a missing escalation contact must degrade gracefully (the CLI
|
|
17
|
+
falls back to printing the bare error_code + hint instead of crashing).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import uuid
|
|
23
|
+
from datetime import UTC, datetime, timedelta
|
|
24
|
+
|
|
25
|
+
from map_types.enums import AgentRole
|
|
26
|
+
from sqlalchemy import select
|
|
27
|
+
from sqlalchemy.orm import Session
|
|
28
|
+
|
|
29
|
+
from server.domain.models import Agent, Experiment, ExperimentLog, Review, TopicComment
|
|
30
|
+
|
|
31
|
+
# Activity window for "active agent" lookup (plan (b) 2b).
|
|
32
|
+
ACTIVE_LOOKBACK_DAYS = 7
|
|
33
|
+
_ACTIVITY_SINCE = timedelta(days=ACTIVE_LOOKBACK_DAYS)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve_escalation_target(
|
|
37
|
+
db: Session,
|
|
38
|
+
*,
|
|
39
|
+
experiment: Experiment | None,
|
|
40
|
+
caller_agent_id: uuid.UUID | None,
|
|
41
|
+
project_id: uuid.UUID | None = None,
|
|
42
|
+
) -> uuid.UUID | None:
|
|
43
|
+
"""Pick the best escalation contact for a STATE_MACHINE.* error.
|
|
44
|
+
|
|
45
|
+
Returns the agent UUID, or ``None`` if no candidate exists (caller
|
|
46
|
+
should print the hint without an escalation name).
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
db: SQLAlchemy session.
|
|
50
|
+
experiment: Source experiment if available; used to read the
|
|
51
|
+
``escalation_target_agent_id`` override.
|
|
52
|
+
caller_agent_id: Agent UUID of the CLI / SDK caller. Preferred
|
|
53
|
+
for the role-based fallback so the caller gets pinged about
|
|
54
|
+
their own action rather than a cross-role mis-route.
|
|
55
|
+
project_id: Project scope for the same-role active-agent lookup.
|
|
56
|
+
Defaults to ``experiment.project_id`` when ``experiment`` is
|
|
57
|
+
provided.
|
|
58
|
+
"""
|
|
59
|
+
target_id, _tier = resolve_escalation_target_with_tier(
|
|
60
|
+
db,
|
|
61
|
+
experiment=experiment,
|
|
62
|
+
caller_agent_id=caller_agent_id,
|
|
63
|
+
project_id=project_id,
|
|
64
|
+
)
|
|
65
|
+
return target_id
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def resolve_escalation_target_with_tier(
|
|
69
|
+
db: Session,
|
|
70
|
+
*,
|
|
71
|
+
experiment: Experiment | None,
|
|
72
|
+
caller_agent_id: uuid.UUID | None,
|
|
73
|
+
project_id: uuid.UUID | None = None,
|
|
74
|
+
) -> tuple[uuid.UUID | None, str]:
|
|
75
|
+
"""Same as ``resolve_escalation_target`` but also returns the chosen tier.
|
|
76
|
+
|
|
77
|
+
Tiers: ``"experiment_override"`` → ``"current_caller"`` →
|
|
78
|
+
``"same_role_active"`` → ``"admin"`` → ``"none"``. The tier is exposed
|
|
79
|
+
on ``EscalationTargetRead.tier`` so CLI / SDK callers can show the
|
|
80
|
+
user *why* a contact was chosen.
|
|
81
|
+
"""
|
|
82
|
+
if experiment is not None and experiment.escalation_target_agent_id is not None:
|
|
83
|
+
return experiment.escalation_target_agent_id, "experiment_override"
|
|
84
|
+
|
|
85
|
+
scope_project_id = project_id or (experiment.project_id if experiment is not None else None)
|
|
86
|
+
if scope_project_id is None:
|
|
87
|
+
target = _admin_agent(db)
|
|
88
|
+
return target, "admin" if target is not None else "none"
|
|
89
|
+
|
|
90
|
+
# Tier 2a: the current caller (if they belong to this project).
|
|
91
|
+
caller: Agent | None = None
|
|
92
|
+
if caller_agent_id is not None:
|
|
93
|
+
caller = db.get(Agent, caller_agent_id)
|
|
94
|
+
if (
|
|
95
|
+
caller is not None
|
|
96
|
+
and caller.project_id == scope_project_id
|
|
97
|
+
and caller.role != AgentRole.admin
|
|
98
|
+
):
|
|
99
|
+
return caller.id, "current_caller"
|
|
100
|
+
|
|
101
|
+
# Tier 2b: same-role + same-project agent with any recent activity.
|
|
102
|
+
if caller is not None and caller.project_id == scope_project_id:
|
|
103
|
+
target_role = caller.role
|
|
104
|
+
exclude_id = caller.id
|
|
105
|
+
else:
|
|
106
|
+
target_role = AgentRole.agent
|
|
107
|
+
exclude_id = caller.id if caller is not None else None
|
|
108
|
+
same_role_id = _recent_same_role_agent(
|
|
109
|
+
db,
|
|
110
|
+
project_id=scope_project_id,
|
|
111
|
+
role=target_role,
|
|
112
|
+
exclude_agent_id=exclude_id,
|
|
113
|
+
)
|
|
114
|
+
if same_role_id is not None:
|
|
115
|
+
return same_role_id, "same_role_active"
|
|
116
|
+
|
|
117
|
+
# Tier 2c: admin — prefer same-project admin, fall back to any admin.
|
|
118
|
+
target = _admin_agent(db, project_id=scope_project_id)
|
|
119
|
+
return target, "admin" if target is not None else "none"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _recent_same_role_agent(
|
|
123
|
+
db: Session,
|
|
124
|
+
*,
|
|
125
|
+
project_id: uuid.UUID,
|
|
126
|
+
role: AgentRole,
|
|
127
|
+
exclude_agent_id: uuid.UUID | None,
|
|
128
|
+
) -> uuid.UUID | None:
|
|
129
|
+
"""Return the most recently active same-role agent in the project.
|
|
130
|
+
|
|
131
|
+
"Active" = any row in ``experiment_logs`` / ``comments`` / ``reviews``
|
|
132
|
+
with ``created_at >= now - 7 days`` AND ``author_agent_id`` belongs
|
|
133
|
+
to an Agent of the requested role and project. Excludes
|
|
134
|
+
``exclude_agent_id`` so the caller doesn't see themselves.
|
|
135
|
+
|
|
136
|
+
Tie-breaker: most recent ``created_at``. Returns ``None`` when no
|
|
137
|
+
same-role agent has any recent activity.
|
|
138
|
+
|
|
139
|
+
Implementation note: we run three small SELECTs (one per source
|
|
140
|
+
table) and pick the max ``created_at`` in Python. This avoids the
|
|
141
|
+
awkward ``CompoundSelect`` chaining for three ``union_all``s and
|
|
142
|
+
keeps the query small enough that the optimizer's row counts stay
|
|
143
|
+
predictable. The 7-day lookback bounds the row count regardless.
|
|
144
|
+
"""
|
|
145
|
+
cutoff = datetime.now(UTC) - _ACTIVITY_SINCE
|
|
146
|
+
|
|
147
|
+
agents_in_project = select(Agent.id).where(
|
|
148
|
+
Agent.project_id == project_id, Agent.role == role
|
|
149
|
+
)
|
|
150
|
+
if exclude_agent_id is not None:
|
|
151
|
+
agents_in_project = agents_in_project.where(Agent.id != exclude_agent_id)
|
|
152
|
+
project_role_agents = set(db.scalars(agents_in_project).all())
|
|
153
|
+
if not project_role_agents:
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
last_seen_by_agent: dict[uuid.UUID, datetime] = {}
|
|
157
|
+
|
|
158
|
+
log_rows = db.execute(
|
|
159
|
+
select(ExperimentLog.author_agent_id, ExperimentLog.created_at).where(
|
|
160
|
+
ExperimentLog.created_at >= cutoff,
|
|
161
|
+
ExperimentLog.author_agent_id.in_(project_role_agents),
|
|
162
|
+
)
|
|
163
|
+
).all()
|
|
164
|
+
for agent_id, ts in log_rows:
|
|
165
|
+
prev = last_seen_by_agent.get(agent_id)
|
|
166
|
+
if prev is None or ts > prev:
|
|
167
|
+
last_seen_by_agent[agent_id] = ts
|
|
168
|
+
|
|
169
|
+
comment_rows = db.execute(
|
|
170
|
+
select(TopicComment.author_agent_id, TopicComment.created_at).where(
|
|
171
|
+
TopicComment.created_at >= cutoff,
|
|
172
|
+
TopicComment.author_agent_id.in_(project_role_agents),
|
|
173
|
+
)
|
|
174
|
+
).all()
|
|
175
|
+
for agent_id, ts in comment_rows:
|
|
176
|
+
prev = last_seen_by_agent.get(agent_id)
|
|
177
|
+
if prev is None or ts > prev:
|
|
178
|
+
last_seen_by_agent[agent_id] = ts
|
|
179
|
+
|
|
180
|
+
review_rows = db.execute(
|
|
181
|
+
select(Review.reviewer_agent_id, Review.created_at).where(
|
|
182
|
+
Review.created_at >= cutoff,
|
|
183
|
+
Review.reviewer_agent_id.in_(project_role_agents),
|
|
184
|
+
)
|
|
185
|
+
).all()
|
|
186
|
+
for agent_id, ts in review_rows:
|
|
187
|
+
prev = last_seen_by_agent.get(agent_id)
|
|
188
|
+
if prev is None or ts > prev:
|
|
189
|
+
last_seen_by_agent[agent_id] = ts
|
|
190
|
+
|
|
191
|
+
if not last_seen_by_agent:
|
|
192
|
+
return None
|
|
193
|
+
return max(last_seen_by_agent, key=lambda k: last_seen_by_agent[k])
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _admin_agent(db: Session, *, project_id: uuid.UUID | None = None) -> uuid.UUID | None:
|
|
197
|
+
"""Return an admin agent, preferring the same-project admin.
|
|
198
|
+
|
|
199
|
+
Order: same-project admin (lex order by id) → any-project admin
|
|
200
|
+
(lex order by id). Returns ``None`` when no admin exists anywhere.
|
|
201
|
+
The order is deterministic per-project — when callers need a
|
|
202
|
+
specific admin, they should set ``experiment.escalation_target_agent_id``
|
|
203
|
+
instead.
|
|
204
|
+
"""
|
|
205
|
+
if project_id is not None:
|
|
206
|
+
scoped = (
|
|
207
|
+
select(Agent.id)
|
|
208
|
+
.where(Agent.role == AgentRole.admin, Agent.project_id == project_id)
|
|
209
|
+
.order_by(Agent.id)
|
|
210
|
+
.limit(1)
|
|
211
|
+
)
|
|
212
|
+
row = db.execute(scoped).first()
|
|
213
|
+
if row is not None:
|
|
214
|
+
return row[0]
|
|
215
|
+
global_admin = (
|
|
216
|
+
select(Agent.id)
|
|
217
|
+
.where(Agent.role == AgentRole.admin)
|
|
218
|
+
.order_by(Agent.id)
|
|
219
|
+
.limit(1)
|
|
220
|
+
)
|
|
221
|
+
row = db.execute(global_admin).first()
|
|
222
|
+
return row[0] if row is not None else None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def escalation_label(
|
|
226
|
+
db: Session,
|
|
227
|
+
*,
|
|
228
|
+
escalation_target_id: uuid.UUID | None,
|
|
229
|
+
) -> str:
|
|
230
|
+
"""Render the escalation contact for CLI / SDK error messages.
|
|
231
|
+
|
|
232
|
+
Returns the agent name when found, or a placeholder when the agent
|
|
233
|
+
is missing (deleted / cross-project). Never raises.
|
|
234
|
+
"""
|
|
235
|
+
if escalation_target_id is None:
|
|
236
|
+
return "<no escalation contact>"
|
|
237
|
+
agent = db.get(Agent, escalation_target_id)
|
|
238
|
+
if agent is None:
|
|
239
|
+
return f"<agent {escalation_target_id} not found>"
|
|
240
|
+
return f"@{agent.name}"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
__all__ = [
|
|
244
|
+
"ACTIVE_LOOKBACK_DAYS",
|
|
245
|
+
"escalation_label",
|
|
246
|
+
"resolve_escalation_target",
|
|
247
|
+
"resolve_escalation_target_with_tier",
|
|
248
|
+
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
from map_client.plan_evidence import (
|
|
5
|
+
PlanEvidenceKeys,
|
|
6
|
+
PlanFrontmatterParseError,
|
|
7
|
+
parse_plan_evidence_keys,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
# arch experiment (0519e2a3) PR2: ``EVIDENCE_METADATA_KEYS`` and
|
|
11
|
+
# ``metadata_has_completion_evidence`` moved to ``map_sdk.evidence``.
|
|
12
|
+
# Keep them re-exported from here so existing server-side imports
|
|
13
|
+
# keep working without churning every call site in this PR. CLI
|
|
14
|
+
# switched to ``from map_sdk.evidence import ...`` already.
|
|
15
|
+
from map_sdk.evidence import ( # noqa: E402, F401
|
|
16
|
+
EVIDENCE_METADATA_KEYS,
|
|
17
|
+
metadata_has_completion_evidence,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# --- 8ac93d4e I1.b — plan evidence_keys soft validation ---------------------
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
WarningCode = Literal["MISSING_EVIDENCE_KEY"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class EvidenceWarning:
|
|
28
|
+
code: WarningCode
|
|
29
|
+
missing_key: str
|
|
30
|
+
plan_required: bool = True
|
|
31
|
+
log_provided: bool = False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class EvidenceValidationResult:
|
|
36
|
+
warnings: list[EvidenceWarning] = field(default_factory=list)
|
|
37
|
+
parse_error: str | None = None
|
|
38
|
+
plan_keys: tuple[str, ...] = ()
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def valid(self) -> bool:
|
|
42
|
+
"""Soft validation: always True. Host is expected to follow up
|
|
43
|
+
with another log if warnings indicate missing evidence."""
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate_log_evidence(
|
|
48
|
+
*,
|
|
49
|
+
plan_md: str | None,
|
|
50
|
+
metadata: dict | None,
|
|
51
|
+
) -> EvidenceValidationResult:
|
|
52
|
+
"""Compare ``metadata`` keys against plan ``evidence_keys``.
|
|
53
|
+
|
|
54
|
+
Cases:
|
|
55
|
+
* ``plan_md`` is None or empty → empty result (no plan to validate against).
|
|
56
|
+
* Plan has frontmatter but YAML parse fails → ``parse_error`` set,
|
|
57
|
+
no warnings (caller surfaces stderr warn).
|
|
58
|
+
* Plan frontmatter OK + has ``evidence_keys`` → return
|
|
59
|
+
``MISSING_EVIDENCE_KEY`` warning for each plan key absent from
|
|
60
|
+
``metadata``.
|
|
61
|
+
* Plan frontmatter OK but no ``evidence_keys`` key → empty result.
|
|
62
|
+
"""
|
|
63
|
+
if not plan_md:
|
|
64
|
+
return EvidenceValidationResult()
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
parsed: PlanEvidenceKeys = parse_plan_evidence_keys(plan_md)
|
|
68
|
+
except PlanFrontmatterParseError as exc:
|
|
69
|
+
return EvidenceValidationResult(parse_error=str(exc))
|
|
70
|
+
|
|
71
|
+
if not parsed.keys:
|
|
72
|
+
return EvidenceValidationResult(plan_keys=parsed.keys)
|
|
73
|
+
|
|
74
|
+
provided = set(metadata.keys()) if isinstance(metadata, dict) else set()
|
|
75
|
+
warnings: list[EvidenceWarning] = []
|
|
76
|
+
for key in parsed.keys:
|
|
77
|
+
if key not in provided:
|
|
78
|
+
warnings.append(
|
|
79
|
+
EvidenceWarning(
|
|
80
|
+
code="MISSING_EVIDENCE_KEY",
|
|
81
|
+
missing_key=key,
|
|
82
|
+
plan_required=True,
|
|
83
|
+
log_provided=False,
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
return EvidenceValidationResult(warnings=warnings, plan_keys=parsed.keys)
|
|
87
|
+
|
|
88
|
+
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Per-agent experiment actions and blocked_on (experiment plan v2 AC#3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from map_types.enums import PhaseOwner
|
|
9
|
+
from sqlalchemy import func, select
|
|
10
|
+
from sqlalchemy.orm import Session, joinedload
|
|
11
|
+
|
|
12
|
+
from server.domain.models import (
|
|
13
|
+
Agent,
|
|
14
|
+
AgentRole,
|
|
15
|
+
Experiment,
|
|
16
|
+
ExperimentLog,
|
|
17
|
+
ExperimentPhase,
|
|
18
|
+
Review,
|
|
19
|
+
ReviewItem,
|
|
20
|
+
ReviewItemKind,
|
|
21
|
+
ReviewItemStatus,
|
|
22
|
+
)
|
|
23
|
+
from server.domain.schemas import ExperimentDetailRead, ExperimentSummaryRead
|
|
24
|
+
from server.services.review_service import (
|
|
25
|
+
_prior_version_reviews_fully_resolved,
|
|
26
|
+
_qualifying_non_creator_reviews,
|
|
27
|
+
_review_has_item_activity,
|
|
28
|
+
compute_legacy_self_review,
|
|
29
|
+
count_open_unreasonable_for_experiment,
|
|
30
|
+
has_review_on_older_plan_version,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
_REPLY_STATES = (ReviewItemStatus.addressed, ReviewItemStatus.rebutted)
|
|
34
|
+
|
|
35
|
+
# 0db51e10 I3(5d): phase visibility whitelist. ``hidden_for_current_persona``
|
|
36
|
+
# is the per-actor fold semantics for phases excluded by the configured
|
|
37
|
+
# whitelist. Empty / ``None`` whitelist = every phase visible (backward
|
|
38
|
+
# compat). When a whitelist is configured, phases outside it return
|
|
39
|
+
# ``([], "hidden_for_current_persona")`` so the UI can fold the row
|
|
40
|
+
# without raising.
|
|
41
|
+
HIDDEN_FOR_CURRENT_PERSONA = "hidden_for_current_persona"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_phase_visible(
|
|
45
|
+
phase: ExperimentPhase,
|
|
46
|
+
*,
|
|
47
|
+
phase_whitelist: list[ExperimentPhase] | None,
|
|
48
|
+
) -> bool:
|
|
49
|
+
"""Return True iff ``phase`` is in the configured whitelist.
|
|
50
|
+
|
|
51
|
+
``None`` or empty whitelist = visible to every role (legacy
|
|
52
|
+
behaviour). When the whitelist is non-empty the predicate is
|
|
53
|
+
exact-match against ``phase.value`` so callers can pass either
|
|
54
|
+
``[ExperimentPhase.result_review]`` or
|
|
55
|
+
``["result_review"]`` interchangeably.
|
|
56
|
+
"""
|
|
57
|
+
if not phase_whitelist:
|
|
58
|
+
return True
|
|
59
|
+
allowed = {p.value if isinstance(p, ExperimentPhase) else str(p) for p in phase_whitelist}
|
|
60
|
+
return phase.value in allowed
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _reviews_for_current_plan(db: Session, experiment: Experiment) -> list[Review]:
|
|
64
|
+
return list(
|
|
65
|
+
db.scalars(
|
|
66
|
+
select(Review)
|
|
67
|
+
.where(
|
|
68
|
+
Review.experiment_id == experiment.id,
|
|
69
|
+
Review.plan_version == experiment.current_plan_version,
|
|
70
|
+
)
|
|
71
|
+
.options(joinedload(Review.items))
|
|
72
|
+
).unique()
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _has_non_creator_review(reviews: list[Review], creator_id: uuid.UUID) -> bool:
|
|
77
|
+
return len(_qualifying_non_creator_reviews(reviews, creator_id)) > 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _actor_review_for_current_plan(reviews: list[Review], actor_id: uuid.UUID) -> Review | None:
|
|
81
|
+
for review in reviews:
|
|
82
|
+
if review.reviewer_agent_id == actor_id:
|
|
83
|
+
return review
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _count_open_status_unreasonable(db: Session, experiment_id: uuid.UUID) -> int:
|
|
88
|
+
stmt = (
|
|
89
|
+
select(func.count())
|
|
90
|
+
.select_from(ReviewItem)
|
|
91
|
+
.join(Review)
|
|
92
|
+
.where(
|
|
93
|
+
Review.experiment_id == experiment_id,
|
|
94
|
+
ReviewItem.kind == ReviewItemKind.unreasonable,
|
|
95
|
+
ReviewItem.status == ReviewItemStatus.open,
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
return db.scalar(stmt) or 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _reviewer_has_pending_addressed_items(
|
|
102
|
+
db: Session, experiment_id: uuid.UUID, actor_id: uuid.UUID
|
|
103
|
+
) -> bool:
|
|
104
|
+
stmt = (
|
|
105
|
+
select(func.count())
|
|
106
|
+
.select_from(ReviewItem)
|
|
107
|
+
.join(Review)
|
|
108
|
+
.where(
|
|
109
|
+
Review.experiment_id == experiment_id,
|
|
110
|
+
Review.reviewer_agent_id == actor_id,
|
|
111
|
+
Review.archived_at.is_(None),
|
|
112
|
+
ReviewItem.kind == ReviewItemKind.unreasonable,
|
|
113
|
+
ReviewItem.status.in_(_REPLY_STATES),
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
return (db.scalar(stmt) or 0) > 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def compute_experiment_capabilities(
|
|
120
|
+
db: Session,
|
|
121
|
+
experiment: Experiment,
|
|
122
|
+
actor: Agent,
|
|
123
|
+
*,
|
|
124
|
+
phase_whitelist: list[ExperimentPhase] | None = None,
|
|
125
|
+
) -> tuple[list[str], str | None]:
|
|
126
|
+
is_creator = actor.id == experiment.creator_agent_id
|
|
127
|
+
is_admin = actor.role == AgentRole.admin
|
|
128
|
+
phase = experiment.phase
|
|
129
|
+
|
|
130
|
+
# 0db51e10 I3(5d): phase visibility whitelist. When configured and
|
|
131
|
+
# the current phase is NOT in the whitelist, fold the row as
|
|
132
|
+
# ``hidden_for_current_persona`` so the UI can collapse it without
|
|
133
|
+
# raising. Whitelist is dynamic — callers pass the resolved value
|
|
134
|
+
# from server config / per-test override, no module-level global.
|
|
135
|
+
if not _is_phase_visible(phase, phase_whitelist=phase_whitelist):
|
|
136
|
+
return [], HIDDEN_FOR_CURRENT_PERSONA
|
|
137
|
+
|
|
138
|
+
actions: list[str] = []
|
|
139
|
+
blocked_on: str | None = None
|
|
140
|
+
|
|
141
|
+
if phase == ExperimentPhase.result_review:
|
|
142
|
+
if is_creator:
|
|
143
|
+
return [], "awaiting_result_approval"
|
|
144
|
+
return ["accept_result", "reject_result"], "none"
|
|
145
|
+
|
|
146
|
+
if _reviewer_has_pending_addressed_items(db, experiment.id, actor.id):
|
|
147
|
+
return ["resolve_item"], "awaiting_addressed_item_ack"
|
|
148
|
+
|
|
149
|
+
if phase == ExperimentPhase.draft:
|
|
150
|
+
if is_creator or is_admin:
|
|
151
|
+
return ["submit_for_review"], "none"
|
|
152
|
+
return actions, blocked_on
|
|
153
|
+
|
|
154
|
+
if phase == ExperimentPhase.review:
|
|
155
|
+
reviews = _reviews_for_current_plan(db, experiment)
|
|
156
|
+
if is_creator or is_admin:
|
|
157
|
+
open_unreasonable = count_open_unreasonable_for_experiment(db, experiment.id)
|
|
158
|
+
if open_unreasonable > 0:
|
|
159
|
+
blocked_on = "open_unreasonable_item"
|
|
160
|
+
if _count_open_status_unreasonable(db, experiment.id) > 0:
|
|
161
|
+
actions.append("plan_revise")
|
|
162
|
+
elif not _has_non_creator_review(reviews, experiment.creator_agent_id):
|
|
163
|
+
if _prior_version_reviews_fully_resolved(db, experiment):
|
|
164
|
+
# Reviewer resolved every unreasonable item they raised on a
|
|
165
|
+
# prior plan version → counts as accepting the revision, so
|
|
166
|
+
# the creator may approve without a fresh current-version
|
|
167
|
+
# review (mirrors assert_approve_eligibility's carve-out).
|
|
168
|
+
blocked_on = "none"
|
|
169
|
+
actions = ["approve", "withdraw"]
|
|
170
|
+
elif has_review_on_older_plan_version(db, experiment):
|
|
171
|
+
blocked_on = "awaiting_review_for_current_plan_version"
|
|
172
|
+
else:
|
|
173
|
+
blocked_on = "awaiting_non_creator_review"
|
|
174
|
+
else:
|
|
175
|
+
blocked_on = "none"
|
|
176
|
+
actions = ["approve", "withdraw"]
|
|
177
|
+
return actions, blocked_on
|
|
178
|
+
|
|
179
|
+
if actor.id != experiment.creator_agent_id:
|
|
180
|
+
actor_review = _actor_review_for_current_plan(reviews, actor.id)
|
|
181
|
+
if actor_review is None:
|
|
182
|
+
return ["review_add"], "none"
|
|
183
|
+
if not _review_has_item_activity(actor_review):
|
|
184
|
+
return ["review_withdraw"], "none"
|
|
185
|
+
return actions, blocked_on
|
|
186
|
+
|
|
187
|
+
if is_creator or is_admin:
|
|
188
|
+
if phase == ExperimentPhase.approved:
|
|
189
|
+
return ["start"], "none"
|
|
190
|
+
if phase == ExperimentPhase.running:
|
|
191
|
+
return ["complete"], "none"
|
|
192
|
+
|
|
193
|
+
return actions, blocked_on
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def experiment_summary_for_actor(
|
|
197
|
+
db: Session,
|
|
198
|
+
experiment: Experiment,
|
|
199
|
+
actor: Agent,
|
|
200
|
+
*,
|
|
201
|
+
extra_updates: dict[str, Any] | None = None,
|
|
202
|
+
phase_whitelist: list[ExperimentPhase] | None = None,
|
|
203
|
+
) -> ExperimentSummaryRead:
|
|
204
|
+
from server.services.log_service import get_latest_log
|
|
205
|
+
from server.services.phase_owner_resolver import (
|
|
206
|
+
is_informational_only,
|
|
207
|
+
owner_for,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
actions, blocked_on = compute_experiment_capabilities(
|
|
211
|
+
db, experiment, actor, phase_whitelist=phase_whitelist
|
|
212
|
+
)
|
|
213
|
+
legacy = compute_legacy_self_review(db, experiment)
|
|
214
|
+
# log_count / latest_log_summary: skip the per-experiment SELECTs
|
|
215
|
+
# when extra_updates already provides them — looping callers (e.g.
|
|
216
|
+
# get_todos over many experiments) pre-compute via the bulk helpers
|
|
217
|
+
# in log_service to drop the N+1.
|
|
218
|
+
has_log_count = extra_updates is not None and "log_count" in extra_updates
|
|
219
|
+
has_latest_summary = (
|
|
220
|
+
extra_updates is not None and "latest_log_summary" in extra_updates
|
|
221
|
+
)
|
|
222
|
+
if has_log_count:
|
|
223
|
+
log_count: int = extra_updates["log_count"]
|
|
224
|
+
else:
|
|
225
|
+
log_count = (
|
|
226
|
+
db.scalar(
|
|
227
|
+
select(func.count())
|
|
228
|
+
.select_from(ExperimentLog)
|
|
229
|
+
.where(ExperimentLog.experiment_id == experiment.id)
|
|
230
|
+
)
|
|
231
|
+
or 0
|
|
232
|
+
)
|
|
233
|
+
if has_latest_summary:
|
|
234
|
+
latest_log_summary: str | None = extra_updates["latest_log_summary"]
|
|
235
|
+
else:
|
|
236
|
+
latest_log = get_latest_log(db, experiment.id)
|
|
237
|
+
latest_log_summary = latest_log.summary if latest_log else None
|
|
238
|
+
|
|
239
|
+
update: dict[str, Any] = {
|
|
240
|
+
"actions": actions,
|
|
241
|
+
"blocked_on": blocked_on,
|
|
242
|
+
"legacy_self_review": legacy,
|
|
243
|
+
"log_count": log_count,
|
|
244
|
+
"latest_log_summary": latest_log_summary,
|
|
245
|
+
"phase_owner": owner_for(experiment.phase),
|
|
246
|
+
"informational_only": is_informational_only(
|
|
247
|
+
experiment.phase, actions=actions, blocked_on=blocked_on
|
|
248
|
+
),
|
|
249
|
+
"hidden_for_current_persona": (
|
|
250
|
+
actions == []
|
|
251
|
+
and blocked_on == HIDDEN_FOR_CURRENT_PERSONA
|
|
252
|
+
),
|
|
253
|
+
}
|
|
254
|
+
if extra_updates:
|
|
255
|
+
update.update(extra_updates)
|
|
256
|
+
return ExperimentSummaryRead.model_validate(experiment).model_copy(update=update)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def apply_capabilities_to_detail(
|
|
260
|
+
detail: ExperimentDetailRead,
|
|
261
|
+
actions: list[str],
|
|
262
|
+
blocked_on: str | None,
|
|
263
|
+
*,
|
|
264
|
+
legacy_self_review: bool = False,
|
|
265
|
+
phase_owner: PhaseOwner | None = None,
|
|
266
|
+
informational_only: bool | None = None,
|
|
267
|
+
) -> ExperimentDetailRead:
|
|
268
|
+
update: dict[str, Any] = {
|
|
269
|
+
"actions": actions,
|
|
270
|
+
"blocked_on": blocked_on,
|
|
271
|
+
"legacy_self_review": legacy_self_review,
|
|
272
|
+
}
|
|
273
|
+
if phase_owner is not None:
|
|
274
|
+
update["phase_owner"] = phase_owner
|
|
275
|
+
if informational_only is not None:
|
|
276
|
+
update["informational_only"] = informational_only
|
|
277
|
+
return detail.model_copy(update=update)
|