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