fuli-context 0.7.4-beta.3 → 0.7.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -6
- package/README.zh-CN.md +69 -6
- package/compose.graphiti.yml +6 -6
- package/dist/web/assets/AboutPage-sLV9WChi.js +1 -0
- package/dist/web/assets/ConnectionsPage-BWn4ywGJ.js +1 -0
- package/dist/web/assets/KnowledgeEditDialog-CZ5ziPrD.js +1 -0
- package/dist/web/assets/KnowledgeInspector-CRQWOaos.js +1 -0
- package/dist/web/assets/KnowledgeOrganizerPage-Dfj3vIpU.js +1 -0
- package/dist/web/assets/KnowledgePage-BRYC9S5Y.js +1 -0
- package/dist/web/assets/KnowledgeWorkspace-ChEB5Bwk.js +4 -0
- package/dist/web/assets/OverviewPage-Cd66M0RT.js +1 -0
- package/dist/web/assets/PersonalProfilePage-RCFHL0Wz.js +1 -0
- package/dist/web/assets/PersonalProjectsPage-DEh8XWby.js +1 -0
- package/dist/web/assets/ProjectAgentsPage-Crh7PrXa.css +1 -0
- package/dist/web/assets/ProjectAgentsPage-DPnaH-TR.js +11 -0
- package/dist/web/assets/PublicProjectsPage-mstPEjHe.js +1 -0
- package/dist/web/assets/ReviewPage-6Y1SmfZy.js +1 -0
- package/dist/web/assets/SearchableSelect-D3HM8YF8.js +1 -0
- package/dist/web/assets/SettingsPage--LdTMUm7.js +1 -0
- package/dist/web/assets/SettingsPage-CwZp2ka4.css +1 -0
- package/dist/web/assets/WritingTastePage-CIpKswvs.js +1 -0
- package/dist/web/assets/{console-D_2zC4lm.js → console-BT1P2Db4.js} +1 -1
- package/dist/web/assets/{conversation-launcher-settings-DQmMH2j7.js → conversation-launcher-settings-DrJEotdf.js} +1 -1
- package/dist/web/assets/i18n-js6gfc7j.js +9 -0
- package/dist/web/assets/{identity-DmGcY3Y6.js → identity-CfDfanpa.js} +1 -1
- package/dist/web/assets/index-BnH5mcZx.css +1 -0
- package/dist/web/assets/index-GqJf1xqg.js +2 -0
- package/dist/web/assets/{model-CVoOdRsL.js → model-DJkLAJsg.js} +1 -1
- package/dist/web/assets/{useApi-CROJJdhE-CBNg4CdH.js → useApi-CROJJdhE-BdSti7Y2.js} +1 -1
- package/dist/web/assets/vue-router-Kk67EaCP.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_coordination_models.py +22 -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 +626 -0
- package/graph-provider/fuli_graph/project_agent_task_models.py +545 -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_coordination_policy.py +122 -0
- 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 +705 -0
- package/graph-provider/fuli_graph/store_project_agent_task_staffing.py +586 -0
- package/graph-provider/fuli_graph/store_project_agent_tasks.py +1151 -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 +419 -0
- package/src/agent-tools/graph-handlers.js +65 -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 +275 -0
- package/src/graphiti/project-agent-mapping.js +1024 -0
- package/src/graphiti/project-agent-workflows.js +576 -0
- package/src/graphiti/provider-client.js +282 -1
- package/src/http/api-router.js +63 -9
- package/src/http/graph-api-router.js +290 -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 +71 -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,586 @@
|
|
|
1
|
+
"""Agent assignment selection, history continuity, and parallel staffing."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from fastapi import HTTPException
|
|
6
|
+
|
|
7
|
+
from .project_agent_access import authorize_project_agent
|
|
8
|
+
from .project_agent_models import ProjectAgentProfile
|
|
9
|
+
from .project_agent_task_models import ProjectAgentParallelPlan
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class StoreProjectAgentTaskStaffing:
|
|
13
|
+
"""Keep task routing's staffing mechanics separate from task persistence."""
|
|
14
|
+
|
|
15
|
+
async def _select_agents(self, actor, space, request):
|
|
16
|
+
if request.staffing_intent == 'unassigned':
|
|
17
|
+
return [], [], 'explicit_unassigned', ['user explicitly left task unassigned']
|
|
18
|
+
assignment_rows = await self._assignment_candidates(
|
|
19
|
+
request.personal_space_id,
|
|
20
|
+
request.personal_project_id,
|
|
21
|
+
)
|
|
22
|
+
if request.lead_agent_id:
|
|
23
|
+
await authorize_project_agent(
|
|
24
|
+
self,
|
|
25
|
+
actor,
|
|
26
|
+
space,
|
|
27
|
+
request.personal_project_id,
|
|
28
|
+
request.lead_agent_id,
|
|
29
|
+
require_active=True,
|
|
30
|
+
)
|
|
31
|
+
matched = [
|
|
32
|
+
row for row in assignment_rows
|
|
33
|
+
if row['agent_id'] == request.lead_agent_id
|
|
34
|
+
]
|
|
35
|
+
if not matched:
|
|
36
|
+
raise HTTPException(status_code=409, detail='lead Agent assignment is inactive')
|
|
37
|
+
self._require_agent_client_allowed(
|
|
38
|
+
matched[0],
|
|
39
|
+
request.source_application,
|
|
40
|
+
)
|
|
41
|
+
return matched[:1], matched, 'explicit_agent', ['explicit Agent selected']
|
|
42
|
+
rows = [
|
|
43
|
+
row for row in assignment_rows
|
|
44
|
+
if self._agent_client_allowed(row, request.source_application)
|
|
45
|
+
]
|
|
46
|
+
if request.staffing_intent in {'new_durable', 'temporary'}:
|
|
47
|
+
return (
|
|
48
|
+
[],
|
|
49
|
+
rows,
|
|
50
|
+
'explicit_temporary_agent'
|
|
51
|
+
if request.staffing_intent == 'temporary'
|
|
52
|
+
else 'explicit_new_agent',
|
|
53
|
+
['user explicitly requested recruitment'],
|
|
54
|
+
)
|
|
55
|
+
policy = await self.get_project_agent_coordination_policy(
|
|
56
|
+
actor,
|
|
57
|
+
request.personal_space_id,
|
|
58
|
+
request.personal_project_id,
|
|
59
|
+
)
|
|
60
|
+
if not policy.auto_reuse_previous_agent:
|
|
61
|
+
return [], rows, 'manual_agent_selection', [
|
|
62
|
+
'project policy requires an explicit @Agent selection',
|
|
63
|
+
]
|
|
64
|
+
continuity, project_history = await self._rank_project_continuity(
|
|
65
|
+
rows,
|
|
66
|
+
request,
|
|
67
|
+
)
|
|
68
|
+
if continuity:
|
|
69
|
+
selected = continuity[0]
|
|
70
|
+
return [selected], continuity, 'project_continuity', [
|
|
71
|
+
self._project_history_match_basis(
|
|
72
|
+
project_history.get(selected['agent_id']),
|
|
73
|
+
),
|
|
74
|
+
]
|
|
75
|
+
normalized_work_kind = request.work_kind.casefold()
|
|
76
|
+
required = {item.casefold() for item in request.required_capabilities}
|
|
77
|
+
candidates, history = await self._rank_agent_candidates(rows, request)
|
|
78
|
+
if not candidates:
|
|
79
|
+
unavailable_match = any(
|
|
80
|
+
not self._agent_client_allowed(row, request.source_application)
|
|
81
|
+
and (
|
|
82
|
+
normalized_work_kind in {
|
|
83
|
+
item.casefold() for item in row.get('work_kinds', [])
|
|
84
|
+
}
|
|
85
|
+
or (
|
|
86
|
+
required
|
|
87
|
+
and required.issubset({
|
|
88
|
+
item.casefold()
|
|
89
|
+
for item in row.get('capabilities', [])
|
|
90
|
+
})
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
for row in assignment_rows
|
|
94
|
+
)
|
|
95
|
+
if unavailable_match:
|
|
96
|
+
return [], rows, 'agent_unavailable', [
|
|
97
|
+
'matching Agent is unavailable to source client: '
|
|
98
|
+
f'{request.source_application or "other"}',
|
|
99
|
+
]
|
|
100
|
+
if len(rows) == 1:
|
|
101
|
+
return [rows[0]], rows, 'sole_active_assignment', [
|
|
102
|
+
'the project has one active Agent assignment',
|
|
103
|
+
]
|
|
104
|
+
return [], rows, 'no_match', ['no active assignment matched exactly']
|
|
105
|
+
selected = candidates[0]
|
|
106
|
+
if normalized_work_kind in {
|
|
107
|
+
item.casefold() for item in selected.get('work_kinds', [])
|
|
108
|
+
}:
|
|
109
|
+
match_basis = [
|
|
110
|
+
f'exact work kind: {request.work_kind}',
|
|
111
|
+
]
|
|
112
|
+
reason = 'exact_work_kind'
|
|
113
|
+
else:
|
|
114
|
+
match_basis = ['all required capabilities matched exactly']
|
|
115
|
+
reason = 'exact_capability'
|
|
116
|
+
history_basis = self._history_match_basis(
|
|
117
|
+
history.get(selected['agent_id']),
|
|
118
|
+
request.work_kind,
|
|
119
|
+
)
|
|
120
|
+
if history_basis:
|
|
121
|
+
match_basis.append(history_basis)
|
|
122
|
+
return [selected], candidates, reason, match_basis
|
|
123
|
+
|
|
124
|
+
async def _rank_agent_candidates(self, rows, request):
|
|
125
|
+
"""Rank already source-eligible assignments with task-history continuity."""
|
|
126
|
+
|
|
127
|
+
normalized_work_kind = request.work_kind.casefold()
|
|
128
|
+
required = {item.casefold() for item in request.required_capabilities}
|
|
129
|
+
scored = []
|
|
130
|
+
for row in rows:
|
|
131
|
+
work_kinds = {item.casefold() for item in row.get('work_kinds', [])}
|
|
132
|
+
capabilities = {item.casefold() for item in row.get('capabilities', [])}
|
|
133
|
+
work_match = normalized_work_kind in work_kinds
|
|
134
|
+
capability_match = required.issubset(capabilities)
|
|
135
|
+
if not work_match and not (required and capability_match):
|
|
136
|
+
continue
|
|
137
|
+
score = (2 if work_match else 0) + (1 if capability_match else 0)
|
|
138
|
+
scored.append((score, row))
|
|
139
|
+
if not scored:
|
|
140
|
+
return [], {}
|
|
141
|
+
history = await self._historical_agent_outcomes(
|
|
142
|
+
request.personal_space_id,
|
|
143
|
+
request.personal_project_id,
|
|
144
|
+
request.work_kind,
|
|
145
|
+
[row['agent_id'] for _, row in scored],
|
|
146
|
+
)
|
|
147
|
+
scored.sort(
|
|
148
|
+
key=lambda item: (
|
|
149
|
+
-item[0],
|
|
150
|
+
*self._history_sort_key(history.get(item[1]['agent_id'])),
|
|
151
|
+
item[1]['assigned_at'],
|
|
152
|
+
item[1]['agent_id'],
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
return [item[1] for item in scored], history
|
|
156
|
+
|
|
157
|
+
async def _rank_project_continuity(self, rows, request):
|
|
158
|
+
"""Prefer the last effective project lead before task-type matching."""
|
|
159
|
+
|
|
160
|
+
if not rows:
|
|
161
|
+
return [], {}
|
|
162
|
+
history = await self._historical_project_agent_outcomes(
|
|
163
|
+
request.personal_space_id,
|
|
164
|
+
request.personal_project_id,
|
|
165
|
+
[row['agent_id'] for row in rows],
|
|
166
|
+
)
|
|
167
|
+
candidates = [
|
|
168
|
+
row
|
|
169
|
+
for row in rows
|
|
170
|
+
if self._metric_int(
|
|
171
|
+
(history.get(row['agent_id']) or {}).get('participation_count')
|
|
172
|
+
) > 0
|
|
173
|
+
]
|
|
174
|
+
candidates.sort(
|
|
175
|
+
key=lambda row: (
|
|
176
|
+
*self._project_history_sort_key(history.get(row['agent_id'])),
|
|
177
|
+
row['assigned_at'],
|
|
178
|
+
row['agent_id'],
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
return candidates, history
|
|
182
|
+
|
|
183
|
+
async def _historical_project_agent_outcomes(
|
|
184
|
+
self,
|
|
185
|
+
personal_space_id,
|
|
186
|
+
personal_project_id,
|
|
187
|
+
agent_ids,
|
|
188
|
+
):
|
|
189
|
+
"""Read cross-work-kind lead history for sticky project continuity."""
|
|
190
|
+
|
|
191
|
+
if not agent_ids:
|
|
192
|
+
return {}
|
|
193
|
+
runtime = getattr(self, 'runtime', None)
|
|
194
|
+
driver = getattr(runtime, 'driver', None)
|
|
195
|
+
if driver is None:
|
|
196
|
+
return {}
|
|
197
|
+
records, _, _ = await driver.execute_query(
|
|
198
|
+
'''
|
|
199
|
+
MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
|
|
200
|
+
[:HAS_PROJECT_AGENT_TASK]->
|
|
201
|
+
(task:FuliProjectAgentTask {
|
|
202
|
+
personal_space_id: $personal_space_id,
|
|
203
|
+
personal_project_id: $personal_project_id
|
|
204
|
+
})-[participant:HAS_PARTICIPANT]->
|
|
205
|
+
(agent:FuliProjectAgent)
|
|
206
|
+
WHERE agent.agent_id IN $agent_ids
|
|
207
|
+
AND (participant.role = 'lead'
|
|
208
|
+
OR task.lead_agent_id = agent.agent_id)
|
|
209
|
+
OPTIONAL MATCH (task)-[:HAS_TASK_EVENT]->
|
|
210
|
+
(event:FuliProjectAgentTaskEvent)-[:EVENT_AGENT]->(agent)
|
|
211
|
+
RETURN agent.agent_id AS agent_id,
|
|
212
|
+
count(DISTINCT task.task_id) AS participation_count,
|
|
213
|
+
count(DISTINCT CASE
|
|
214
|
+
WHEN participant.status = 'completed'
|
|
215
|
+
OR event.status = 'completed'
|
|
216
|
+
THEN task.task_id END
|
|
217
|
+
) AS completed_count,
|
|
218
|
+
count(DISTINCT CASE
|
|
219
|
+
WHEN participant.status = 'failed'
|
|
220
|
+
OR event.status = 'failed'
|
|
221
|
+
THEN task.task_id END
|
|
222
|
+
) AS failed_count,
|
|
223
|
+
count(DISTINCT CASE
|
|
224
|
+
WHEN participant.status = 'cancelled'
|
|
225
|
+
OR event.status = 'cancelled'
|
|
226
|
+
THEN task.task_id END
|
|
227
|
+
) AS cancelled_count,
|
|
228
|
+
max(task.updated_at) AS last_task_at,
|
|
229
|
+
max(CASE
|
|
230
|
+
WHEN participant.status = 'completed'
|
|
231
|
+
OR event.status = 'completed'
|
|
232
|
+
THEN coalesce(event.created_at, task.updated_at) END
|
|
233
|
+
) AS last_completed_at
|
|
234
|
+
''',
|
|
235
|
+
personal_space_id=personal_space_id,
|
|
236
|
+
personal_project_id=personal_project_id,
|
|
237
|
+
agent_ids=list(agent_ids),
|
|
238
|
+
routing_='r',
|
|
239
|
+
)
|
|
240
|
+
result = {}
|
|
241
|
+
for row in records or []:
|
|
242
|
+
raw = dict(row)
|
|
243
|
+
agent_id = raw.get('agent_id')
|
|
244
|
+
if not agent_id:
|
|
245
|
+
continue
|
|
246
|
+
result[agent_id] = {
|
|
247
|
+
'participation_count': self._metric_int(
|
|
248
|
+
raw.get('participation_count')
|
|
249
|
+
),
|
|
250
|
+
'completed_count': self._metric_int(raw.get('completed_count')),
|
|
251
|
+
'failed_count': self._metric_int(raw.get('failed_count')),
|
|
252
|
+
'cancelled_count': self._metric_int(raw.get('cancelled_count')),
|
|
253
|
+
'last_task_at': raw.get('last_task_at'),
|
|
254
|
+
'last_completed_at': raw.get('last_completed_at'),
|
|
255
|
+
}
|
|
256
|
+
return result
|
|
257
|
+
|
|
258
|
+
async def _historical_agent_outcomes(
|
|
259
|
+
self,
|
|
260
|
+
personal_space_id,
|
|
261
|
+
personal_project_id,
|
|
262
|
+
work_kind,
|
|
263
|
+
agent_ids,
|
|
264
|
+
):
|
|
265
|
+
"""Read project/work-kind participation without making history mandatory."""
|
|
266
|
+
|
|
267
|
+
if not agent_ids:
|
|
268
|
+
return {}
|
|
269
|
+
runtime = getattr(self, 'runtime', None)
|
|
270
|
+
driver = getattr(runtime, 'driver', None)
|
|
271
|
+
if driver is None:
|
|
272
|
+
return {}
|
|
273
|
+
records, _, _ = await driver.execute_query(
|
|
274
|
+
'''
|
|
275
|
+
MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
|
|
276
|
+
[:HAS_PROJECT_AGENT_TASK]->
|
|
277
|
+
(task:FuliProjectAgentTask {
|
|
278
|
+
personal_space_id: $personal_space_id,
|
|
279
|
+
personal_project_id: $personal_project_id
|
|
280
|
+
})-[participant:HAS_PARTICIPANT]->
|
|
281
|
+
(agent:FuliProjectAgent)
|
|
282
|
+
WHERE agent.agent_id IN $agent_ids
|
|
283
|
+
AND toLower(task.work_kind) = toLower($work_kind)
|
|
284
|
+
OPTIONAL MATCH (task)-[:HAS_TASK_EVENT]->
|
|
285
|
+
(event:FuliProjectAgentTaskEvent)-[:EVENT_AGENT]->(agent)
|
|
286
|
+
WHERE event.status IN ['completed', 'failed', 'cancelled']
|
|
287
|
+
RETURN agent.agent_id AS agent_id,
|
|
288
|
+
count(DISTINCT task.task_id) AS participation_count,
|
|
289
|
+
count(DISTINCT CASE
|
|
290
|
+
WHEN participant.status = 'completed'
|
|
291
|
+
OR event.status = 'completed'
|
|
292
|
+
THEN task.task_id END
|
|
293
|
+
) AS completed_count,
|
|
294
|
+
count(DISTINCT CASE
|
|
295
|
+
WHEN participant.status = 'failed'
|
|
296
|
+
OR event.status = 'failed'
|
|
297
|
+
THEN task.task_id END
|
|
298
|
+
) AS failed_count,
|
|
299
|
+
count(DISTINCT CASE
|
|
300
|
+
WHEN participant.status = 'cancelled'
|
|
301
|
+
OR event.status = 'cancelled'
|
|
302
|
+
THEN task.task_id END
|
|
303
|
+
) AS cancelled_count,
|
|
304
|
+
max(event.created_at) AS last_outcome_at
|
|
305
|
+
''',
|
|
306
|
+
personal_space_id=personal_space_id,
|
|
307
|
+
personal_project_id=personal_project_id,
|
|
308
|
+
work_kind=work_kind,
|
|
309
|
+
agent_ids=list(agent_ids),
|
|
310
|
+
routing_='r',
|
|
311
|
+
)
|
|
312
|
+
result = {}
|
|
313
|
+
for row in records or []:
|
|
314
|
+
raw = dict(row)
|
|
315
|
+
agent_id = raw.get('agent_id')
|
|
316
|
+
if not agent_id:
|
|
317
|
+
continue
|
|
318
|
+
result[agent_id] = {
|
|
319
|
+
'participation_count': self._metric_int(
|
|
320
|
+
raw.get('participation_count')
|
|
321
|
+
),
|
|
322
|
+
'completed_count': self._metric_int(raw.get('completed_count')),
|
|
323
|
+
'failed_count': self._metric_int(raw.get('failed_count')),
|
|
324
|
+
'cancelled_count': self._metric_int(
|
|
325
|
+
raw.get('cancelled_count')
|
|
326
|
+
),
|
|
327
|
+
'last_outcome_at': raw.get('last_outcome_at'),
|
|
328
|
+
}
|
|
329
|
+
return result
|
|
330
|
+
|
|
331
|
+
@staticmethod
|
|
332
|
+
def _metric_int(value):
|
|
333
|
+
try:
|
|
334
|
+
return max(0, int(value or 0))
|
|
335
|
+
except (TypeError, ValueError):
|
|
336
|
+
return 0
|
|
337
|
+
|
|
338
|
+
@classmethod
|
|
339
|
+
def _history_sort_key(cls, history):
|
|
340
|
+
history = history or {}
|
|
341
|
+
completed = cls._metric_int(history.get('completed_count'))
|
|
342
|
+
failed = cls._metric_int(history.get('failed_count'))
|
|
343
|
+
cancelled = cls._metric_int(history.get('cancelled_count'))
|
|
344
|
+
participation = cls._metric_int(history.get('participation_count'))
|
|
345
|
+
# A successful prior task is the strongest continuity signal. Failed
|
|
346
|
+
# and cancelled outcomes lower the signal, while participation remains
|
|
347
|
+
# a deterministic tie-breaker for otherwise equivalent outcomes.
|
|
348
|
+
outcome_score = completed * 3 - failed * 2 - cancelled
|
|
349
|
+
return (
|
|
350
|
+
-outcome_score,
|
|
351
|
+
-completed,
|
|
352
|
+
failed,
|
|
353
|
+
cancelled,
|
|
354
|
+
-participation,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
@classmethod
|
|
358
|
+
def _project_history_sort_key(cls, history):
|
|
359
|
+
history = history or {}
|
|
360
|
+
completed = cls._metric_int(history.get('completed_count'))
|
|
361
|
+
failed = cls._metric_int(history.get('failed_count'))
|
|
362
|
+
cancelled = cls._metric_int(history.get('cancelled_count'))
|
|
363
|
+
participation = cls._metric_int(history.get('participation_count'))
|
|
364
|
+
effective_time = (
|
|
365
|
+
history.get('last_completed_at')
|
|
366
|
+
if completed > 0
|
|
367
|
+
else history.get('last_task_at')
|
|
368
|
+
)
|
|
369
|
+
outcome_score = completed * 3 - failed * 2 - cancelled
|
|
370
|
+
return (
|
|
371
|
+
0 if completed > 0 else 1,
|
|
372
|
+
-cls._history_timestamp(effective_time),
|
|
373
|
+
-outcome_score,
|
|
374
|
+
-participation,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
@staticmethod
|
|
378
|
+
def _history_timestamp(value):
|
|
379
|
+
if value is None:
|
|
380
|
+
return 0.0
|
|
381
|
+
if hasattr(value, 'to_native'):
|
|
382
|
+
value = value.to_native()
|
|
383
|
+
if hasattr(value, 'timestamp'):
|
|
384
|
+
try:
|
|
385
|
+
return float(value.timestamp())
|
|
386
|
+
except (TypeError, ValueError, OSError):
|
|
387
|
+
return 0.0
|
|
388
|
+
try:
|
|
389
|
+
return datetime.fromisoformat(
|
|
390
|
+
str(value).replace('Z', '+00:00')
|
|
391
|
+
).timestamp()
|
|
392
|
+
except (TypeError, ValueError, OSError):
|
|
393
|
+
return 0.0
|
|
394
|
+
|
|
395
|
+
@classmethod
|
|
396
|
+
def _project_history_match_basis(cls, history):
|
|
397
|
+
history = history or {}
|
|
398
|
+
participation = cls._metric_int(history.get('participation_count'))
|
|
399
|
+
completed = cls._metric_int(history.get('completed_count'))
|
|
400
|
+
if completed:
|
|
401
|
+
return (
|
|
402
|
+
'last successful project lead continuity: '
|
|
403
|
+
f'{participation} prior task(s), {completed} completed'
|
|
404
|
+
)
|
|
405
|
+
return f'most recent project lead continuity: {participation} prior task(s)'
|
|
406
|
+
|
|
407
|
+
@classmethod
|
|
408
|
+
def _history_match_basis(cls, history, work_kind):
|
|
409
|
+
if not history:
|
|
410
|
+
return None
|
|
411
|
+
participation = cls._metric_int(history.get('participation_count'))
|
|
412
|
+
if participation <= 0:
|
|
413
|
+
return None
|
|
414
|
+
completed = cls._metric_int(history.get('completed_count'))
|
|
415
|
+
failed = cls._metric_int(history.get('failed_count'))
|
|
416
|
+
cancelled = cls._metric_int(history.get('cancelled_count'))
|
|
417
|
+
return (
|
|
418
|
+
f'historical continuity for {work_kind}: '
|
|
419
|
+
f'{participation} prior task(s), '
|
|
420
|
+
f'{completed} completed, {failed} failed, {cancelled} cancelled'
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
async def _parallel_staffing_candidates(
|
|
424
|
+
self,
|
|
425
|
+
request,
|
|
426
|
+
candidates,
|
|
427
|
+
participants,
|
|
428
|
+
):
|
|
429
|
+
capacity = len(request.parallel_plan.workstream_boundaries) - len(participants)
|
|
430
|
+
if capacity <= 0:
|
|
431
|
+
return []
|
|
432
|
+
pool = list(candidates)
|
|
433
|
+
known_ids = {item['agent_id'] for item in pool}
|
|
434
|
+
for row in await self._assignment_candidates(
|
|
435
|
+
request.personal_space_id,
|
|
436
|
+
request.personal_project_id,
|
|
437
|
+
):
|
|
438
|
+
if row['agent_id'] not in known_ids:
|
|
439
|
+
pool.append(row)
|
|
440
|
+
known_ids.add(row['agent_id'])
|
|
441
|
+
eligible = [
|
|
442
|
+
row
|
|
443
|
+
for row in pool
|
|
444
|
+
if self._agent_client_allowed(row, request.source_application)
|
|
445
|
+
]
|
|
446
|
+
ranked, _ = await self._rank_agent_candidates(eligible, request)
|
|
447
|
+
participant_ids = {item['agent_id'] for item in participants}
|
|
448
|
+
return [
|
|
449
|
+
row
|
|
450
|
+
for row in ranked
|
|
451
|
+
if row['agent_id'] not in participant_ids
|
|
452
|
+
][:capacity]
|
|
453
|
+
|
|
454
|
+
@staticmethod
|
|
455
|
+
def _add_parallel_collaborators(plan, participants, candidates):
|
|
456
|
+
if not plan.enabled:
|
|
457
|
+
return []
|
|
458
|
+
limit = len(plan.workstream_boundaries)
|
|
459
|
+
participant_ids = {item['agent_id'] for item in participants}
|
|
460
|
+
added = []
|
|
461
|
+
for candidate in candidates:
|
|
462
|
+
if len(participants) >= limit:
|
|
463
|
+
break
|
|
464
|
+
agent_id = candidate['agent_id']
|
|
465
|
+
if agent_id in participant_ids:
|
|
466
|
+
continue
|
|
467
|
+
participant = {
|
|
468
|
+
'agent_id': agent_id,
|
|
469
|
+
'role': 'collaborator',
|
|
470
|
+
'assignment_summary': candidate.get('responsibility'),
|
|
471
|
+
}
|
|
472
|
+
participants.append(participant)
|
|
473
|
+
participant_ids.add(agent_id)
|
|
474
|
+
added.append(candidate)
|
|
475
|
+
return added
|
|
476
|
+
|
|
477
|
+
async def _assignment_candidates(
|
|
478
|
+
self,
|
|
479
|
+
personal_space_id,
|
|
480
|
+
personal_project_id,
|
|
481
|
+
*,
|
|
482
|
+
include_temporary=False,
|
|
483
|
+
):
|
|
484
|
+
records, _, _ = await self.runtime.driver.execute_query(
|
|
485
|
+
'''
|
|
486
|
+
MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})-
|
|
487
|
+
[:CONTAINS_PROJECT]->
|
|
488
|
+
(:FuliPersonalProject {project_id: $personal_project_id})-
|
|
489
|
+
[:HAS_PROJECT_AGENT_ASSIGNMENT]->
|
|
490
|
+
(assignment:FuliProjectAgentAssignment {status: 'active'})-
|
|
491
|
+
[:ASSIGNED_AGENT]->(agent:FuliProjectAgent {status: 'active'})
|
|
492
|
+
WHERE agent.agent_type = 'durable'
|
|
493
|
+
OR ($include_temporary = true AND agent.agent_type = 'temporary')
|
|
494
|
+
RETURN assignment, agent
|
|
495
|
+
ORDER BY assignment.assigned_at, agent.agent_id
|
|
496
|
+
''',
|
|
497
|
+
personal_space_id=personal_space_id,
|
|
498
|
+
personal_project_id=personal_project_id,
|
|
499
|
+
include_temporary=include_temporary,
|
|
500
|
+
routing_='r',
|
|
501
|
+
)
|
|
502
|
+
result = []
|
|
503
|
+
for row in records:
|
|
504
|
+
assignment = dict(row['assignment'])
|
|
505
|
+
agent = dict(row['agent'])
|
|
506
|
+
profile = ProjectAgentProfile.model_validate_json(agent['profile_json'])
|
|
507
|
+
result.append({
|
|
508
|
+
'agent_id': agent['agent_id'],
|
|
509
|
+
'assignment_id': assignment.get('assignment_id') or assignment['id'],
|
|
510
|
+
'responsibility': assignment.get('responsibility'),
|
|
511
|
+
'work_kinds': list(
|
|
512
|
+
assignment.get('work_kinds') or profile.work_kinds
|
|
513
|
+
),
|
|
514
|
+
'capabilities': list(
|
|
515
|
+
assignment.get('capabilities') or profile.capabilities
|
|
516
|
+
),
|
|
517
|
+
'model_strategy_override': assignment.get('model_strategy_json'),
|
|
518
|
+
'executor_policy_override': assignment.get(
|
|
519
|
+
'executor_policy_json'
|
|
520
|
+
),
|
|
521
|
+
'profile': profile,
|
|
522
|
+
'assigned_at': str(assignment.get('assigned_at') or ''),
|
|
523
|
+
})
|
|
524
|
+
return result
|
|
525
|
+
|
|
526
|
+
async def _explicit_collaborators(self, actor, space, request):
|
|
527
|
+
if not request.collaborator_agent_ids:
|
|
528
|
+
return []
|
|
529
|
+
rows = await self._assignment_candidates(
|
|
530
|
+
request.personal_space_id,
|
|
531
|
+
request.personal_project_id,
|
|
532
|
+
)
|
|
533
|
+
result = []
|
|
534
|
+
for agent_id in request.collaborator_agent_ids:
|
|
535
|
+
await authorize_project_agent(
|
|
536
|
+
self,
|
|
537
|
+
actor,
|
|
538
|
+
space,
|
|
539
|
+
request.personal_project_id,
|
|
540
|
+
agent_id,
|
|
541
|
+
require_active=True,
|
|
542
|
+
)
|
|
543
|
+
row = next((item for item in rows if item['agent_id'] == agent_id), None)
|
|
544
|
+
if not row:
|
|
545
|
+
raise HTTPException(
|
|
546
|
+
status_code=409,
|
|
547
|
+
detail=f'collaborator Agent assignment is inactive: {agent_id}',
|
|
548
|
+
)
|
|
549
|
+
self._require_agent_client_allowed(
|
|
550
|
+
row,
|
|
551
|
+
request.source_application,
|
|
552
|
+
)
|
|
553
|
+
result.append(row)
|
|
554
|
+
return result
|
|
555
|
+
|
|
556
|
+
@staticmethod
|
|
557
|
+
def _agent_client_allowed(candidate, source_application):
|
|
558
|
+
profile = candidate.get('profile') if isinstance(candidate, dict) else candidate
|
|
559
|
+
allowed_clients = set(profile.allowed_clients)
|
|
560
|
+
return (source_application or 'other') in allowed_clients
|
|
561
|
+
|
|
562
|
+
@classmethod
|
|
563
|
+
def _require_agent_client_allowed(cls, candidate, source_application):
|
|
564
|
+
if cls._agent_client_allowed(candidate, source_application):
|
|
565
|
+
return
|
|
566
|
+
agent_id = candidate.get('agent_id') if isinstance(candidate, dict) else None
|
|
567
|
+
raise HTTPException(
|
|
568
|
+
status_code=403,
|
|
569
|
+
detail=(
|
|
570
|
+
f'project Agent is not available to this source client: '
|
|
571
|
+
f'{agent_id or "selected Agent"}'
|
|
572
|
+
),
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
@staticmethod
|
|
576
|
+
def _verify_parallel_plan(plan: ProjectAgentParallelPlan, participants: list[dict]):
|
|
577
|
+
if plan.enabled and len(participants) > len(plan.workstream_boundaries):
|
|
578
|
+
raise HTTPException(
|
|
579
|
+
status_code=422,
|
|
580
|
+
detail='parallel work exceeds declared workstream boundaries',
|
|
581
|
+
)
|
|
582
|
+
if plan.enabled and len(participants) < 2:
|
|
583
|
+
raise HTTPException(
|
|
584
|
+
status_code=422,
|
|
585
|
+
detail='parallel work requires at least two active Agents',
|
|
586
|
+
)
|