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.
- package/README.md +77 -6
- package/README.zh-CN.md +69 -6
- package/compose.graphiti.yml +6 -6
- package/dist/web/assets/AboutPage-ByCUor-j.js +1 -0
- package/dist/web/assets/ConnectionsPage-DkdkZ40Q.js +1 -0
- package/dist/web/assets/KnowledgeEditDialog-DSe3Gwep.js +1 -0
- package/dist/web/assets/KnowledgeInspector-CYAjAb9J.js +1 -0
- package/dist/web/assets/KnowledgeOrganizerPage-CV4F54R_.js +1 -0
- package/dist/web/assets/KnowledgePage-vYYXipOi.js +1 -0
- package/dist/web/assets/KnowledgeWorkspace-z4u5mXOG.js +4 -0
- package/dist/web/assets/OverviewPage-BWOfeS6k.js +1 -0
- package/dist/web/assets/PersonalProfilePage-c3w4-h7a.js +1 -0
- package/dist/web/assets/PersonalProjectsPage-B7AlLEwf.js +1 -0
- package/dist/web/assets/ProjectAgentsPage-CgftpIPB.css +1 -0
- package/dist/web/assets/ProjectAgentsPage-DTHcL_ox.js +11 -0
- package/dist/web/assets/PublicProjectsPage-DdH0oGB3.js +1 -0
- package/dist/web/assets/ReviewPage-BYIpcIHG.js +1 -0
- package/dist/web/assets/SearchableSelect-Dz13ClQb.js +1 -0
- package/dist/web/assets/SettingsPage-2VkbXgZi.js +1 -0
- package/dist/web/assets/SettingsPage-CwZp2ka4.css +1 -0
- package/dist/web/assets/WritingTastePage-BOPQZuva.js +1 -0
- package/dist/web/assets/{console-D_2zC4lm.js → console-BlAhujsH.js} +1 -1
- package/dist/web/assets/{conversation-launcher-settings-DQmMH2j7.js → conversation-launcher-settings-DTYxiLJt.js} +1 -1
- package/dist/web/assets/i18n-Ci3heuiL.js +9 -0
- package/dist/web/assets/{identity-DmGcY3Y6.js → identity-4_-v6cB7.js} +1 -1
- package/dist/web/assets/index-BnH5mcZx.css +1 -0
- package/dist/web/assets/index-C67Q0JY2.js +2 -0
- package/dist/web/assets/{model-CVoOdRsL.js → model-yG0YqUyZ.js} +1 -1
- package/dist/web/assets/{useApi-CROJJdhE-CBNg4CdH.js → useApi-CROJJdhE-twhdZArA.js} +1 -1
- package/dist/web/assets/vue-router-C-wot_Di.js +1 -0
- package/dist/web/index.html +6 -6
- package/graph-provider/fuli_graph/app.py +8 -0
- package/graph-provider/fuli_graph/collaboration_context.py +69 -6
- package/graph-provider/fuli_graph/knowledge_search.py +84 -5
- package/graph-provider/fuli_graph/model_validation.py +130 -0
- package/graph-provider/fuli_graph/models.py +24 -5
- package/graph-provider/fuli_graph/project_agent_access.py +54 -0
- package/graph-provider/fuli_graph/project_agent_executor_models.py +635 -0
- package/graph-provider/fuli_graph/project_agent_models.py +278 -0
- package/graph-provider/fuli_graph/project_agent_routes.py +597 -0
- package/graph-provider/fuli_graph/project_agent_task_models.py +542 -0
- package/graph-provider/fuli_graph/runtime.py +259 -0
- package/graph-provider/fuli_graph/search_projection.py +3 -0
- package/graph-provider/fuli_graph/store.py +6 -0
- package/graph-provider/fuli_graph/store_knowledge.py +62 -9
- package/graph-provider/fuli_graph/store_project_agent_executor_learning.py +756 -0
- package/graph-provider/fuli_graph/store_project_agent_executor_routing.py +601 -0
- package/graph-provider/fuli_graph/store_project_agent_executors.py +1087 -0
- package/graph-provider/fuli_graph/store_project_agent_task_reads.py +458 -0
- package/graph-provider/fuli_graph/store_project_agent_task_recruitment.py +699 -0
- package/graph-provider/fuli_graph/store_project_agent_task_staffing.py +409 -0
- package/graph-provider/fuli_graph/store_project_agent_tasks.py +1144 -0
- package/graph-provider/fuli_graph/store_project_agents.py +965 -0
- package/graph-provider/pyproject.toml +2 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/skills/capturing-session-knowledge/SKILL.md +39 -0
- package/src/adaptive-runtime/executor-pool.js +127 -0
- package/src/adaptive-runtime/graph-services.js +166 -0
- package/src/adaptive-runtime/lease-client.js +335 -0
- package/src/adaptive-runtime/runtime-broker.js +318 -0
- package/src/adaptive-runtime/settings.js +111 -0
- package/src/adaptive-runtime/state.js +74 -0
- package/src/agent-tools/graph-definitions.js +396 -0
- package/src/agent-tools/graph-handlers.js +61 -0
- package/src/agent-tools/project-agent-definitions.js +307 -0
- package/src/backup/graph-backup.js +289 -0
- package/src/backup/identity-reconciliation.js +208 -0
- package/src/backup/runtime-adapters.js +269 -0
- package/src/cli/command-registry.js +6 -2
- package/src/cli/graph-data-command.js +112 -0
- package/src/cli/local-runtime-command.js +16 -2
- package/src/cli/setup-command.js +19 -3
- package/src/cli/update-command.js +4 -0
- package/src/cli.js +6 -0
- package/src/graphiti/collaboration-preference-projection.js +29 -1
- package/src/graphiti/collaboration-preference-workflow.js +121 -0
- package/src/graphiti/federated-application.js +53 -90
- package/src/graphiti/project-agent-control-plane.js +263 -0
- package/src/graphiti/project-agent-mapping.js +1003 -0
- package/src/graphiti/project-agent-workflows.js +557 -0
- package/src/graphiti/provider-client.js +270 -1
- package/src/http/api-router.js +63 -9
- package/src/http/graph-api-router.js +278 -0
- package/src/local-runtime/lifecycle.js +46 -10
- package/src/mcp/context-resources.js +15 -6
- package/src/mcp/create-mcp-server.js +51 -16
- package/src/mcp/instructions.js +7 -5
- package/src/mcp/runtime.js +17 -5
- package/src/mcp/session-id.js +38 -1
- package/src/mcp/tool-annotations.js +69 -0
- package/src/mcp-server.js +23 -6
- package/src/native-runtime/runtime.js +717 -0
- package/src/server.js +6 -2
- package/src/setup/agent-installation-status.js +7 -1
- package/src/setup/agents.js +3 -1
- package/src/setup/claude-code-config.js +3 -1
- package/src/setup/codex-bootstrap.js +11 -10
- package/src/setup/codex-config.js +7 -1
- package/src/setup/cursor-config.js +3 -1
- package/src/setup/graph-runtime.js +158 -13
- package/src/setup/neo4j-memory-profile.js +43 -0
- package/src/setup/options.js +38 -2
- package/src/setup/paths.js +14 -0
- package/src/setup/setup.js +37 -1
- package/src/system/resource-monitor.js +181 -16
- package/src/system/runtime-settings.js +10 -1
- package/src/system/system-service.js +27 -2
- package/dist/web/assets/AboutPage-Dpf5fRWv.js +0 -1
- package/dist/web/assets/ConnectionsPage-D21aUDCP.js +0 -1
- package/dist/web/assets/KnowledgeEditDialog-u7lpfN3j.js +0 -1
- package/dist/web/assets/KnowledgeInspector-k4xnHtXd.js +0 -1
- package/dist/web/assets/KnowledgeOrganizerPage-Bdj7Rn84.js +0 -1
- package/dist/web/assets/KnowledgePage-UHVAbC1W.js +0 -1
- package/dist/web/assets/KnowledgeWorkspace-Lq3d-MeC.js +0 -4
- package/dist/web/assets/OverviewPage-CycAgA1V.js +0 -1
- package/dist/web/assets/PersonalProfilePage-U9oIV5FT.js +0 -1
- package/dist/web/assets/PersonalProjectsPage-C5RpXKcA.js +0 -1
- package/dist/web/assets/PublicProjectsPage-DiWxm35E.js +0 -1
- package/dist/web/assets/ReviewPage-jxRfGSJK.js +0 -1
- package/dist/web/assets/SearchableSelect-B3-5IPup.js +0 -1
- package/dist/web/assets/SettingsPage-BqfGDOho.js +0 -1
- package/dist/web/assets/SettingsPage-CAfz4qiV.css +0 -1
- package/dist/web/assets/WritingTastePage-BJxhMqJR.js +0 -1
- package/dist/web/assets/i18n-CwqXDI1C.js +0 -5
- package/dist/web/assets/index-IUGLt4lG.js +0 -2
- package/dist/web/assets/index-X2j29j8i.css +0 -1
- package/dist/web/assets/vue-router-CWSsUUio.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
|