fuli-context 0.7.4-beta.3 → 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 (128) 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-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-D_2zC4lm.js → console-BlAhujsH.js} +1 -1
  23. package/dist/web/assets/{conversation-launcher-settings-DQmMH2j7.js → conversation-launcher-settings-DTYxiLJt.js} +1 -1
  24. package/dist/web/assets/i18n-Ci3heuiL.js +9 -0
  25. package/dist/web/assets/{identity-DmGcY3Y6.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-CVoOdRsL.js → model-yG0YqUyZ.js} +1 -1
  29. package/dist/web/assets/{useApi-CROJJdhE-CBNg4CdH.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/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_executor_models.py +635 -0
  39. package/graph-provider/fuli_graph/project_agent_models.py +278 -0
  40. package/graph-provider/fuli_graph/project_agent_routes.py +597 -0
  41. package/graph-provider/fuli_graph/project_agent_task_models.py +542 -0
  42. package/graph-provider/fuli_graph/runtime.py +259 -0
  43. package/graph-provider/fuli_graph/search_projection.py +3 -0
  44. package/graph-provider/fuli_graph/store.py +6 -0
  45. package/graph-provider/fuli_graph/store_knowledge.py +62 -9
  46. package/graph-provider/fuli_graph/store_project_agent_executor_learning.py +756 -0
  47. package/graph-provider/fuli_graph/store_project_agent_executor_routing.py +601 -0
  48. package/graph-provider/fuli_graph/store_project_agent_executors.py +1087 -0
  49. package/graph-provider/fuli_graph/store_project_agent_task_reads.py +458 -0
  50. package/graph-provider/fuli_graph/store_project_agent_task_recruitment.py +699 -0
  51. package/graph-provider/fuli_graph/store_project_agent_task_staffing.py +409 -0
  52. package/graph-provider/fuli_graph/store_project_agent_tasks.py +1144 -0
  53. package/graph-provider/fuli_graph/store_project_agents.py +965 -0
  54. package/graph-provider/pyproject.toml +2 -1
  55. package/npm-shrinkwrap.json +2 -2
  56. package/package.json +1 -1
  57. package/skills/capturing-session-knowledge/SKILL.md +39 -0
  58. package/src/adaptive-runtime/executor-pool.js +127 -0
  59. package/src/adaptive-runtime/graph-services.js +166 -0
  60. package/src/adaptive-runtime/lease-client.js +335 -0
  61. package/src/adaptive-runtime/runtime-broker.js +318 -0
  62. package/src/adaptive-runtime/settings.js +111 -0
  63. package/src/adaptive-runtime/state.js +74 -0
  64. package/src/agent-tools/graph-definitions.js +396 -0
  65. package/src/agent-tools/graph-handlers.js +61 -0
  66. package/src/agent-tools/project-agent-definitions.js +307 -0
  67. package/src/backup/graph-backup.js +289 -0
  68. package/src/backup/identity-reconciliation.js +208 -0
  69. package/src/backup/runtime-adapters.js +269 -0
  70. package/src/cli/command-registry.js +6 -2
  71. package/src/cli/graph-data-command.js +112 -0
  72. package/src/cli/local-runtime-command.js +16 -2
  73. package/src/cli/setup-command.js +19 -3
  74. package/src/cli/update-command.js +4 -0
  75. package/src/cli.js +6 -0
  76. package/src/graphiti/collaboration-preference-projection.js +29 -1
  77. package/src/graphiti/collaboration-preference-workflow.js +121 -0
  78. package/src/graphiti/federated-application.js +53 -90
  79. package/src/graphiti/project-agent-control-plane.js +263 -0
  80. package/src/graphiti/project-agent-mapping.js +1003 -0
  81. package/src/graphiti/project-agent-workflows.js +557 -0
  82. package/src/graphiti/provider-client.js +270 -1
  83. package/src/http/api-router.js +63 -9
  84. package/src/http/graph-api-router.js +278 -0
  85. package/src/local-runtime/lifecycle.js +46 -10
  86. package/src/mcp/context-resources.js +15 -6
  87. package/src/mcp/create-mcp-server.js +51 -16
  88. package/src/mcp/instructions.js +7 -5
  89. package/src/mcp/runtime.js +17 -5
  90. package/src/mcp/session-id.js +38 -1
  91. package/src/mcp/tool-annotations.js +69 -0
  92. package/src/mcp-server.js +23 -6
  93. package/src/native-runtime/runtime.js +717 -0
  94. package/src/server.js +6 -2
  95. package/src/setup/agent-installation-status.js +7 -1
  96. package/src/setup/agents.js +3 -1
  97. package/src/setup/claude-code-config.js +3 -1
  98. package/src/setup/codex-bootstrap.js +11 -10
  99. package/src/setup/codex-config.js +7 -1
  100. package/src/setup/cursor-config.js +3 -1
  101. package/src/setup/graph-runtime.js +158 -13
  102. package/src/setup/neo4j-memory-profile.js +43 -0
  103. package/src/setup/options.js +38 -2
  104. package/src/setup/paths.js +14 -0
  105. package/src/setup/setup.js +37 -1
  106. package/src/system/resource-monitor.js +181 -16
  107. package/src/system/runtime-settings.js +10 -1
  108. package/src/system/system-service.js +27 -2
  109. package/dist/web/assets/AboutPage-Dpf5fRWv.js +0 -1
  110. package/dist/web/assets/ConnectionsPage-D21aUDCP.js +0 -1
  111. package/dist/web/assets/KnowledgeEditDialog-u7lpfN3j.js +0 -1
  112. package/dist/web/assets/KnowledgeInspector-k4xnHtXd.js +0 -1
  113. package/dist/web/assets/KnowledgeOrganizerPage-Bdj7Rn84.js +0 -1
  114. package/dist/web/assets/KnowledgePage-UHVAbC1W.js +0 -1
  115. package/dist/web/assets/KnowledgeWorkspace-Lq3d-MeC.js +0 -4
  116. package/dist/web/assets/OverviewPage-CycAgA1V.js +0 -1
  117. package/dist/web/assets/PersonalProfilePage-U9oIV5FT.js +0 -1
  118. package/dist/web/assets/PersonalProjectsPage-C5RpXKcA.js +0 -1
  119. package/dist/web/assets/PublicProjectsPage-DiWxm35E.js +0 -1
  120. package/dist/web/assets/ReviewPage-jxRfGSJK.js +0 -1
  121. package/dist/web/assets/SearchableSelect-B3-5IPup.js +0 -1
  122. package/dist/web/assets/SettingsPage-BqfGDOho.js +0 -1
  123. package/dist/web/assets/SettingsPage-CAfz4qiV.css +0 -1
  124. package/dist/web/assets/WritingTastePage-BJxhMqJR.js +0 -1
  125. package/dist/web/assets/i18n-CwqXDI1C.js +0 -5
  126. package/dist/web/assets/index-IUGLt4lG.js +0 -2
  127. package/dist/web/assets/index-X2j29j8i.css +0 -1
  128. package/dist/web/assets/vue-router-CWSsUUio.js +0 -1
@@ -0,0 +1,699 @@
1
+ """Recruitment policy, decisions, provisioning, and audit reads for Agent tasks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from fastapi import HTTPException
8
+
9
+ from .personal_project_access import authorize_personal_project
10
+ from .project_agent_models import (
11
+ ProjectAgentAssignmentCreate,
12
+ ProjectAgentModelStrategy,
13
+ ProjectAgentProfile,
14
+ ProjectAgentUpsert,
15
+ )
16
+ from .project_agent_task_models import (
17
+ ProjectAgentRecruitmentDecision,
18
+ ProjectAgentRecruitmentPolicyRecord,
19
+ ProjectAgentRecruitmentPolicyUpdate,
20
+ ProjectAgentRecruitmentRecord,
21
+ ProjectAgentRoutingDecisionRecord,
22
+ )
23
+ from .provider_values import native_datetime, now_utc, stable_uuid
24
+
25
+
26
+ class StoreProjectAgentTaskRecruitment:
27
+ """Own the audited HR lifecycle independently from task routing mechanics."""
28
+
29
+ async def get_project_agent_recruitment_policy(
30
+ self,
31
+ actor: dict,
32
+ personal_space_id: str,
33
+ ) -> ProjectAgentRecruitmentPolicyRecord:
34
+ self._require_personal()
35
+ await self.authorize(actor, personal_space_id, 'reader')
36
+ records, _, _ = await self.runtime.driver.execute_query(
37
+ '''
38
+ MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})-
39
+ [:HAS_PROJECT_AGENT_RECRUITMENT_POLICY]->
40
+ (policy:FuliProjectAgentRecruitmentPolicy)
41
+ RETURN policy
42
+ ''',
43
+ personal_space_id=personal_space_id,
44
+ routing_='r',
45
+ )
46
+ if not records:
47
+ return ProjectAgentRecruitmentPolicyRecord(
48
+ personal_space_id=personal_space_id,
49
+ confirmation_mode='automatic',
50
+ updated_at=None,
51
+ )
52
+ raw = dict(records[0]['policy'])
53
+ return ProjectAgentRecruitmentPolicyRecord(
54
+ personal_space_id=personal_space_id,
55
+ confirmation_mode=raw.get('confirmation_mode') or 'automatic',
56
+ updated_at=native_datetime(raw.get('updated_at')),
57
+ )
58
+
59
+ async def update_project_agent_recruitment_policy(
60
+ self,
61
+ actor: dict,
62
+ request: ProjectAgentRecruitmentPolicyUpdate,
63
+ ) -> ProjectAgentRecruitmentPolicyRecord:
64
+ self._require_personal()
65
+ await self.authorize(actor, request.personal_space_id, 'maintainer')
66
+ updated_at = now_utc()
67
+ await self.runtime.driver.execute_query(
68
+ '''
69
+ MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})
70
+ MERGE (policy:FuliProjectAgentRecruitmentPolicy {
71
+ personal_space_id: $personal_space_id
72
+ })
73
+ SET policy.confirmation_mode = $confirmation_mode,
74
+ policy.updated_at = $updated_at
75
+ MERGE (space)-[:HAS_PROJECT_AGENT_RECRUITMENT_POLICY]->(policy)
76
+ ''',
77
+ personal_space_id=request.personal_space_id,
78
+ confirmation_mode=request.confirmation_mode,
79
+ updated_at=updated_at,
80
+ )
81
+ return ProjectAgentRecruitmentPolicyRecord(
82
+ personal_space_id=request.personal_space_id,
83
+ confirmation_mode=request.confirmation_mode,
84
+ updated_at=updated_at,
85
+ )
86
+
87
+ async def decide_project_agent_recruitment(
88
+ self,
89
+ actor: dict,
90
+ request: ProjectAgentRecruitmentDecision,
91
+ ) -> ProjectAgentRecruitmentRecord:
92
+ self._require_personal()
93
+ space = await self.authorize(actor, request.personal_space_id, 'maintainer')
94
+ await authorize_personal_project(
95
+ self,
96
+ actor,
97
+ space,
98
+ request.personal_project_id,
99
+ )
100
+ records, _, _ = await self.runtime.driver.execute_query(
101
+ '''
102
+ MATCH (recruitment:FuliProjectAgentRecruitment {
103
+ personal_space_id: $personal_space_id,
104
+ recruitment_id: $recruitment_id,
105
+ personal_project_id: $personal_project_id
106
+ })
107
+ WHERE recruitment.status = 'awaiting_confirmation'
108
+ AND recruitment.revision = $expected_revision
109
+ RETURN recruitment
110
+ ''',
111
+ personal_space_id=request.personal_space_id,
112
+ personal_project_id=request.personal_project_id,
113
+ recruitment_id=request.recruitment_id,
114
+ expected_revision=request.expected_revision,
115
+ routing_='r',
116
+ )
117
+ if not records:
118
+ raise HTTPException(
119
+ status_code=409,
120
+ detail='recruitment is not awaiting confirmation or revision is stale',
121
+ )
122
+ raw = dict(records[0]['recruitment'])
123
+ updated_at = now_utc()
124
+ if request.decision == 'cancel':
125
+ await self.runtime.driver.execute_query(
126
+ '''
127
+ MATCH (recruitment:FuliProjectAgentRecruitment {
128
+ recruitment_id: $recruitment_id,
129
+ personal_space_id: $personal_space_id
130
+ })
131
+ SET recruitment.status = 'cancelled',
132
+ recruitment.decision_reason = $reason,
133
+ recruitment.revision = recruitment.revision + 1,
134
+ recruitment.updated_at = $updated_at
135
+ ''',
136
+ recruitment_id=request.recruitment_id,
137
+ personal_space_id=request.personal_space_id,
138
+ reason=request.reason,
139
+ updated_at=updated_at,
140
+ )
141
+ await self._mark_recruitment_task_blocked(
142
+ raw,
143
+ request.reason,
144
+ updated_at,
145
+ )
146
+ raw.update({
147
+ 'status': 'cancelled',
148
+ 'revision': int(raw.get('revision') or 0) + 1,
149
+ 'updated_at': updated_at,
150
+ })
151
+ return self._recruitment(raw)
152
+ if not raw.get('hr_agent_id'):
153
+ raise HTTPException(
154
+ status_code=409,
155
+ detail='recruitment cannot proceed without an active HR Agent',
156
+ )
157
+ selected = await self._provision_recruitment(actor, raw)
158
+ await self._activate_approved_recruitment(
159
+ actor,
160
+ raw,
161
+ selected,
162
+ request.reason,
163
+ )
164
+ record = await self._get_recruitment(
165
+ request.personal_space_id,
166
+ request.recruitment_id,
167
+ )
168
+ if not record:
169
+ raise HTTPException(status_code=404, detail='recruitment not found')
170
+ return record
171
+
172
+ async def _mark_recruitment_task_blocked(self, recruitment, reason, updated_at):
173
+ event_id = stable_uuid(
174
+ self.settings.provider_id,
175
+ recruitment['personal_space_id'],
176
+ 'project-agent-task-event',
177
+ recruitment['task_id'],
178
+ 'recruitment-cancelled',
179
+ )
180
+ await self.runtime.driver.execute_query(
181
+ '''
182
+ MATCH (space:FuliSpace {
183
+ id: $personal_space_id, kind: 'personal'
184
+ })-[:HAS_PROJECT_AGENT_TASK]->
185
+ (task:FuliProjectAgentTask {task_id: $task_id})
186
+ MATCH (space)-[:HAS_PROJECT_AGENT_RECRUITMENT]->
187
+ (recruitment:FuliProjectAgentRecruitment {
188
+ recruitment_id: $recruitment_id
189
+ })
190
+ SET task.status = 'blocked',
191
+ task.routing_outcome = 'blocked',
192
+ task.executor_blocked_reason = $reason,
193
+ task.updated_at = $updated_at,
194
+ task.revision = coalesce(task.revision, 0) + 1
195
+ MERGE (event:FuliProjectAgentTaskEvent {id: $event_id})
196
+ ON CREATE SET event.event_id = $event_id,
197
+ event.task_id = $task_id,
198
+ event.status = 'blocked',
199
+ event.actor_kind = 'hr',
200
+ event.summary = $reason,
201
+ event.created_at = $updated_at
202
+ MERGE (task)-[:HAS_TASK_EVENT]->(event)
203
+ ''',
204
+ task_id=recruitment['task_id'],
205
+ personal_space_id=recruitment['personal_space_id'],
206
+ recruitment_id=recruitment['recruitment_id'],
207
+ event_id=event_id,
208
+ reason=f'recruitment cancelled: {reason}',
209
+ updated_at=updated_at,
210
+ )
211
+
212
+ async def _activate_approved_recruitment(
213
+ self,
214
+ actor,
215
+ recruitment,
216
+ selected,
217
+ approval_reason,
218
+ ):
219
+ row = await self._find_task_row(
220
+ recruitment['personal_space_id'],
221
+ recruitment['task_id'],
222
+ )
223
+ if not row:
224
+ raise HTTPException(status_code=404, detail='recruitment task not found')
225
+ raw_task = dict(row['task'])
226
+ task_model_override = raw_task.get('task_model_strategy_override_json')
227
+ if task_model_override:
228
+ model_strategy = ProjectAgentModelStrategy.model_validate_json(
229
+ task_model_override
230
+ )
231
+ model_source = 'task'
232
+ else:
233
+ model_strategy = selected['profile'].default_model_strategy
234
+ model_source = 'agent'
235
+ task_executor_override = raw_task.get(
236
+ 'task_executor_policy_override_json'
237
+ )
238
+ executor_decision = None
239
+ resolver = getattr(self, 'resolve_project_agent_executor', None)
240
+ if callable(resolver):
241
+ executor_decision = await resolver(
242
+ actor,
243
+ personal_space_id=recruitment['personal_space_id'],
244
+ personal_project_id=recruitment['personal_project_id'],
245
+ task_id=recruitment['task_id'],
246
+ agent_id=selected['agent_id'],
247
+ work_kind=raw_task['work_kind'],
248
+ required_capabilities=list(
249
+ raw_task.get('required_capabilities') or []
250
+ ),
251
+ model_strategy=model_strategy,
252
+ task_override=(
253
+ json.loads(task_executor_override)
254
+ if task_executor_override else None
255
+ ),
256
+ assignment_id=selected.get('assignment_id'),
257
+ model_strategy_source=model_source,
258
+ idempotency_key=f"recruit:{recruitment['recruitment_id']}",
259
+ )
260
+ blocked = bool(
261
+ executor_decision
262
+ and self._executor_decision_blocked(executor_decision)
263
+ )
264
+ status = 'blocked' if blocked else 'queued'
265
+ outcome = 'blocked' if blocked else 'recruited'
266
+ now = now_utc()
267
+ decision_id = stable_uuid(
268
+ self.settings.provider_id,
269
+ recruitment['personal_space_id'],
270
+ 'project-agent-routing-decision',
271
+ recruitment['task_id'],
272
+ 'approved-recruitment',
273
+ )
274
+ decision = ProjectAgentRoutingDecisionRecord(
275
+ decision_id=decision_id,
276
+ task_id=recruitment['task_id'],
277
+ coordinator_agent_id=recruitment['coordinator_agent_id'],
278
+ complexity=raw_task.get('complexity') or 'standard',
279
+ complexity_basis=list(raw_task.get('complexity_basis') or []),
280
+ selected_model_strategy=model_strategy,
281
+ model_strategy_source=model_source,
282
+ outcome=outcome,
283
+ reason=recruitment['reason_code'],
284
+ match_basis=['HR recruitment approved and Agent assigned'],
285
+ candidate_agent_ids=[selected['agent_id']],
286
+ created_at=now,
287
+ **self._decision_executor_fields(executor_decision),
288
+ )
289
+ executor_raw = (
290
+ executor_decision.model_dump(mode='json')
291
+ if hasattr(executor_decision, 'model_dump')
292
+ else executor_decision
293
+ )
294
+ explanation = self._routing_explanation(
295
+ recruitment['reason_code'],
296
+ ['HR recruitment approved and Agent assigned'],
297
+ executor_decision,
298
+ )
299
+ event_id = stable_uuid(
300
+ self.settings.provider_id,
301
+ recruitment['personal_space_id'],
302
+ 'project-agent-task-event',
303
+ recruitment['task_id'],
304
+ 'recruitment-approved',
305
+ )
306
+ await self.runtime.driver.execute_query(
307
+ '''
308
+ MATCH (space:FuliSpace {
309
+ id: $personal_space_id, kind: 'personal'
310
+ })-[:HAS_PROJECT_AGENT_TASK]->
311
+ (task:FuliProjectAgentTask {task_id: $task_id})
312
+ MATCH (space)-[:HAS_PROJECT_AGENT_IDENTITY]->
313
+ (agent:FuliProjectAgent {agent_id: $agent_id})
314
+ OPTIONAL MATCH (task)-[old_route:ROUTED_BY]->()
315
+ DELETE old_route
316
+ SET task.status = $status,
317
+ task.routing_outcome = $routing_outcome,
318
+ task.routing_explanation = $routing_explanation,
319
+ task.lead_agent_id = $agent_id,
320
+ task.effective_model_strategy_json = $model_strategy_json,
321
+ task.model_strategy_source = $model_strategy_source,
322
+ task.executor_decision_json = $executor_decision_json,
323
+ task.executor_policy_json = $executor_policy_json,
324
+ task.selected_executor_id = $selected_executor_id,
325
+ task.matched_executor_rule_id = $matched_rule_id,
326
+ task.executor_selection_reason = $selection_reason,
327
+ task.executor_fallback_outcome = $fallback_outcome,
328
+ task.executor_fallback_reason = $fallback_reason,
329
+ task.executor_blocked_reason = $blocked_reason,
330
+ task.updated_at = $updated_at,
331
+ task.revision = coalesce(task.revision, 0) + 1
332
+ MERGE (task)-[participant:HAS_PARTICIPANT]->(agent)
333
+ SET participant.role = 'lead',
334
+ participant.status = $status,
335
+ participant.assignment_summary = $assignment_summary,
336
+ participant.joined_at = coalesce(participant.joined_at, $updated_at),
337
+ participant.updated_at = $updated_at
338
+ MERGE (decision_node:FuliProjectAgentRoutingDecision {id: $decision_id})
339
+ ON CREATE SET decision_node.decision_id = $decision_id,
340
+ decision_node.task_id = $task_id,
341
+ decision_node.created_at = $updated_at
342
+ SET decision_node.decision_json = $decision_json
343
+ MERGE (task)-[:ROUTED_BY]->(decision_node)
344
+ MERGE (event:FuliProjectAgentTaskEvent {id: $event_id})
345
+ ON CREATE SET event.event_id = $event_id,
346
+ event.task_id = $task_id,
347
+ event.agent_id = $agent_id,
348
+ event.status = $status,
349
+ event.actor_kind = 'hr',
350
+ event.summary = $event_summary,
351
+ event.created_at = $updated_at
352
+ MERGE (task)-[:HAS_TASK_EVENT]->(event)
353
+ MERGE (event)-[:EVENT_AGENT]->(agent)
354
+ ''',
355
+ task_id=recruitment['task_id'],
356
+ personal_space_id=recruitment['personal_space_id'],
357
+ agent_id=selected['agent_id'],
358
+ status=status,
359
+ routing_outcome=outcome,
360
+ routing_explanation=explanation,
361
+ model_strategy_json=model_strategy.model_dump_json(),
362
+ model_strategy_source=model_source,
363
+ executor_decision_json=(
364
+ json.dumps(executor_raw, sort_keys=True)
365
+ if executor_raw else None
366
+ ),
367
+ executor_policy_json=(
368
+ json.dumps(executor_raw.get('executor_policy'), sort_keys=True)
369
+ if executor_raw and executor_raw.get('executor_policy') else None
370
+ ),
371
+ selected_executor_id=(
372
+ (executor_raw or {}).get('selected_executor_id')
373
+ or (executor_raw or {}).get('executor_id')
374
+ ),
375
+ matched_rule_id=(executor_raw or {}).get('matched_rule_id'),
376
+ selection_reason=(executor_raw or {}).get('selection_reason'),
377
+ fallback_outcome=(executor_raw or {}).get('fallback_outcome'),
378
+ fallback_reason=(executor_raw or {}).get('fallback_reason'),
379
+ blocked_reason=(executor_raw or {}).get('blocked_reason'),
380
+ assignment_summary=selected.get('responsibility'),
381
+ decision_id=decision_id,
382
+ decision_json=decision.model_dump_json(),
383
+ event_id=event_id,
384
+ event_summary=f'HR recruitment approved: {approval_reason}',
385
+ updated_at=now,
386
+ )
387
+
388
+ async def list_project_agent_recruitments(
389
+ self,
390
+ actor: dict,
391
+ personal_space_id: str,
392
+ *,
393
+ personal_project_id: str | None = None,
394
+ task_id: str | None = None,
395
+ status: str | None = None,
396
+ ) -> list[ProjectAgentRecruitmentRecord]:
397
+ self._require_personal()
398
+ space = await self.authorize(actor, personal_space_id, 'reader')
399
+ if personal_project_id:
400
+ await authorize_personal_project(
401
+ self,
402
+ actor,
403
+ space,
404
+ personal_project_id,
405
+ )
406
+ records, _, _ = await self.runtime.driver.execute_query(
407
+ '''
408
+ MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})-
409
+ [:HAS_PROJECT_AGENT_RECRUITMENT]->
410
+ (recruitment:FuliProjectAgentRecruitment)
411
+ WHERE ($personal_project_id IS NULL
412
+ OR recruitment.personal_project_id = $personal_project_id)
413
+ AND ($task_id IS NULL OR recruitment.task_id = $task_id)
414
+ AND ($status IS NULL OR recruitment.status = $status)
415
+ RETURN recruitment
416
+ ORDER BY recruitment.created_at DESC, recruitment.recruitment_id
417
+ ''',
418
+ personal_space_id=personal_space_id,
419
+ personal_project_id=personal_project_id,
420
+ task_id=task_id,
421
+ status=status,
422
+ routing_='r',
423
+ )
424
+ return [self._recruitment(dict(row['recruitment'])) for row in records]
425
+
426
+ async def _open_recruitment(
427
+ self,
428
+ actor,
429
+ space,
430
+ request,
431
+ task_id,
432
+ coordinator_agent_id,
433
+ reason_code,
434
+ ):
435
+ hr_records, _, _ = await self.runtime.driver.execute_query(
436
+ '''
437
+ MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})-
438
+ [:HAS_PROJECT_AGENT_IDENTITY]->
439
+ (hr:FuliProjectAgent {agent_type: 'hr', status: 'active'})
440
+ RETURN hr ORDER BY hr.created_at, hr.agent_id LIMIT 1
441
+ ''',
442
+ personal_space_id=request.personal_space_id,
443
+ routing_='r',
444
+ )
445
+ hr_agent_id = (
446
+ dict(hr_records[0]['hr'])['agent_id'] if hr_records else None
447
+ )
448
+ policy = await self.get_project_agent_recruitment_policy(
449
+ actor,
450
+ request.personal_space_id,
451
+ )
452
+ position_kind = (
453
+ 'temporary'
454
+ if request.staffing_intent == 'temporary'
455
+ else 'durable'
456
+ )
457
+ profile = request.recruitment_profile or ProjectAgentProfile(
458
+ name=f'{request.work_kind} Agent',
459
+ responsibility=request.objective,
460
+ agent_type=position_kind,
461
+ work_kinds=[request.work_kind],
462
+ capabilities=request.required_capabilities,
463
+ status='active',
464
+ )
465
+ if profile.agent_type != position_kind:
466
+ profile = profile.model_copy(update={'agent_type': position_kind})
467
+ self._require_agent_client_allowed(
468
+ profile,
469
+ request.source_application,
470
+ )
471
+ recruitment_id = stable_uuid(
472
+ self.settings.provider_id,
473
+ request.personal_space_id,
474
+ 'project-agent-recruitment',
475
+ task_id,
476
+ )
477
+ proposed_agent_id = stable_uuid(
478
+ self.settings.provider_id,
479
+ request.personal_space_id,
480
+ 'recruited-project-agent',
481
+ task_id,
482
+ position_kind,
483
+ )
484
+ if not hr_agent_id:
485
+ status = 'no_hr'
486
+ elif policy.confirmation_mode == 'require_confirmation':
487
+ status = 'awaiting_confirmation'
488
+ else:
489
+ status = 'requested'
490
+ now = now_utc()
491
+ raw = {
492
+ 'id': recruitment_id,
493
+ 'recruitment_id': recruitment_id,
494
+ 'personal_space_id': request.personal_space_id,
495
+ 'personal_project_id': request.personal_project_id,
496
+ 'task_id': task_id,
497
+ 'coordinator_agent_id': coordinator_agent_id,
498
+ 'hr_agent_id': hr_agent_id,
499
+ 'position_kind': position_kind,
500
+ 'work_kind': request.work_kind,
501
+ 'required_capabilities': request.required_capabilities,
502
+ 'reason_code': reason_code,
503
+ 'reason': request.routing_reason,
504
+ 'status': status,
505
+ 'confirmation_mode': policy.confirmation_mode,
506
+ 'proposed_agent_id': proposed_agent_id,
507
+ 'proposed_profile_json': profile.model_dump_json(),
508
+ 'occupation_emoji': profile.occupation_emoji,
509
+ 'trigger_source_application': request.source_application,
510
+ 'trigger_source_session_id': request.source_session_id,
511
+ 'revision': 0,
512
+ 'created_at': now,
513
+ 'updated_at': now,
514
+ 'test_source': profile.test_source,
515
+ 'cleanup_eligible': profile.cleanup_eligible,
516
+ }
517
+ await self._persist_recruitment(raw)
518
+ record = self._recruitment(raw)
519
+ if status != 'requested' or not hr_agent_id:
520
+ return record, None
521
+ recruited = await self._provision_recruitment(actor, raw)
522
+ return await self._get_recruitment(
523
+ request.personal_space_id,
524
+ recruitment_id,
525
+ ), recruited
526
+
527
+ async def _persist_recruitment(self, raw):
528
+ await self.runtime.driver.execute_query(
529
+ '''
530
+ MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})
531
+ MATCH (space)-[:CONTAINS_PROJECT]->
532
+ (project:FuliPersonalProject {project_id: $personal_project_id})
533
+ MERGE (recruitment:FuliProjectAgentRecruitment {id: $id})
534
+ ON CREATE SET recruitment += $properties
535
+ MERGE (space)-[:HAS_PROJECT_AGENT_RECRUITMENT]->(recruitment)
536
+ MERGE (project)-[:HAS_PROJECT_AGENT_RECRUITMENT]->(recruitment)
537
+ WITH space, recruitment
538
+ OPTIONAL MATCH (space)-[:HAS_PROJECT_AGENT_IDENTITY]->
539
+ (hr:FuliProjectAgent {agent_id: $hr_agent_id})
540
+ FOREACH (_ IN CASE WHEN hr IS NULL THEN [] ELSE [1] END |
541
+ MERGE (recruitment)-[:RECRUITED_BY]->(hr)
542
+ )
543
+ ''',
544
+ personal_space_id=raw['personal_space_id'],
545
+ personal_project_id=raw['personal_project_id'],
546
+ id=raw['id'],
547
+ properties=raw,
548
+ hr_agent_id=raw.get('hr_agent_id'),
549
+ )
550
+
551
+ async def _provision_recruitment(self, actor, raw):
552
+ profile = ProjectAgentProfile.model_validate_json(
553
+ raw['proposed_profile_json']
554
+ )
555
+ occupation_emoji = raw.get('occupation_emoji') or raw.get(
556
+ 'occupationEmoji'
557
+ )
558
+ if not profile.occupation_emoji and occupation_emoji:
559
+ profile = profile.model_copy(
560
+ update={'occupation_emoji': occupation_emoji}
561
+ )
562
+ agent_id = raw['proposed_agent_id']
563
+ await self.upsert_project_agent(
564
+ actor,
565
+ ProjectAgentUpsert(
566
+ personal_space_id=raw['personal_space_id'],
567
+ agent_id=agent_id,
568
+ profile=profile,
569
+ ),
570
+ recruitment_id=raw['recruitment_id'],
571
+ )
572
+ await self.create_project_agent_assignment(
573
+ actor,
574
+ ProjectAgentAssignmentCreate(
575
+ personal_space_id=raw['personal_space_id'],
576
+ personal_project_id=raw['personal_project_id'],
577
+ agent_id=agent_id,
578
+ idempotency_key=f"recruit:{raw['recruitment_id']}",
579
+ responsibility=profile.responsibility,
580
+ work_kinds=profile.work_kinds or [raw['work_kind']],
581
+ capabilities=profile.capabilities,
582
+ reason=raw['reason'],
583
+ source_application=raw.get('trigger_source_application'),
584
+ source_session_id=raw.get('trigger_source_session_id'),
585
+ ),
586
+ )
587
+ updated_at = now_utc()
588
+ await self.runtime.driver.execute_query(
589
+ '''
590
+ MATCH (space:FuliSpace {
591
+ id: $personal_space_id, kind: 'personal'
592
+ })-[:HAS_PROJECT_AGENT_RECRUITMENT]->
593
+ (recruitment:FuliProjectAgentRecruitment {
594
+ recruitment_id: $recruitment_id
595
+ })
596
+ MATCH (space)-[:HAS_PROJECT_AGENT_IDENTITY]->
597
+ (agent:FuliProjectAgent {agent_id: $agent_id})
598
+ SET recruitment.status = 'fulfilled',
599
+ recruitment.recruited_agent_id = $agent_id,
600
+ recruitment.fulfilled_at = $updated_at,
601
+ recruitment.updated_at = $updated_at,
602
+ recruitment.revision = recruitment.revision + 1,
603
+ agent.recruitment_id = $recruitment_id,
604
+ agent.recruited_at = $updated_at,
605
+ agent.recruitment_reason = recruitment.reason,
606
+ agent.recruitment_source_application =
607
+ recruitment.trigger_source_application,
608
+ agent.temporary_task_id = CASE recruitment.position_kind
609
+ WHEN 'temporary' THEN recruitment.task_id ELSE null END,
610
+ agent.test_source = recruitment.test_source,
611
+ agent.cleanup_eligible =
612
+ coalesce(recruitment.cleanup_eligible, false)
613
+ MERGE (recruitment)-[:CREATED_AGENT]->(agent)
614
+ ''',
615
+ recruitment_id=raw['recruitment_id'],
616
+ personal_space_id=raw['personal_space_id'],
617
+ agent_id=agent_id,
618
+ updated_at=updated_at,
619
+ )
620
+ assignment_rows = await self._assignment_candidates(
621
+ raw['personal_space_id'],
622
+ raw['personal_project_id'],
623
+ include_temporary=True,
624
+ )
625
+ selected = next(item for item in assignment_rows if item['agent_id'] == agent_id)
626
+ return selected
627
+
628
+ async def _get_recruitment(self, personal_space_id, recruitment_id):
629
+ records, _, _ = await self.runtime.driver.execute_query(
630
+ '''
631
+ MATCH (recruitment:FuliProjectAgentRecruitment {
632
+ personal_space_id: $personal_space_id,
633
+ recruitment_id: $recruitment_id
634
+ })
635
+ RETURN recruitment
636
+ ''',
637
+ personal_space_id=personal_space_id,
638
+ recruitment_id=recruitment_id,
639
+ routing_='r',
640
+ )
641
+ return (
642
+ self._recruitment(dict(records[0]['recruitment']))
643
+ if records else None
644
+ )
645
+
646
+ async def _task_recruitment(self, personal_space_id, task_id):
647
+ records, _, _ = await self.runtime.driver.execute_query(
648
+ '''
649
+ MATCH (recruitment:FuliProjectAgentRecruitment {
650
+ personal_space_id: $personal_space_id,
651
+ task_id: $task_id
652
+ })
653
+ RETURN recruitment ORDER BY recruitment.created_at DESC LIMIT 1
654
+ ''',
655
+ personal_space_id=personal_space_id,
656
+ task_id=task_id,
657
+ routing_='r',
658
+ )
659
+ return self._recruitment(dict(records[0]['recruitment'])) if records else None
660
+
661
+ def _recruitment(self, raw):
662
+ proposed_profile = ProjectAgentProfile.model_validate_json(
663
+ raw['proposed_profile_json']
664
+ )
665
+ occupation_emoji = raw.get('occupation_emoji') or raw.get(
666
+ 'occupationEmoji'
667
+ )
668
+ if not proposed_profile.occupation_emoji and occupation_emoji:
669
+ proposed_profile = proposed_profile.model_copy(
670
+ update={'occupation_emoji': occupation_emoji}
671
+ )
672
+ fields = {
673
+ 'recruitment_id': raw.get('recruitment_id') or raw['id'],
674
+ 'personal_space_id': raw['personal_space_id'],
675
+ 'personal_project_id': raw['personal_project_id'],
676
+ 'task_id': raw['task_id'],
677
+ 'coordinator_agent_id': raw['coordinator_agent_id'],
678
+ 'hr_agent_id': raw.get('hr_agent_id'),
679
+ 'position_kind': raw['position_kind'],
680
+ 'work_kind': raw['work_kind'],
681
+ 'required_capabilities': list(raw.get('required_capabilities') or []),
682
+ 'reason_code': raw['reason_code'],
683
+ 'reason': raw['reason'],
684
+ 'status': raw['status'],
685
+ 'confirmation_mode': raw['confirmation_mode'],
686
+ 'proposed_agent_id': raw['proposed_agent_id'],
687
+ 'proposed_profile': proposed_profile,
688
+ 'trigger_source_application': raw.get('trigger_source_application'),
689
+ 'trigger_source_session_id': raw.get('trigger_source_session_id'),
690
+ 'revision': int(raw.get('revision') or 0),
691
+ 'recruited_agent_id': raw.get('recruited_agent_id'),
692
+ 'created_at': native_datetime(raw['created_at']),
693
+ 'updated_at': native_datetime(raw['updated_at']),
694
+ 'fulfilled_at': native_datetime(raw.get('fulfilled_at')),
695
+ }
696
+ for name in ('test_source', 'cleanup_eligible'):
697
+ if name in ProjectAgentRecruitmentRecord.model_fields:
698
+ fields[name] = raw.get(name)
699
+ return ProjectAgentRecruitmentRecord(**fields)