fuli-context 0.7.5 → 0.7.7

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 (148) hide show
  1. package/README.md +69 -13
  2. package/README.zh-CN.md +60 -14
  3. package/dist/web/assets/{AboutPage-ByCUor-j.js → AboutPage-BcZeFcHe.js} +1 -1
  4. package/dist/web/assets/{ConnectionsPage-DkdkZ40Q.js → ConnectionsPage-xAQYl1SQ.js} +1 -1
  5. package/dist/web/assets/EmployeeRecruitDialog-CbPAmhy7.js +1 -0
  6. package/dist/web/assets/EmployeeRecruitDialog-CfFlyKSh.css +1 -0
  7. package/dist/web/assets/EmployeeWorkbenchPage-BQvTLoc8.css +1 -0
  8. package/dist/web/assets/EmployeeWorkbenchPage-DF3EL0Ui.js +1 -0
  9. package/dist/web/assets/KnowledgeEditDialog-UnnBDt0I.js +1 -0
  10. package/dist/web/assets/{KnowledgeEditDialog-DWUV_NPp.css → KnowledgeEditDialog-iv-dgUeD.css} +1 -1
  11. package/dist/web/assets/{KnowledgeInspector-CYAjAb9J.js → KnowledgeInspector-BqPJH5Rr.js} +1 -1
  12. package/dist/web/assets/{KnowledgeOrganizerPage-CV4F54R_.js → KnowledgeOrganizerPage-CyvRflXM.js} +1 -1
  13. package/dist/web/assets/KnowledgePage-59eczTqe.js +1 -0
  14. package/dist/web/assets/{KnowledgeWorkspace-z4u5mXOG.js → KnowledgeWorkspace-CfIjhOog.js} +1 -1
  15. package/dist/web/assets/{OverviewPage-BWOfeS6k.js → OverviewPage-Xyfm5xkZ.js} +1 -1
  16. package/dist/web/assets/{PersonalProfilePage-c3w4-h7a.js → PersonalProfilePage-DB9mByB3.js} +1 -1
  17. package/dist/web/assets/PersonalProjectsPage-_-YXVi5O.js +1 -0
  18. package/dist/web/assets/ProjectAgentsPage-C3vW6wmk.css +1 -0
  19. package/dist/web/assets/ProjectAgentsPage-DeeQByPX.js +11 -0
  20. package/dist/web/assets/{PublicProjectsPage-DdH0oGB3.js → PublicProjectsPage-BEZ1YPs3.js} +1 -1
  21. package/dist/web/assets/{ReviewPage-BYIpcIHG.js → ReviewPage-CQYGwFBH.js} +1 -1
  22. package/dist/web/assets/SearchableSelect-D27H0MoP.js +1 -0
  23. package/dist/web/assets/{SettingsPage-2VkbXgZi.js → SettingsPage-BlEZ03Yi.js} +1 -1
  24. package/dist/web/assets/{WritingTastePage-BOPQZuva.js → WritingTastePage-DvRt_nmP.js} +1 -1
  25. package/dist/web/assets/catalog-DFvIaHVS.js +1 -0
  26. package/dist/web/assets/{console-BlAhujsH.js → console-tOxOl8Vq.js} +1 -1
  27. package/dist/web/assets/conversation-launcher-settings-DuFSpPAo.js +1 -0
  28. package/dist/web/assets/{i18n-Ci3heuiL.js → i18n-CgBHZthI.js} +3 -3
  29. package/dist/web/assets/{identity-4_-v6cB7.js → identity-CFo_Uw1H.js} +1 -1
  30. package/dist/web/assets/index-CJc5ppXH.css +1 -0
  31. package/dist/web/assets/index-yCi3cz2l.js +2 -0
  32. package/dist/web/assets/{model-yG0YqUyZ.js → model-BtnVr7fX.js} +1 -1
  33. package/dist/web/assets/{useApi-CROJJdhE-twhdZArA.js → useApi-CROJJdhE-B6MHm_Av.js} +1 -1
  34. package/dist/web/assets/{vue-router-C-wot_Di.js → vue-router-CzHm-y-x.js} +1 -1
  35. package/dist/web/index.html +7 -6
  36. package/docs/employee-agents.md +99 -0
  37. package/docs/project-agent-memory.md +479 -0
  38. package/graph-provider/fuli_graph/knowledge_audit_models.py +171 -0
  39. package/graph-provider/fuli_graph/model_base.py +5 -0
  40. package/graph-provider/fuli_graph/model_validation.py +32 -0
  41. package/graph-provider/fuli_graph/models.py +26 -170
  42. package/graph-provider/fuli_graph/project_agent_context_models.py +29 -0
  43. package/graph-provider/fuli_graph/project_agent_coordination_models.py +22 -0
  44. package/graph-provider/fuli_graph/project_agent_executor_models.py +35 -7
  45. package/graph-provider/fuli_graph/project_agent_memory_models.py +79 -0
  46. package/graph-provider/fuli_graph/project_agent_memory_routes.py +46 -0
  47. package/graph-provider/fuli_graph/project_agent_models.py +9 -0
  48. package/graph-provider/fuli_graph/project_agent_recruitment_values.py +48 -0
  49. package/graph-provider/fuli_graph/project_agent_routes.py +50 -1
  50. package/graph-provider/fuli_graph/project_agent_task_models.py +220 -29
  51. package/graph-provider/fuli_graph/runtime.py +37 -1
  52. package/graph-provider/fuli_graph/store.py +6 -0
  53. package/graph-provider/fuli_graph/store_project_agent_context.py +65 -0
  54. package/graph-provider/fuli_graph/store_project_agent_coordination_policy.py +122 -0
  55. package/graph-provider/fuli_graph/store_project_agent_executor_learning.py +175 -11
  56. package/graph-provider/fuli_graph/store_project_agent_executor_routing.py +33 -10
  57. package/graph-provider/fuli_graph/store_project_agent_executors.py +181 -14
  58. package/graph-provider/fuli_graph/store_project_agent_memory.py +174 -0
  59. package/graph-provider/fuli_graph/store_project_agent_task_activity.py +423 -0
  60. package/graph-provider/fuli_graph/store_project_agent_task_persistence.py +152 -0
  61. package/graph-provider/fuli_graph/store_project_agent_task_reads.py +187 -21
  62. package/graph-provider/fuli_graph/store_project_agent_task_recruitment.py +756 -164
  63. package/graph-provider/fuli_graph/store_project_agent_task_staffing.py +396 -7
  64. package/graph-provider/fuli_graph/store_project_agent_tasks.py +265 -465
  65. package/graph-provider/fuli_graph/store_project_agents.py +214 -46
  66. package/graph-provider/fuli_graph/store_task_contexts.py +217 -0
  67. package/graph-provider/fuli_graph/store_transactions.py +40 -0
  68. package/graph-provider/fuli_graph/task_context_models.py +50 -0
  69. package/graph-provider/fuli_graph/task_context_routes.py +31 -0
  70. package/graph-provider/fuli_graph/workflow_observation_models.py +1 -1
  71. package/graph-provider/pyproject.toml +1 -1
  72. package/npm-shrinkwrap.json +8 -8
  73. package/package.json +5 -2
  74. package/skills/capturing-session-knowledge/SKILL.md +18 -6
  75. package/src/adaptive-runtime/executor-pool.js +46 -5
  76. package/src/adaptive-runtime/graph-services.js +10 -3
  77. package/src/adaptive-runtime/lease-client.js +66 -14
  78. package/src/adaptive-runtime/runtime-broker.js +24 -9
  79. package/src/agent-tools/employee-definitions.js +43 -0
  80. package/src/agent-tools/graph-definitions.js +48 -6
  81. package/src/agent-tools/graph-handlers.js +15 -0
  82. package/src/agent-tools/project-agent-definitions.js +35 -4
  83. package/src/agent-tools/project-agent-memory-definitions.js +36 -0
  84. package/src/agent-tools.js +6 -2
  85. package/src/agents/codex/lifecycle-hook.js +107 -0
  86. package/src/agents/codex/lifecycle-hooks.js +231 -0
  87. package/src/agents/cursor/lifecycle-hook.js +133 -0
  88. package/src/agents/cursor/lifecycle-hooks.js +50 -0
  89. package/src/agents/lifecycle-message.js +19 -0
  90. package/src/app/agent-request-context.js +23 -0
  91. package/src/cli/command-registry.js +2 -0
  92. package/src/cli/employee-command.js +17 -0
  93. package/src/cli/remote-mcp-command.js +282 -0
  94. package/src/cli/setup-command.js +5 -0
  95. package/src/cli.js +10 -0
  96. package/src/employees/catalog/jefa.json +21 -0
  97. package/src/employees/install-package.js +77 -0
  98. package/src/employees/management-policy.js +88 -0
  99. package/src/employees/manifest.js +53 -0
  100. package/src/employees/package-registry.js +107 -0
  101. package/src/employees/project-selection.js +50 -0
  102. package/src/employees/recruitment.js +199 -0
  103. package/src/employees/service.js +83 -0
  104. package/src/employees/task-entry.js +39 -0
  105. package/src/graphiti/agent-knowledge-workflows.js +135 -25
  106. package/src/graphiti/federated-application.js +78 -35
  107. package/src/graphiti/project-agent-control-plane.js +32 -3
  108. package/src/graphiti/project-agent-mapping.js +175 -4
  109. package/src/graphiti/project-agent-memory.js +115 -0
  110. package/src/graphiti/project-agent-task-entry.js +126 -0
  111. package/src/graphiti/project-agent-workflows.js +49 -14
  112. package/src/graphiti/provider-client.js +78 -4
  113. package/src/http/api-router.js +25 -5
  114. package/src/http/employee-api-router.js +64 -0
  115. package/src/http/error-mapping.js +4 -0
  116. package/src/http/graph-api-router.js +14 -0
  117. package/src/http/request-policy.js +6 -3
  118. package/src/http/static-handler.js +4 -3
  119. package/src/mcp/create-mcp-server.js +98 -37
  120. package/src/mcp/direct-call.js +38 -0
  121. package/src/mcp/instructions.js +8 -7
  122. package/src/mcp/provider-task-context-registry.js +74 -0
  123. package/src/mcp/remote-http-server.js +761 -0
  124. package/src/mcp/session-id.js +29 -3
  125. package/src/mcp/task-context-registry.js +88 -29
  126. package/src/mcp/tool-annotations.js +12 -2
  127. package/src/mcp/tool-result.js +8 -3
  128. package/src/mcp-server.js +20 -8
  129. package/src/native-runtime/runtime.js +58 -8
  130. package/src/server.js +76 -28
  131. package/src/setup/agent-installation-status.js +38 -6
  132. package/src/setup/agents.js +20 -8
  133. package/src/setup/claude-code-config.js +55 -13
  134. package/src/setup/codex-bootstrap.js +11 -10
  135. package/src/setup/codex-config.js +74 -7
  136. package/src/setup/config-backup.js +18 -2
  137. package/src/setup/cursor-config.js +22 -5
  138. package/src/setup/setup.js +30 -22
  139. package/src/storage/json-file.js +11 -3
  140. package/dist/web/assets/KnowledgeEditDialog-DSe3Gwep.js +0 -1
  141. package/dist/web/assets/KnowledgePage-vYYXipOi.js +0 -1
  142. package/dist/web/assets/PersonalProjectsPage-B7AlLEwf.js +0 -1
  143. package/dist/web/assets/ProjectAgentsPage-CgftpIPB.css +0 -1
  144. package/dist/web/assets/ProjectAgentsPage-DTHcL_ox.js +0 -11
  145. package/dist/web/assets/SearchableSelect-Dz13ClQb.js +0 -1
  146. package/dist/web/assets/conversation-launcher-settings-DTYxiLJt.js +0 -1
  147. package/dist/web/assets/index-BnH5mcZx.css +0 -1
  148. package/dist/web/assets/index-C67Q0JY2.js +0 -2
@@ -1,5 +1,7 @@
1
1
  """Agent assignment selection, history continuity, and parallel staffing."""
2
2
 
3
+ from datetime import datetime
4
+
3
5
  from fastapi import HTTPException
4
6
 
5
7
  from .project_agent_access import authorize_project_agent
@@ -50,9 +52,48 @@ class StoreProjectAgentTaskStaffing:
50
52
  else 'explicit_new_agent',
51
53
  ['user explicitly requested recruitment'],
52
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
+ ]
53
64
  normalized_work_kind = request.work_kind.casefold()
54
65
  required = {item.casefold() for item in request.required_capabilities}
55
- candidates, history = await self._rank_agent_candidates(rows, request)
66
+ eligible = [row for row in rows if required.issubset({
67
+ item.casefold() for item in row.get('capabilities', [])
68
+ })]
69
+ candidates, history = await self._rank_agent_candidates(eligible, request)
70
+ # Continuity may break a tie between qualified specialists; it must
71
+ # never override a capability requirement or a stronger work-kind fit.
72
+ continuity_pool = eligible
73
+ if candidates:
74
+ best_work_match = normalized_work_kind in {
75
+ item.casefold() for item in candidates[0].get('work_kinds', [])
76
+ }
77
+ continuity_pool = [row for row in candidates if (
78
+ normalized_work_kind in {item.casefold() for item in row.get('work_kinds', [])}
79
+ ) == best_work_match]
80
+ if continuity_pool:
81
+ least_busy = min(self._metric_int(row.get('active_task_count')) for row in continuity_pool)
82
+ continuity_pool = [row for row in continuity_pool
83
+ if self._metric_int(row.get('active_task_count')) == least_busy]
84
+ continuity, project_history = await self._rank_project_continuity(
85
+ continuity_pool,
86
+ request,
87
+ )
88
+ if continuity:
89
+ selected = continuity[0]
90
+ remaining = [row for row in candidates or eligible if row['agent_id'] != selected['agent_id']]
91
+ return [selected], [selected, *remaining], 'project_continuity', [
92
+ self._project_history_match_basis(
93
+ project_history.get(selected['agent_id']),
94
+ ),
95
+ *self._automatic_selection_basis(selected, request),
96
+ ]
56
97
  if not candidates:
57
98
  unavailable_match = any(
58
99
  not self._agent_client_allowed(row, request.source_application)
@@ -75,6 +116,21 @@ class StoreProjectAgentTaskStaffing:
75
116
  'matching Agent is unavailable to source client: '
76
117
  f'{request.source_application or "other"}',
77
118
  ]
119
+ if len(eligible) == 1:
120
+ return [eligible[0]], eligible, 'sole_active_assignment', [
121
+ 'the project has one active Agent assignment',
122
+ *self._automatic_selection_basis(eligible[0], request),
123
+ ]
124
+ if eligible and normalized_work_kind == 'project_context' and not required:
125
+ eligible.sort(key=lambda row: (
126
+ self._metric_int(row.get('active_task_count')),
127
+ -int(self._metric_int(row.get('memory_revision')) > 0),
128
+ row['assigned_at'], row['agent_id'],
129
+ ))
130
+ return eligible[:1], eligible, 'project_default', [
131
+ f'{len(eligible)} eligible project roles; working-memory continuity and stable tie-break',
132
+ *self._automatic_selection_basis(eligible[0], request),
133
+ ]
78
134
  return [], rows, 'no_match', ['no active assignment matched exactly']
79
135
  selected = candidates[0]
80
136
  if normalized_work_kind in {
@@ -93,8 +149,24 @@ class StoreProjectAgentTaskStaffing:
93
149
  )
94
150
  if history_basis:
95
151
  match_basis.append(history_basis)
152
+ match_basis.extend(item for item in self._automatic_selection_basis(selected, request)
153
+ if item not in match_basis)
96
154
  return [selected], candidates, reason, match_basis
97
155
 
156
+ def _automatic_selection_basis(self, selected, request):
157
+ work_kind = request.work_kind.casefold()
158
+ if work_kind == 'project_context':
159
+ work_basis = 'general project context: continuity only, not semantic task matching'
160
+ elif work_kind in {item.casefold() for item in selected.get('work_kinds', [])}:
161
+ work_basis = f'exact work kind: {request.work_kind}'
162
+ else:
163
+ match = ('explicit capabilities matched' if request.required_capabilities
164
+ else 'continuity only, not semantic task matching')
165
+ work_basis = f'no exact work-kind match: {request.work_kind}; {match}'
166
+ return [work_basis,
167
+ f'selected active task count: {self._metric_int(selected.get("active_task_count"))}; '
168
+ 'work-kind fit and load precede history']
169
+
98
170
  async def _rank_agent_candidates(self, rows, request):
99
171
  """Rank already source-eligible assignments with task-history continuity."""
100
172
 
@@ -106,6 +178,8 @@ class StoreProjectAgentTaskStaffing:
106
178
  capabilities = {item.casefold() for item in row.get('capabilities', [])}
107
179
  work_match = normalized_work_kind in work_kinds
108
180
  capability_match = required.issubset(capabilities)
181
+ if not capability_match:
182
+ continue
109
183
  if not work_match and not (required and capability_match):
110
184
  continue
111
185
  score = (2 if work_match else 0) + (1 if capability_match else 0)
@@ -121,13 +195,135 @@ class StoreProjectAgentTaskStaffing:
121
195
  scored.sort(
122
196
  key=lambda item: (
123
197
  -item[0],
198
+ self._metric_int(item[1].get('active_task_count')),
124
199
  *self._history_sort_key(history.get(item[1]['agent_id'])),
200
+ -int(self._metric_int(item[1].get('memory_revision')) > 0),
125
201
  item[1]['assigned_at'],
126
202
  item[1]['agent_id'],
127
203
  )
128
204
  )
129
205
  return [item[1] for item in scored], history
130
206
 
207
+ async def _rank_project_continuity(self, rows, request):
208
+ """Prefer an effective previous lead among equally qualified available roles."""
209
+
210
+ if not rows:
211
+ return [], {}
212
+ history = await self._historical_project_agent_outcomes(
213
+ request.personal_space_id,
214
+ request.personal_project_id,
215
+ [row['agent_id'] for row in rows],
216
+ )
217
+ candidates = [
218
+ row
219
+ for row in rows
220
+ if sum(
221
+ self._metric_int(
222
+ (history.get(row['agent_id']) or {}).get(metric)
223
+ )
224
+ for metric in (
225
+ 'completed_count',
226
+ 'failed_count',
227
+ 'cancelled_count',
228
+ )
229
+ ) > 0
230
+ ]
231
+ candidates.sort(
232
+ key=lambda row: (
233
+ *self._project_history_sort_key(history.get(row['agent_id'])),
234
+ row['assigned_at'],
235
+ row['agent_id'],
236
+ )
237
+ )
238
+ return candidates, history
239
+
240
+ async def _historical_project_agent_outcomes(
241
+ self,
242
+ personal_space_id,
243
+ personal_project_id,
244
+ agent_ids,
245
+ ):
246
+ """Read cross-work-kind lead history for sticky project continuity."""
247
+
248
+ if not agent_ids:
249
+ return {}
250
+ runtime = getattr(self, 'runtime', None)
251
+ driver = getattr(runtime, 'driver', None)
252
+ if driver is None:
253
+ return {}
254
+ records, _, _ = await driver.execute_query(
255
+ '''
256
+ MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
257
+ [:HAS_PROJECT_AGENT_TASK]->
258
+ (task:FuliProjectAgentTask {
259
+ personal_space_id: $personal_space_id,
260
+ personal_project_id: $personal_project_id
261
+ })-[participant:HAS_PARTICIPANT]->
262
+ (agent:FuliProjectAgent)
263
+ WHERE agent.agent_id IN $agent_ids
264
+ AND (participant.role = 'lead'
265
+ OR task.lead_agent_id = agent.agent_id)
266
+ OPTIONAL MATCH (task)-[:HAS_TASK_EVENT]->
267
+ (event:FuliProjectAgentTaskEvent)-[:EVENT_AGENT]->(agent)
268
+ RETURN agent.agent_id AS agent_id,
269
+ count(DISTINCT CASE
270
+ WHEN participant.status IN
271
+ ['completed', 'failed', 'cancelled']
272
+ OR event.status IN
273
+ ['completed', 'failed', 'cancelled']
274
+ THEN task.task_id END
275
+ ) AS participation_count,
276
+ count(DISTINCT CASE
277
+ WHEN participant.status = 'completed'
278
+ OR event.status = 'completed'
279
+ THEN task.task_id END
280
+ ) AS completed_count,
281
+ count(DISTINCT CASE
282
+ WHEN participant.status = 'failed'
283
+ OR event.status = 'failed'
284
+ THEN task.task_id END
285
+ ) AS failed_count,
286
+ count(DISTINCT CASE
287
+ WHEN participant.status = 'cancelled'
288
+ OR event.status = 'cancelled'
289
+ THEN task.task_id END
290
+ ) AS cancelled_count,
291
+ max(CASE
292
+ WHEN participant.status IN
293
+ ['completed', 'failed', 'cancelled']
294
+ OR event.status IN
295
+ ['completed', 'failed', 'cancelled']
296
+ THEN coalesce(event.created_at, task.updated_at) END
297
+ ) AS last_task_at,
298
+ max(CASE
299
+ WHEN participant.status = 'completed'
300
+ OR event.status = 'completed'
301
+ THEN coalesce(event.created_at, task.updated_at) END
302
+ ) AS last_completed_at
303
+ ''',
304
+ personal_space_id=personal_space_id,
305
+ personal_project_id=personal_project_id,
306
+ agent_ids=list(agent_ids),
307
+ routing_='r',
308
+ )
309
+ result = {}
310
+ for row in records or []:
311
+ raw = dict(row)
312
+ agent_id = raw.get('agent_id')
313
+ if not agent_id:
314
+ continue
315
+ result[agent_id] = {
316
+ 'participation_count': self._metric_int(
317
+ raw.get('participation_count')
318
+ ),
319
+ 'completed_count': self._metric_int(raw.get('completed_count')),
320
+ 'failed_count': self._metric_int(raw.get('failed_count')),
321
+ 'cancelled_count': self._metric_int(raw.get('cancelled_count')),
322
+ 'last_task_at': raw.get('last_task_at'),
323
+ 'last_completed_at': raw.get('last_completed_at'),
324
+ }
325
+ return result
326
+
131
327
  async def _historical_agent_outcomes(
132
328
  self,
133
329
  personal_space_id,
@@ -156,9 +352,14 @@ class StoreProjectAgentTaskStaffing:
156
352
  AND toLower(task.work_kind) = toLower($work_kind)
157
353
  OPTIONAL MATCH (task)-[:HAS_TASK_EVENT]->
158
354
  (event:FuliProjectAgentTaskEvent)-[:EVENT_AGENT]->(agent)
159
- WHERE event.status IN ['completed', 'failed', 'cancelled']
160
355
  RETURN agent.agent_id AS agent_id,
161
- count(DISTINCT task.task_id) AS participation_count,
356
+ count(DISTINCT CASE
357
+ WHEN participant.status IN
358
+ ['completed', 'failed', 'cancelled']
359
+ OR event.status IN
360
+ ['completed', 'failed', 'cancelled']
361
+ THEN task.task_id END
362
+ ) AS participation_count,
162
363
  count(DISTINCT CASE
163
364
  WHEN participant.status = 'completed'
164
365
  OR event.status = 'completed'
@@ -174,7 +375,13 @@ class StoreProjectAgentTaskStaffing:
174
375
  OR event.status = 'cancelled'
175
376
  THEN task.task_id END
176
377
  ) AS cancelled_count,
177
- max(event.created_at) AS last_outcome_at
378
+ max(CASE
379
+ WHEN participant.status IN
380
+ ['completed', 'failed', 'cancelled']
381
+ OR event.status IN
382
+ ['completed', 'failed', 'cancelled']
383
+ THEN coalesce(event.created_at, task.updated_at) END
384
+ ) AS last_outcome_at
178
385
  ''',
179
386
  personal_space_id=personal_space_id,
180
387
  personal_project_id=personal_project_id,
@@ -227,6 +434,56 @@ class StoreProjectAgentTaskStaffing:
227
434
  -participation,
228
435
  )
229
436
 
437
+ @classmethod
438
+ def _project_history_sort_key(cls, history):
439
+ history = history or {}
440
+ completed = cls._metric_int(history.get('completed_count'))
441
+ failed = cls._metric_int(history.get('failed_count'))
442
+ cancelled = cls._metric_int(history.get('cancelled_count'))
443
+ participation = cls._metric_int(history.get('participation_count'))
444
+ effective_time = (
445
+ history.get('last_completed_at')
446
+ if completed > 0
447
+ else history.get('last_task_at')
448
+ )
449
+ outcome_score = completed * 3 - failed * 2 - cancelled
450
+ return (
451
+ 0 if completed > 0 else 1,
452
+ -cls._history_timestamp(effective_time),
453
+ -outcome_score,
454
+ -participation,
455
+ )
456
+
457
+ @staticmethod
458
+ def _history_timestamp(value):
459
+ if value is None:
460
+ return 0.0
461
+ if hasattr(value, 'to_native'):
462
+ value = value.to_native()
463
+ if hasattr(value, 'timestamp'):
464
+ try:
465
+ return float(value.timestamp())
466
+ except (TypeError, ValueError, OSError):
467
+ return 0.0
468
+ try:
469
+ return datetime.fromisoformat(
470
+ str(value).replace('Z', '+00:00')
471
+ ).timestamp()
472
+ except (TypeError, ValueError, OSError):
473
+ return 0.0
474
+
475
+ @classmethod
476
+ def _project_history_match_basis(cls, history):
477
+ history = history or {}
478
+ participation = cls._metric_int(history.get('participation_count'))
479
+ completed = cls._metric_int(history.get('completed_count'))
480
+ if completed:
481
+ return (
482
+ 'last successful project lead continuity: '
483
+ f'{participation} prior task(s), {completed} completed'
484
+ )
485
+ return f'most recent project lead continuity: {participation} prior task(s)'
486
+
230
487
  @classmethod
231
488
  def _history_match_basis(cls, history, work_kind):
232
489
  if not history:
@@ -297,6 +554,111 @@ class StoreProjectAgentTaskStaffing:
297
554
  added.append(candidate)
298
555
  return added
299
556
 
557
+ async def _parallel_plan_participant_projection(
558
+ self,
559
+ actor,
560
+ request,
561
+ *,
562
+ lead,
563
+ participants,
564
+ routing_reason,
565
+ recruitment_slots=None,
566
+ ):
567
+ """Project every Agent automatic recruitment will add before writes.
568
+
569
+ Parallel-plan validation runs before recruitment because recruitment is
570
+ itself durable (and automatic recruitment provisions an Agent and an
571
+ assignment). Confirmation-mode recruitment does not create an Agent yet
572
+ and must therefore not count as an active participant.
573
+ """
574
+
575
+ projected = list(participants)
576
+ slots = recruitment_slots
577
+ if slots is None:
578
+ slots = await self._automatic_parallel_recruitment_slots(
579
+ actor,
580
+ request,
581
+ lead=lead,
582
+ participants=participants,
583
+ routing_reason=routing_reason,
584
+ )
585
+ for slot in slots:
586
+ participant = {
587
+ 'agent_id': slot['placeholder_agent_id'],
588
+ 'role': slot['participant_role'],
589
+ }
590
+ if slot['participant_role'] == 'lead':
591
+ projected.insert(0, participant)
592
+ else:
593
+ projected.append(participant)
594
+ return projected
595
+
596
+ async def _automatic_parallel_recruitment_slots(
597
+ self,
598
+ actor,
599
+ request,
600
+ *,
601
+ lead,
602
+ participants,
603
+ routing_reason,
604
+ ):
605
+ plan = request.parallel_plan
606
+ if not plan.enabled:
607
+ return []
608
+ if (
609
+ request.staffing_intent == 'unassigned'
610
+ or routing_reason == 'manual_agent_selection'
611
+ ):
612
+ return []
613
+
614
+ roles = []
615
+ projected_count = len(participants)
616
+ if not lead:
617
+ roles.append('lead')
618
+ projected_count += 1
619
+ while projected_count < 2:
620
+ roles.append('collaborator')
621
+ projected_count += 1
622
+ if not roles:
623
+ return []
624
+
625
+ policy = await self.get_project_agent_coordination_policy(
626
+ actor,
627
+ request.personal_space_id,
628
+ request.personal_project_id,
629
+ )
630
+ if policy.ask_before_recruitment:
631
+ return []
632
+ hr_records, _, _ = await self.runtime.driver.execute_query(
633
+ '''
634
+ MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
635
+ [:HAS_PROJECT_AGENT_IDENTITY]->
636
+ (hr:FuliProjectAgent {agent_type: 'hr', status: 'active'})
637
+ RETURN hr LIMIT 1
638
+ ''',
639
+ personal_space_id=request.personal_space_id,
640
+ routing_='r',
641
+ )
642
+ if not hr_records:
643
+ return []
644
+
645
+ collaborator_index = 0
646
+ slots = []
647
+ for role in roles:
648
+ if role == 'lead':
649
+ slot = 'lead'
650
+ else:
651
+ collaborator_index += 1
652
+ slot = f'collaborator-{collaborator_index}'
653
+ slots.append({
654
+ 'participant_role': role,
655
+ 'recruitment_slot': slot,
656
+ 'placeholder_agent_id': (
657
+ f"__recruited_{slot.replace('-', '_')}__"
658
+ ),
659
+ })
660
+ return slots
661
+
300
662
  async def _assignment_candidates(
301
663
  self,
302
664
  personal_space_id,
@@ -306,7 +668,7 @@ class StoreProjectAgentTaskStaffing:
306
668
  ):
307
669
  records, _, _ = await self.runtime.driver.execute_query(
308
670
  '''
309
- MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})-
671
+ MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
310
672
  [:CONTAINS_PROJECT]->
311
673
  (:FuliPersonalProject {project_id: $personal_project_id})-
312
674
  [:HAS_PROJECT_AGENT_ASSIGNMENT]->
@@ -314,7 +676,27 @@ class StoreProjectAgentTaskStaffing:
314
676
  [:ASSIGNED_AGENT]->(agent:FuliProjectAgent {status: 'active'})
315
677
  WHERE agent.agent_type = 'durable'
316
678
  OR ($include_temporary = true AND agent.agent_type = 'temporary')
317
- RETURN assignment, agent
679
+ OPTIONAL MATCH (agent)-[:HAS_WORKING_MEMORY]->(memory:FuliProjectAgentMemory {
680
+ personal_space_id: $personal_space_id, personal_project_id: $personal_project_id
681
+ })
682
+ WITH space, assignment, agent, coalesce(max(memory.revision), 0) AS memory_revision
683
+ OPTIONAL MATCH (space)-[:HAS_PROJECT_AGENT_TASK]->(active_task:FuliProjectAgentTask)
684
+ -[participant:HAS_PARTICIPANT]->(agent)
685
+ WHERE active_task.personal_project_id = $personal_project_id
686
+ AND active_task.status IN ['queued', 'running', 'awaiting_review']
687
+ AND participant.status IN ['queued', 'running', 'awaiting_review']
688
+ WITH space, assignment, agent, memory_revision,
689
+ count(DISTINCT active_task) AS task_count
690
+ OPTIONAL MATCH (session:FuliTaskContextSession)-[:HAS_CONTEXT]->
691
+ (context:FuliTaskContext {personal_space_id: $personal_space_id,
692
+ personal_project_id: $personal_project_id,
693
+ project_agent_id: agent.agent_id, completed: false})
694
+ WHERE session.current_token = context.token
695
+ AND context.created_at > datetime() - duration('PT2H')
696
+ WITH assignment, agent, memory_revision, task_count,
697
+ count(DISTINCT context) AS context_count
698
+ RETURN assignment, agent, memory_revision,
699
+ task_count + context_count AS active_task_count
318
700
  ORDER BY assignment.assigned_at, agent.agent_id
319
701
  ''',
320
702
  personal_space_id=personal_space_id,
@@ -343,6 +725,8 @@ class StoreProjectAgentTaskStaffing:
343
725
  ),
344
726
  'profile': profile,
345
727
  'assigned_at': str(assignment.get('assigned_at') or ''),
728
+ 'active_task_count': row.get('active_task_count') or 0,
729
+ 'memory_revision': row.get('memory_revision') or 0,
346
730
  })
347
731
  return result
348
732
 
@@ -405,5 +789,10 @@ class StoreProjectAgentTaskStaffing:
405
789
  if plan.enabled and len(participants) < 2:
406
790
  raise HTTPException(
407
791
  status_code=422,
408
- detail='parallel work requires at least two active Agents',
792
+ detail=(
793
+ 'parallel work requires at least two active Agents; '
794
+ 'automatic recruitment needs an active HR Agent, otherwise '
795
+ 'recruit another qualified collaborator or assign enough '
796
+ 'existing Agents before enabling parallel work'
797
+ ),
409
798
  )