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,965 @@
1
+ import hashlib
2
+ import json
3
+
4
+ from fastapi import HTTPException
5
+
6
+ from .personal_project_access import authorize_personal_project
7
+ from .project_agent_access import authorize_project_agent
8
+ from .project_agent_models import (
9
+ ProjectAgentAssignmentCreate,
10
+ ProjectAgentAssignmentEnd,
11
+ ProjectAgentAssignmentRecord,
12
+ ProjectAgentAssignmentReplace,
13
+ ProjectAgentAssignmentReplaceResult,
14
+ ProjectAgentExecutorPolicy,
15
+ ProjectAgentModelStrategy,
16
+ ProjectAgentProfile,
17
+ ProjectAgentRecord,
18
+ ProjectAgentUpsert,
19
+ )
20
+ from .provider_values import native_datetime, now_utc, stable_uuid
21
+
22
+
23
+ SYSTEM_COORDINATOR_AGENT_ID = 'fuli-project-coordinator'
24
+
25
+
26
+ class StoreProjectAgents:
27
+ async def ensure_system_project_coordinator(
28
+ self,
29
+ actor: dict,
30
+ personal_space_id: str,
31
+ ) -> ProjectAgentRecord:
32
+ self._require_personal()
33
+ await self.authorize(actor, personal_space_id, 'maintainer')
34
+ profile = ProjectAgentProfile(
35
+ name='项目协调人',
36
+ responsibility='评估任务、选择模型策略、路由已有 Agent,并审计职责缺口。',
37
+ agent_type='coordinator',
38
+ work_kinds=['task-coordination'],
39
+ capabilities=['任务评估', '模型策略', 'Agent 路由'],
40
+ initial_preferences=['质量与可验收完成优先于成本和时间'],
41
+ status='active',
42
+ )
43
+ updated_at = now_utc()
44
+ node_id = stable_uuid(
45
+ self.settings.provider_id,
46
+ personal_space_id,
47
+ 'project-agent',
48
+ SYSTEM_COORDINATOR_AGENT_ID,
49
+ )
50
+ await self.runtime.driver.execute_query(
51
+ '''
52
+ MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})
53
+ MERGE (agent:FuliProjectAgent {id: $id})
54
+ ON CREATE SET agent.agent_id = $agent_id,
55
+ agent.created_at = $updated_at,
56
+ agent.system_managed = true
57
+ ON CREATE SET agent.profile_json = $profile_json,
58
+ agent.name = $name,
59
+ agent.occupation_emoji = $occupation_emoji,
60
+ agent.responsibility = $responsibility,
61
+ agent.capabilities = $capabilities,
62
+ agent.work_kinds = $work_kinds,
63
+ agent.agent_type = 'coordinator',
64
+ agent.memory_scope = 'reviewed_agent',
65
+ agent.status = 'active',
66
+ agent.updated_at = $updated_at
67
+ MERGE (space)-[:HAS_PROJECT_AGENT_IDENTITY]->(agent)
68
+ RETURN agent
69
+ ''',
70
+ personal_space_id=personal_space_id,
71
+ id=node_id,
72
+ agent_id=SYSTEM_COORDINATOR_AGENT_ID,
73
+ profile_json=profile.model_dump_json(),
74
+ name=profile.name,
75
+ occupation_emoji=profile.occupation_emoji,
76
+ responsibility=profile.responsibility,
77
+ capabilities=profile.capabilities,
78
+ work_kinds=profile.work_kinds,
79
+ updated_at=updated_at,
80
+ )
81
+ return await self.get_project_agent(
82
+ actor,
83
+ personal_space_id,
84
+ None,
85
+ SYSTEM_COORDINATOR_AGENT_ID,
86
+ )
87
+
88
+ async def upsert_project_agent(
89
+ self,
90
+ actor: dict,
91
+ request: ProjectAgentUpsert,
92
+ *,
93
+ recruitment_id: str | None = None,
94
+ ) -> ProjectAgentRecord:
95
+ self._require_personal()
96
+ if request.profile.agent_type == 'temporary' and not recruitment_id:
97
+ raise HTTPException(
98
+ status_code=422,
99
+ detail='temporary Agents must be created by an audited HR recruitment',
100
+ )
101
+ if (
102
+ request.profile.agent_type == 'coordinator'
103
+ and request.agent_id != SYSTEM_COORDINATOR_AGENT_ID
104
+ ):
105
+ raise HTTPException(
106
+ status_code=422,
107
+ detail='only the system-managed identity may use coordinator type',
108
+ )
109
+ if (
110
+ request.agent_id == SYSTEM_COORDINATOR_AGENT_ID
111
+ and request.profile.agent_type != 'coordinator'
112
+ ):
113
+ raise HTTPException(
114
+ status_code=422,
115
+ detail='the system coordinator identity cannot change Agent type',
116
+ )
117
+ space = await self.authorize(actor, request.personal_space_id, 'maintainer')
118
+ if request.personal_project_id:
119
+ await authorize_personal_project(
120
+ self,
121
+ actor,
122
+ space,
123
+ request.personal_project_id,
124
+ )
125
+ node_id = stable_uuid(
126
+ self.settings.provider_id,
127
+ request.personal_space_id,
128
+ 'project-agent',
129
+ request.agent_id,
130
+ )
131
+ updated_at = now_utc()
132
+ memory_scope = (
133
+ 'task_only'
134
+ if request.profile.agent_type == 'temporary'
135
+ else 'reviewed_agent'
136
+ )
137
+ await self.runtime.driver.execute_query(
138
+ '''
139
+ MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})
140
+ MERGE (agent:FuliProjectAgent {id: $id})
141
+ ON CREATE SET agent.agent_id = $agent_id,
142
+ agent.created_at = $updated_at,
143
+ agent.system_managed = false
144
+ SET agent.profile_json = $profile_json,
145
+ agent.name = $name,
146
+ agent.occupation_emoji = $occupation_emoji,
147
+ agent.responsibility = $responsibility,
148
+ agent.capabilities = $capabilities,
149
+ agent.work_kinds = $work_kinds,
150
+ agent.agent_type = $agent_type,
151
+ agent.memory_scope = $memory_scope,
152
+ agent.status = $status,
153
+ agent.test_source = $test_source,
154
+ agent.cleanup_eligible = $cleanup_eligible,
155
+ agent.recruitment_id = coalesce(
156
+ agent.recruitment_id, $recruitment_id),
157
+ agent.updated_at = $updated_at
158
+ MERGE (space)-[:HAS_PROJECT_AGENT_IDENTITY]->(agent)
159
+ RETURN agent
160
+ ''',
161
+ personal_space_id=request.personal_space_id,
162
+ id=node_id,
163
+ agent_id=request.agent_id,
164
+ profile_json=request.profile.model_dump_json(),
165
+ name=request.profile.name,
166
+ occupation_emoji=request.profile.occupation_emoji,
167
+ responsibility=request.profile.responsibility,
168
+ capabilities=request.profile.capabilities,
169
+ work_kinds=request.profile.work_kinds,
170
+ agent_type=request.profile.agent_type,
171
+ memory_scope=memory_scope,
172
+ status=request.profile.status,
173
+ test_source=request.profile.test_source,
174
+ cleanup_eligible=request.profile.cleanup_eligible,
175
+ recruitment_id=recruitment_id,
176
+ updated_at=updated_at,
177
+ )
178
+ if request.personal_project_id:
179
+ await self._upsert_legacy_assignment(request, updated_at)
180
+ return await self.get_project_agent(
181
+ actor,
182
+ request.personal_space_id,
183
+ request.personal_project_id,
184
+ request.agent_id,
185
+ )
186
+
187
+ async def _upsert_legacy_assignment(
188
+ self,
189
+ request: ProjectAgentUpsert,
190
+ updated_at,
191
+ ) -> None:
192
+ assignment_id = stable_uuid(
193
+ self.settings.provider_id,
194
+ request.personal_space_id,
195
+ 'project-agent-assignment',
196
+ 'legacy',
197
+ request.personal_project_id,
198
+ request.agent_id,
199
+ )
200
+ await self.runtime.driver.execute_query(
201
+ '''
202
+ MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
203
+ [:CONTAINS_PROJECT]->
204
+ (project:FuliPersonalProject {project_id: $personal_project_id})
205
+ MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
206
+ [:HAS_PROJECT_AGENT_IDENTITY]->
207
+ (agent:FuliProjectAgent {agent_id: $agent_id})
208
+ MERGE (assignment:FuliProjectAgentAssignment {id: $assignment_id})
209
+ ON CREATE SET assignment.assignment_id = $assignment_id,
210
+ assignment.assigned_at = $updated_at,
211
+ assignment.revision = 0,
212
+ assignment.reason = 'legacy project Agent profile'
213
+ SET assignment.responsibility = $responsibility,
214
+ assignment.work_kinds = $work_kinds,
215
+ assignment.capabilities = $capabilities,
216
+ assignment.model_strategy_json = $model_strategy_json,
217
+ assignment.executor_policy_json = $executor_policy_json,
218
+ assignment.status = 'active',
219
+ assignment.updated_at = $updated_at
220
+ MERGE (project)-[:HAS_PROJECT_AGENT_ASSIGNMENT]->(assignment)
221
+ MERGE (assignment)-[:ASSIGNED_AGENT]->(agent)
222
+ ''',
223
+ personal_space_id=request.personal_space_id,
224
+ personal_project_id=request.personal_project_id,
225
+ agent_id=request.agent_id,
226
+ assignment_id=assignment_id,
227
+ responsibility=request.profile.responsibility,
228
+ work_kinds=request.profile.work_kinds,
229
+ capabilities=request.profile.capabilities,
230
+ model_strategy_json=None,
231
+ executor_policy_json=(
232
+ request.profile.executor_policy.model_dump_json()
233
+ if request.profile.executor_policy.mode == 'locked'
234
+ or request.profile.executor_policy.preferred_executor_ids
235
+ else None
236
+ ),
237
+ updated_at=updated_at,
238
+ )
239
+
240
+ async def list_project_agents(
241
+ self,
242
+ actor: dict,
243
+ personal_space_id: str,
244
+ personal_project_id: str | None = None,
245
+ *,
246
+ status: str | None = None,
247
+ capability: str | None = None,
248
+ ) -> list[ProjectAgentRecord]:
249
+ self._require_personal()
250
+ space = await self.authorize(actor, personal_space_id, 'reader')
251
+ if personal_project_id:
252
+ await authorize_personal_project(
253
+ self,
254
+ actor,
255
+ space,
256
+ personal_project_id,
257
+ )
258
+ records = await self._project_agent_rows(
259
+ personal_space_id,
260
+ personal_project_id=personal_project_id,
261
+ agent_id=None,
262
+ status=status,
263
+ capability=capability,
264
+ )
265
+ return [
266
+ self._project_agent_from_row(row, personal_space_id, personal_project_id)
267
+ for row in records
268
+ ]
269
+
270
+ async def get_project_agent(
271
+ self,
272
+ actor: dict,
273
+ personal_space_id: str,
274
+ personal_project_id: str | None,
275
+ agent_id: str,
276
+ ) -> ProjectAgentRecord:
277
+ self._require_personal()
278
+ space = await self.authorize(actor, personal_space_id, 'reader')
279
+ if personal_project_id:
280
+ await authorize_personal_project(
281
+ self,
282
+ actor,
283
+ space,
284
+ personal_project_id,
285
+ )
286
+ records = await self._project_agent_rows(
287
+ personal_space_id,
288
+ personal_project_id=None,
289
+ agent_id=agent_id,
290
+ status=None,
291
+ capability=None,
292
+ )
293
+ if not records:
294
+ raise HTTPException(status_code=404, detail='project Agent not found')
295
+ return self._project_agent_from_row(
296
+ records[0],
297
+ personal_space_id,
298
+ personal_project_id,
299
+ )
300
+
301
+ async def archive_project_agent(
302
+ self,
303
+ actor: dict,
304
+ personal_space_id: str,
305
+ agent_id: str,
306
+ *,
307
+ reason: str,
308
+ ) -> ProjectAgentRecord:
309
+ """Archive one reusable identity while preserving every audit edge."""
310
+
311
+ self._require_personal()
312
+ if agent_id == SYSTEM_COORDINATOR_AGENT_ID:
313
+ raise HTTPException(
314
+ status_code=422,
315
+ detail='the system coordinator cannot be archived',
316
+ )
317
+ await self.authorize(actor, personal_space_id, 'maintainer')
318
+ if not reason.strip():
319
+ raise HTTPException(status_code=422, detail='archive reason is required')
320
+ records, _, _ = await self.runtime.driver.execute_query(
321
+ '''
322
+ MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
323
+ [:HAS_PROJECT_AGENT_IDENTITY]->
324
+ (agent:FuliProjectAgent {agent_id: $agent_id})
325
+ OPTIONAL MATCH (space)-[:HAS_PROJECT_AGENT_TASK]->
326
+ (task:FuliProjectAgentTask)-
327
+ [participant:HAS_PARTICIPANT]->(agent)
328
+ WITH space, agent, collect(DISTINCT {
329
+ task_id: task.task_id,
330
+ status: participant.status
331
+ }) AS tasks
332
+ WITH space, agent, [item IN tasks
333
+ WHERE item.task_id IS NOT NULL
334
+ AND item.status IN [
335
+ 'awaiting_recruitment', 'queued', 'running',
336
+ 'paused', 'blocked', 'awaiting_review'
337
+ ]] AS open_tasks
338
+ WHERE agent.status = 'archived' OR size(open_tasks) = 0
339
+ SET agent.status = 'archived',
340
+ agent.archive_reason = $reason,
341
+ agent.archived_at = coalesce(agent.archived_at, $updated_at),
342
+ agent.updated_at = $updated_at
343
+ WITH space, agent
344
+ OPTIONAL MATCH (space)-[:CONTAINS_PROJECT]->
345
+ (:FuliPersonalProject)-
346
+ [:HAS_PROJECT_AGENT_ASSIGNMENT]->
347
+ (assignment:FuliProjectAgentAssignment {status: 'active'})-
348
+ [:ASSIGNED_AGENT]->(agent)
349
+ SET assignment.status = 'ended',
350
+ assignment.end_reason = $reason,
351
+ assignment.ended_at = $updated_at,
352
+ assignment.updated_at = $updated_at,
353
+ assignment.revision = coalesce(assignment.revision, 0) + 1
354
+ RETURN agent
355
+ ''',
356
+ personal_space_id=personal_space_id,
357
+ agent_id=agent_id,
358
+ reason=reason,
359
+ updated_at=now_utc(),
360
+ )
361
+ if not records:
362
+ raise HTTPException(
363
+ status_code=409,
364
+ detail='Agent is missing or still has non-terminal task work',
365
+ )
366
+ return await self.get_project_agent(
367
+ actor,
368
+ personal_space_id,
369
+ None,
370
+ agent_id,
371
+ )
372
+
373
+ async def _project_agent_rows(
374
+ self,
375
+ personal_space_id: str,
376
+ *,
377
+ personal_project_id: str | None,
378
+ agent_id: str | None,
379
+ status: str | None,
380
+ capability: str | None,
381
+ ):
382
+ records, _, _ = await self.runtime.driver.execute_query(
383
+ '''
384
+ MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
385
+ [:HAS_PROJECT_AGENT_IDENTITY]->(agent:FuliProjectAgent)
386
+ WHERE ($agent_id IS NULL OR agent.agent_id = $agent_id)
387
+ AND ($status IS NULL OR agent.status = $status)
388
+ AND (
389
+ $personal_project_id IS NULL
390
+ OR EXISTS {
391
+ MATCH (space)-[:CONTAINS_PROJECT]->
392
+ (:FuliPersonalProject {project_id: $personal_project_id})-
393
+ [:HAS_PROJECT_AGENT_ASSIGNMENT]->
394
+ (:FuliProjectAgentAssignment {status: 'active'})-
395
+ [:ASSIGNED_AGENT]->(agent)
396
+ }
397
+ )
398
+ AND (
399
+ $capability IS NULL
400
+ OR any(item IN coalesce(agent.capabilities, [])
401
+ WHERE toLower(item) CONTAINS toLower($capability))
402
+ OR toLower(agent.responsibility) CONTAINS toLower($capability)
403
+ OR EXISTS {
404
+ MATCH (space)-[:CONTAINS_PROJECT]->
405
+ (:FuliPersonalProject)-
406
+ [:HAS_PROJECT_AGENT_ASSIGNMENT]->
407
+ (assignment:FuliProjectAgentAssignment)-
408
+ [:ASSIGNED_AGENT]->(agent)
409
+ WHERE any(item IN coalesce(assignment.capabilities, [])
410
+ WHERE toLower(item) CONTAINS toLower($capability))
411
+ OR toLower(assignment.responsibility)
412
+ CONTAINS toLower($capability)
413
+ }
414
+ )
415
+ OPTIONAL MATCH (space)-[:CONTAINS_PROJECT]->
416
+ (project:FuliPersonalProject)-
417
+ [:HAS_PROJECT_AGENT_ASSIGNMENT]->
418
+ (assignment:FuliProjectAgentAssignment)-
419
+ [:ASSIGNED_AGENT]->(agent)
420
+ WITH space, agent, collect(DISTINCT {
421
+ assignment: assignment,
422
+ personal_project_id: project.project_id
423
+ }) AS assignment_rows
424
+ OPTIONAL MATCH (space)-[:HAS_PROJECT_AGENT_TASK]->
425
+ (task:FuliProjectAgentTask)-
426
+ [participant:HAS_PARTICIPANT]->(agent)
427
+ WITH space, agent, assignment_rows,
428
+ collect(DISTINCT {
429
+ task_id: task.task_id,
430
+ status: participant.status,
431
+ updated_at: participant.updated_at,
432
+ source_application: task.source_application
433
+ }) AS task_rows,
434
+ collect(DISTINCT task.source_application) AS task_clients
435
+ OPTIONAL MATCH (space)-[:HAS_PROJECT_AGENT_TASK]->
436
+ (:FuliProjectAgentTask)-[:HAS_TASK_EVENT]->
437
+ (event:FuliProjectAgentTaskEvent)-[:EVENT_AGENT]->(agent)
438
+ WITH agent,
439
+ assignment_rows,
440
+ task_rows,
441
+ task_clients,
442
+ collect(DISTINCT event.source_application) AS event_clients
443
+ RETURN agent,
444
+ assignment_rows,
445
+ task_rows,
446
+ task_clients + event_clients AS observed_clients
447
+ ORDER BY CASE agent.agent_type
448
+ WHEN 'coordinator' THEN 0
449
+ WHEN 'hr' THEN 1
450
+ WHEN 'durable' THEN 2
451
+ ELSE 3
452
+ END,
453
+ agent.name,
454
+ agent.agent_id
455
+ ''',
456
+ personal_space_id=personal_space_id,
457
+ personal_project_id=personal_project_id,
458
+ agent_id=agent_id,
459
+ status=status,
460
+ capability=capability,
461
+ routing_='r',
462
+ )
463
+ return records
464
+
465
+ async def create_project_agent_assignment(
466
+ self,
467
+ actor: dict,
468
+ request: ProjectAgentAssignmentCreate,
469
+ ) -> ProjectAgentAssignmentRecord:
470
+ self._require_personal()
471
+ space = await self.authorize(actor, request.personal_space_id, 'maintainer')
472
+ await authorize_personal_project(
473
+ self,
474
+ actor,
475
+ space,
476
+ request.personal_project_id,
477
+ )
478
+ await authorize_project_agent(
479
+ self,
480
+ actor,
481
+ space,
482
+ request.personal_project_id,
483
+ request.agent_id,
484
+ require_active=True,
485
+ allow_unassigned=True,
486
+ )
487
+ assignment_id = stable_uuid(
488
+ self.settings.provider_id,
489
+ request.personal_space_id,
490
+ 'project-agent-assignment',
491
+ request.idempotency_key,
492
+ )
493
+ payload_hash = self._assignment_payload_hash(request)
494
+ updated_at = now_utc()
495
+ records, _, _ = await self.runtime.driver.execute_query(
496
+ '''
497
+ MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})-
498
+ [:CONTAINS_PROJECT]->
499
+ (project:FuliPersonalProject {project_id: $personal_project_id})
500
+ MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})-
501
+ [:HAS_PROJECT_AGENT_IDENTITY]->
502
+ (agent:FuliProjectAgent {agent_id: $agent_id})
503
+ MERGE (assignment:FuliProjectAgentAssignment {id: $assignment_id})
504
+ ON CREATE SET assignment.assignment_id = $assignment_id,
505
+ assignment.payload_hash = $payload_hash,
506
+ assignment.responsibility = $responsibility,
507
+ assignment.work_kinds = $work_kinds,
508
+ assignment.capabilities = $capabilities,
509
+ assignment.model_strategy_json = $model_strategy_json,
510
+ assignment.executor_policy_json =
511
+ $executor_policy_json,
512
+ assignment.reason = $reason,
513
+ assignment.status = 'active',
514
+ assignment.revision = 0,
515
+ assignment.source_application = $source_application,
516
+ assignment.source_session_id = $source_session_id,
517
+ assignment.assigned_at = $updated_at,
518
+ assignment.updated_at = $updated_at
519
+ WITH project, agent, assignment,
520
+ assignment.payload_hash = $payload_hash AS payload_matches
521
+ FOREACH (_ IN CASE WHEN payload_matches THEN [1] ELSE [] END |
522
+ MERGE (project)-[:HAS_PROJECT_AGENT_ASSIGNMENT]->(assignment)
523
+ MERGE (assignment)-[:ASSIGNED_AGENT]->(agent)
524
+ )
525
+ RETURN assignment, project.project_id AS personal_project_id,
526
+ agent.agent_id AS agent_id, payload_matches
527
+ ''',
528
+ personal_space_id=request.personal_space_id,
529
+ personal_project_id=request.personal_project_id,
530
+ agent_id=request.agent_id,
531
+ assignment_id=assignment_id,
532
+ payload_hash=payload_hash,
533
+ responsibility=request.responsibility,
534
+ work_kinds=request.work_kinds,
535
+ capabilities=request.capabilities,
536
+ model_strategy_json=(
537
+ request.model_strategy_override.model_dump_json()
538
+ if request.model_strategy_override
539
+ else None
540
+ ),
541
+ executor_policy_json=(
542
+ request.executor_policy_override.model_dump_json()
543
+ if request.executor_policy_override else None
544
+ ),
545
+ reason=request.reason,
546
+ source_application=request.source_application,
547
+ source_session_id=request.source_session_id,
548
+ updated_at=updated_at,
549
+ )
550
+ if not records:
551
+ raise HTTPException(status_code=404, detail='project or Agent not found')
552
+ assignment = dict(records[0]['assignment'])
553
+ if assignment.get('payload_hash') != payload_hash:
554
+ raise HTTPException(
555
+ status_code=409,
556
+ detail='assignment idempotency key was used with different input',
557
+ )
558
+ return self._assignment(
559
+ assignment,
560
+ request.personal_space_id,
561
+ records[0]['personal_project_id'],
562
+ records[0]['agent_id'],
563
+ )
564
+
565
+ async def list_project_agent_assignments(
566
+ self,
567
+ actor: dict,
568
+ personal_space_id: str,
569
+ *,
570
+ personal_project_id: str | None = None,
571
+ agent_id: str | None = None,
572
+ status: str | None = None,
573
+ ) -> list[ProjectAgentAssignmentRecord]:
574
+ self._require_personal()
575
+ space = await self.authorize(actor, personal_space_id, 'reader')
576
+ if personal_project_id:
577
+ await authorize_personal_project(
578
+ self,
579
+ actor,
580
+ space,
581
+ personal_project_id,
582
+ )
583
+ records, _, _ = await self.runtime.driver.execute_query(
584
+ '''
585
+ MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})-
586
+ [:CONTAINS_PROJECT]->(project:FuliPersonalProject)-
587
+ [:HAS_PROJECT_AGENT_ASSIGNMENT]->
588
+ (assignment:FuliProjectAgentAssignment)-
589
+ [:ASSIGNED_AGENT]->(agent:FuliProjectAgent)
590
+ WHERE ($personal_project_id IS NULL
591
+ OR project.project_id = $personal_project_id)
592
+ AND ($agent_id IS NULL OR agent.agent_id = $agent_id)
593
+ AND ($status IS NULL OR assignment.status = $status)
594
+ RETURN assignment, project.project_id AS personal_project_id,
595
+ agent.agent_id AS agent_id
596
+ ORDER BY assignment.assigned_at DESC, assignment.assignment_id
597
+ ''',
598
+ personal_space_id=personal_space_id,
599
+ personal_project_id=personal_project_id,
600
+ agent_id=agent_id,
601
+ status=status,
602
+ routing_='r',
603
+ )
604
+ return [
605
+ self._assignment(
606
+ dict(row['assignment']),
607
+ personal_space_id,
608
+ row['personal_project_id'],
609
+ row['agent_id'],
610
+ )
611
+ for row in records
612
+ ]
613
+
614
+ async def end_project_agent_assignment(
615
+ self,
616
+ actor: dict,
617
+ request: ProjectAgentAssignmentEnd,
618
+ ) -> ProjectAgentAssignmentRecord:
619
+ self._require_personal()
620
+ space = await self.authorize(actor, request.personal_space_id, 'maintainer')
621
+ await authorize_personal_project(
622
+ self,
623
+ actor,
624
+ space,
625
+ request.personal_project_id,
626
+ )
627
+ updated_at = now_utc()
628
+ records, _, _ = await self.runtime.driver.execute_query(
629
+ '''
630
+ MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})-
631
+ [:CONTAINS_PROJECT]->
632
+ (project:FuliPersonalProject {project_id: $personal_project_id})-
633
+ [:HAS_PROJECT_AGENT_ASSIGNMENT]->
634
+ (assignment:FuliProjectAgentAssignment {
635
+ assignment_id: $assignment_id
636
+ })-[:ASSIGNED_AGENT]->(agent:FuliProjectAgent)
637
+ WHERE assignment.status = 'active'
638
+ AND assignment.revision = $expected_revision
639
+ SET assignment.status = 'ended',
640
+ assignment.end_reason = $reason,
641
+ assignment.ended_at = $updated_at,
642
+ assignment.updated_at = $updated_at,
643
+ assignment.revision = assignment.revision + 1
644
+ RETURN assignment, project.project_id AS personal_project_id,
645
+ agent.agent_id AS agent_id
646
+ ''',
647
+ personal_space_id=request.personal_space_id,
648
+ personal_project_id=request.personal_project_id,
649
+ assignment_id=request.assignment_id,
650
+ expected_revision=request.expected_revision,
651
+ reason=request.reason,
652
+ updated_at=updated_at,
653
+ )
654
+ if not records:
655
+ raise HTTPException(
656
+ status_code=409,
657
+ detail='assignment is not active or revision is stale',
658
+ )
659
+ row = records[0]
660
+ return self._assignment(
661
+ dict(row['assignment']),
662
+ request.personal_space_id,
663
+ row['personal_project_id'],
664
+ row['agent_id'],
665
+ )
666
+
667
+ async def replace_project_agent_assignment(
668
+ self,
669
+ actor: dict,
670
+ request: ProjectAgentAssignmentReplace,
671
+ ) -> ProjectAgentAssignmentReplaceResult:
672
+ self._require_personal()
673
+ space = await self.authorize(actor, request.personal_space_id, 'maintainer')
674
+ await authorize_personal_project(
675
+ self,
676
+ actor,
677
+ space,
678
+ request.personal_project_id,
679
+ )
680
+ await authorize_project_agent(
681
+ self,
682
+ actor,
683
+ space,
684
+ request.personal_project_id,
685
+ request.replacement_agent_id,
686
+ require_active=True,
687
+ allow_unassigned=True,
688
+ )
689
+ replacement_id = stable_uuid(
690
+ self.settings.provider_id,
691
+ request.personal_space_id,
692
+ 'project-agent-assignment',
693
+ request.idempotency_key,
694
+ )
695
+ payload_hash = self._assignment_payload_hash(request)
696
+ updated_at = now_utc()
697
+ records, _, _ = await self.runtime.driver.execute_query(
698
+ '''
699
+ MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})-
700
+ [:CONTAINS_PROJECT]->
701
+ (project:FuliPersonalProject {project_id: $personal_project_id})-
702
+ [:HAS_PROJECT_AGENT_ASSIGNMENT]->
703
+ (ended:FuliProjectAgentAssignment {
704
+ assignment_id: $assignment_id
705
+ })-[:ASSIGNED_AGENT]->(ended_agent:FuliProjectAgent)
706
+ MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})-
707
+ [:HAS_PROJECT_AGENT_IDENTITY]->
708
+ (replacement_agent:FuliProjectAgent {
709
+ agent_id: $replacement_agent_id
710
+ })
711
+ OPTIONAL MATCH (existing_replacement:FuliProjectAgentAssignment {
712
+ id: $replacement_id
713
+ })
714
+ WITH project, ended, ended_agent, replacement_agent,
715
+ existing_replacement
716
+ WHERE existing_replacement IS NOT NULL
717
+ OR (ended.status = 'active'
718
+ AND ended.revision = $expected_revision)
719
+ OR (ended.status = 'ended'
720
+ AND ended.revision = $expected_revision + 1
721
+ AND ended.replaced_by_assignment_id = $replacement_id)
722
+ MERGE (replacement:FuliProjectAgentAssignment {id: $replacement_id})
723
+ ON CREATE SET replacement.assignment_id = $replacement_id,
724
+ replacement.payload_hash = $payload_hash,
725
+ replacement.responsibility = $responsibility,
726
+ replacement.work_kinds = $work_kinds,
727
+ replacement.capabilities = $capabilities,
728
+ replacement.model_strategy_json = $model_strategy_json,
729
+ replacement.executor_policy_json =
730
+ $executor_policy_json,
731
+ replacement.reason = $reason,
732
+ replacement.status = 'active',
733
+ replacement.revision = 0,
734
+ replacement.source_application = $source_application,
735
+ replacement.source_session_id = $source_session_id,
736
+ replacement.assigned_at = $updated_at,
737
+ replacement.updated_at = $updated_at
738
+ WITH project, ended, ended_agent, replacement_agent, replacement,
739
+ replacement.payload_hash = $payload_hash AS payload_matches,
740
+ (ended.status = 'active'
741
+ AND ended.revision = $expected_revision) AS can_apply,
742
+ (ended.status = 'ended'
743
+ AND ended.revision = $expected_revision + 1
744
+ AND ended.replaced_by_assignment_id = $replacement_id)
745
+ AS exact_replay
746
+ FOREACH (_ IN CASE
747
+ WHEN payload_matches AND can_apply THEN [1] ELSE [] END |
748
+ MERGE (project)-[:HAS_PROJECT_AGENT_ASSIGNMENT]->(replacement)
749
+ MERGE (replacement)-[:ASSIGNED_AGENT]->(replacement_agent)
750
+ SET ended.status = 'ended',
751
+ ended.end_reason = $reason,
752
+ ended.ended_at = $updated_at,
753
+ ended.updated_at = $updated_at,
754
+ ended.replaced_by_assignment_id = $replacement_id,
755
+ ended.revision = ended.revision + 1
756
+ )
757
+ RETURN ended, replacement,
758
+ ended_agent.agent_id AS ended_agent_id,
759
+ replacement_agent.agent_id AS replacement_agent_id,
760
+ payload_matches, can_apply, exact_replay
761
+ ''',
762
+ personal_space_id=request.personal_space_id,
763
+ personal_project_id=request.personal_project_id,
764
+ assignment_id=request.assignment_id,
765
+ expected_revision=request.expected_revision,
766
+ replacement_agent_id=request.replacement_agent_id,
767
+ replacement_id=replacement_id,
768
+ payload_hash=payload_hash,
769
+ responsibility=request.responsibility,
770
+ work_kinds=request.work_kinds,
771
+ capabilities=request.capabilities,
772
+ model_strategy_json=(
773
+ request.model_strategy_override.model_dump_json()
774
+ if request.model_strategy_override
775
+ else None
776
+ ),
777
+ executor_policy_json=(
778
+ request.executor_policy_override.model_dump_json()
779
+ if request.executor_policy_override else None
780
+ ),
781
+ reason=request.reason,
782
+ source_application=request.source_application,
783
+ source_session_id=request.source_session_id,
784
+ updated_at=updated_at,
785
+ )
786
+ if not records:
787
+ raise HTTPException(
788
+ status_code=409,
789
+ detail='assignment is not active or revision is stale',
790
+ )
791
+ row = records[0]
792
+ if row.get('payload_matches') is not True:
793
+ raise HTTPException(
794
+ status_code=409,
795
+ detail='assignment idempotency key was used with different input',
796
+ )
797
+ if row.get('can_apply') is not True and row.get('exact_replay') is not True:
798
+ raise HTTPException(
799
+ status_code=409,
800
+ detail='assignment is not active or revision is stale',
801
+ )
802
+ ended_raw = dict(row['ended'])
803
+ return ProjectAgentAssignmentReplaceResult(
804
+ ended=self._assignment(
805
+ ended_raw,
806
+ request.personal_space_id,
807
+ request.personal_project_id,
808
+ row['ended_agent_id'],
809
+ ),
810
+ replacement=self._assignment(
811
+ dict(row['replacement']),
812
+ request.personal_space_id,
813
+ request.personal_project_id,
814
+ row['replacement_agent_id'],
815
+ ),
816
+ )
817
+
818
+ @staticmethod
819
+ def _assignment_payload_hash(
820
+ request: ProjectAgentAssignmentCreate | ProjectAgentAssignmentReplace,
821
+ ) -> str:
822
+ payload = request.model_dump(mode='json', exclude={'idempotency_key'})
823
+ return hashlib.sha256(
824
+ json.dumps(payload, sort_keys=True, separators=(',', ':')).encode()
825
+ ).hexdigest()
826
+
827
+ def _project_agent_from_row(
828
+ self,
829
+ row,
830
+ personal_space_id: str,
831
+ projection_project_id: str | None,
832
+ ) -> ProjectAgentRecord:
833
+ raw = dict(row['agent'])
834
+ profile = ProjectAgentProfile.model_validate_json(raw['profile_json'])
835
+ # ``occupation_emoji`` was introduced after the original profile JSON
836
+ # contract. Prefer the durable profile value, but tolerate an older
837
+ # JSON payload while the explicit node property is being backfilled.
838
+ occupation_emoji = raw.get('occupation_emoji') or raw.get(
839
+ 'occupationEmoji'
840
+ )
841
+ if not profile.occupation_emoji and occupation_emoji:
842
+ profile = profile.model_copy(
843
+ update={'occupation_emoji': occupation_emoji}
844
+ )
845
+ if raw.get('status') and profile.status != raw['status']:
846
+ profile = profile.model_copy(update={'status': raw['status']})
847
+ assignments = []
848
+ for item in row.get('assignment_rows') or []:
849
+ value = dict(item)
850
+ assignment_raw = value.get('assignment')
851
+ project_id = value.get('personal_project_id')
852
+ if assignment_raw is None or not project_id:
853
+ continue
854
+ assignments.append(
855
+ self._assignment(
856
+ dict(assignment_raw),
857
+ personal_space_id,
858
+ project_id,
859
+ raw['agent_id'],
860
+ )
861
+ )
862
+ assignments.sort(key=lambda item: item.assigned_at, reverse=True)
863
+ nonterminal = {
864
+ 'awaiting_recruitment',
865
+ 'queued',
866
+ 'running',
867
+ 'paused',
868
+ 'blocked',
869
+ 'awaiting_review',
870
+ }
871
+ tasks = [
872
+ dict(value)
873
+ for value in (row.get('task_rows') or [])
874
+ if value and value.get('task_id') and value.get('status') in nonterminal
875
+ ]
876
+ priority = {
877
+ 'running': 0,
878
+ 'awaiting_review': 1,
879
+ 'blocked': 2,
880
+ 'paused': 3,
881
+ 'queued': 4,
882
+ 'awaiting_recruitment': 5,
883
+ }
884
+ tasks.sort(
885
+ key=lambda item: (
886
+ priority.get(item.get('status'), 99),
887
+ -(native_datetime(item['updated_at']).timestamp()
888
+ if item.get('updated_at') else 0),
889
+ )
890
+ )
891
+ work_status = 'ended' if (
892
+ profile.agent_type == 'temporary' and profile.status == 'archived'
893
+ ) else (tasks[0]['status'] if tasks else 'idle')
894
+ observed_clients = sorted({
895
+ value
896
+ for value in (row.get('observed_clients') or [])
897
+ if value in {'codex', 'claude_code', 'cursor', 'kiro', 'other'}
898
+ })
899
+ return ProjectAgentRecord(
900
+ agent_id=raw['agent_id'],
901
+ personal_space_id=personal_space_id,
902
+ personal_project_id=projection_project_id,
903
+ profile=profile,
904
+ executor_policy=profile.executor_policy,
905
+ memory_scope=raw.get('memory_scope') or (
906
+ 'task_only' if profile.agent_type == 'temporary' else 'reviewed_agent'
907
+ ),
908
+ assignments=assignments,
909
+ recruitment_id=raw.get('recruitment_id'),
910
+ temporary_task_id=raw.get('temporary_task_id'),
911
+ work_status=work_status,
912
+ open_task_count=len(tasks),
913
+ current_task_id=tasks[0]['task_id'] if tasks else None,
914
+ observed_clients=observed_clients,
915
+ recruited_at=(
916
+ native_datetime(raw['recruited_at'])
917
+ if raw.get('recruited_at') else None
918
+ ),
919
+ recruitment_reason=raw.get('recruitment_reason'),
920
+ recruitment_source_application=raw.get(
921
+ 'recruitment_source_application'
922
+ ),
923
+ created_at=native_datetime(raw['created_at']),
924
+ updated_at=native_datetime(raw['updated_at']),
925
+ )
926
+
927
+ def _assignment(
928
+ self,
929
+ raw: dict,
930
+ personal_space_id: str,
931
+ personal_project_id: str,
932
+ agent_id: str,
933
+ ) -> ProjectAgentAssignmentRecord:
934
+ model_strategy_json = raw.get('model_strategy_json')
935
+ executor_policy_json = raw.get('executor_policy_json')
936
+ return ProjectAgentAssignmentRecord(
937
+ assignment_id=raw.get('assignment_id') or raw['id'],
938
+ personal_space_id=personal_space_id,
939
+ personal_project_id=personal_project_id,
940
+ agent_id=raw.get('agent_id') or agent_id,
941
+ responsibility=raw['responsibility'],
942
+ work_kinds=list(raw.get('work_kinds') or []),
943
+ capabilities=list(raw.get('capabilities') or []),
944
+ model_strategy_override=(
945
+ ProjectAgentModelStrategy.model_validate_json(model_strategy_json)
946
+ if model_strategy_json else None
947
+ ),
948
+ executor_policy_override=(
949
+ ProjectAgentExecutorPolicy.model_validate_json(executor_policy_json)
950
+ if executor_policy_json else None
951
+ ),
952
+ reason=raw.get('reason') or 'assignment created',
953
+ status=raw.get('status') or 'active',
954
+ revision=int(raw.get('revision') or 0),
955
+ source_application=raw.get('source_application'),
956
+ source_session_id=raw.get('source_session_id'),
957
+ assigned_at=native_datetime(raw['assigned_at']),
958
+ updated_at=native_datetime(raw.get('updated_at') or raw['assigned_at']),
959
+ ended_at=(
960
+ native_datetime(raw['ended_at'])
961
+ if raw.get('ended_at') else None
962
+ ),
963
+ end_reason=raw.get('end_reason'),
964
+ replaced_by_assignment_id=raw.get('replaced_by_assignment_id'),
965
+ )