fuli-context 0.7.4-beta.2 → 0.7.5

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 (133) hide show
  1. package/README.md +93 -7
  2. package/README.zh-CN.md +84 -7
  3. package/compose.graphiti.yml +6 -6
  4. package/dist/web/assets/AboutPage-ByCUor-j.js +1 -0
  5. package/dist/web/assets/ConnectionsPage-DkdkZ40Q.js +1 -0
  6. package/dist/web/assets/KnowledgeEditDialog-DSe3Gwep.js +1 -0
  7. package/dist/web/assets/KnowledgeInspector-CYAjAb9J.js +1 -0
  8. package/dist/web/assets/KnowledgeOrganizerPage-CV4F54R_.js +1 -0
  9. package/dist/web/assets/KnowledgePage-vYYXipOi.js +1 -0
  10. package/dist/web/assets/KnowledgeWorkspace-z4u5mXOG.js +4 -0
  11. package/dist/web/assets/OverviewPage-BWOfeS6k.js +1 -0
  12. package/dist/web/assets/PersonalProfilePage-c3w4-h7a.js +1 -0
  13. package/dist/web/assets/PersonalProjectsPage-B7AlLEwf.js +1 -0
  14. package/dist/web/assets/ProjectAgentsPage-CgftpIPB.css +1 -0
  15. package/dist/web/assets/ProjectAgentsPage-DTHcL_ox.js +11 -0
  16. package/dist/web/assets/PublicProjectsPage-DdH0oGB3.js +1 -0
  17. package/dist/web/assets/ReviewPage-BYIpcIHG.js +1 -0
  18. package/dist/web/assets/SearchableSelect-Dz13ClQb.js +1 -0
  19. package/dist/web/assets/SettingsPage-2VkbXgZi.js +1 -0
  20. package/dist/web/assets/SettingsPage-CwZp2ka4.css +1 -0
  21. package/dist/web/assets/WritingTastePage-BOPQZuva.js +1 -0
  22. package/dist/web/assets/{console-DMvHxKf9.js → console-BlAhujsH.js} +1 -1
  23. package/dist/web/assets/{conversation-launcher-settings-Dnlcw7Rj.js → conversation-launcher-settings-DTYxiLJt.js} +1 -1
  24. package/dist/web/assets/i18n-Ci3heuiL.js +9 -0
  25. package/dist/web/assets/{identity-C9mLdR8L.js → identity-4_-v6cB7.js} +1 -1
  26. package/dist/web/assets/index-BnH5mcZx.css +1 -0
  27. package/dist/web/assets/index-C67Q0JY2.js +2 -0
  28. package/dist/web/assets/{model-CsYAR8O3.js → model-yG0YqUyZ.js} +1 -1
  29. package/dist/web/assets/{useApi-CROJJdhE-BLQiPVw0.js → useApi-CROJJdhE-twhdZArA.js} +1 -1
  30. package/dist/web/assets/vue-router-C-wot_Di.js +1 -0
  31. package/dist/web/index.html +6 -6
  32. package/docs/public-personal-architecture.md +73 -0
  33. package/graph-provider/fuli_graph/app.py +8 -0
  34. package/graph-provider/fuli_graph/collaboration_context.py +69 -6
  35. package/graph-provider/fuli_graph/knowledge_search.py +84 -5
  36. package/graph-provider/fuli_graph/model_validation.py +134 -0
  37. package/graph-provider/fuli_graph/models.py +24 -5
  38. package/graph-provider/fuli_graph/project_agent_access.py +54 -0
  39. package/graph-provider/fuli_graph/project_agent_executor_models.py +635 -0
  40. package/graph-provider/fuli_graph/project_agent_models.py +278 -0
  41. package/graph-provider/fuli_graph/project_agent_routes.py +597 -0
  42. package/graph-provider/fuli_graph/project_agent_task_models.py +542 -0
  43. package/graph-provider/fuli_graph/runtime.py +259 -0
  44. package/graph-provider/fuli_graph/search_projection.py +3 -0
  45. package/graph-provider/fuli_graph/store.py +6 -0
  46. package/graph-provider/fuli_graph/store_knowledge.py +62 -9
  47. package/graph-provider/fuli_graph/store_project_agent_executor_learning.py +756 -0
  48. package/graph-provider/fuli_graph/store_project_agent_executor_routing.py +601 -0
  49. package/graph-provider/fuli_graph/store_project_agent_executors.py +1087 -0
  50. package/graph-provider/fuli_graph/store_project_agent_task_reads.py +458 -0
  51. package/graph-provider/fuli_graph/store_project_agent_task_recruitment.py +699 -0
  52. package/graph-provider/fuli_graph/store_project_agent_task_staffing.py +409 -0
  53. package/graph-provider/fuli_graph/store_project_agent_tasks.py +1144 -0
  54. package/graph-provider/fuli_graph/store_project_agents.py +965 -0
  55. package/graph-provider/pyproject.toml +2 -1
  56. package/npm-shrinkwrap.json +2 -2
  57. package/package.json +2 -1
  58. package/skills/capturing-session-knowledge/SKILL.md +43 -2
  59. package/src/adaptive-runtime/executor-pool.js +127 -0
  60. package/src/adaptive-runtime/graph-services.js +166 -0
  61. package/src/adaptive-runtime/lease-client.js +335 -0
  62. package/src/adaptive-runtime/runtime-broker.js +318 -0
  63. package/src/adaptive-runtime/settings.js +111 -0
  64. package/src/adaptive-runtime/state.js +74 -0
  65. package/src/agent-tools/graph-definitions.js +396 -0
  66. package/src/agent-tools/graph-handlers.js +61 -0
  67. package/src/agent-tools/project-agent-definitions.js +307 -0
  68. package/src/backup/graph-backup.js +289 -0
  69. package/src/backup/identity-reconciliation.js +208 -0
  70. package/src/backup/runtime-adapters.js +269 -0
  71. package/src/cli/command-registry.js +7 -2
  72. package/src/cli/graph-data-command.js +112 -0
  73. package/src/cli/local-runtime-command.js +16 -2
  74. package/src/cli/setup-command.js +19 -3
  75. package/src/cli/update-command.js +4 -0
  76. package/src/cli/workspace-connection-command.js +333 -0
  77. package/src/cli.js +11 -0
  78. package/src/graphiti/collaboration-preference-projection.js +29 -1
  79. package/src/graphiti/collaboration-preference-workflow.js +121 -0
  80. package/src/graphiti/federated-application.js +74 -112
  81. package/src/graphiti/knowledge-provider-mapping.js +7 -1
  82. package/src/graphiti/project-agent-control-plane.js +263 -0
  83. package/src/graphiti/project-agent-mapping.js +1003 -0
  84. package/src/graphiti/project-agent-workflows.js +557 -0
  85. package/src/graphiti/provider-client.js +270 -1
  86. package/src/graphiti/runtime-config.js +6 -1
  87. package/src/graphiti/workspace-provider-client.js +403 -0
  88. package/src/http/api-router.js +63 -9
  89. package/src/http/graph-api-router.js +278 -0
  90. package/src/local-runtime/lifecycle.js +46 -10
  91. package/src/mcp/context-resources.js +15 -6
  92. package/src/mcp/create-mcp-server.js +51 -16
  93. package/src/mcp/instructions.js +7 -5
  94. package/src/mcp/runtime.js +17 -5
  95. package/src/mcp/session-id.js +38 -1
  96. package/src/mcp/tool-annotations.js +69 -0
  97. package/src/mcp-server.js +23 -6
  98. package/src/native-runtime/runtime.js +717 -0
  99. package/src/server.js +6 -2
  100. package/src/setup/agent-installation-status.js +7 -1
  101. package/src/setup/agents.js +3 -1
  102. package/src/setup/claude-code-config.js +3 -1
  103. package/src/setup/codex-bootstrap.js +11 -10
  104. package/src/setup/codex-config.js +7 -1
  105. package/src/setup/cursor-config.js +3 -1
  106. package/src/setup/graph-runtime.js +158 -13
  107. package/src/setup/neo4j-memory-profile.js +43 -0
  108. package/src/setup/options.js +38 -2
  109. package/src/setup/paths.js +14 -0
  110. package/src/setup/setup.js +37 -1
  111. package/src/system/resource-monitor.js +181 -16
  112. package/src/system/runtime-settings.js +10 -1
  113. package/src/system/system-service.js +27 -2
  114. package/dist/web/assets/AboutPage-CftRexSY.js +0 -1
  115. package/dist/web/assets/ConnectionsPage-D5J91x6d.js +0 -1
  116. package/dist/web/assets/KnowledgeEditDialog-wotVahvr.js +0 -1
  117. package/dist/web/assets/KnowledgeInspector-BzkW0mVs.js +0 -1
  118. package/dist/web/assets/KnowledgeOrganizerPage-BebUDiHt.js +0 -1
  119. package/dist/web/assets/KnowledgePage-OUT07aKH.js +0 -1
  120. package/dist/web/assets/KnowledgeWorkspace-1pHJ0MNZ.js +0 -4
  121. package/dist/web/assets/OverviewPage-CHE6XhdS.js +0 -1
  122. package/dist/web/assets/PersonalProfilePage-CXXiabUy.js +0 -1
  123. package/dist/web/assets/PersonalProjectsPage-d2wYGW8n.js +0 -1
  124. package/dist/web/assets/PublicProjectsPage-hxLShtnZ.js +0 -1
  125. package/dist/web/assets/ReviewPage-DUeyu7E8.js +0 -1
  126. package/dist/web/assets/SearchableSelect-C5RiB5F8.js +0 -1
  127. package/dist/web/assets/SettingsPage-BMuASSzQ.js +0 -1
  128. package/dist/web/assets/SettingsPage-CAfz4qiV.css +0 -1
  129. package/dist/web/assets/WritingTastePage-B9BCYIwQ.js +0 -1
  130. package/dist/web/assets/i18n-CWfd8Dtt.js +0 -5
  131. package/dist/web/assets/index-X2j29j8i.css +0 -1
  132. package/dist/web/assets/index-nP5p5OaY.js +0 -2
  133. package/dist/web/assets/vue-router-DN8x6iY1.js +0 -1
@@ -0,0 +1,409 @@
1
+ """Agent assignment selection, history continuity, and parallel staffing."""
2
+
3
+ from fastapi import HTTPException
4
+
5
+ from .project_agent_access import authorize_project_agent
6
+ from .project_agent_models import ProjectAgentProfile
7
+ from .project_agent_task_models import ProjectAgentParallelPlan
8
+
9
+
10
+ class StoreProjectAgentTaskStaffing:
11
+ """Keep task routing's staffing mechanics separate from task persistence."""
12
+
13
+ async def _select_agents(self, actor, space, request):
14
+ if request.staffing_intent == 'unassigned':
15
+ return [], [], 'explicit_unassigned', ['user explicitly left task unassigned']
16
+ assignment_rows = await self._assignment_candidates(
17
+ request.personal_space_id,
18
+ request.personal_project_id,
19
+ )
20
+ if request.lead_agent_id:
21
+ await authorize_project_agent(
22
+ self,
23
+ actor,
24
+ space,
25
+ request.personal_project_id,
26
+ request.lead_agent_id,
27
+ require_active=True,
28
+ )
29
+ matched = [
30
+ row for row in assignment_rows
31
+ if row['agent_id'] == request.lead_agent_id
32
+ ]
33
+ if not matched:
34
+ raise HTTPException(status_code=409, detail='lead Agent assignment is inactive')
35
+ self._require_agent_client_allowed(
36
+ matched[0],
37
+ request.source_application,
38
+ )
39
+ return matched[:1], matched, 'explicit_agent', ['explicit Agent selected']
40
+ rows = [
41
+ row for row in assignment_rows
42
+ if self._agent_client_allowed(row, request.source_application)
43
+ ]
44
+ if request.staffing_intent in {'new_durable', 'temporary'}:
45
+ return (
46
+ [],
47
+ rows,
48
+ 'explicit_temporary_agent'
49
+ if request.staffing_intent == 'temporary'
50
+ else 'explicit_new_agent',
51
+ ['user explicitly requested recruitment'],
52
+ )
53
+ normalized_work_kind = request.work_kind.casefold()
54
+ required = {item.casefold() for item in request.required_capabilities}
55
+ candidates, history = await self._rank_agent_candidates(rows, request)
56
+ if not candidates:
57
+ unavailable_match = any(
58
+ not self._agent_client_allowed(row, request.source_application)
59
+ and (
60
+ normalized_work_kind in {
61
+ item.casefold() for item in row.get('work_kinds', [])
62
+ }
63
+ or (
64
+ required
65
+ and required.issubset({
66
+ item.casefold()
67
+ for item in row.get('capabilities', [])
68
+ })
69
+ )
70
+ )
71
+ for row in assignment_rows
72
+ )
73
+ if unavailable_match:
74
+ return [], rows, 'agent_unavailable', [
75
+ 'matching Agent is unavailable to source client: '
76
+ f'{request.source_application or "other"}',
77
+ ]
78
+ return [], rows, 'no_match', ['no active assignment matched exactly']
79
+ selected = candidates[0]
80
+ if normalized_work_kind in {
81
+ item.casefold() for item in selected.get('work_kinds', [])
82
+ }:
83
+ match_basis = [
84
+ f'exact work kind: {request.work_kind}',
85
+ ]
86
+ reason = 'exact_work_kind'
87
+ else:
88
+ match_basis = ['all required capabilities matched exactly']
89
+ reason = 'exact_capability'
90
+ history_basis = self._history_match_basis(
91
+ history.get(selected['agent_id']),
92
+ request.work_kind,
93
+ )
94
+ if history_basis:
95
+ match_basis.append(history_basis)
96
+ return [selected], candidates, reason, match_basis
97
+
98
+ async def _rank_agent_candidates(self, rows, request):
99
+ """Rank already source-eligible assignments with task-history continuity."""
100
+
101
+ normalized_work_kind = request.work_kind.casefold()
102
+ required = {item.casefold() for item in request.required_capabilities}
103
+ scored = []
104
+ for row in rows:
105
+ work_kinds = {item.casefold() for item in row.get('work_kinds', [])}
106
+ capabilities = {item.casefold() for item in row.get('capabilities', [])}
107
+ work_match = normalized_work_kind in work_kinds
108
+ capability_match = required.issubset(capabilities)
109
+ if not work_match and not (required and capability_match):
110
+ continue
111
+ score = (2 if work_match else 0) + (1 if capability_match else 0)
112
+ scored.append((score, row))
113
+ if not scored:
114
+ return [], {}
115
+ history = await self._historical_agent_outcomes(
116
+ request.personal_space_id,
117
+ request.personal_project_id,
118
+ request.work_kind,
119
+ [row['agent_id'] for _, row in scored],
120
+ )
121
+ scored.sort(
122
+ key=lambda item: (
123
+ -item[0],
124
+ *self._history_sort_key(history.get(item[1]['agent_id'])),
125
+ item[1]['assigned_at'],
126
+ item[1]['agent_id'],
127
+ )
128
+ )
129
+ return [item[1] for item in scored], history
130
+
131
+ async def _historical_agent_outcomes(
132
+ self,
133
+ personal_space_id,
134
+ personal_project_id,
135
+ work_kind,
136
+ agent_ids,
137
+ ):
138
+ """Read project/work-kind participation without making history mandatory."""
139
+
140
+ if not agent_ids:
141
+ return {}
142
+ runtime = getattr(self, 'runtime', None)
143
+ driver = getattr(runtime, 'driver', None)
144
+ if driver is None:
145
+ return {}
146
+ records, _, _ = await driver.execute_query(
147
+ '''
148
+ MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
149
+ [:HAS_PROJECT_AGENT_TASK]->
150
+ (task:FuliProjectAgentTask {
151
+ personal_space_id: $personal_space_id,
152
+ personal_project_id: $personal_project_id
153
+ })-[participant:HAS_PARTICIPANT]->
154
+ (agent:FuliProjectAgent)
155
+ WHERE agent.agent_id IN $agent_ids
156
+ AND toLower(task.work_kind) = toLower($work_kind)
157
+ OPTIONAL MATCH (task)-[:HAS_TASK_EVENT]->
158
+ (event:FuliProjectAgentTaskEvent)-[:EVENT_AGENT]->(agent)
159
+ WHERE event.status IN ['completed', 'failed', 'cancelled']
160
+ RETURN agent.agent_id AS agent_id,
161
+ count(DISTINCT task.task_id) AS participation_count,
162
+ count(DISTINCT CASE
163
+ WHEN participant.status = 'completed'
164
+ OR event.status = 'completed'
165
+ THEN task.task_id END
166
+ ) AS completed_count,
167
+ count(DISTINCT CASE
168
+ WHEN participant.status = 'failed'
169
+ OR event.status = 'failed'
170
+ THEN task.task_id END
171
+ ) AS failed_count,
172
+ count(DISTINCT CASE
173
+ WHEN participant.status = 'cancelled'
174
+ OR event.status = 'cancelled'
175
+ THEN task.task_id END
176
+ ) AS cancelled_count,
177
+ max(event.created_at) AS last_outcome_at
178
+ ''',
179
+ personal_space_id=personal_space_id,
180
+ personal_project_id=personal_project_id,
181
+ work_kind=work_kind,
182
+ agent_ids=list(agent_ids),
183
+ routing_='r',
184
+ )
185
+ result = {}
186
+ for row in records or []:
187
+ raw = dict(row)
188
+ agent_id = raw.get('agent_id')
189
+ if not agent_id:
190
+ continue
191
+ result[agent_id] = {
192
+ 'participation_count': self._metric_int(
193
+ raw.get('participation_count')
194
+ ),
195
+ 'completed_count': self._metric_int(raw.get('completed_count')),
196
+ 'failed_count': self._metric_int(raw.get('failed_count')),
197
+ 'cancelled_count': self._metric_int(
198
+ raw.get('cancelled_count')
199
+ ),
200
+ 'last_outcome_at': raw.get('last_outcome_at'),
201
+ }
202
+ return result
203
+
204
+ @staticmethod
205
+ def _metric_int(value):
206
+ try:
207
+ return max(0, int(value or 0))
208
+ except (TypeError, ValueError):
209
+ return 0
210
+
211
+ @classmethod
212
+ def _history_sort_key(cls, history):
213
+ history = history or {}
214
+ completed = cls._metric_int(history.get('completed_count'))
215
+ failed = cls._metric_int(history.get('failed_count'))
216
+ cancelled = cls._metric_int(history.get('cancelled_count'))
217
+ participation = cls._metric_int(history.get('participation_count'))
218
+ # A successful prior task is the strongest continuity signal. Failed
219
+ # and cancelled outcomes lower the signal, while participation remains
220
+ # a deterministic tie-breaker for otherwise equivalent outcomes.
221
+ outcome_score = completed * 3 - failed * 2 - cancelled
222
+ return (
223
+ -outcome_score,
224
+ -completed,
225
+ failed,
226
+ cancelled,
227
+ -participation,
228
+ )
229
+
230
+ @classmethod
231
+ def _history_match_basis(cls, history, work_kind):
232
+ if not history:
233
+ return None
234
+ participation = cls._metric_int(history.get('participation_count'))
235
+ if participation <= 0:
236
+ return None
237
+ completed = cls._metric_int(history.get('completed_count'))
238
+ failed = cls._metric_int(history.get('failed_count'))
239
+ cancelled = cls._metric_int(history.get('cancelled_count'))
240
+ return (
241
+ f'historical continuity for {work_kind}: '
242
+ f'{participation} prior task(s), '
243
+ f'{completed} completed, {failed} failed, {cancelled} cancelled'
244
+ )
245
+
246
+ async def _parallel_staffing_candidates(
247
+ self,
248
+ request,
249
+ candidates,
250
+ participants,
251
+ ):
252
+ capacity = len(request.parallel_plan.workstream_boundaries) - len(participants)
253
+ if capacity <= 0:
254
+ return []
255
+ pool = list(candidates)
256
+ known_ids = {item['agent_id'] for item in pool}
257
+ for row in await self._assignment_candidates(
258
+ request.personal_space_id,
259
+ request.personal_project_id,
260
+ ):
261
+ if row['agent_id'] not in known_ids:
262
+ pool.append(row)
263
+ known_ids.add(row['agent_id'])
264
+ eligible = [
265
+ row
266
+ for row in pool
267
+ if self._agent_client_allowed(row, request.source_application)
268
+ ]
269
+ ranked, _ = await self._rank_agent_candidates(eligible, request)
270
+ participant_ids = {item['agent_id'] for item in participants}
271
+ return [
272
+ row
273
+ for row in ranked
274
+ if row['agent_id'] not in participant_ids
275
+ ][:capacity]
276
+
277
+ @staticmethod
278
+ def _add_parallel_collaborators(plan, participants, candidates):
279
+ if not plan.enabled:
280
+ return []
281
+ limit = len(plan.workstream_boundaries)
282
+ participant_ids = {item['agent_id'] for item in participants}
283
+ added = []
284
+ for candidate in candidates:
285
+ if len(participants) >= limit:
286
+ break
287
+ agent_id = candidate['agent_id']
288
+ if agent_id in participant_ids:
289
+ continue
290
+ participant = {
291
+ 'agent_id': agent_id,
292
+ 'role': 'collaborator',
293
+ 'assignment_summary': candidate.get('responsibility'),
294
+ }
295
+ participants.append(participant)
296
+ participant_ids.add(agent_id)
297
+ added.append(candidate)
298
+ return added
299
+
300
+ async def _assignment_candidates(
301
+ self,
302
+ personal_space_id,
303
+ personal_project_id,
304
+ *,
305
+ include_temporary=False,
306
+ ):
307
+ records, _, _ = await self.runtime.driver.execute_query(
308
+ '''
309
+ MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})-
310
+ [:CONTAINS_PROJECT]->
311
+ (:FuliPersonalProject {project_id: $personal_project_id})-
312
+ [:HAS_PROJECT_AGENT_ASSIGNMENT]->
313
+ (assignment:FuliProjectAgentAssignment {status: 'active'})-
314
+ [:ASSIGNED_AGENT]->(agent:FuliProjectAgent {status: 'active'})
315
+ WHERE agent.agent_type = 'durable'
316
+ OR ($include_temporary = true AND agent.agent_type = 'temporary')
317
+ RETURN assignment, agent
318
+ ORDER BY assignment.assigned_at, agent.agent_id
319
+ ''',
320
+ personal_space_id=personal_space_id,
321
+ personal_project_id=personal_project_id,
322
+ include_temporary=include_temporary,
323
+ routing_='r',
324
+ )
325
+ result = []
326
+ for row in records:
327
+ assignment = dict(row['assignment'])
328
+ agent = dict(row['agent'])
329
+ profile = ProjectAgentProfile.model_validate_json(agent['profile_json'])
330
+ result.append({
331
+ 'agent_id': agent['agent_id'],
332
+ 'assignment_id': assignment.get('assignment_id') or assignment['id'],
333
+ 'responsibility': assignment.get('responsibility'),
334
+ 'work_kinds': list(
335
+ assignment.get('work_kinds') or profile.work_kinds
336
+ ),
337
+ 'capabilities': list(
338
+ assignment.get('capabilities') or profile.capabilities
339
+ ),
340
+ 'model_strategy_override': assignment.get('model_strategy_json'),
341
+ 'executor_policy_override': assignment.get(
342
+ 'executor_policy_json'
343
+ ),
344
+ 'profile': profile,
345
+ 'assigned_at': str(assignment.get('assigned_at') or ''),
346
+ })
347
+ return result
348
+
349
+ async def _explicit_collaborators(self, actor, space, request):
350
+ if not request.collaborator_agent_ids:
351
+ return []
352
+ rows = await self._assignment_candidates(
353
+ request.personal_space_id,
354
+ request.personal_project_id,
355
+ )
356
+ result = []
357
+ for agent_id in request.collaborator_agent_ids:
358
+ await authorize_project_agent(
359
+ self,
360
+ actor,
361
+ space,
362
+ request.personal_project_id,
363
+ agent_id,
364
+ require_active=True,
365
+ )
366
+ row = next((item for item in rows if item['agent_id'] == agent_id), None)
367
+ if not row:
368
+ raise HTTPException(
369
+ status_code=409,
370
+ detail=f'collaborator Agent assignment is inactive: {agent_id}',
371
+ )
372
+ self._require_agent_client_allowed(
373
+ row,
374
+ request.source_application,
375
+ )
376
+ result.append(row)
377
+ return result
378
+
379
+ @staticmethod
380
+ def _agent_client_allowed(candidate, source_application):
381
+ profile = candidate.get('profile') if isinstance(candidate, dict) else candidate
382
+ allowed_clients = set(profile.allowed_clients)
383
+ return (source_application or 'other') in allowed_clients
384
+
385
+ @classmethod
386
+ def _require_agent_client_allowed(cls, candidate, source_application):
387
+ if cls._agent_client_allowed(candidate, source_application):
388
+ return
389
+ agent_id = candidate.get('agent_id') if isinstance(candidate, dict) else None
390
+ raise HTTPException(
391
+ status_code=403,
392
+ detail=(
393
+ f'project Agent is not available to this source client: '
394
+ f'{agent_id or "selected Agent"}'
395
+ ),
396
+ )
397
+
398
+ @staticmethod
399
+ def _verify_parallel_plan(plan: ProjectAgentParallelPlan, participants: list[dict]):
400
+ if plan.enabled and len(participants) > len(plan.workstream_boundaries):
401
+ raise HTTPException(
402
+ status_code=422,
403
+ detail='parallel work exceeds declared workstream boundaries',
404
+ )
405
+ if plan.enabled and len(participants) < 2:
406
+ raise HTTPException(
407
+ status_code=422,
408
+ detail='parallel work requires at least two active Agents',
409
+ )