fuli-context 0.7.4-beta.3 → 0.7.6

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 (130) hide show
  1. package/README.md +77 -6
  2. package/README.zh-CN.md +69 -6
  3. package/compose.graphiti.yml +6 -6
  4. package/dist/web/assets/AboutPage-sLV9WChi.js +1 -0
  5. package/dist/web/assets/ConnectionsPage-BWn4ywGJ.js +1 -0
  6. package/dist/web/assets/KnowledgeEditDialog-CZ5ziPrD.js +1 -0
  7. package/dist/web/assets/KnowledgeInspector-CRQWOaos.js +1 -0
  8. package/dist/web/assets/KnowledgeOrganizerPage-Dfj3vIpU.js +1 -0
  9. package/dist/web/assets/KnowledgePage-BRYC9S5Y.js +1 -0
  10. package/dist/web/assets/KnowledgeWorkspace-ChEB5Bwk.js +4 -0
  11. package/dist/web/assets/OverviewPage-Cd66M0RT.js +1 -0
  12. package/dist/web/assets/PersonalProfilePage-RCFHL0Wz.js +1 -0
  13. package/dist/web/assets/PersonalProjectsPage-DEh8XWby.js +1 -0
  14. package/dist/web/assets/ProjectAgentsPage-Crh7PrXa.css +1 -0
  15. package/dist/web/assets/ProjectAgentsPage-DPnaH-TR.js +11 -0
  16. package/dist/web/assets/PublicProjectsPage-mstPEjHe.js +1 -0
  17. package/dist/web/assets/ReviewPage-6Y1SmfZy.js +1 -0
  18. package/dist/web/assets/SearchableSelect-D3HM8YF8.js +1 -0
  19. package/dist/web/assets/SettingsPage--LdTMUm7.js +1 -0
  20. package/dist/web/assets/SettingsPage-CwZp2ka4.css +1 -0
  21. package/dist/web/assets/WritingTastePage-CIpKswvs.js +1 -0
  22. package/dist/web/assets/{console-D_2zC4lm.js → console-BT1P2Db4.js} +1 -1
  23. package/dist/web/assets/{conversation-launcher-settings-DQmMH2j7.js → conversation-launcher-settings-DrJEotdf.js} +1 -1
  24. package/dist/web/assets/i18n-js6gfc7j.js +9 -0
  25. package/dist/web/assets/{identity-DmGcY3Y6.js → identity-CfDfanpa.js} +1 -1
  26. package/dist/web/assets/index-BnH5mcZx.css +1 -0
  27. package/dist/web/assets/index-GqJf1xqg.js +2 -0
  28. package/dist/web/assets/{model-CVoOdRsL.js → model-DJkLAJsg.js} +1 -1
  29. package/dist/web/assets/{useApi-CROJJdhE-CBNg4CdH.js → useApi-CROJJdhE-BdSti7Y2.js} +1 -1
  30. package/dist/web/assets/vue-router-Kk67EaCP.js +1 -0
  31. package/dist/web/index.html +6 -6
  32. package/graph-provider/fuli_graph/app.py +8 -0
  33. package/graph-provider/fuli_graph/collaboration_context.py +69 -6
  34. package/graph-provider/fuli_graph/knowledge_search.py +84 -5
  35. package/graph-provider/fuli_graph/model_validation.py +130 -0
  36. package/graph-provider/fuli_graph/models.py +24 -5
  37. package/graph-provider/fuli_graph/project_agent_access.py +54 -0
  38. package/graph-provider/fuli_graph/project_agent_coordination_models.py +22 -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 +626 -0
  42. package/graph-provider/fuli_graph/project_agent_task_models.py +545 -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_coordination_policy.py +122 -0
  48. package/graph-provider/fuli_graph/store_project_agent_executor_learning.py +756 -0
  49. package/graph-provider/fuli_graph/store_project_agent_executor_routing.py +601 -0
  50. package/graph-provider/fuli_graph/store_project_agent_executors.py +1087 -0
  51. package/graph-provider/fuli_graph/store_project_agent_task_reads.py +458 -0
  52. package/graph-provider/fuli_graph/store_project_agent_task_recruitment.py +705 -0
  53. package/graph-provider/fuli_graph/store_project_agent_task_staffing.py +586 -0
  54. package/graph-provider/fuli_graph/store_project_agent_tasks.py +1151 -0
  55. package/graph-provider/fuli_graph/store_project_agents.py +965 -0
  56. package/graph-provider/pyproject.toml +2 -1
  57. package/npm-shrinkwrap.json +2 -2
  58. package/package.json +1 -1
  59. package/skills/capturing-session-knowledge/SKILL.md +39 -0
  60. package/src/adaptive-runtime/executor-pool.js +127 -0
  61. package/src/adaptive-runtime/graph-services.js +166 -0
  62. package/src/adaptive-runtime/lease-client.js +335 -0
  63. package/src/adaptive-runtime/runtime-broker.js +318 -0
  64. package/src/adaptive-runtime/settings.js +111 -0
  65. package/src/adaptive-runtime/state.js +74 -0
  66. package/src/agent-tools/graph-definitions.js +419 -0
  67. package/src/agent-tools/graph-handlers.js +65 -0
  68. package/src/agent-tools/project-agent-definitions.js +307 -0
  69. package/src/backup/graph-backup.js +289 -0
  70. package/src/backup/identity-reconciliation.js +208 -0
  71. package/src/backup/runtime-adapters.js +269 -0
  72. package/src/cli/command-registry.js +6 -2
  73. package/src/cli/graph-data-command.js +112 -0
  74. package/src/cli/local-runtime-command.js +16 -2
  75. package/src/cli/setup-command.js +19 -3
  76. package/src/cli/update-command.js +4 -0
  77. package/src/cli.js +6 -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 +53 -90
  81. package/src/graphiti/project-agent-control-plane.js +275 -0
  82. package/src/graphiti/project-agent-mapping.js +1024 -0
  83. package/src/graphiti/project-agent-workflows.js +576 -0
  84. package/src/graphiti/provider-client.js +282 -1
  85. package/src/http/api-router.js +63 -9
  86. package/src/http/graph-api-router.js +290 -0
  87. package/src/local-runtime/lifecycle.js +46 -10
  88. package/src/mcp/context-resources.js +15 -6
  89. package/src/mcp/create-mcp-server.js +51 -16
  90. package/src/mcp/instructions.js +7 -5
  91. package/src/mcp/runtime.js +17 -5
  92. package/src/mcp/session-id.js +38 -1
  93. package/src/mcp/tool-annotations.js +71 -0
  94. package/src/mcp-server.js +23 -6
  95. package/src/native-runtime/runtime.js +717 -0
  96. package/src/server.js +6 -2
  97. package/src/setup/agent-installation-status.js +7 -1
  98. package/src/setup/agents.js +3 -1
  99. package/src/setup/claude-code-config.js +3 -1
  100. package/src/setup/codex-bootstrap.js +11 -10
  101. package/src/setup/codex-config.js +7 -1
  102. package/src/setup/cursor-config.js +3 -1
  103. package/src/setup/graph-runtime.js +158 -13
  104. package/src/setup/neo4j-memory-profile.js +43 -0
  105. package/src/setup/options.js +38 -2
  106. package/src/setup/paths.js +14 -0
  107. package/src/setup/setup.js +37 -1
  108. package/src/system/resource-monitor.js +181 -16
  109. package/src/system/runtime-settings.js +10 -1
  110. package/src/system/system-service.js +27 -2
  111. package/dist/web/assets/AboutPage-Dpf5fRWv.js +0 -1
  112. package/dist/web/assets/ConnectionsPage-D21aUDCP.js +0 -1
  113. package/dist/web/assets/KnowledgeEditDialog-u7lpfN3j.js +0 -1
  114. package/dist/web/assets/KnowledgeInspector-k4xnHtXd.js +0 -1
  115. package/dist/web/assets/KnowledgeOrganizerPage-Bdj7Rn84.js +0 -1
  116. package/dist/web/assets/KnowledgePage-UHVAbC1W.js +0 -1
  117. package/dist/web/assets/KnowledgeWorkspace-Lq3d-MeC.js +0 -4
  118. package/dist/web/assets/OverviewPage-CycAgA1V.js +0 -1
  119. package/dist/web/assets/PersonalProfilePage-U9oIV5FT.js +0 -1
  120. package/dist/web/assets/PersonalProjectsPage-C5RpXKcA.js +0 -1
  121. package/dist/web/assets/PublicProjectsPage-DiWxm35E.js +0 -1
  122. package/dist/web/assets/ReviewPage-jxRfGSJK.js +0 -1
  123. package/dist/web/assets/SearchableSelect-B3-5IPup.js +0 -1
  124. package/dist/web/assets/SettingsPage-BqfGDOho.js +0 -1
  125. package/dist/web/assets/SettingsPage-CAfz4qiV.css +0 -1
  126. package/dist/web/assets/WritingTastePage-BJxhMqJR.js +0 -1
  127. package/dist/web/assets/i18n-CwqXDI1C.js +0 -5
  128. package/dist/web/assets/index-IUGLt4lG.js +0 -2
  129. package/dist/web/assets/index-X2j29j8i.css +0 -1
  130. package/dist/web/assets/vue-router-CWSsUUio.js +0 -1
@@ -0,0 +1,586 @@
1
+ """Agent assignment selection, history continuity, and parallel staffing."""
2
+
3
+ from datetime import datetime
4
+
5
+ from fastapi import HTTPException
6
+
7
+ from .project_agent_access import authorize_project_agent
8
+ from .project_agent_models import ProjectAgentProfile
9
+ from .project_agent_task_models import ProjectAgentParallelPlan
10
+
11
+
12
+ class StoreProjectAgentTaskStaffing:
13
+ """Keep task routing's staffing mechanics separate from task persistence."""
14
+
15
+ async def _select_agents(self, actor, space, request):
16
+ if request.staffing_intent == 'unassigned':
17
+ return [], [], 'explicit_unassigned', ['user explicitly left task unassigned']
18
+ assignment_rows = await self._assignment_candidates(
19
+ request.personal_space_id,
20
+ request.personal_project_id,
21
+ )
22
+ if request.lead_agent_id:
23
+ await authorize_project_agent(
24
+ self,
25
+ actor,
26
+ space,
27
+ request.personal_project_id,
28
+ request.lead_agent_id,
29
+ require_active=True,
30
+ )
31
+ matched = [
32
+ row for row in assignment_rows
33
+ if row['agent_id'] == request.lead_agent_id
34
+ ]
35
+ if not matched:
36
+ raise HTTPException(status_code=409, detail='lead Agent assignment is inactive')
37
+ self._require_agent_client_allowed(
38
+ matched[0],
39
+ request.source_application,
40
+ )
41
+ return matched[:1], matched, 'explicit_agent', ['explicit Agent selected']
42
+ rows = [
43
+ row for row in assignment_rows
44
+ if self._agent_client_allowed(row, request.source_application)
45
+ ]
46
+ if request.staffing_intent in {'new_durable', 'temporary'}:
47
+ return (
48
+ [],
49
+ rows,
50
+ 'explicit_temporary_agent'
51
+ if request.staffing_intent == 'temporary'
52
+ else 'explicit_new_agent',
53
+ ['user explicitly requested recruitment'],
54
+ )
55
+ policy = await self.get_project_agent_coordination_policy(
56
+ actor,
57
+ request.personal_space_id,
58
+ request.personal_project_id,
59
+ )
60
+ if not policy.auto_reuse_previous_agent:
61
+ return [], rows, 'manual_agent_selection', [
62
+ 'project policy requires an explicit @Agent selection',
63
+ ]
64
+ continuity, project_history = await self._rank_project_continuity(
65
+ rows,
66
+ request,
67
+ )
68
+ if continuity:
69
+ selected = continuity[0]
70
+ return [selected], continuity, 'project_continuity', [
71
+ self._project_history_match_basis(
72
+ project_history.get(selected['agent_id']),
73
+ ),
74
+ ]
75
+ normalized_work_kind = request.work_kind.casefold()
76
+ required = {item.casefold() for item in request.required_capabilities}
77
+ candidates, history = await self._rank_agent_candidates(rows, request)
78
+ if not candidates:
79
+ unavailable_match = any(
80
+ not self._agent_client_allowed(row, request.source_application)
81
+ and (
82
+ normalized_work_kind in {
83
+ item.casefold() for item in row.get('work_kinds', [])
84
+ }
85
+ or (
86
+ required
87
+ and required.issubset({
88
+ item.casefold()
89
+ for item in row.get('capabilities', [])
90
+ })
91
+ )
92
+ )
93
+ for row in assignment_rows
94
+ )
95
+ if unavailable_match:
96
+ return [], rows, 'agent_unavailable', [
97
+ 'matching Agent is unavailable to source client: '
98
+ f'{request.source_application or "other"}',
99
+ ]
100
+ if len(rows) == 1:
101
+ return [rows[0]], rows, 'sole_active_assignment', [
102
+ 'the project has one active Agent assignment',
103
+ ]
104
+ return [], rows, 'no_match', ['no active assignment matched exactly']
105
+ selected = candidates[0]
106
+ if normalized_work_kind in {
107
+ item.casefold() for item in selected.get('work_kinds', [])
108
+ }:
109
+ match_basis = [
110
+ f'exact work kind: {request.work_kind}',
111
+ ]
112
+ reason = 'exact_work_kind'
113
+ else:
114
+ match_basis = ['all required capabilities matched exactly']
115
+ reason = 'exact_capability'
116
+ history_basis = self._history_match_basis(
117
+ history.get(selected['agent_id']),
118
+ request.work_kind,
119
+ )
120
+ if history_basis:
121
+ match_basis.append(history_basis)
122
+ return [selected], candidates, reason, match_basis
123
+
124
+ async def _rank_agent_candidates(self, rows, request):
125
+ """Rank already source-eligible assignments with task-history continuity."""
126
+
127
+ normalized_work_kind = request.work_kind.casefold()
128
+ required = {item.casefold() for item in request.required_capabilities}
129
+ scored = []
130
+ for row in rows:
131
+ work_kinds = {item.casefold() for item in row.get('work_kinds', [])}
132
+ capabilities = {item.casefold() for item in row.get('capabilities', [])}
133
+ work_match = normalized_work_kind in work_kinds
134
+ capability_match = required.issubset(capabilities)
135
+ if not work_match and not (required and capability_match):
136
+ continue
137
+ score = (2 if work_match else 0) + (1 if capability_match else 0)
138
+ scored.append((score, row))
139
+ if not scored:
140
+ return [], {}
141
+ history = await self._historical_agent_outcomes(
142
+ request.personal_space_id,
143
+ request.personal_project_id,
144
+ request.work_kind,
145
+ [row['agent_id'] for _, row in scored],
146
+ )
147
+ scored.sort(
148
+ key=lambda item: (
149
+ -item[0],
150
+ *self._history_sort_key(history.get(item[1]['agent_id'])),
151
+ item[1]['assigned_at'],
152
+ item[1]['agent_id'],
153
+ )
154
+ )
155
+ return [item[1] for item in scored], history
156
+
157
+ async def _rank_project_continuity(self, rows, request):
158
+ """Prefer the last effective project lead before task-type matching."""
159
+
160
+ if not rows:
161
+ return [], {}
162
+ history = await self._historical_project_agent_outcomes(
163
+ request.personal_space_id,
164
+ request.personal_project_id,
165
+ [row['agent_id'] for row in rows],
166
+ )
167
+ candidates = [
168
+ row
169
+ for row in rows
170
+ if self._metric_int(
171
+ (history.get(row['agent_id']) or {}).get('participation_count')
172
+ ) > 0
173
+ ]
174
+ candidates.sort(
175
+ key=lambda row: (
176
+ *self._project_history_sort_key(history.get(row['agent_id'])),
177
+ row['assigned_at'],
178
+ row['agent_id'],
179
+ )
180
+ )
181
+ return candidates, history
182
+
183
+ async def _historical_project_agent_outcomes(
184
+ self,
185
+ personal_space_id,
186
+ personal_project_id,
187
+ agent_ids,
188
+ ):
189
+ """Read cross-work-kind lead history for sticky project continuity."""
190
+
191
+ if not agent_ids:
192
+ return {}
193
+ runtime = getattr(self, 'runtime', None)
194
+ driver = getattr(runtime, 'driver', None)
195
+ if driver is None:
196
+ return {}
197
+ records, _, _ = await driver.execute_query(
198
+ '''
199
+ MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
200
+ [:HAS_PROJECT_AGENT_TASK]->
201
+ (task:FuliProjectAgentTask {
202
+ personal_space_id: $personal_space_id,
203
+ personal_project_id: $personal_project_id
204
+ })-[participant:HAS_PARTICIPANT]->
205
+ (agent:FuliProjectAgent)
206
+ WHERE agent.agent_id IN $agent_ids
207
+ AND (participant.role = 'lead'
208
+ OR task.lead_agent_id = agent.agent_id)
209
+ OPTIONAL MATCH (task)-[:HAS_TASK_EVENT]->
210
+ (event:FuliProjectAgentTaskEvent)-[:EVENT_AGENT]->(agent)
211
+ RETURN agent.agent_id AS agent_id,
212
+ count(DISTINCT task.task_id) AS participation_count,
213
+ count(DISTINCT CASE
214
+ WHEN participant.status = 'completed'
215
+ OR event.status = 'completed'
216
+ THEN task.task_id END
217
+ ) AS completed_count,
218
+ count(DISTINCT CASE
219
+ WHEN participant.status = 'failed'
220
+ OR event.status = 'failed'
221
+ THEN task.task_id END
222
+ ) AS failed_count,
223
+ count(DISTINCT CASE
224
+ WHEN participant.status = 'cancelled'
225
+ OR event.status = 'cancelled'
226
+ THEN task.task_id END
227
+ ) AS cancelled_count,
228
+ max(task.updated_at) AS last_task_at,
229
+ max(CASE
230
+ WHEN participant.status = 'completed'
231
+ OR event.status = 'completed'
232
+ THEN coalesce(event.created_at, task.updated_at) END
233
+ ) AS last_completed_at
234
+ ''',
235
+ personal_space_id=personal_space_id,
236
+ personal_project_id=personal_project_id,
237
+ agent_ids=list(agent_ids),
238
+ routing_='r',
239
+ )
240
+ result = {}
241
+ for row in records or []:
242
+ raw = dict(row)
243
+ agent_id = raw.get('agent_id')
244
+ if not agent_id:
245
+ continue
246
+ result[agent_id] = {
247
+ 'participation_count': self._metric_int(
248
+ raw.get('participation_count')
249
+ ),
250
+ 'completed_count': self._metric_int(raw.get('completed_count')),
251
+ 'failed_count': self._metric_int(raw.get('failed_count')),
252
+ 'cancelled_count': self._metric_int(raw.get('cancelled_count')),
253
+ 'last_task_at': raw.get('last_task_at'),
254
+ 'last_completed_at': raw.get('last_completed_at'),
255
+ }
256
+ return result
257
+
258
+ async def _historical_agent_outcomes(
259
+ self,
260
+ personal_space_id,
261
+ personal_project_id,
262
+ work_kind,
263
+ agent_ids,
264
+ ):
265
+ """Read project/work-kind participation without making history mandatory."""
266
+
267
+ if not agent_ids:
268
+ return {}
269
+ runtime = getattr(self, 'runtime', None)
270
+ driver = getattr(runtime, 'driver', None)
271
+ if driver is None:
272
+ return {}
273
+ records, _, _ = await driver.execute_query(
274
+ '''
275
+ MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
276
+ [:HAS_PROJECT_AGENT_TASK]->
277
+ (task:FuliProjectAgentTask {
278
+ personal_space_id: $personal_space_id,
279
+ personal_project_id: $personal_project_id
280
+ })-[participant:HAS_PARTICIPANT]->
281
+ (agent:FuliProjectAgent)
282
+ WHERE agent.agent_id IN $agent_ids
283
+ AND toLower(task.work_kind) = toLower($work_kind)
284
+ OPTIONAL MATCH (task)-[:HAS_TASK_EVENT]->
285
+ (event:FuliProjectAgentTaskEvent)-[:EVENT_AGENT]->(agent)
286
+ WHERE event.status IN ['completed', 'failed', 'cancelled']
287
+ RETURN agent.agent_id AS agent_id,
288
+ count(DISTINCT task.task_id) AS participation_count,
289
+ count(DISTINCT CASE
290
+ WHEN participant.status = 'completed'
291
+ OR event.status = 'completed'
292
+ THEN task.task_id END
293
+ ) AS completed_count,
294
+ count(DISTINCT CASE
295
+ WHEN participant.status = 'failed'
296
+ OR event.status = 'failed'
297
+ THEN task.task_id END
298
+ ) AS failed_count,
299
+ count(DISTINCT CASE
300
+ WHEN participant.status = 'cancelled'
301
+ OR event.status = 'cancelled'
302
+ THEN task.task_id END
303
+ ) AS cancelled_count,
304
+ max(event.created_at) AS last_outcome_at
305
+ ''',
306
+ personal_space_id=personal_space_id,
307
+ personal_project_id=personal_project_id,
308
+ work_kind=work_kind,
309
+ agent_ids=list(agent_ids),
310
+ routing_='r',
311
+ )
312
+ result = {}
313
+ for row in records or []:
314
+ raw = dict(row)
315
+ agent_id = raw.get('agent_id')
316
+ if not agent_id:
317
+ continue
318
+ result[agent_id] = {
319
+ 'participation_count': self._metric_int(
320
+ raw.get('participation_count')
321
+ ),
322
+ 'completed_count': self._metric_int(raw.get('completed_count')),
323
+ 'failed_count': self._metric_int(raw.get('failed_count')),
324
+ 'cancelled_count': self._metric_int(
325
+ raw.get('cancelled_count')
326
+ ),
327
+ 'last_outcome_at': raw.get('last_outcome_at'),
328
+ }
329
+ return result
330
+
331
+ @staticmethod
332
+ def _metric_int(value):
333
+ try:
334
+ return max(0, int(value or 0))
335
+ except (TypeError, ValueError):
336
+ return 0
337
+
338
+ @classmethod
339
+ def _history_sort_key(cls, history):
340
+ history = history or {}
341
+ completed = cls._metric_int(history.get('completed_count'))
342
+ failed = cls._metric_int(history.get('failed_count'))
343
+ cancelled = cls._metric_int(history.get('cancelled_count'))
344
+ participation = cls._metric_int(history.get('participation_count'))
345
+ # A successful prior task is the strongest continuity signal. Failed
346
+ # and cancelled outcomes lower the signal, while participation remains
347
+ # a deterministic tie-breaker for otherwise equivalent outcomes.
348
+ outcome_score = completed * 3 - failed * 2 - cancelled
349
+ return (
350
+ -outcome_score,
351
+ -completed,
352
+ failed,
353
+ cancelled,
354
+ -participation,
355
+ )
356
+
357
+ @classmethod
358
+ def _project_history_sort_key(cls, history):
359
+ history = history or {}
360
+ completed = cls._metric_int(history.get('completed_count'))
361
+ failed = cls._metric_int(history.get('failed_count'))
362
+ cancelled = cls._metric_int(history.get('cancelled_count'))
363
+ participation = cls._metric_int(history.get('participation_count'))
364
+ effective_time = (
365
+ history.get('last_completed_at')
366
+ if completed > 0
367
+ else history.get('last_task_at')
368
+ )
369
+ outcome_score = completed * 3 - failed * 2 - cancelled
370
+ return (
371
+ 0 if completed > 0 else 1,
372
+ -cls._history_timestamp(effective_time),
373
+ -outcome_score,
374
+ -participation,
375
+ )
376
+
377
+ @staticmethod
378
+ def _history_timestamp(value):
379
+ if value is None:
380
+ return 0.0
381
+ if hasattr(value, 'to_native'):
382
+ value = value.to_native()
383
+ if hasattr(value, 'timestamp'):
384
+ try:
385
+ return float(value.timestamp())
386
+ except (TypeError, ValueError, OSError):
387
+ return 0.0
388
+ try:
389
+ return datetime.fromisoformat(
390
+ str(value).replace('Z', '+00:00')
391
+ ).timestamp()
392
+ except (TypeError, ValueError, OSError):
393
+ return 0.0
394
+
395
+ @classmethod
396
+ def _project_history_match_basis(cls, history):
397
+ history = history or {}
398
+ participation = cls._metric_int(history.get('participation_count'))
399
+ completed = cls._metric_int(history.get('completed_count'))
400
+ if completed:
401
+ return (
402
+ 'last successful project lead continuity: '
403
+ f'{participation} prior task(s), {completed} completed'
404
+ )
405
+ return f'most recent project lead continuity: {participation} prior task(s)'
406
+
407
+ @classmethod
408
+ def _history_match_basis(cls, history, work_kind):
409
+ if not history:
410
+ return None
411
+ participation = cls._metric_int(history.get('participation_count'))
412
+ if participation <= 0:
413
+ return None
414
+ completed = cls._metric_int(history.get('completed_count'))
415
+ failed = cls._metric_int(history.get('failed_count'))
416
+ cancelled = cls._metric_int(history.get('cancelled_count'))
417
+ return (
418
+ f'historical continuity for {work_kind}: '
419
+ f'{participation} prior task(s), '
420
+ f'{completed} completed, {failed} failed, {cancelled} cancelled'
421
+ )
422
+
423
+ async def _parallel_staffing_candidates(
424
+ self,
425
+ request,
426
+ candidates,
427
+ participants,
428
+ ):
429
+ capacity = len(request.parallel_plan.workstream_boundaries) - len(participants)
430
+ if capacity <= 0:
431
+ return []
432
+ pool = list(candidates)
433
+ known_ids = {item['agent_id'] for item in pool}
434
+ for row in await self._assignment_candidates(
435
+ request.personal_space_id,
436
+ request.personal_project_id,
437
+ ):
438
+ if row['agent_id'] not in known_ids:
439
+ pool.append(row)
440
+ known_ids.add(row['agent_id'])
441
+ eligible = [
442
+ row
443
+ for row in pool
444
+ if self._agent_client_allowed(row, request.source_application)
445
+ ]
446
+ ranked, _ = await self._rank_agent_candidates(eligible, request)
447
+ participant_ids = {item['agent_id'] for item in participants}
448
+ return [
449
+ row
450
+ for row in ranked
451
+ if row['agent_id'] not in participant_ids
452
+ ][:capacity]
453
+
454
+ @staticmethod
455
+ def _add_parallel_collaborators(plan, participants, candidates):
456
+ if not plan.enabled:
457
+ return []
458
+ limit = len(plan.workstream_boundaries)
459
+ participant_ids = {item['agent_id'] for item in participants}
460
+ added = []
461
+ for candidate in candidates:
462
+ if len(participants) >= limit:
463
+ break
464
+ agent_id = candidate['agent_id']
465
+ if agent_id in participant_ids:
466
+ continue
467
+ participant = {
468
+ 'agent_id': agent_id,
469
+ 'role': 'collaborator',
470
+ 'assignment_summary': candidate.get('responsibility'),
471
+ }
472
+ participants.append(participant)
473
+ participant_ids.add(agent_id)
474
+ added.append(candidate)
475
+ return added
476
+
477
+ async def _assignment_candidates(
478
+ self,
479
+ personal_space_id,
480
+ personal_project_id,
481
+ *,
482
+ include_temporary=False,
483
+ ):
484
+ records, _, _ = await self.runtime.driver.execute_query(
485
+ '''
486
+ MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})-
487
+ [:CONTAINS_PROJECT]->
488
+ (:FuliPersonalProject {project_id: $personal_project_id})-
489
+ [:HAS_PROJECT_AGENT_ASSIGNMENT]->
490
+ (assignment:FuliProjectAgentAssignment {status: 'active'})-
491
+ [:ASSIGNED_AGENT]->(agent:FuliProjectAgent {status: 'active'})
492
+ WHERE agent.agent_type = 'durable'
493
+ OR ($include_temporary = true AND agent.agent_type = 'temporary')
494
+ RETURN assignment, agent
495
+ ORDER BY assignment.assigned_at, agent.agent_id
496
+ ''',
497
+ personal_space_id=personal_space_id,
498
+ personal_project_id=personal_project_id,
499
+ include_temporary=include_temporary,
500
+ routing_='r',
501
+ )
502
+ result = []
503
+ for row in records:
504
+ assignment = dict(row['assignment'])
505
+ agent = dict(row['agent'])
506
+ profile = ProjectAgentProfile.model_validate_json(agent['profile_json'])
507
+ result.append({
508
+ 'agent_id': agent['agent_id'],
509
+ 'assignment_id': assignment.get('assignment_id') or assignment['id'],
510
+ 'responsibility': assignment.get('responsibility'),
511
+ 'work_kinds': list(
512
+ assignment.get('work_kinds') or profile.work_kinds
513
+ ),
514
+ 'capabilities': list(
515
+ assignment.get('capabilities') or profile.capabilities
516
+ ),
517
+ 'model_strategy_override': assignment.get('model_strategy_json'),
518
+ 'executor_policy_override': assignment.get(
519
+ 'executor_policy_json'
520
+ ),
521
+ 'profile': profile,
522
+ 'assigned_at': str(assignment.get('assigned_at') or ''),
523
+ })
524
+ return result
525
+
526
+ async def _explicit_collaborators(self, actor, space, request):
527
+ if not request.collaborator_agent_ids:
528
+ return []
529
+ rows = await self._assignment_candidates(
530
+ request.personal_space_id,
531
+ request.personal_project_id,
532
+ )
533
+ result = []
534
+ for agent_id in request.collaborator_agent_ids:
535
+ await authorize_project_agent(
536
+ self,
537
+ actor,
538
+ space,
539
+ request.personal_project_id,
540
+ agent_id,
541
+ require_active=True,
542
+ )
543
+ row = next((item for item in rows if item['agent_id'] == agent_id), None)
544
+ if not row:
545
+ raise HTTPException(
546
+ status_code=409,
547
+ detail=f'collaborator Agent assignment is inactive: {agent_id}',
548
+ )
549
+ self._require_agent_client_allowed(
550
+ row,
551
+ request.source_application,
552
+ )
553
+ result.append(row)
554
+ return result
555
+
556
+ @staticmethod
557
+ def _agent_client_allowed(candidate, source_application):
558
+ profile = candidate.get('profile') if isinstance(candidate, dict) else candidate
559
+ allowed_clients = set(profile.allowed_clients)
560
+ return (source_application or 'other') in allowed_clients
561
+
562
+ @classmethod
563
+ def _require_agent_client_allowed(cls, candidate, source_application):
564
+ if cls._agent_client_allowed(candidate, source_application):
565
+ return
566
+ agent_id = candidate.get('agent_id') if isinstance(candidate, dict) else None
567
+ raise HTTPException(
568
+ status_code=403,
569
+ detail=(
570
+ f'project Agent is not available to this source client: '
571
+ f'{agent_id or "selected Agent"}'
572
+ ),
573
+ )
574
+
575
+ @staticmethod
576
+ def _verify_parallel_plan(plan: ProjectAgentParallelPlan, participants: list[dict]):
577
+ if plan.enabled and len(participants) > len(plan.workstream_boundaries):
578
+ raise HTTPException(
579
+ status_code=422,
580
+ detail='parallel work exceeds declared workstream boundaries',
581
+ )
582
+ if plan.enabled and len(participants) < 2:
583
+ raise HTTPException(
584
+ status_code=422,
585
+ detail='parallel work requires at least two active Agents',
586
+ )