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,1151 @@
1
+ import json
2
+ from collections import defaultdict
3
+ from datetime import date
4
+
5
+ from fastapi import HTTPException
6
+
7
+ from .personal_project_access import authorize_personal_project
8
+ from .project_agent_access import authorize_project_agent
9
+ from .project_agent_executor_models import (
10
+ ProjectAgentExecutorActualReport,
11
+ ProjectAgentExecutorOutcomeEvidenceCreate,
12
+ )
13
+ from .project_agent_models import (
14
+ ProjectAgentModelStrategy,
15
+ ProjectAgentProfile,
16
+ )
17
+ from .project_agent_task_models import (
18
+ ProjectAgentActivityDay,
19
+ ProjectAgentActivityResult,
20
+ ProjectAgentActivityTask,
21
+ ProjectAgentRoutingDecisionRecord,
22
+ ProjectAgentTaskActivityCreate,
23
+ ProjectAgentTaskRecord,
24
+ ProjectAgentTaskRouteResult,
25
+ ProjectAgentTaskSubmit,
26
+ )
27
+ from .provider_values import native_datetime, now_utc, stable_uuid
28
+ from .store_project_agents import SYSTEM_COORDINATOR_AGENT_ID
29
+ from .store_project_agent_task_reads import StoreProjectAgentTaskReads
30
+ from .store_project_agent_task_recruitment import StoreProjectAgentTaskRecruitment
31
+ from .store_project_agent_task_staffing import StoreProjectAgentTaskStaffing
32
+ from .store_project_agent_coordination_policy import (
33
+ StoreProjectAgentCoordinationPolicy,
34
+ )
35
+
36
+
37
+ TERMINAL_TASK_STATUSES = {'completed', 'failed', 'cancelled'}
38
+
39
+
40
+ class StoreProjectAgentTasks(
41
+ StoreProjectAgentCoordinationPolicy,
42
+ StoreProjectAgentTaskRecruitment,
43
+ StoreProjectAgentTaskReads,
44
+ # Keep the pre-existing base order ahead of the extracted implementation.
45
+ StoreProjectAgentTaskStaffing,
46
+ ):
47
+ """Durable task, routing, recruitment, and verified activity control plane.
48
+
49
+ This is deliberately not a worker or scheduler. Registered clients submit
50
+ tasks and append truthful state events to the same persistent Agent/task
51
+ history. No task is shown as running until a client records that event.
52
+ """
53
+
54
+ async def submit_project_agent_task(
55
+ self,
56
+ actor: dict,
57
+ request: ProjectAgentTaskSubmit,
58
+ ) -> ProjectAgentTaskRouteResult:
59
+ self._require_personal()
60
+ space = await self.authorize(actor, request.personal_space_id, 'maintainer')
61
+ await authorize_personal_project(
62
+ self,
63
+ actor,
64
+ space,
65
+ request.personal_project_id,
66
+ )
67
+ task_id = stable_uuid(
68
+ self.settings.provider_id,
69
+ request.personal_space_id,
70
+ 'project-agent-task',
71
+ request.idempotency_key,
72
+ )
73
+ payload_hash = self._payload_hash(request)
74
+ existing = await self._find_task_row(request.personal_space_id, task_id)
75
+ if existing:
76
+ raw = dict(existing['task'])
77
+ if raw.get('payload_hash') != payload_hash:
78
+ raise HTTPException(
79
+ status_code=409,
80
+ detail='task idempotency key was used with different input',
81
+ )
82
+ task = self._task_from_row(existing)
83
+ recruitment = await self._task_recruitment(
84
+ request.personal_space_id,
85
+ task.task_id,
86
+ )
87
+ assigned_agent = await self._route_assigned_agent(
88
+ actor,
89
+ request,
90
+ task,
91
+ )
92
+ return self._route_result(
93
+ task,
94
+ recruitment,
95
+ assigned_agent=assigned_agent,
96
+ )
97
+
98
+ coordinator = await self.ensure_system_project_coordinator(
99
+ actor,
100
+ request.personal_space_id,
101
+ )
102
+ coordinator_agent_id = request.coordinator_agent_id or coordinator.agent_id
103
+ if request.coordinator_agent_id:
104
+ await authorize_project_agent(
105
+ self,
106
+ actor,
107
+ space,
108
+ request.personal_project_id,
109
+ request.coordinator_agent_id,
110
+ require_active=True,
111
+ allow_unassigned=True,
112
+ )
113
+
114
+ complexity, complexity_basis = self._assess_complexity(request)
115
+ selected, candidates, reason, match_basis = await self._select_agents(
116
+ actor,
117
+ space,
118
+ request,
119
+ )
120
+ lead = selected[0] if selected else None
121
+ participants = []
122
+ if lead:
123
+ participants.append({
124
+ 'agent_id': lead['agent_id'],
125
+ 'role': 'lead',
126
+ 'assignment_summary': lead.get('responsibility'),
127
+ })
128
+ for collaborator in await self._explicit_collaborators(actor, space, request):
129
+ if not any(item['agent_id'] == collaborator['agent_id'] for item in participants):
130
+ participants.append({
131
+ 'agent_id': collaborator['agent_id'],
132
+ 'role': 'collaborator',
133
+ 'assignment_summary': collaborator.get('responsibility'),
134
+ })
135
+ parallel_candidates = []
136
+ auto_collaborators = []
137
+ if (
138
+ request.parallel_plan.enabled
139
+ and request.staffing_intent != 'unassigned'
140
+ ):
141
+ parallel_candidates = await self._parallel_staffing_candidates(
142
+ request,
143
+ candidates,
144
+ participants,
145
+ )
146
+ candidate_ids = {item['agent_id'] for item in candidates}
147
+ candidates.extend(
148
+ item
149
+ for item in parallel_candidates
150
+ if item['agent_id'] not in candidate_ids
151
+ )
152
+ auto_collaborators = self._add_parallel_collaborators(
153
+ request.parallel_plan,
154
+ participants,
155
+ parallel_candidates,
156
+ )
157
+ if auto_collaborators:
158
+ match_basis = list(match_basis)
159
+ match_basis.append(
160
+ 'parallel auto-staffing: added '
161
+ f'{len(auto_collaborators)} collaborator(s) from '
162
+ 'qualified durable Agent candidates'
163
+ )
164
+
165
+ model_strategy, model_source = self._effective_model_strategy(
166
+ request,
167
+ lead,
168
+ coordinator.profile.default_model_strategy,
169
+ )
170
+ recruitment = None
171
+ routing_outcome = 'assigned_existing' if lead else 'unassigned'
172
+ task_status = 'queued' if lead else 'blocked'
173
+ if request.staffing_intent == 'unassigned':
174
+ reason = 'explicit_unassigned'
175
+ routing_outcome = 'unassigned'
176
+ task_status = 'blocked'
177
+ elif reason == 'manual_agent_selection':
178
+ routing_outcome = 'unassigned'
179
+ task_status = 'blocked'
180
+ elif not lead or request.staffing_intent in {'new_durable', 'temporary'}:
181
+ recruitment, recruited = await self._open_recruitment(
182
+ actor,
183
+ space,
184
+ request,
185
+ task_id,
186
+ coordinator_agent_id,
187
+ reason,
188
+ )
189
+ if recruited:
190
+ lead = recruited
191
+ if not any(
192
+ item['agent_id'] == recruited['agent_id']
193
+ for item in participants
194
+ ):
195
+ participants.insert(0, {
196
+ 'agent_id': recruited['agent_id'],
197
+ 'role': 'lead',
198
+ 'assignment_summary': recruited.get('responsibility'),
199
+ })
200
+ routing_outcome = 'recruited'
201
+ task_status = 'queued'
202
+ reason = (
203
+ 'explicit_temporary_agent'
204
+ if request.staffing_intent == 'temporary'
205
+ else 'explicit_new_agent'
206
+ if request.staffing_intent == 'new_durable'
207
+ else 'no_match'
208
+ )
209
+ match_basis = [
210
+ 'HR recruitment recorded before Agent creation',
211
+ *(
212
+ [
213
+ 'parallel auto-staffing: added '
214
+ f'{len(auto_collaborators)} collaborator(s) from '
215
+ 'qualified durable Agent candidates'
216
+ ]
217
+ if auto_collaborators
218
+ else []
219
+ ),
220
+ ]
221
+ model_strategy, model_source = self._effective_model_strategy(
222
+ request,
223
+ recruited,
224
+ coordinator.profile.default_model_strategy,
225
+ )
226
+ elif recruitment and recruitment.status == 'awaiting_confirmation':
227
+ routing_outcome = 'recruitment_required'
228
+ task_status = 'awaiting_recruitment'
229
+ else:
230
+ routing_outcome = 'blocked'
231
+ task_status = 'blocked'
232
+ reason = 'hr_unavailable'
233
+ self._verify_parallel_plan(request.parallel_plan, participants)
234
+
235
+ executor_decision = await self._resolve_executor_if_available(
236
+ actor,
237
+ request,
238
+ lead,
239
+ model_strategy,
240
+ model_source,
241
+ task_id,
242
+ )
243
+ if lead and executor_decision and self._executor_decision_blocked(executor_decision):
244
+ routing_outcome = 'blocked'
245
+ task_status = 'blocked'
246
+
247
+ decision_id = stable_uuid(
248
+ self.settings.provider_id,
249
+ request.personal_space_id,
250
+ 'project-agent-routing-decision',
251
+ task_id,
252
+ )
253
+ now = now_utc()
254
+ decision = ProjectAgentRoutingDecisionRecord(
255
+ decision_id=decision_id,
256
+ task_id=task_id,
257
+ coordinator_agent_id=coordinator_agent_id,
258
+ complexity=complexity,
259
+ complexity_basis=complexity_basis,
260
+ selected_model_strategy=model_strategy,
261
+ model_strategy_source=model_source,
262
+ outcome=routing_outcome,
263
+ reason=reason,
264
+ match_basis=match_basis,
265
+ candidate_agent_ids=[item['agent_id'] for item in candidates],
266
+ parallel_plan=request.parallel_plan,
267
+ created_at=now,
268
+ **self._decision_executor_fields(executor_decision),
269
+ )
270
+ routing_explanation = self._routing_explanation(
271
+ reason,
272
+ match_basis,
273
+ executor_decision,
274
+ )
275
+ await self._persist_task(
276
+ request,
277
+ task_id=task_id,
278
+ payload_hash=payload_hash,
279
+ coordinator_agent_id=coordinator_agent_id,
280
+ lead_agent_id=lead['agent_id'] if lead else None,
281
+ status=task_status,
282
+ routing_outcome=routing_outcome,
283
+ routing_reason=reason,
284
+ routing_explanation=routing_explanation,
285
+ match_basis=match_basis,
286
+ complexity=complexity,
287
+ complexity_basis=complexity_basis,
288
+ model_strategy=model_strategy,
289
+ model_source=model_source,
290
+ participants=participants,
291
+ decision=decision,
292
+ recruitment=recruitment,
293
+ executor_decision=executor_decision,
294
+ created_at=now,
295
+ )
296
+ task = await self.get_project_agent_task(
297
+ actor,
298
+ request.personal_space_id,
299
+ task_id,
300
+ )
301
+ assigned_agent = await self._route_assigned_agent(
302
+ actor,
303
+ request,
304
+ task,
305
+ )
306
+ return self._route_result(
307
+ task,
308
+ recruitment,
309
+ assigned_agent=assigned_agent,
310
+ )
311
+
312
+ async def get_project_agent_task(
313
+ self,
314
+ actor: dict,
315
+ personal_space_id: str,
316
+ task_id: str,
317
+ ) -> ProjectAgentTaskRecord:
318
+ self._require_personal()
319
+ space = await self.authorize(actor, personal_space_id, 'reader')
320
+ row = await self._find_task_row(personal_space_id, task_id)
321
+ if not row:
322
+ raise HTTPException(status_code=404, detail='project Agent task not found')
323
+ task = dict(row['task'])
324
+ await authorize_personal_project(
325
+ self,
326
+ actor,
327
+ space,
328
+ task['personal_project_id'],
329
+ )
330
+ return self._task_from_row(row)
331
+
332
+ async def list_project_agent_tasks(
333
+ self,
334
+ actor: dict,
335
+ personal_space_id: str,
336
+ *,
337
+ personal_project_id: str | None = None,
338
+ agent_id: str | None = None,
339
+ status: str | None = None,
340
+ limit: int = 100,
341
+ ) -> list[ProjectAgentTaskRecord]:
342
+ self._require_personal()
343
+ space = await self.authorize(actor, personal_space_id, 'reader')
344
+ if personal_project_id:
345
+ await authorize_personal_project(
346
+ self,
347
+ actor,
348
+ space,
349
+ personal_project_id,
350
+ )
351
+ records, _, _ = await self.runtime.driver.execute_query(
352
+ self._task_read_query(
353
+ '''
354
+ WHERE ($personal_project_id IS NULL
355
+ OR task.personal_project_id = $personal_project_id)
356
+ AND ($status IS NULL OR task.status = $status)
357
+ AND ($agent_id IS NULL OR EXISTS {
358
+ MATCH (task)-[:HAS_PARTICIPANT]->
359
+ (:FuliProjectAgent {agent_id: $agent_id})
360
+ })
361
+ '''
362
+ ) + '\nORDER BY task.updated_at DESC, task.task_id LIMIT $limit',
363
+ personal_space_id=personal_space_id,
364
+ personal_project_id=personal_project_id,
365
+ agent_id=agent_id,
366
+ status=status,
367
+ limit=limit,
368
+ routing_='r',
369
+ )
370
+ return [self._task_from_row(row) for row in records]
371
+
372
+ async def record_project_agent_task_activity(
373
+ self,
374
+ actor: dict,
375
+ request: ProjectAgentTaskActivityCreate,
376
+ ) -> ProjectAgentTaskRecord:
377
+ self._require_personal()
378
+ space = await self.authorize(actor, request.personal_space_id, 'maintainer')
379
+ await authorize_personal_project(
380
+ self,
381
+ actor,
382
+ space,
383
+ request.personal_project_id,
384
+ )
385
+ row = await self._find_task_row(request.personal_space_id, request.task_id)
386
+ if not row:
387
+ raise HTTPException(status_code=404, detail='project Agent task not found')
388
+ raw_task = dict(row['task'])
389
+ if raw_task['personal_project_id'] != request.personal_project_id:
390
+ raise HTTPException(status_code=404, detail='project Agent task not found')
391
+ event_id = stable_uuid(
392
+ self.settings.provider_id,
393
+ request.personal_space_id,
394
+ 'project-agent-task-event',
395
+ request.task_id,
396
+ request.idempotency_key,
397
+ )
398
+ payload_hash = self._payload_hash(request)
399
+ existing_event = next(
400
+ (
401
+ dict(value)
402
+ for value in (row.get('event_rows') or [])
403
+ if value
404
+ and (value.get('event_id') or value.get('id')) == event_id
405
+ ),
406
+ None,
407
+ )
408
+ if existing_event:
409
+ if existing_event.get('payload_hash') != payload_hash:
410
+ raise HTTPException(
411
+ status_code=409,
412
+ detail=(
413
+ 'task event idempotency key was used with different input'
414
+ ),
415
+ )
416
+ return self._task_from_row(row)
417
+ self._validate_task_transition(
418
+ raw_task.get('status'),
419
+ request.status,
420
+ has_actual_executor=bool(request.actual_executor_id),
421
+ )
422
+ expected_revision = getattr(request, 'expected_revision', None)
423
+ if expected_revision is None:
424
+ expected_revision = raw_task.get('revision', 0)
425
+ if request.agent_id:
426
+ participant = next(
427
+ (
428
+ item for item in self._participant_maps(row)
429
+ if item['agent_id'] == request.agent_id
430
+ ),
431
+ None,
432
+ )
433
+ if not participant:
434
+ raise HTTPException(
435
+ status_code=409,
436
+ detail='Agent is not assigned to this task',
437
+ )
438
+ if participant.get('profile_json'):
439
+ self._require_agent_client_allowed(
440
+ {
441
+ 'agent_id': request.agent_id,
442
+ 'profile': ProjectAgentProfile.model_validate_json(
443
+ participant['profile_json']
444
+ ),
445
+ },
446
+ request.source_application,
447
+ )
448
+ now = now_utc()
449
+ terminal_at = now if request.status in TERMINAL_TASK_STATUSES else None
450
+ actual_executor_id = getattr(request, 'actual_executor_id', None)
451
+ matched_executor_rule_id = getattr(request, 'matched_executor_rule_id', None)
452
+ executor_selection_reason = getattr(request, 'executor_selection_reason', None)
453
+ executor_fallback_reason = getattr(request, 'executor_fallback_reason', None)
454
+ executor_blocked_reason = getattr(request, 'executor_blocked_reason', None)
455
+ worker_id = getattr(request, 'worker_id', None)
456
+ worker_label = getattr(request, 'worker_label', None)
457
+ worker_occupation_emoji = getattr(
458
+ request,
459
+ 'worker_occupation_emoji',
460
+ None,
461
+ )
462
+ worker_status = getattr(request, 'worker_status', None)
463
+ actual_recorder = None
464
+ actual_report = None
465
+ if actual_executor_id:
466
+ actual_recorder = getattr(
467
+ self,
468
+ 'record_project_agent_executor_actual',
469
+ None,
470
+ )
471
+ if not callable(actual_recorder):
472
+ raise HTTPException(
473
+ status_code=503,
474
+ detail='executor evidence control plane is unavailable',
475
+ )
476
+ strategy = (
477
+ ProjectAgentModelStrategy.model_validate_json(
478
+ raw_task['effective_model_strategy_json']
479
+ )
480
+ if raw_task.get('effective_model_strategy_json')
481
+ else ProjectAgentModelStrategy()
482
+ )
483
+ actual_report = ProjectAgentExecutorActualReport(
484
+ personal_space_id=request.personal_space_id,
485
+ personal_project_id=request.personal_project_id,
486
+ task_id=request.task_id,
487
+ run_id=event_id,
488
+ agent_id=request.agent_id,
489
+ executor_id=actual_executor_id,
490
+ provider=request.actual_model_provider,
491
+ model=request.actual_model,
492
+ model_strategy=strategy,
493
+ model_strategy_source=(
494
+ raw_task.get('model_strategy_source') or 'coordinator'
495
+ ),
496
+ matched_rule_id=matched_executor_rule_id,
497
+ fallback_reason=executor_fallback_reason,
498
+ idempotency_key=f'actual:{event_id}',
499
+ occurred_at=now,
500
+ source_application=request.source_application,
501
+ source_session_id=request.source_session_id,
502
+ )
503
+ records, _, _ = await self.runtime.driver.execute_query(
504
+ '''
505
+ MATCH (space:FuliSpace {
506
+ id: $personal_space_id, kind: 'personal'
507
+ })-[:HAS_PROJECT_AGENT_TASK]->
508
+ (task:FuliProjectAgentTask {
509
+ personal_space_id: $personal_space_id,
510
+ task_id: $task_id
511
+ })
512
+ WHERE task.revision = $expected_revision
513
+ OR EXISTS {
514
+ MATCH (task)-[:HAS_TASK_EVENT]->
515
+ (:FuliProjectAgentTaskEvent {id: $event_id})
516
+ }
517
+ OPTIONAL MATCH (space)-[:HAS_PROJECT_AGENT_IDENTITY]->
518
+ (agent:FuliProjectAgent {agent_id: $agent_id})
519
+ WHERE $agent_id IS NULL OR EXISTS {
520
+ MATCH (task)-[:HAS_PARTICIPANT]->(agent)
521
+ }
522
+ MERGE (event:FuliProjectAgentTaskEvent {id: $event_id})
523
+ ON CREATE SET event.event_id = $event_id,
524
+ event.task_id = $task_id,
525
+ event.agent_id = $agent_id,
526
+ event.status = $status,
527
+ event.actor_kind = $actor_kind,
528
+ event.summary = $summary,
529
+ event.payload_hash = $payload_hash,
530
+ event.source_application = $source_application,
531
+ event.source_session_id = $source_session_id,
532
+ event.actual_model_provider = $actual_model_provider,
533
+ event.actual_model = $actual_model,
534
+ event.actual_executor_id = $actual_executor_id,
535
+ event.matched_executor_rule_id =
536
+ $matched_executor_rule_id,
537
+ event.executor_selection_reason =
538
+ $executor_selection_reason,
539
+ event.executor_fallback_reason =
540
+ $executor_fallback_reason,
541
+ event.executor_blocked_reason =
542
+ $executor_blocked_reason,
543
+ event.worker_id = $worker_id,
544
+ event.worker_label = $worker_label,
545
+ event.worker_occupation_emoji = $worker_occupation_emoji,
546
+ event.worker_status = $worker_status,
547
+ event.activity_date = CASE
548
+ WHEN $terminal_at IS NULL THEN null
549
+ ELSE date($terminal_at) END,
550
+ event.created_at = $updated_at
551
+ WITH task, event, agent,
552
+ event.payload_hash = $payload_hash AS same_payload,
553
+ task.revision = $expected_revision AS applied_transition
554
+ FOREACH (_ IN CASE
555
+ WHEN same_payload AND applied_transition
556
+ THEN [1] ELSE [] END |
557
+ MERGE (task)-[:HAS_TASK_EVENT]->(event)
558
+ )
559
+ FOREACH (_ IN CASE
560
+ WHEN same_payload AND applied_transition AND agent IS NOT NULL
561
+ THEN [1] ELSE [] END |
562
+ MERGE (event)-[:EVENT_AGENT]->(agent)
563
+ )
564
+ FOREACH (_ IN CASE
565
+ WHEN same_payload AND applied_transition
566
+ THEN [1] ELSE [] END |
567
+ SET task.status = $status,
568
+ task.result_summary = CASE
569
+ WHEN $status = 'completed' THEN $summary
570
+ ELSE task.result_summary END,
571
+ task.failure_reason = CASE
572
+ WHEN $status = 'failed' THEN $summary
573
+ ELSE task.failure_reason END,
574
+ task.actual_executor_id = coalesce(
575
+ $actual_executor_id, task.actual_executor_id),
576
+ task.actual_model_provider = coalesce(
577
+ $actual_model_provider, task.actual_model_provider),
578
+ task.actual_model = coalesce($actual_model, task.actual_model),
579
+ task.matched_executor_rule_id = coalesce(
580
+ $matched_executor_rule_id,
581
+ task.matched_executor_rule_id),
582
+ task.executor_selection_reason = coalesce(
583
+ $executor_selection_reason,
584
+ task.executor_selection_reason),
585
+ task.executor_fallback_reason = coalesce(
586
+ $executor_fallback_reason,
587
+ task.executor_fallback_reason),
588
+ task.executor_blocked_reason = coalesce(
589
+ $executor_blocked_reason,
590
+ task.executor_blocked_reason),
591
+ task.completed_at = $terminal_at,
592
+ task.updated_at = $updated_at,
593
+ task.revision = task.revision + 1
594
+ )
595
+ FOREACH (_ IN CASE
596
+ WHEN same_payload AND applied_transition AND agent IS NOT NULL
597
+ THEN [1] ELSE [] END |
598
+ SET task.lead_agent_id = CASE
599
+ WHEN task.lead_agent_id IS NULL AND $status = 'running'
600
+ THEN agent.agent_id ELSE task.lead_agent_id END
601
+ )
602
+ WITH task, event, same_payload, applied_transition
603
+ OPTIONAL MATCH (task)-[participant:HAS_PARTICIPANT]->
604
+ (participant_agent:FuliProjectAgent)
605
+ FOREACH (_ IN CASE
606
+ WHEN same_payload
607
+ AND applied_transition
608
+ AND participant IS NOT NULL
609
+ AND NOT (coalesce(participant.status, '') IN
610
+ ['completed', 'failed', 'cancelled'])
611
+ AND (
612
+ $terminal_at IS NOT NULL
613
+ OR $agent_id IS NULL
614
+ OR participant_agent.agent_id = $agent_id
615
+ )
616
+ THEN [1] ELSE [] END |
617
+ SET participant.status = $status,
618
+ participant.updated_at = $updated_at,
619
+ participant.ended_at = $terminal_at
620
+ )
621
+ RETURN DISTINCT task, event, same_payload, applied_transition
622
+ ''',
623
+ personal_space_id=request.personal_space_id,
624
+ task_id=request.task_id,
625
+ expected_revision=expected_revision,
626
+ event_id=event_id,
627
+ agent_id=request.agent_id,
628
+ status=request.status,
629
+ actor_kind=request.actor_kind,
630
+ summary=request.summary,
631
+ payload_hash=payload_hash,
632
+ source_application=request.source_application,
633
+ source_session_id=request.source_session_id,
634
+ actual_model_provider=request.actual_model_provider,
635
+ actual_model=request.actual_model,
636
+ actual_executor_id=actual_executor_id,
637
+ matched_executor_rule_id=matched_executor_rule_id,
638
+ executor_selection_reason=executor_selection_reason,
639
+ executor_fallback_reason=executor_fallback_reason,
640
+ executor_blocked_reason=executor_blocked_reason,
641
+ worker_id=worker_id,
642
+ worker_label=worker_label,
643
+ worker_occupation_emoji=worker_occupation_emoji,
644
+ worker_status=worker_status,
645
+ terminal_at=terminal_at,
646
+ updated_at=now,
647
+ )
648
+ if not records:
649
+ raise HTTPException(
650
+ status_code=409,
651
+ detail='task revision is stale or participant is invalid',
652
+ )
653
+ if not records[0]['same_payload']:
654
+ raise HTTPException(
655
+ status_code=409,
656
+ detail='task event idempotency key was used with different input',
657
+ )
658
+ if not records[0].get('applied_transition'):
659
+ return await self.get_project_agent_task(
660
+ actor,
661
+ request.personal_space_id,
662
+ request.task_id,
663
+ )
664
+ if actual_report:
665
+ await actual_recorder(actor, actual_report)
666
+ evidence_status = (
667
+ request.status
668
+ if request.status in TERMINAL_TASK_STATUSES
669
+ else worker_status
670
+ )
671
+ if (
672
+ evidence_status in TERMINAL_TASK_STATUSES
673
+ and actual_executor_id
674
+ and request.agent_id
675
+ ):
676
+ evidence_recorder = getattr(
677
+ self,
678
+ 'record_project_agent_executor_outcome_evidence',
679
+ None,
680
+ )
681
+ if callable(evidence_recorder):
682
+ strategy = (
683
+ ProjectAgentModelStrategy.model_validate_json(
684
+ raw_task['effective_model_strategy_json']
685
+ )
686
+ if raw_task.get('effective_model_strategy_json')
687
+ else ProjectAgentModelStrategy()
688
+ )
689
+ await evidence_recorder(
690
+ actor,
691
+ ProjectAgentExecutorOutcomeEvidenceCreate(
692
+ personal_space_id=request.personal_space_id,
693
+ personal_project_id=request.personal_project_id,
694
+ work_kind=raw_task['work_kind'],
695
+ agent_id=request.agent_id,
696
+ executor_id=actual_executor_id,
697
+ task_id=request.task_id,
698
+ run_id=event_id,
699
+ model_strategy=strategy,
700
+ evidence_kind='terminal_outcome',
701
+ source='system_terminal',
702
+ terminal_outcome=evidence_status,
703
+ reference_ids=[event_id],
704
+ idempotency_key=f'terminal:{event_id}',
705
+ occurred_at=now,
706
+ ),
707
+ )
708
+ await self._archive_finished_temporary_agent(request, raw_task, now)
709
+ return await self.get_project_agent_task(
710
+ actor,
711
+ request.personal_space_id,
712
+ request.task_id,
713
+ )
714
+
715
+ async def get_project_agent_activity(
716
+ self,
717
+ actor: dict,
718
+ personal_space_id: str,
719
+ agent_id: str,
720
+ from_date: date,
721
+ to_date: date,
722
+ ) -> ProjectAgentActivityResult:
723
+ self._require_personal()
724
+ await self.authorize(actor, personal_space_id, 'reader')
725
+ if from_date > to_date:
726
+ raise HTTPException(status_code=422, detail='from date must not exceed to date')
727
+ records, _, _ = await self.runtime.driver.execute_query(
728
+ '''
729
+ MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
730
+ [:HAS_PROJECT_AGENT_IDENTITY]->
731
+ (agent:FuliProjectAgent {agent_id: $agent_id})
732
+ MATCH (space)-[:HAS_PROJECT_AGENT_TASK]->
733
+ (task:FuliProjectAgentTask)-[:HAS_TASK_EVENT]->
734
+ (event:FuliProjectAgentTaskEvent)-[:EVENT_AGENT]->(agent)
735
+ WHERE event.status IN ['completed', 'failed', 'cancelled']
736
+ AND event.activity_date >= date($from_date)
737
+ AND event.activity_date <= date($to_date)
738
+ RETURN event, task.title AS title
739
+ ORDER BY event.created_at DESC, event.event_id
740
+ ''',
741
+ personal_space_id=personal_space_id,
742
+ agent_id=agent_id,
743
+ from_date=from_date.isoformat(),
744
+ to_date=to_date.isoformat(),
745
+ routing_='r',
746
+ )
747
+ grouped = defaultdict(list)
748
+ for row in records:
749
+ event = dict(row['event'])
750
+ occurred_at = native_datetime(event['created_at'])
751
+ grouped[occurred_at.date()].append(ProjectAgentActivityTask(
752
+ task_id=event['task_id'],
753
+ title=row['title'],
754
+ status=event['status'],
755
+ summary=event['summary'],
756
+ occurred_at=occurred_at,
757
+ source_application=event.get('source_application'),
758
+ actual_model_provider=event.get('actual_model_provider'),
759
+ actual_model=event.get('actual_model'),
760
+ **self._activity_executor_fields(event),
761
+ ))
762
+ days = []
763
+ for activity_date in sorted(grouped):
764
+ tasks = grouped[activity_date]
765
+ counts = {
766
+ status: sum(item.status == status for item in tasks)
767
+ for status in TERMINAL_TASK_STATUSES
768
+ }
769
+ days.append(ProjectAgentActivityDay(
770
+ date=activity_date,
771
+ completed=counts['completed'],
772
+ failed=counts['failed'],
773
+ cancelled=counts['cancelled'],
774
+ total=len(tasks),
775
+ tasks=tasks,
776
+ ))
777
+ return ProjectAgentActivityResult(
778
+ agent_id=agent_id,
779
+ personal_space_id=personal_space_id,
780
+ from_date=from_date,
781
+ to_date=to_date,
782
+ days=days,
783
+ )
784
+
785
+ async def _route_assigned_agent(self, actor, request, task):
786
+ """Project the selected durable identity into the route response."""
787
+
788
+ agent_id = getattr(task, 'lead_agent_id', None)
789
+ getter = getattr(self, 'get_project_agent', None)
790
+ if not agent_id or not callable(getter):
791
+ return None
792
+ try:
793
+ return await getter(
794
+ actor,
795
+ request.personal_space_id,
796
+ request.personal_project_id,
797
+ agent_id,
798
+ )
799
+ except HTTPException as exc:
800
+ # A task remains readable if an Agent was archived immediately
801
+ # after persistence; the selected identity is then unavailable.
802
+ if exc.status_code == 404:
803
+ return None
804
+ raise
805
+
806
+ @staticmethod
807
+ def _validate_task_transition(current, requested, *, has_actual_executor=False):
808
+ if not current:
809
+ return
810
+ if current in TERMINAL_TASK_STATUSES:
811
+ raise HTTPException(
812
+ status_code=409,
813
+ detail=f'illegal project Agent task transition: {current} -> {requested}',
814
+ )
815
+ if current == requested:
816
+ return
817
+ allowed = {
818
+ 'awaiting_recruitment': {'blocked', 'cancelled'},
819
+ 'queued': {'running', 'paused', 'blocked', 'failed', 'cancelled'},
820
+ 'running': {
821
+ 'paused', 'blocked', 'awaiting_review',
822
+ 'completed', 'failed', 'cancelled',
823
+ },
824
+ 'paused': {'running', 'blocked', 'failed', 'cancelled'},
825
+ 'awaiting_review': {'running', 'completed', 'failed', 'cancelled'},
826
+ 'blocked': {'cancelled', 'failed'},
827
+ 'completed': set(),
828
+ 'failed': set(),
829
+ 'cancelled': set(),
830
+ }
831
+ if current == 'blocked' and has_actual_executor:
832
+ allowed['blocked'] = {'queued', 'running', 'cancelled', 'failed'}
833
+ if requested not in allowed.get(current, set()):
834
+ raise HTTPException(
835
+ status_code=409,
836
+ detail=f'illegal project Agent task transition: {current} -> {requested}',
837
+ )
838
+
839
+ @staticmethod
840
+ def _assess_complexity(request):
841
+ if request.complexity_hint:
842
+ return request.complexity_hint, ['explicit task complexity hint']
843
+ basis = []
844
+ points = 0
845
+ if len(request.objective) > 1200:
846
+ points += 2
847
+ basis.append('long objective')
848
+ elif len(request.objective) > 400:
849
+ points += 1
850
+ basis.append('multi-part objective')
851
+ if len(request.required_capabilities) >= 4:
852
+ points += 2
853
+ basis.append('four or more required capabilities')
854
+ elif len(request.required_capabilities) >= 2:
855
+ points += 1
856
+ basis.append('multiple required capabilities')
857
+ if request.parallel_plan.enabled:
858
+ points += 2
859
+ basis.append('verified parallel work requested')
860
+ if points >= 4:
861
+ return 'complex', basis or ['complex task shape']
862
+ if points >= 2:
863
+ return 'standard', basis or ['standard task shape']
864
+ return 'simple', basis or ['bounded task shape']
865
+
866
+ @staticmethod
867
+ def _effective_model_strategy(request, selected, coordinator_strategy):
868
+ if request.model_strategy_override:
869
+ return request.model_strategy_override, 'task'
870
+ if selected and selected.get('model_strategy_override'):
871
+ value = selected['model_strategy_override']
872
+ return (
873
+ value
874
+ if isinstance(value, ProjectAgentModelStrategy)
875
+ else ProjectAgentModelStrategy.model_validate_json(value),
876
+ 'assignment',
877
+ )
878
+ if selected and selected.get('profile'):
879
+ return selected['profile'].default_model_strategy, 'agent'
880
+ return coordinator_strategy, 'coordinator'
881
+
882
+ async def _resolve_executor_if_available(
883
+ self,
884
+ actor,
885
+ request,
886
+ selected,
887
+ model_strategy,
888
+ model_strategy_source,
889
+ task_id,
890
+ ):
891
+ resolver = getattr(self, 'resolve_project_agent_executor', None)
892
+ if not callable(resolver) or not selected:
893
+ return None
894
+ return await resolver(
895
+ actor,
896
+ personal_space_id=request.personal_space_id,
897
+ personal_project_id=request.personal_project_id,
898
+ task_id=task_id,
899
+ agent_id=selected['agent_id'],
900
+ work_kind=request.work_kind,
901
+ required_capabilities=request.required_capabilities,
902
+ model_strategy=model_strategy,
903
+ task_override=(
904
+ getattr(request, 'executor_policy_override', None)
905
+ or getattr(request, 'executor_override', None)
906
+ ),
907
+ assignment_id=selected.get('assignment_id'),
908
+ model_strategy_source=model_strategy_source,
909
+ )
910
+
911
+ @staticmethod
912
+ def _executor_decision_blocked(decision):
913
+ value = decision.model_dump(mode='json') if hasattr(decision, 'model_dump') else decision
914
+ return value.get('outcome') == 'blocked' or value.get('status') == 'blocked'
915
+
916
+ @staticmethod
917
+ def _decision_executor_fields(decision):
918
+ if not decision:
919
+ return {}
920
+ raw = decision.model_dump(mode='json') if hasattr(decision, 'model_dump') else dict(decision)
921
+ aliases = {
922
+ # The routing decision audit field is JSON data. Keep the
923
+ # provider-neutral snapshot instead of leaking a Pydantic model
924
+ # into the parent record (which fails validation in real routing).
925
+ 'executor_decision': raw,
926
+ 'selected_executor_id': raw.get('selected_executor_id') or raw.get('executor_id'),
927
+ 'matched_executor_rule_id': raw.get('matched_rule_id') or raw.get('rule_id'),
928
+ 'executor_selection_reason': raw.get('selection_reason') or raw.get('reason'),
929
+ 'executor_fallback_outcome': raw.get('fallback_outcome'),
930
+ 'executor_policy': raw.get('executor_policy'),
931
+ }
932
+ return {
933
+ key: value
934
+ for key, value in aliases.items()
935
+ if key in ProjectAgentRoutingDecisionRecord.model_fields
936
+ }
937
+
938
+ @staticmethod
939
+ def _activity_executor_fields(event):
940
+ return {
941
+ key: value
942
+ for key, value in {
943
+ 'actual_executor_id': event.get('actual_executor_id'),
944
+ 'matched_executor_rule_id': event.get('matched_executor_rule_id'),
945
+ 'worker_id': event.get('worker_id'),
946
+ 'worker_label': event.get('worker_label'),
947
+ 'worker_occupation_emoji': event.get(
948
+ 'worker_occupation_emoji'
949
+ ),
950
+ 'worker_status': event.get('worker_status'),
951
+ }.items()
952
+ if key in ProjectAgentActivityTask.model_fields
953
+ }
954
+
955
+ async def _persist_task(
956
+ self,
957
+ request,
958
+ *,
959
+ task_id,
960
+ payload_hash,
961
+ coordinator_agent_id,
962
+ lead_agent_id,
963
+ status,
964
+ routing_outcome,
965
+ routing_reason,
966
+ routing_explanation,
967
+ match_basis,
968
+ complexity,
969
+ complexity_basis,
970
+ model_strategy,
971
+ model_source,
972
+ participants,
973
+ decision,
974
+ recruitment,
975
+ executor_decision,
976
+ created_at,
977
+ ):
978
+ decision_raw = decision.model_dump(mode='json')
979
+ executor_raw = (
980
+ executor_decision.model_dump(mode='json')
981
+ if hasattr(executor_decision, 'model_dump')
982
+ else executor_decision
983
+ )
984
+ initial_event_id = stable_uuid(
985
+ self.settings.provider_id,
986
+ request.personal_space_id,
987
+ 'project-agent-task-event',
988
+ task_id,
989
+ 'created',
990
+ )
991
+ await self.runtime.driver.execute_query(
992
+ '''
993
+ MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})
994
+ MATCH (space)-[:CONTAINS_PROJECT]->
995
+ (project:FuliPersonalProject {project_id: $personal_project_id})
996
+ MATCH (space)-[:HAS_PROJECT_AGENT_IDENTITY]->
997
+ (coordinator:FuliProjectAgent {agent_id: $coordinator_agent_id})
998
+ CREATE (task:FuliProjectAgentTask {
999
+ id: $task_id,
1000
+ task_id: $task_id,
1001
+ personal_space_id: $personal_space_id,
1002
+ personal_project_id: $personal_project_id,
1003
+ payload_hash: $payload_hash,
1004
+ title: $title,
1005
+ objective: $objective,
1006
+ work_kind: $work_kind,
1007
+ required_capabilities: $required_capabilities,
1008
+ duration: $duration,
1009
+ staffing_intent: $staffing_intent,
1010
+ status: $status,
1011
+ revision: 0,
1012
+ routing_outcome: $routing_outcome,
1013
+ routing_reason: $routing_reason,
1014
+ routing_explanation: $routing_explanation,
1015
+ match_basis: $match_basis,
1016
+ coordinator_agent_id: $coordinator_agent_id,
1017
+ lead_agent_id: $lead_agent_id,
1018
+ complexity: $complexity,
1019
+ complexity_basis: $complexity_basis,
1020
+ effective_model_strategy_json: $model_strategy_json,
1021
+ task_model_strategy_override_json:
1022
+ $task_model_strategy_override_json,
1023
+ task_executor_policy_override_json:
1024
+ $task_executor_policy_override_json,
1025
+ model_strategy_source: $model_source,
1026
+ source_application: $source_application,
1027
+ source_session_id: $source_session_id,
1028
+ executor_decision_json: $executor_decision_json,
1029
+ executor_policy_json: $executor_policy_json,
1030
+ selected_executor_id: $selected_executor_id,
1031
+ created_at: $created_at,
1032
+ updated_at: $created_at
1033
+ })
1034
+ CREATE (decision_node:FuliProjectAgentRoutingDecision {
1035
+ id: $decision_id,
1036
+ decision_id: $decision_id,
1037
+ task_id: $task_id,
1038
+ decision_json: $decision_json,
1039
+ created_at: $created_at
1040
+ })
1041
+ CREATE (event:FuliProjectAgentTaskEvent {
1042
+ id: $initial_event_id,
1043
+ event_id: $initial_event_id,
1044
+ task_id: $task_id,
1045
+ status: $status,
1046
+ actor_kind: 'system',
1047
+ summary: $routing_explanation,
1048
+ source_application: $source_application,
1049
+ source_session_id: $source_session_id,
1050
+ created_at: $created_at
1051
+ })
1052
+ MERGE (space)-[:HAS_PROJECT_AGENT_TASK]->(task)
1053
+ MERGE (project)-[:HAS_PROJECT_AGENT_TASK]->(task)
1054
+ MERGE (task)-[:COORDINATED_BY]->(coordinator)
1055
+ MERGE (task)-[:ROUTED_BY]->(decision_node)
1056
+ MERGE (task)-[:HAS_TASK_EVENT]->(event)
1057
+ ''',
1058
+ task_id=task_id,
1059
+ personal_space_id=request.personal_space_id,
1060
+ personal_project_id=request.personal_project_id,
1061
+ payload_hash=payload_hash,
1062
+ title=request.title,
1063
+ objective=request.objective,
1064
+ work_kind=request.work_kind,
1065
+ required_capabilities=request.required_capabilities,
1066
+ duration=request.duration,
1067
+ staffing_intent=request.staffing_intent,
1068
+ status=status,
1069
+ routing_outcome=routing_outcome,
1070
+ routing_reason=routing_reason,
1071
+ routing_explanation=routing_explanation,
1072
+ match_basis=match_basis,
1073
+ coordinator_agent_id=coordinator_agent_id,
1074
+ lead_agent_id=lead_agent_id,
1075
+ complexity=complexity,
1076
+ complexity_basis=complexity_basis,
1077
+ model_strategy_json=model_strategy.model_dump_json() if model_strategy else None,
1078
+ task_model_strategy_override_json=(
1079
+ request.model_strategy_override.model_dump_json()
1080
+ if request.model_strategy_override else None
1081
+ ),
1082
+ task_executor_policy_override_json=(
1083
+ (
1084
+ request.executor_policy_override
1085
+ or request.executor_override
1086
+ ).model_dump_json()
1087
+ if request.executor_policy_override or request.executor_override
1088
+ else None
1089
+ ),
1090
+ model_source=model_source,
1091
+ source_application=request.source_application,
1092
+ source_session_id=request.source_session_id,
1093
+ executor_decision_json=(
1094
+ json.dumps(executor_raw, sort_keys=True) if executor_raw else None
1095
+ ),
1096
+ executor_policy_json=(
1097
+ json.dumps((executor_raw or {}).get('executor_policy'), sort_keys=True)
1098
+ if (executor_raw or {}).get('executor_policy') else None
1099
+ ),
1100
+ selected_executor_id=(
1101
+ (executor_raw or {}).get('selected_executor_id')
1102
+ or (executor_raw or {}).get('executor_id')
1103
+ ),
1104
+ created_at=created_at,
1105
+ decision_id=decision.decision_id,
1106
+ decision_json=json.dumps(decision_raw, sort_keys=True),
1107
+ initial_event_id=initial_event_id,
1108
+ )
1109
+ for participant in participants:
1110
+ await self.runtime.driver.execute_query(
1111
+ '''
1112
+ MATCH (space:FuliSpace {
1113
+ id: $personal_space_id, kind: 'personal'
1114
+ })-[:HAS_PROJECT_AGENT_TASK]->
1115
+ (task:FuliProjectAgentTask {task_id: $task_id})
1116
+ MATCH (space)-[:HAS_PROJECT_AGENT_IDENTITY]->
1117
+ (agent:FuliProjectAgent {agent_id: $agent_id})
1118
+ MERGE (task)-[participant:HAS_PARTICIPANT]->(agent)
1119
+ SET participant.role = $role,
1120
+ participant.status = $status,
1121
+ participant.assignment_summary = $assignment_summary,
1122
+ participant.joined_at = $created_at,
1123
+ participant.updated_at = $created_at
1124
+ ''',
1125
+ personal_space_id=request.personal_space_id,
1126
+ task_id=task_id,
1127
+ agent_id=participant['agent_id'],
1128
+ role=participant['role'],
1129
+ status=status,
1130
+ assignment_summary=participant.get('assignment_summary'),
1131
+ created_at=created_at,
1132
+ )
1133
+ if recruitment:
1134
+ await self.runtime.driver.execute_query(
1135
+ '''
1136
+ MATCH (space:FuliSpace {
1137
+ id: $personal_space_id, kind: 'personal'
1138
+ })-[:HAS_PROJECT_AGENT_TASK]->
1139
+ (task:FuliProjectAgentTask {task_id: $task_id})
1140
+ MATCH (space)-[:HAS_PROJECT_AGENT_RECRUITMENT]->
1141
+ (recruitment:FuliProjectAgentRecruitment {
1142
+ recruitment_id: $recruitment_id
1143
+ })
1144
+ SET task.recruitment_id = $recruitment_id,
1145
+ task.hr_agent_id = recruitment.hr_agent_id
1146
+ MERGE (task)-[:TRIGGERED_RECRUITMENT]->(recruitment)
1147
+ ''',
1148
+ personal_space_id=request.personal_space_id,
1149
+ task_id=task_id,
1150
+ recruitment_id=recruitment.recruitment_id,
1151
+ )