fuli-context 0.7.4-beta.2 → 0.7.5

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