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,601 @@
1
+ """Policy-aware executor selection and its durable audit decision."""
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_executor_models import (
11
+ ProjectAgentExecutorModelRecord,
12
+ ProjectAgentExecutorRecord,
13
+ ProjectAgentExecutorRoutingRuleRecord,
14
+ ProjectAgentExecutorScope,
15
+ ProjectAgentExecutorSelection,
16
+ project_agent_model_strategy_key,
17
+ )
18
+ from .project_agent_models import ProjectAgentExecutorPolicy, ProjectAgentModelStrategy
19
+ from .provider_values import now_utc, stable_uuid
20
+
21
+
22
+ _RULE_SCOPE_RANK = {'global': 0, 'space': 1, 'project': 2, 'task': 3}
23
+
24
+
25
+ class StoreProjectAgentExecutorRouting:
26
+ """Resolve registered executors without owning directory mutations."""
27
+
28
+ async def resolve_project_agent_executor(
29
+ self,
30
+ actor: dict,
31
+ *,
32
+ personal_space_id: str,
33
+ personal_project_id: str,
34
+ task_id: str,
35
+ agent_id: str,
36
+ work_kind: str,
37
+ required_capabilities: list[str] | None = None,
38
+ model_strategy: ProjectAgentModelStrategy | None = None,
39
+ task_override: ProjectAgentExecutorPolicy | dict | None = None,
40
+ executor_policy: ProjectAgentExecutorPolicy | dict | None = None,
41
+ assignment_id: str | None = None,
42
+ model_strategy_source: str | None = None,
43
+ idempotency_key: str | None = None,
44
+ ) -> ProjectAgentExecutorSelection:
45
+ """Resolve and persist an executor decision without creating a worker.
46
+
47
+ Rule precedence is task > project > space > global. An empty rule
48
+ directory is valid and means there is no work-kind mapping; flexible
49
+ selection then uses only the global executor priority. A locked Agent
50
+ policy is evaluated first and never falls back outside its allow-list.
51
+ """
52
+
53
+ self._require_personal()
54
+ space = await self.authorize(actor, personal_space_id, 'maintainer')
55
+ await authorize_personal_project(
56
+ self,
57
+ actor,
58
+ space,
59
+ personal_project_id,
60
+ )
61
+ required = [item.strip() for item in (required_capabilities or [])]
62
+ if any(not item for item in required):
63
+ raise HTTPException(status_code=422, detail='required capabilities cannot be blank')
64
+ agent_rows, _, _ = await self.runtime.driver.execute_query(
65
+ '''
66
+ MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
67
+ [:HAS_PROJECT_AGENT_IDENTITY]->
68
+ (agent:FuliProjectAgent {agent_id: $agent_id})
69
+ OPTIONAL MATCH (space)-[:CONTAINS_PROJECT]->
70
+ (:FuliPersonalProject {project_id: $personal_project_id})-
71
+ [:HAS_PROJECT_AGENT_ASSIGNMENT]->
72
+ (assignment:FuliProjectAgentAssignment)-[:ASSIGNED_AGENT]->(agent)
73
+ WHERE assignment.status = 'active'
74
+ AND ($assignment_id IS NULL OR assignment.assignment_id = $assignment_id)
75
+ AND ($assignment_id IS NOT NULL
76
+ OR $work_kind IN coalesce(assignment.work_kinds, []))
77
+ RETURN agent, assignment
78
+ ''',
79
+ personal_space_id=personal_space_id,
80
+ agent_id=agent_id,
81
+ personal_project_id=personal_project_id,
82
+ assignment_id=assignment_id,
83
+ work_kind=work_kind,
84
+ routing_='r',
85
+ )
86
+ if not agent_rows:
87
+ raise HTTPException(status_code=404, detail='Agent not found')
88
+ agent_raw = dict(agent_rows[0].get('agent') or {})
89
+ assignment_raw = dict(agent_rows[0].get('assignment') or {})
90
+ agent_policy = self._policy_from_agent(agent_raw)
91
+ assignment_policy = self._policy_from_assignment(assignment_raw)
92
+ override = task_override if task_override is not None else executor_policy
93
+ # An Agent-level lock is the highest authority. It cannot be
94
+ # bypassed by a Task, Assignment, or routing-rule override.
95
+ if agent_policy.mode == 'locked':
96
+ policy = agent_policy
97
+ resolved_strategy_source = model_strategy_source or 'agent'
98
+ elif override is not None:
99
+ policy = (
100
+ override
101
+ if isinstance(override, ProjectAgentExecutorPolicy)
102
+ else ProjectAgentExecutorPolicy.model_validate(override)
103
+ )
104
+ resolved_strategy_source = model_strategy_source or 'task'
105
+ elif assignment_policy is not None:
106
+ policy = assignment_policy
107
+ resolved_strategy_source = model_strategy_source or 'assignment'
108
+ else:
109
+ policy = agent_policy
110
+ resolved_strategy_source = model_strategy_source or 'agent'
111
+ strategy = model_strategy or self._strategy_from_agent(agent_raw)
112
+ strategy_key = project_agent_model_strategy_key(strategy)
113
+ rules_records, _, _ = await self.runtime.driver.execute_query(
114
+ '''
115
+ MATCH (rule:FuliProjectAgentExecutorRoutingRule)
116
+ WHERE rule.status = 'active'
117
+ AND rule.work_kind = $work_kind
118
+ AND (rule.scope = 'global'
119
+ OR (rule.scope = 'space'
120
+ AND rule.personal_space_id = $personal_space_id)
121
+ OR (rule.scope = 'project'
122
+ AND rule.personal_space_id = $personal_space_id
123
+ AND rule.personal_project_id = $personal_project_id)
124
+ OR (rule.scope = 'task'
125
+ AND rule.personal_space_id = $personal_space_id
126
+ AND rule.personal_project_id = $personal_project_id
127
+ AND rule.task_id = $task_id))
128
+ RETURN rule
129
+ ORDER BY CASE rule.scope
130
+ WHEN 'task' THEN 3
131
+ WHEN 'project' THEN 2
132
+ WHEN 'space' THEN 1
133
+ ELSE 0 END DESC,
134
+ coalesce(rule.priority, 100), rule.rule_id
135
+ ''',
136
+ personal_space_id=personal_space_id,
137
+ personal_project_id=personal_project_id,
138
+ task_id=task_id,
139
+ work_kind=work_kind,
140
+ routing_='r',
141
+ )
142
+ requested_capabilities = {item.casefold() for item in required}
143
+ rules = [
144
+ rule
145
+ for rule in (
146
+ self._routing_rule_from_raw(dict(row['rule']))
147
+ for row in rules_records
148
+ if row.get('rule')
149
+ )
150
+ if {
151
+ item.casefold() for item in rule.required_capabilities
152
+ }.issubset(requested_capabilities)
153
+ ]
154
+ top_scope = max((_RULE_SCOPE_RANK[item.scope] for item in rules), default=None)
155
+ top_rules = [
156
+ item for item in rules
157
+ if top_scope is not None and _RULE_SCOPE_RANK[item.scope] == top_scope
158
+ ]
159
+ executor_records, _, _ = await self.runtime.driver.execute_query(
160
+ '''
161
+ MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})-
162
+ [permission:HAS_EXECUTOR_PERMISSION]->
163
+ (executor:FuliProjectAgentExecutor)
164
+ OPTIONAL MATCH (aggregate:FuliProjectAgentExecutorOutcomeAggregate {
165
+ personal_space_id: $personal_space_id,
166
+ personal_project_id: $personal_project_id,
167
+ work_kind: $work_kind,
168
+ agent_id: $agent_id,
169
+ model_strategy_key: $model_strategy_key,
170
+ executor_id: executor.executor_id
171
+ })
172
+ RETURN executor, permission, aggregate
173
+ ORDER BY coalesce(executor.global_priority, 100), executor.executor_id
174
+ ''',
175
+ personal_space_id=personal_space_id,
176
+ personal_project_id=personal_project_id,
177
+ work_kind=work_kind,
178
+ agent_id=agent_id,
179
+ model_strategy_key=strategy_key,
180
+ routing_='r',
181
+ )
182
+ executor_map = {
183
+ str(row.get('executor', {}).get('executor_id')): self._executor_from_row(
184
+ row,
185
+ personal_space_id,
186
+ )
187
+ for row in executor_records
188
+ if row.get('executor')
189
+ }
190
+ aggregate_map = {}
191
+ for row in executor_records:
192
+ if not row.get('executor') or not row.get('aggregate'):
193
+ continue
194
+ aggregate_raw = dict(row['aggregate'])
195
+ aggregate_json = aggregate_raw.get('aggregate_json')
196
+ if aggregate_json:
197
+ try:
198
+ aggregate_raw.update(json.loads(aggregate_json))
199
+ except (TypeError, ValueError):
200
+ pass
201
+ aggregate_key = aggregate_raw.get('model_strategy_key')
202
+ if not aggregate_key:
203
+ aggregate_strategy_json = aggregate_raw.get('model_strategy_json')
204
+ if aggregate_strategy_json:
205
+ try:
206
+ aggregate_strategy = ProjectAgentModelStrategy.model_validate_json(
207
+ aggregate_strategy_json
208
+ )
209
+ except Exception:
210
+ aggregate_strategy = ProjectAgentModelStrategy()
211
+ else:
212
+ aggregate_strategy = ProjectAgentModelStrategy()
213
+ aggregate_key = project_agent_model_strategy_key(aggregate_strategy)
214
+ # The Cypher pattern is strategy-scoped. Keep the defensive
215
+ # filter as well so a compatible driver/cache cannot merge a
216
+ # different strategy bucket into this route decision.
217
+ if aggregate_key != strategy_key:
218
+ continue
219
+ aggregate_map[str(row['executor'].get('executor_id'))] = aggregate_raw
220
+ candidates, candidate_rule_id, candidate_scope = self._candidate_ids(
221
+ policy,
222
+ executor_map,
223
+ top_rules,
224
+ )
225
+ if policy.mode == 'flexible' and top_rules and not policy.preferred_executor_ids:
226
+ candidates = self._tie_break_same_level_candidates(
227
+ candidates,
228
+ top_rules,
229
+ executor_map,
230
+ aggregate_map,
231
+ )
232
+ selected = None
233
+ selected_model = None
234
+ unavailable_reasons: dict[str, str] = {}
235
+ for candidate_id in candidates:
236
+ executor = executor_map.get(candidate_id)
237
+ if executor is None:
238
+ unavailable_reasons[candidate_id] = 'executor is not registered in this space'
239
+ continue
240
+ reason = self._executor_unavailable_reason(
241
+ executor,
242
+ required,
243
+ strategy,
244
+ )
245
+ if reason:
246
+ unavailable_reasons[candidate_id] = reason
247
+ continue
248
+ selected = executor
249
+ selected_model = self._select_model(executor.available_models, strategy)
250
+ break
251
+ timestamp = now_utc()
252
+ selection_id = stable_uuid(
253
+ self.settings.provider_id,
254
+ personal_space_id,
255
+ 'project-agent-executor-selection',
256
+ task_id,
257
+ idempotency_key or task_id,
258
+ )
259
+ first_candidate = candidates[0] if candidates else None
260
+ if selected:
261
+ used_fallback = bool(first_candidate and first_candidate != selected.executor_id)
262
+ if policy.mode == 'locked':
263
+ status = 'selected'
264
+ fallback_outcome = 'not_needed'
265
+ fallback_reason = None
266
+ elif used_fallback:
267
+ status = 'fallback'
268
+ fallback_outcome = (
269
+ 'same_rule_candidate'
270
+ if candidate_scope is not None and candidate_scope != 'global'
271
+ else 'global_priority'
272
+ )
273
+ fallback_reason = (
274
+ f'{first_candidate} unavailable: '
275
+ f'{unavailable_reasons.get(first_candidate, "not selected")}'
276
+ )
277
+ else:
278
+ status = 'selected'
279
+ fallback_outcome = 'not_needed'
280
+ fallback_reason = None
281
+ selection_reason = (
282
+ 'locked executor allow-list'
283
+ if policy.mode == 'locked'
284
+ else 'explicit executor preference'
285
+ if policy.preferred_executor_ids
286
+ else 'same-level routing rule'
287
+ if candidate_rule_id
288
+ else 'global executor priority'
289
+ )
290
+ blocked_reason = None
291
+ selected_id = selected.executor_id
292
+ selected_provider = selected_model.provider if selected_model else None
293
+ selected_model_name = selected_model.model if selected_model else None
294
+ else:
295
+ # A flexible policy may try every eligible candidate, but an empty
296
+ # result is still a durable block for the Task. Returning
297
+ # ``unavailable`` here would let the task store leave a task
298
+ # queued with no executable owner.
299
+ status = 'blocked'
300
+ fallback_outcome = (
301
+ 'blocked_locked' if policy.mode == 'locked' else 'blocked_no_candidate'
302
+ )
303
+ fallback_reason = None
304
+ selection_reason = (
305
+ 'locked allow-list unavailable'
306
+ if policy.mode == 'locked'
307
+ else 'no usable executor'
308
+ )
309
+ blocked_reason = '; '.join(
310
+ f'{key}: {value}' for key, value in unavailable_reasons.items()
311
+ ) or 'no registered executor candidate'
312
+ selected_id = None
313
+ selected_provider = None
314
+ selected_model_name = None
315
+ selection = ProjectAgentExecutorSelection(
316
+ selection_id=selection_id,
317
+ task_id=task_id,
318
+ personal_space_id=personal_space_id,
319
+ personal_project_id=personal_project_id,
320
+ agent_id=agent_id,
321
+ status=status,
322
+ outcome=status,
323
+ selected_executor_id=selected_id,
324
+ executor_id=selected_id,
325
+ selected_provider=selected_provider,
326
+ selected_model=selected_model_name,
327
+ matched_rule_id=candidate_rule_id,
328
+ rule_id=candidate_rule_id,
329
+ matched_rule_scope=candidate_scope,
330
+ candidate_executor_ids=candidates,
331
+ selection_reason=selection_reason,
332
+ reason=selection_reason,
333
+ fallback_outcome=fallback_outcome,
334
+ fallback_from_executor_id=(
335
+ first_candidate
336
+ if selected and first_candidate != selected_id
337
+ else None
338
+ ),
339
+ fallback_reason=fallback_reason,
340
+ blocked_reason=blocked_reason,
341
+ model_strategy=strategy,
342
+ model_strategy_key=strategy_key,
343
+ model_strategy_source=resolved_strategy_source,
344
+ executor_policy=policy,
345
+ idempotency_key=idempotency_key,
346
+ payload_fingerprint=self._payload_hash({
347
+ 'task_id': task_id,
348
+ 'agent_id': agent_id,
349
+ 'work_kind': work_kind,
350
+ 'required_capabilities': required,
351
+ 'model_strategy': strategy.model_dump(mode='json'),
352
+ 'policy': policy.model_dump(mode='json'),
353
+ }),
354
+ created_at=timestamp,
355
+ )
356
+ await self._persist_executor_selection(selection)
357
+ return selection
358
+
359
+ async def _persist_executor_selection(
360
+ self,
361
+ selection: ProjectAgentExecutorSelection,
362
+ ) -> None:
363
+ raw = selection.model_dump(mode='json')
364
+ records, _, _ = await self.runtime.driver.execute_query(
365
+ '''
366
+ MERGE (decision:FuliProjectAgentExecutorDecision {
367
+ id: $selection_id
368
+ })
369
+ ON CREATE SET decision.selection_id = $selection_id,
370
+ decision.task_id = $task_id,
371
+ decision.personal_space_id = $personal_space_id,
372
+ decision.payload_fingerprint = $payload_fingerprint,
373
+ decision.model_strategy_key = $model_strategy_key,
374
+ decision.created_at = $created_at
375
+ WITH decision,
376
+ coalesce(decision.payload_fingerprint, $payload_fingerprint)
377
+ = $payload_fingerprint AS same_payload
378
+ WHERE same_payload
379
+ SET decision.decision_json = $decision_json,
380
+ decision.selected_executor_id = $selected_executor_id,
381
+ decision.matched_rule_id = $matched_rule_id,
382
+ decision.selection_status = $selection_status,
383
+ decision.fallback_outcome = $fallback_outcome,
384
+ decision.blocked_reason = $blocked_reason,
385
+ decision.updated_at = $created_at
386
+ WITH decision
387
+ OPTIONAL MATCH (:FuliSpace {
388
+ id: $personal_space_id, kind: 'personal'
389
+ })-[:HAS_PROJECT_AGENT_TASK]->
390
+ (task:FuliProjectAgentTask {
391
+ personal_space_id: $personal_space_id,
392
+ task_id: $task_id
393
+ })
394
+ FOREACH (_ IN CASE WHEN task IS NULL THEN [] ELSE [1] END |
395
+ SET task.selected_executor_id = $selected_executor_id,
396
+ task.matched_executor_rule_id = $matched_rule_id,
397
+ task.executor_selection_reason = $selection_reason,
398
+ task.executor_fallback_outcome = $fallback_outcome,
399
+ task.executor_fallback_reason = $fallback_reason,
400
+ task.executor_blocked_reason = $blocked_reason,
401
+ task.executor_decision_json = $decision_json,
402
+ task.updated_at = $created_at
403
+ )
404
+ RETURN decision
405
+ ''',
406
+ selection_id=selection.selection_id,
407
+ personal_space_id=selection.personal_space_id,
408
+ task_id=selection.task_id,
409
+ payload_fingerprint=selection.payload_fingerprint,
410
+ model_strategy_key=selection.model_strategy_key,
411
+ created_at=selection.created_at,
412
+ decision_json=json.dumps(raw, sort_keys=True),
413
+ selected_executor_id=selection.selected_executor_id,
414
+ matched_rule_id=selection.matched_rule_id,
415
+ selection_status=selection.status,
416
+ fallback_outcome=selection.fallback_outcome,
417
+ blocked_reason=selection.blocked_reason,
418
+ selection_reason=selection.selection_reason,
419
+ fallback_reason=selection.fallback_reason,
420
+ )
421
+ if not records:
422
+ raise HTTPException(
423
+ status_code=409,
424
+ detail='executor selection idempotency key was reused with different input',
425
+ )
426
+
427
+ @staticmethod
428
+ def _policy_from_agent(raw: dict) -> ProjectAgentExecutorPolicy:
429
+ value = raw.get('executor_policy') or raw.get('executor_policy_json')
430
+ if value is None and raw.get('profile_json'):
431
+ try:
432
+ profile = json.loads(raw['profile_json'])
433
+ value = profile.get('executor_policy')
434
+ except (TypeError, ValueError):
435
+ value = None
436
+ try:
437
+ if isinstance(value, str):
438
+ return ProjectAgentExecutorPolicy.model_validate_json(value)
439
+ return ProjectAgentExecutorPolicy.model_validate(value or {})
440
+ except Exception:
441
+ return ProjectAgentExecutorPolicy()
442
+
443
+ @staticmethod
444
+ def _policy_from_assignment(
445
+ raw: dict,
446
+ ) -> ProjectAgentExecutorPolicy | None:
447
+ if not raw:
448
+ return None
449
+ value = raw.get('executor_policy') or raw.get('executor_policy_json')
450
+ if value is None:
451
+ return None
452
+ try:
453
+ if isinstance(value, str):
454
+ return ProjectAgentExecutorPolicy.model_validate_json(value)
455
+ return ProjectAgentExecutorPolicy.model_validate(value)
456
+ except Exception:
457
+ return None
458
+
459
+ @staticmethod
460
+ def _strategy_from_agent(raw: dict) -> ProjectAgentModelStrategy:
461
+ value = raw.get('default_model_strategy') or raw.get('default_model_strategy_json')
462
+ if value is None and raw.get('profile_json'):
463
+ try:
464
+ profile = json.loads(raw['profile_json'])
465
+ value = profile.get('default_model_strategy')
466
+ except (TypeError, ValueError):
467
+ value = None
468
+ try:
469
+ if isinstance(value, str):
470
+ return ProjectAgentModelStrategy.model_validate_json(value)
471
+ return ProjectAgentModelStrategy.model_validate(value or {})
472
+ except Exception:
473
+ return ProjectAgentModelStrategy()
474
+
475
+ @staticmethod
476
+ def _candidate_ids(
477
+ policy: ProjectAgentExecutorPolicy,
478
+ executor_map: dict[str, ProjectAgentExecutorRecord],
479
+ top_rules: list[ProjectAgentExecutorRoutingRuleRecord],
480
+ ) -> tuple[list[str], str | None, ProjectAgentExecutorScope | None]:
481
+ if policy.mode == 'locked':
482
+ # The list is an allow-list, not a single executor. Its explicit
483
+ # order is retained; no candidate outside it can be considered.
484
+ return (
485
+ list(policy.locked_executor_ids),
486
+ None,
487
+ None,
488
+ )
489
+ candidate_ids: list[str] = []
490
+ if policy.preferred_executor_ids:
491
+ candidate_ids.extend(policy.preferred_executor_ids)
492
+ for rule in top_rules:
493
+ for executor_id in rule.executor_ids:
494
+ if executor_id not in candidate_ids:
495
+ candidate_ids.append(executor_id)
496
+ for executor_id in sorted(
497
+ executor_map,
498
+ key=lambda item: (executor_map[item].global_priority, item),
499
+ ):
500
+ if executor_id not in candidate_ids:
501
+ candidate_ids.append(executor_id)
502
+ return (
503
+ candidate_ids,
504
+ top_rules[0].rule_id if top_rules else None,
505
+ top_rules[0].scope if top_rules else None,
506
+ )
507
+
508
+ @staticmethod
509
+ def _tie_break_same_level_candidates(
510
+ candidate_ids: list[str],
511
+ rules: list[ProjectAgentExecutorRoutingRuleRecord],
512
+ executor_map: dict[str, ProjectAgentExecutorRecord],
513
+ aggregate_map: dict[str, dict],
514
+ ) -> list[str]:
515
+ rule_priority: dict[str, tuple[int, int]] = {}
516
+ for rule_index, rule in enumerate(rules):
517
+ for executor_index, executor_id in enumerate(rule.executor_ids):
518
+ rule_priority.setdefault(executor_id, (rule.priority, executor_index))
519
+ # Outcome evidence only resolves candidates named by same-level rules.
520
+ # Explicit preferences and global priority remain the primary order.
521
+ def key(executor_id: str):
522
+ explicit = rule_priority.get(executor_id, (1_000_000, 1_000_000))
523
+ aggregate = aggregate_map.get(executor_id) or {}
524
+ neutral = bool(aggregate.get('neutral_due_to_insufficient_evidence', True))
525
+ evidence_score = (
526
+ float(aggregate.get('weighted_success', 0))
527
+ - float(aggregate.get('weighted_failure', 0))
528
+ if not neutral else 0.0
529
+ )
530
+ executor = executor_map.get(executor_id)
531
+ return (
532
+ explicit[0],
533
+ explicit[1],
534
+ -evidence_score,
535
+ executor.global_priority if executor else 1_000_000,
536
+ executor_id,
537
+ )
538
+
539
+ return sorted(candidate_ids, key=key)
540
+
541
+ @staticmethod
542
+ def _executor_unavailable_reason(
543
+ executor: ProjectAgentExecutorRecord,
544
+ required_capabilities: list[str],
545
+ strategy: ProjectAgentModelStrategy,
546
+ ) -> str | None:
547
+ if executor.registration_status != 'registered':
548
+ return f'registration status is {executor.registration_status}'
549
+ if executor.permission_status != 'authorized' or not executor.workspace_permission:
550
+ return 'workspace permission is not authorized'
551
+ if executor.preflight_status != 'passed':
552
+ return f'preflight status is {executor.preflight_status}'
553
+ available_capabilities = {
554
+ item.casefold() for item in executor.capabilities
555
+ }
556
+ missing = [
557
+ item for item in required_capabilities
558
+ if item.casefold() not in available_capabilities
559
+ ]
560
+ if missing:
561
+ return f'missing capabilities: {", ".join(missing)}'
562
+ if executor.health_status == 'unhealthy':
563
+ return 'executor health is unhealthy'
564
+ if executor.health_required and executor.health_status != 'healthy':
565
+ return f'health status is {executor.health_status}'
566
+ if not StoreProjectAgentExecutorRouting._select_model(
567
+ executor.available_models,
568
+ strategy,
569
+ ):
570
+ return 'no reported model satisfies the provider-neutral strategy'
571
+ return None
572
+
573
+ @staticmethod
574
+ def _select_model(
575
+ models: list[ProjectAgentExecutorModelRecord],
576
+ strategy: ProjectAgentModelStrategy,
577
+ ) -> ProjectAgentExecutorModelRecord | None:
578
+ available = [item for item in models if item.available]
579
+ if not available:
580
+ return None
581
+ for model in available:
582
+ mode_ok = (
583
+ not model.strategy_modes
584
+ or strategy.mode in model.strategy_modes
585
+ or strategy.mode == 'adaptive'
586
+ )
587
+ effort_ok = (
588
+ not model.reasoning_efforts
589
+ or strategy.reasoning_effort in model.reasoning_efforts
590
+ or strategy.reasoning_effort == 'default'
591
+ )
592
+ capabilities = {item.casefold() for item in model.capabilities}
593
+ hints_ok = all(
594
+ hint.casefold() in capabilities
595
+ for hint in strategy.capability_hints
596
+ )
597
+ if mode_ok and effort_ok and hints_ok:
598
+ return model
599
+ return None
600
+
601
+ route_task_executor = resolve_project_agent_executor