fuli-context 0.7.4-beta.2 → 0.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +93 -7
  2. package/README.zh-CN.md +84 -7
  3. package/compose.graphiti.yml +6 -6
  4. package/dist/web/assets/AboutPage-ByCUor-j.js +1 -0
  5. package/dist/web/assets/ConnectionsPage-DkdkZ40Q.js +1 -0
  6. package/dist/web/assets/KnowledgeEditDialog-DSe3Gwep.js +1 -0
  7. package/dist/web/assets/KnowledgeInspector-CYAjAb9J.js +1 -0
  8. package/dist/web/assets/KnowledgeOrganizerPage-CV4F54R_.js +1 -0
  9. package/dist/web/assets/KnowledgePage-vYYXipOi.js +1 -0
  10. package/dist/web/assets/KnowledgeWorkspace-z4u5mXOG.js +4 -0
  11. package/dist/web/assets/OverviewPage-BWOfeS6k.js +1 -0
  12. package/dist/web/assets/PersonalProfilePage-c3w4-h7a.js +1 -0
  13. package/dist/web/assets/PersonalProjectsPage-B7AlLEwf.js +1 -0
  14. package/dist/web/assets/ProjectAgentsPage-CgftpIPB.css +1 -0
  15. package/dist/web/assets/ProjectAgentsPage-DTHcL_ox.js +11 -0
  16. package/dist/web/assets/PublicProjectsPage-DdH0oGB3.js +1 -0
  17. package/dist/web/assets/ReviewPage-BYIpcIHG.js +1 -0
  18. package/dist/web/assets/SearchableSelect-Dz13ClQb.js +1 -0
  19. package/dist/web/assets/SettingsPage-2VkbXgZi.js +1 -0
  20. package/dist/web/assets/SettingsPage-CwZp2ka4.css +1 -0
  21. package/dist/web/assets/WritingTastePage-BOPQZuva.js +1 -0
  22. package/dist/web/assets/{console-DMvHxKf9.js → console-BlAhujsH.js} +1 -1
  23. package/dist/web/assets/{conversation-launcher-settings-Dnlcw7Rj.js → conversation-launcher-settings-DTYxiLJt.js} +1 -1
  24. package/dist/web/assets/i18n-Ci3heuiL.js +9 -0
  25. package/dist/web/assets/{identity-C9mLdR8L.js → identity-4_-v6cB7.js} +1 -1
  26. package/dist/web/assets/index-BnH5mcZx.css +1 -0
  27. package/dist/web/assets/index-C67Q0JY2.js +2 -0
  28. package/dist/web/assets/{model-CsYAR8O3.js → model-yG0YqUyZ.js} +1 -1
  29. package/dist/web/assets/{useApi-CROJJdhE-BLQiPVw0.js → useApi-CROJJdhE-twhdZArA.js} +1 -1
  30. package/dist/web/assets/vue-router-C-wot_Di.js +1 -0
  31. package/dist/web/index.html +6 -6
  32. package/docs/public-personal-architecture.md +73 -0
  33. package/graph-provider/fuli_graph/app.py +8 -0
  34. package/graph-provider/fuli_graph/collaboration_context.py +69 -6
  35. package/graph-provider/fuli_graph/knowledge_search.py +84 -5
  36. package/graph-provider/fuli_graph/model_validation.py +134 -0
  37. package/graph-provider/fuli_graph/models.py +24 -5
  38. package/graph-provider/fuli_graph/project_agent_access.py +54 -0
  39. package/graph-provider/fuli_graph/project_agent_executor_models.py +635 -0
  40. package/graph-provider/fuli_graph/project_agent_models.py +278 -0
  41. package/graph-provider/fuli_graph/project_agent_routes.py +597 -0
  42. package/graph-provider/fuli_graph/project_agent_task_models.py +542 -0
  43. package/graph-provider/fuli_graph/runtime.py +259 -0
  44. package/graph-provider/fuli_graph/search_projection.py +3 -0
  45. package/graph-provider/fuli_graph/store.py +6 -0
  46. package/graph-provider/fuli_graph/store_knowledge.py +62 -9
  47. package/graph-provider/fuli_graph/store_project_agent_executor_learning.py +756 -0
  48. package/graph-provider/fuli_graph/store_project_agent_executor_routing.py +601 -0
  49. package/graph-provider/fuli_graph/store_project_agent_executors.py +1087 -0
  50. package/graph-provider/fuli_graph/store_project_agent_task_reads.py +458 -0
  51. package/graph-provider/fuli_graph/store_project_agent_task_recruitment.py +699 -0
  52. package/graph-provider/fuli_graph/store_project_agent_task_staffing.py +409 -0
  53. package/graph-provider/fuli_graph/store_project_agent_tasks.py +1144 -0
  54. package/graph-provider/fuli_graph/store_project_agents.py +965 -0
  55. package/graph-provider/pyproject.toml +2 -1
  56. package/npm-shrinkwrap.json +2 -2
  57. package/package.json +2 -1
  58. package/skills/capturing-session-knowledge/SKILL.md +43 -2
  59. package/src/adaptive-runtime/executor-pool.js +127 -0
  60. package/src/adaptive-runtime/graph-services.js +166 -0
  61. package/src/adaptive-runtime/lease-client.js +335 -0
  62. package/src/adaptive-runtime/runtime-broker.js +318 -0
  63. package/src/adaptive-runtime/settings.js +111 -0
  64. package/src/adaptive-runtime/state.js +74 -0
  65. package/src/agent-tools/graph-definitions.js +396 -0
  66. package/src/agent-tools/graph-handlers.js +61 -0
  67. package/src/agent-tools/project-agent-definitions.js +307 -0
  68. package/src/backup/graph-backup.js +289 -0
  69. package/src/backup/identity-reconciliation.js +208 -0
  70. package/src/backup/runtime-adapters.js +269 -0
  71. package/src/cli/command-registry.js +7 -2
  72. package/src/cli/graph-data-command.js +112 -0
  73. package/src/cli/local-runtime-command.js +16 -2
  74. package/src/cli/setup-command.js +19 -3
  75. package/src/cli/update-command.js +4 -0
  76. package/src/cli/workspace-connection-command.js +333 -0
  77. package/src/cli.js +11 -0
  78. package/src/graphiti/collaboration-preference-projection.js +29 -1
  79. package/src/graphiti/collaboration-preference-workflow.js +121 -0
  80. package/src/graphiti/federated-application.js +74 -112
  81. package/src/graphiti/knowledge-provider-mapping.js +7 -1
  82. package/src/graphiti/project-agent-control-plane.js +263 -0
  83. package/src/graphiti/project-agent-mapping.js +1003 -0
  84. package/src/graphiti/project-agent-workflows.js +557 -0
  85. package/src/graphiti/provider-client.js +270 -1
  86. package/src/graphiti/runtime-config.js +6 -1
  87. package/src/graphiti/workspace-provider-client.js +403 -0
  88. package/src/http/api-router.js +63 -9
  89. package/src/http/graph-api-router.js +278 -0
  90. package/src/local-runtime/lifecycle.js +46 -10
  91. package/src/mcp/context-resources.js +15 -6
  92. package/src/mcp/create-mcp-server.js +51 -16
  93. package/src/mcp/instructions.js +7 -5
  94. package/src/mcp/runtime.js +17 -5
  95. package/src/mcp/session-id.js +38 -1
  96. package/src/mcp/tool-annotations.js +69 -0
  97. package/src/mcp-server.js +23 -6
  98. package/src/native-runtime/runtime.js +717 -0
  99. package/src/server.js +6 -2
  100. package/src/setup/agent-installation-status.js +7 -1
  101. package/src/setup/agents.js +3 -1
  102. package/src/setup/claude-code-config.js +3 -1
  103. package/src/setup/codex-bootstrap.js +11 -10
  104. package/src/setup/codex-config.js +7 -1
  105. package/src/setup/cursor-config.js +3 -1
  106. package/src/setup/graph-runtime.js +158 -13
  107. package/src/setup/neo4j-memory-profile.js +43 -0
  108. package/src/setup/options.js +38 -2
  109. package/src/setup/paths.js +14 -0
  110. package/src/setup/setup.js +37 -1
  111. package/src/system/resource-monitor.js +181 -16
  112. package/src/system/runtime-settings.js +10 -1
  113. package/src/system/system-service.js +27 -2
  114. package/dist/web/assets/AboutPage-CftRexSY.js +0 -1
  115. package/dist/web/assets/ConnectionsPage-D5J91x6d.js +0 -1
  116. package/dist/web/assets/KnowledgeEditDialog-wotVahvr.js +0 -1
  117. package/dist/web/assets/KnowledgeInspector-BzkW0mVs.js +0 -1
  118. package/dist/web/assets/KnowledgeOrganizerPage-BebUDiHt.js +0 -1
  119. package/dist/web/assets/KnowledgePage-OUT07aKH.js +0 -1
  120. package/dist/web/assets/KnowledgeWorkspace-1pHJ0MNZ.js +0 -4
  121. package/dist/web/assets/OverviewPage-CHE6XhdS.js +0 -1
  122. package/dist/web/assets/PersonalProfilePage-CXXiabUy.js +0 -1
  123. package/dist/web/assets/PersonalProjectsPage-d2wYGW8n.js +0 -1
  124. package/dist/web/assets/PublicProjectsPage-hxLShtnZ.js +0 -1
  125. package/dist/web/assets/ReviewPage-DUeyu7E8.js +0 -1
  126. package/dist/web/assets/SearchableSelect-C5RiB5F8.js +0 -1
  127. package/dist/web/assets/SettingsPage-BMuASSzQ.js +0 -1
  128. package/dist/web/assets/SettingsPage-CAfz4qiV.css +0 -1
  129. package/dist/web/assets/WritingTastePage-B9BCYIwQ.js +0 -1
  130. package/dist/web/assets/i18n-CWfd8Dtt.js +0 -5
  131. package/dist/web/assets/index-X2j29j8i.css +0 -1
  132. package/dist/web/assets/index-nP5p5OaY.js +0 -2
  133. package/dist/web/assets/vue-router-DN8x6iY1.js +0 -1
@@ -0,0 +1,756 @@
1
+ """Auditable, decayed executor outcome evidence and learning aggregates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ from datetime import datetime
8
+
9
+ from fastapi import HTTPException
10
+
11
+ from .personal_project_access import authorize_personal_project
12
+ from .project_agent_executor_models import (
13
+ ProjectAgentExecutorEvidenceContribution,
14
+ ProjectAgentExecutorEvidenceIgnore,
15
+ ProjectAgentExecutorOutcomeAggregate,
16
+ ProjectAgentExecutorOutcomeEvidenceCreate,
17
+ ProjectAgentExecutorOutcomeEvidenceRecord,
18
+ ProjectAgentExecutorOutcomeReset,
19
+ project_agent_model_strategy_key,
20
+ )
21
+ from .project_agent_models import ProjectAgentModelStrategy
22
+ from .provider_values import native_datetime, now_utc, stable_uuid
23
+
24
+
25
+ _TERMINAL_NEUTRAL = {'cancelled'}
26
+ _DEFAULT_HALF_LIFE_DAYS = 30.0
27
+ _DEFAULT_RECENT_DAYS = 7
28
+ _MINIMUM_EVIDENCE_SAMPLES = 3
29
+
30
+
31
+ def project_agent_executor_outcome_bucket_id(
32
+ provider_id: str,
33
+ personal_space_id: str,
34
+ personal_project_id: str,
35
+ work_kind: str,
36
+ agent_id: str,
37
+ executor_id: str,
38
+ model_strategy_key: str,
39
+ *,
40
+ bucket_kind: str,
41
+ ) -> str:
42
+ """Return the provider- and space-scoped identity for a learning cache."""
43
+
44
+ if bucket_kind not in {'aggregate', 'reset'}:
45
+ raise ValueError('unsupported Project Agent executor outcome bucket kind')
46
+ return stable_uuid(
47
+ provider_id,
48
+ personal_space_id,
49
+ f'project-agent-executor-outcome-{bucket_kind}',
50
+ personal_project_id,
51
+ work_kind,
52
+ agent_id,
53
+ executor_id,
54
+ model_strategy_key,
55
+ )
56
+
57
+
58
+ class StoreProjectAgentExecutorLearning:
59
+ """Persist explainable evidence without overriding explicit user policy."""
60
+
61
+ async def record_project_agent_executor_outcome_evidence(
62
+ self,
63
+ actor: dict,
64
+ request: ProjectAgentExecutorOutcomeEvidenceCreate,
65
+ ) -> ProjectAgentExecutorOutcomeEvidenceRecord:
66
+ self._require_personal()
67
+ space = await self.authorize(actor, request.personal_space_id, 'maintainer')
68
+ await authorize_personal_project(
69
+ self,
70
+ actor,
71
+ space,
72
+ request.personal_project_id,
73
+ )
74
+ evidence_id = stable_uuid(
75
+ self.settings.provider_id,
76
+ request.personal_space_id,
77
+ 'project-agent-executor-evidence',
78
+ request.task_id,
79
+ request.run_id or '',
80
+ request.executor_id,
81
+ project_agent_model_strategy_key(request.model_strategy),
82
+ request.idempotency_key,
83
+ )
84
+ strategy_key = project_agent_model_strategy_key(request.model_strategy)
85
+ payload_hash = self._payload_hash(request)
86
+ timestamp = now_utc()
87
+ records, _, _ = await self.runtime.driver.execute_query(
88
+ '''
89
+ MATCH (task:FuliProjectAgentTask {
90
+ personal_space_id: $personal_space_id,
91
+ personal_project_id: $personal_project_id,
92
+ task_id: $task_id
93
+ })
94
+ MERGE (evidence:FuliProjectAgentExecutorOutcomeEvidence {
95
+ id: $evidence_id
96
+ })
97
+ ON CREATE SET evidence.evidence_id = $evidence_id,
98
+ evidence.payload_hash = $payload_hash,
99
+ evidence.personal_space_id = $personal_space_id,
100
+ evidence.personal_project_id = $personal_project_id,
101
+ evidence.work_kind = $work_kind,
102
+ evidence.agent_id = $agent_id,
103
+ evidence.executor_id = $executor_id,
104
+ evidence.task_id = $task_id,
105
+ evidence.run_id = $run_id,
106
+ evidence.model_strategy_json = $model_strategy_json,
107
+ evidence.model_strategy_key = $model_strategy_key,
108
+ evidence.evidence_kind = $evidence_kind,
109
+ evidence.source = $source,
110
+ evidence.terminal_outcome = $terminal_outcome,
111
+ evidence.rating = $rating,
112
+ evidence.reference_ids = $reference_ids,
113
+ evidence.note = $note,
114
+ evidence.occurred_at = $occurred_at,
115
+ evidence.ignored = false,
116
+ evidence.created_at = $created_at
117
+ MERGE (task)-[:HAS_EXECUTOR_OUTCOME_EVIDENCE]->(evidence)
118
+ RETURN evidence
119
+ ''',
120
+ personal_space_id=request.personal_space_id,
121
+ personal_project_id=request.personal_project_id,
122
+ work_kind=request.work_kind,
123
+ agent_id=request.agent_id,
124
+ executor_id=request.executor_id,
125
+ task_id=request.task_id,
126
+ run_id=request.run_id,
127
+ evidence_id=evidence_id,
128
+ payload_hash=payload_hash,
129
+ model_strategy_json=request.model_strategy.model_dump_json(),
130
+ model_strategy_key=strategy_key,
131
+ evidence_kind=request.evidence_kind,
132
+ source=request.source,
133
+ terminal_outcome=request.terminal_outcome,
134
+ rating=request.rating,
135
+ reference_ids=request.reference_ids,
136
+ note=request.note,
137
+ occurred_at=request.occurred_at,
138
+ created_at=timestamp,
139
+ )
140
+ if not records:
141
+ raise HTTPException(status_code=404, detail='project Agent task not found')
142
+ raw = dict(records[0].get('evidence') or {})
143
+ if raw.get('payload_hash') not in {None, payload_hash}:
144
+ raise HTTPException(
145
+ status_code=409,
146
+ detail='outcome evidence idempotency key was reused',
147
+ )
148
+ evidence = self._outcome_evidence_from_raw(raw, request)
149
+ await self.aggregate_project_agent_executor_outcomes(
150
+ actor,
151
+ personal_space_id=request.personal_space_id,
152
+ personal_project_id=request.personal_project_id,
153
+ work_kind=request.work_kind,
154
+ agent_id=request.agent_id,
155
+ executor_id=request.executor_id,
156
+ model_strategy=request.model_strategy,
157
+ )
158
+ return evidence
159
+
160
+ async def list_project_agent_executor_outcome_evidence(
161
+ self,
162
+ actor: dict,
163
+ personal_space_id: str,
164
+ personal_project_id: str,
165
+ *,
166
+ work_kind: str | None = None,
167
+ executor_id: str | None = None,
168
+ agent_id: str | None = None,
169
+ model_strategy_key: str | None = None,
170
+ include_ignored: bool = False,
171
+ ) -> list[ProjectAgentExecutorOutcomeEvidenceRecord]:
172
+ self._require_personal()
173
+ space = await self.authorize(actor, personal_space_id, 'reader')
174
+ await authorize_personal_project(
175
+ self,
176
+ actor,
177
+ space,
178
+ personal_project_id,
179
+ )
180
+ records, _, _ = await self.runtime.driver.execute_query(
181
+ '''
182
+ MATCH (evidence:FuliProjectAgentExecutorOutcomeEvidence {
183
+ personal_space_id: $personal_space_id,
184
+ personal_project_id: $personal_project_id
185
+ })
186
+ WHERE ($work_kind IS NULL OR evidence.work_kind = $work_kind)
187
+ AND ($executor_id IS NULL OR evidence.executor_id = $executor_id)
188
+ AND ($agent_id IS NULL OR evidence.agent_id = $agent_id)
189
+ AND ($model_strategy_key IS NULL
190
+ OR evidence.model_strategy_key = $model_strategy_key)
191
+ AND ($include_ignored = true OR coalesce(evidence.ignored, false) = false)
192
+ RETURN evidence
193
+ ORDER BY evidence.occurred_at DESC, evidence.evidence_id
194
+ ''',
195
+ personal_space_id=personal_space_id,
196
+ personal_project_id=personal_project_id,
197
+ work_kind=work_kind,
198
+ executor_id=executor_id,
199
+ agent_id=agent_id,
200
+ model_strategy_key=model_strategy_key,
201
+ include_ignored=include_ignored,
202
+ routing_='r',
203
+ )
204
+ evidence = [
205
+ self._outcome_evidence_from_raw(dict(row['evidence']))
206
+ for row in records
207
+ if row.get('evidence')
208
+ ]
209
+ if model_strategy_key is not None:
210
+ evidence = [
211
+ item
212
+ for item in evidence
213
+ if item.model_strategy_key == model_strategy_key
214
+ ]
215
+ return evidence
216
+
217
+ async def list_project_agent_executor_outcome_aggregates(
218
+ self,
219
+ actor: dict,
220
+ personal_space_id: str,
221
+ *,
222
+ personal_project_id: str | None = None,
223
+ work_kind: str | None = None,
224
+ agent_id: str | None = None,
225
+ executor_id: str | None = None,
226
+ model_strategy_key: str | None = None,
227
+ ) -> list[ProjectAgentExecutorOutcomeAggregate]:
228
+ """List durable learning buckets without combining strategies."""
229
+
230
+ self._require_personal()
231
+ space = await self.authorize(actor, personal_space_id, 'reader')
232
+ if personal_project_id is not None:
233
+ await authorize_personal_project(
234
+ self,
235
+ actor,
236
+ space,
237
+ personal_project_id,
238
+ )
239
+ records, _, _ = await self.runtime.driver.execute_query(
240
+ '''
241
+ MATCH (aggregate:FuliProjectAgentExecutorOutcomeAggregate {
242
+ personal_space_id: $personal_space_id
243
+ })
244
+ WHERE ($personal_project_id IS NULL
245
+ OR aggregate.personal_project_id = $personal_project_id)
246
+ AND ($work_kind IS NULL OR aggregate.work_kind = $work_kind)
247
+ AND ($agent_id IS NULL OR aggregate.agent_id = $agent_id)
248
+ AND ($executor_id IS NULL OR aggregate.executor_id = $executor_id)
249
+ AND ($model_strategy_key IS NULL
250
+ OR aggregate.model_strategy_key = $model_strategy_key)
251
+ RETURN aggregate
252
+ ORDER BY aggregate.as_of DESC, aggregate.executor_id
253
+ ''',
254
+ personal_space_id=personal_space_id,
255
+ personal_project_id=personal_project_id,
256
+ work_kind=work_kind,
257
+ agent_id=agent_id,
258
+ executor_id=executor_id,
259
+ model_strategy_key=model_strategy_key,
260
+ routing_='r',
261
+ )
262
+ aggregates: list[ProjectAgentExecutorOutcomeAggregate] = []
263
+ for row in records:
264
+ raw = dict(row.get('aggregate') or {})
265
+ aggregate_json = raw.get('aggregate_json')
266
+ if aggregate_json:
267
+ try:
268
+ raw.update(json.loads(aggregate_json))
269
+ except (TypeError, ValueError):
270
+ pass
271
+ # Storage identities are intentionally internal: the public model
272
+ # remains the provider-neutral bucket contract.
273
+ raw.pop('id', None)
274
+ raw.pop('aggregate_id', None)
275
+ raw.pop('aggregate_json', None)
276
+ if not raw.get('model_strategy_key'):
277
+ strategy_value = raw.get('model_strategy')
278
+ if isinstance(strategy_value, str):
279
+ try:
280
+ strategy_value = json.loads(strategy_value)
281
+ except (TypeError, ValueError):
282
+ strategy_value = None
283
+ try:
284
+ strategy = ProjectAgentModelStrategy.model_validate(
285
+ strategy_value or {}
286
+ )
287
+ except Exception:
288
+ strategy = ProjectAgentModelStrategy()
289
+ raw['model_strategy'] = strategy
290
+ raw['model_strategy_key'] = project_agent_model_strategy_key(strategy)
291
+ try:
292
+ aggregate = ProjectAgentExecutorOutcomeAggregate.model_validate(raw)
293
+ except Exception:
294
+ # An incomplete legacy node cannot be presented as an audited
295
+ # learning bucket; it remains available for migration/repair.
296
+ continue
297
+ if (
298
+ model_strategy_key is None
299
+ or aggregate.model_strategy_key == model_strategy_key
300
+ ):
301
+ aggregates.append(aggregate)
302
+ return aggregates
303
+
304
+ async def ignore_project_agent_executor_outcome_evidence(
305
+ self,
306
+ actor: dict,
307
+ request: ProjectAgentExecutorEvidenceIgnore,
308
+ ) -> ProjectAgentExecutorOutcomeEvidenceRecord:
309
+ self._require_personal()
310
+ space = await self.authorize(actor, request.personal_space_id, 'maintainer')
311
+ await authorize_personal_project(
312
+ self,
313
+ actor,
314
+ space,
315
+ request.personal_project_id,
316
+ )
317
+ records, _, _ = await self.runtime.driver.execute_query(
318
+ '''
319
+ MATCH (evidence:FuliProjectAgentExecutorOutcomeEvidence {
320
+ personal_space_id: $personal_space_id,
321
+ personal_project_id: $personal_project_id,
322
+ agent_id: $agent_id,
323
+ evidence_id: $evidence_id
324
+ })
325
+ WHERE evidence.ignore_idempotency_key IS NULL
326
+ OR evidence.ignore_idempotency_key = $idempotency_key
327
+ SET evidence.ignored = true,
328
+ evidence.ignored_reason = $reason,
329
+ evidence.ignore_idempotency_key = $idempotency_key,
330
+ evidence.ignored_at = $updated_at
331
+ RETURN evidence
332
+ ''',
333
+ personal_space_id=request.personal_space_id,
334
+ personal_project_id=request.personal_project_id,
335
+ agent_id=request.agent_id,
336
+ evidence_id=request.evidence_id,
337
+ reason=request.reason,
338
+ idempotency_key=request.idempotency_key,
339
+ updated_at=now_utc(),
340
+ )
341
+ if not records:
342
+ raise HTTPException(
343
+ status_code=409,
344
+ detail='outcome evidence is missing or ignore idempotency key was reused',
345
+ )
346
+ evidence = self._outcome_evidence_from_raw(dict(records[0]['evidence']))
347
+ # Ignoring evidence changes the effective sample immediately. Rebuild
348
+ # only this strategy bucket so a later route can use the new signal
349
+ # without requiring a separate UI aggregation action.
350
+ await self.aggregate_project_agent_executor_outcomes(
351
+ actor,
352
+ personal_space_id=evidence.personal_space_id,
353
+ personal_project_id=evidence.personal_project_id,
354
+ work_kind=evidence.work_kind,
355
+ agent_id=evidence.agent_id,
356
+ executor_id=evidence.executor_id,
357
+ model_strategy=evidence.model_strategy,
358
+ )
359
+ return evidence
360
+
361
+ async def reset_project_agent_executor_outcomes(
362
+ self,
363
+ actor: dict,
364
+ request: ProjectAgentExecutorOutcomeReset,
365
+ ) -> ProjectAgentExecutorOutcomeAggregate | None:
366
+ self._require_personal()
367
+ space = await self.authorize(actor, request.personal_space_id, 'maintainer')
368
+ await authorize_personal_project(
369
+ self,
370
+ actor,
371
+ space,
372
+ request.personal_project_id,
373
+ )
374
+ strategy_key = project_agent_model_strategy_key(request.model_strategy)
375
+ reset_id = project_agent_executor_outcome_bucket_id(
376
+ self.settings.provider_id,
377
+ request.personal_space_id,
378
+ request.personal_project_id,
379
+ request.work_kind,
380
+ request.agent_id,
381
+ request.executor_id,
382
+ strategy_key,
383
+ bucket_kind='reset',
384
+ )
385
+ payload_hash = self._payload_hash(request)
386
+ records, _, _ = await self.runtime.driver.execute_query(
387
+ '''
388
+ MERGE (reset:FuliProjectAgentExecutorOutcomeReset {
389
+ id: $reset_id
390
+ })
391
+ ON CREATE SET reset.reset_id = $reset_id,
392
+ reset.personal_space_id = $personal_space_id,
393
+ reset.personal_project_id = $personal_project_id,
394
+ reset.work_kind = $work_kind,
395
+ reset.agent_id = $agent_id,
396
+ reset.executor_id = $executor_id,
397
+ reset.model_strategy_key = $model_strategy_key,
398
+ reset.payload_hash = $payload_hash,
399
+ reset.idempotency_key = $idempotency_key
400
+ WITH reset,
401
+ reset.payload_hash IS NULL
402
+ OR reset.payload_hash = $payload_hash
403
+ OR (
404
+ reset.idempotency_key <> $idempotency_key
405
+ AND (reset.reset_at IS NULL OR reset.reset_at < $reset_at)
406
+ ) AS accepted
407
+ WHERE accepted
408
+ SET reset.reset_at = $reset_at,
409
+ reset.reason = $reason,
410
+ reset.model_strategy_json = $model_strategy_json,
411
+ reset.model_strategy_key = $model_strategy_key,
412
+ reset.payload_hash = $payload_hash,
413
+ reset.idempotency_key = $idempotency_key,
414
+ reset.updated_at = $reset_at
415
+ RETURN reset
416
+ ''',
417
+ reset_id=reset_id,
418
+ personal_space_id=request.personal_space_id,
419
+ personal_project_id=request.personal_project_id,
420
+ work_kind=request.work_kind,
421
+ agent_id=request.agent_id,
422
+ executor_id=request.executor_id,
423
+ model_strategy_json=request.model_strategy.model_dump_json(),
424
+ model_strategy_key=strategy_key,
425
+ payload_hash=payload_hash,
426
+ reset_at=request.reset_at,
427
+ reason=request.reason,
428
+ idempotency_key=request.idempotency_key,
429
+ )
430
+ if not records:
431
+ raise HTTPException(
432
+ status_code=409,
433
+ detail='outcome reset idempotency key was reused with different input',
434
+ )
435
+ return await self.aggregate_project_agent_executor_outcomes(
436
+ actor,
437
+ personal_space_id=request.personal_space_id,
438
+ personal_project_id=request.personal_project_id,
439
+ work_kind=request.work_kind,
440
+ agent_id=request.agent_id,
441
+ executor_id=request.executor_id,
442
+ model_strategy=request.model_strategy,
443
+ )
444
+
445
+ async def aggregate_project_agent_executor_outcomes(
446
+ self,
447
+ actor: dict,
448
+ *,
449
+ personal_space_id: str,
450
+ personal_project_id: str,
451
+ work_kind: str,
452
+ executor_id: str,
453
+ agent_id: str,
454
+ model_strategy: ProjectAgentModelStrategy | None = None,
455
+ as_of: datetime | None = None,
456
+ decay_half_life_days: float = _DEFAULT_HALF_LIFE_DAYS,
457
+ ) -> ProjectAgentExecutorOutcomeAggregate:
458
+ self._require_personal()
459
+ space = await self.authorize(actor, personal_space_id, 'reader')
460
+ await authorize_personal_project(
461
+ self,
462
+ actor,
463
+ space,
464
+ personal_project_id,
465
+ )
466
+ if decay_half_life_days <= 0:
467
+ raise HTTPException(status_code=422, detail='decay half-life must be positive')
468
+ strategy = model_strategy or ProjectAgentModelStrategy()
469
+ strategy_key = project_agent_model_strategy_key(strategy)
470
+ reset_id = project_agent_executor_outcome_bucket_id(
471
+ self.settings.provider_id,
472
+ personal_space_id,
473
+ personal_project_id,
474
+ work_kind,
475
+ agent_id,
476
+ executor_id,
477
+ strategy_key,
478
+ bucket_kind='reset',
479
+ )
480
+ aggregate_id = project_agent_executor_outcome_bucket_id(
481
+ self.settings.provider_id,
482
+ personal_space_id,
483
+ personal_project_id,
484
+ work_kind,
485
+ agent_id,
486
+ executor_id,
487
+ strategy_key,
488
+ bucket_kind='aggregate',
489
+ )
490
+ reset_records, _, _ = await self.runtime.driver.execute_query(
491
+ '''
492
+ OPTIONAL MATCH (reset:FuliProjectAgentExecutorOutcomeReset {
493
+ id: $reset_id
494
+ })
495
+ RETURN reset
496
+ ''',
497
+ reset_id=reset_id,
498
+ routing_='r',
499
+ )
500
+ records, _, _ = await self.runtime.driver.execute_query(
501
+ '''
502
+ MATCH (evidence:FuliProjectAgentExecutorOutcomeEvidence {
503
+ personal_space_id: $personal_space_id,
504
+ personal_project_id: $personal_project_id,
505
+ work_kind: $work_kind,
506
+ agent_id: $agent_id,
507
+ executor_id: $executor_id,
508
+ model_strategy_key: $model_strategy_key
509
+ })
510
+ RETURN evidence
511
+ ''',
512
+ personal_space_id=personal_space_id,
513
+ personal_project_id=personal_project_id,
514
+ work_kind=work_kind,
515
+ agent_id=agent_id,
516
+ executor_id=executor_id,
517
+ model_strategy_key=strategy_key,
518
+ routing_='r',
519
+ )
520
+ current = as_of or now_utc()
521
+ reset_at = None
522
+ for row in reset_records:
523
+ reset_raw = row.get('reset')
524
+ if not reset_raw:
525
+ continue
526
+ candidate = native_datetime(dict(reset_raw).get('reset_at'))
527
+ if candidate and (reset_at is None or candidate > reset_at):
528
+ reset_at = candidate
529
+ evidence_rows = []
530
+ for row in records:
531
+ raw = row.get('evidence')
532
+ if raw:
533
+ raw = dict(raw)
534
+ raw_key = raw.get('model_strategy_key')
535
+ if not raw_key:
536
+ evidence_strategy_json = raw.get('model_strategy_json')
537
+ if evidence_strategy_json:
538
+ try:
539
+ evidence_strategy = ProjectAgentModelStrategy.model_validate_json(
540
+ evidence_strategy_json
541
+ )
542
+ except Exception:
543
+ evidence_strategy = ProjectAgentModelStrategy()
544
+ else:
545
+ evidence_strategy = ProjectAgentModelStrategy()
546
+ raw_key = project_agent_model_strategy_key(evidence_strategy)
547
+ if raw_key == strategy_key:
548
+ evidence_rows.append(raw)
549
+ evidence = [
550
+ self._outcome_evidence_from_raw(raw)
551
+ for raw in evidence_rows
552
+ if not raw.get('ignored')
553
+ and (
554
+ reset_at is None
555
+ or native_datetime(raw.get('occurred_at')) > reset_at
556
+ )
557
+ ]
558
+ contributions: list[ProjectAgentExecutorEvidenceContribution] = []
559
+ success_count = 0
560
+ failure_count = 0
561
+ rework_count = 0
562
+ rating_values: list[int] = []
563
+ recent_count = 0
564
+ weighted_success = 0.0
565
+ weighted_failure = 0.0
566
+ evidence_refs: set[str] = set()
567
+ for item in evidence:
568
+ occurred = item.occurred_at
569
+ age = max(0.0, (current - occurred).total_seconds() / 86400)
570
+ weight = math.pow(0.5, age / decay_half_life_days)
571
+ if age <= _DEFAULT_RECENT_DAYS:
572
+ recent_count += 1
573
+ evidence_refs.update(item.reference_ids)
574
+ if item.task_id:
575
+ evidence_refs.add(f'task:{item.task_id}')
576
+ if item.run_id:
577
+ evidence_refs.add(f'run:{item.run_id}')
578
+ signal, value = self._evidence_signal(item)
579
+ if signal == 'success':
580
+ success_count += 1
581
+ weighted_success += weight * value
582
+ elif signal == 'failure':
583
+ failure_count += 1
584
+ weighted_failure += weight * value
585
+ elif signal == 'rework':
586
+ rework_count += 1
587
+ weighted_failure += weight * value
588
+ elif signal == 'rating' and item.rating is not None:
589
+ rating_values.append(item.rating)
590
+ weighted_success += weight * max(0.0, (item.rating - 3) / 2)
591
+ weighted_failure += weight * max(0.0, (3 - item.rating) / 2)
592
+ contributions.append(
593
+ ProjectAgentExecutorEvidenceContribution(
594
+ evidence_id=item.evidence_id,
595
+ evidence_kind=item.evidence_kind,
596
+ signal=signal,
597
+ value=value,
598
+ decay_weight=weight,
599
+ occurred_at=occurred,
600
+ reference_ids=item.reference_ids,
601
+ )
602
+ )
603
+ sample_count = len(evidence)
604
+ aggregate = ProjectAgentExecutorOutcomeAggregate(
605
+ personal_space_id=personal_space_id,
606
+ personal_project_id=personal_project_id,
607
+ work_kind=work_kind,
608
+ agent_id=agent_id,
609
+ executor_id=executor_id,
610
+ model_strategy=strategy,
611
+ model_strategy_key=strategy_key,
612
+ sample_count=sample_count,
613
+ recent_count=recent_count,
614
+ success_count=success_count,
615
+ rework_count=rework_count,
616
+ failure_count=failure_count,
617
+ rating_count=len(rating_values),
618
+ average_rating=(sum(rating_values) / len(rating_values)) if rating_values else None,
619
+ neutral_due_to_insufficient_evidence=sample_count < _MINIMUM_EVIDENCE_SAMPLES,
620
+ ignored=False,
621
+ reset_at=reset_at,
622
+ as_of=current,
623
+ decay_half_life_days=decay_half_life_days,
624
+ weighted_success=weighted_success,
625
+ weighted_failure=weighted_failure,
626
+ evidence_refs=sorted(evidence_refs),
627
+ evidence_contributions=contributions,
628
+ )
629
+ await self.runtime.driver.execute_query(
630
+ '''
631
+ MERGE (aggregate:FuliProjectAgentExecutorOutcomeAggregate {
632
+ id: $aggregate_id
633
+ })
634
+ ON CREATE SET aggregate.aggregate_id = $aggregate_id,
635
+ aggregate.personal_space_id = $personal_space_id,
636
+ aggregate.personal_project_id = $personal_project_id,
637
+ aggregate.work_kind = $work_kind,
638
+ aggregate.agent_id = $agent_id,
639
+ aggregate.executor_id = $executor_id,
640
+ aggregate.model_strategy_key = $model_strategy_key
641
+ WITH aggregate
642
+ WHERE aggregate.as_of IS NULL OR aggregate.as_of <= $as_of
643
+ SET aggregate.aggregate_json = $aggregate_json,
644
+ aggregate.sample_count = $sample_count,
645
+ aggregate.recent_count = $recent_count,
646
+ aggregate.success_count = $success_count,
647
+ aggregate.rework_count = $rework_count,
648
+ aggregate.failure_count = $failure_count,
649
+ aggregate.rating_count = $rating_count,
650
+ aggregate.average_rating = $average_rating,
651
+ aggregate.neutral_due_to_insufficient_evidence =
652
+ $neutral_due_to_insufficient_evidence,
653
+ aggregate.reset_at = $reset_at,
654
+ aggregate.as_of = $as_of,
655
+ aggregate.decay_half_life_days = $decay_half_life_days,
656
+ aggregate.weighted_success = $weighted_success,
657
+ aggregate.weighted_failure = $weighted_failure,
658
+ aggregate.evidence_refs = $evidence_refs
659
+ ''',
660
+ aggregate_id=aggregate_id,
661
+ personal_space_id=personal_space_id,
662
+ personal_project_id=personal_project_id,
663
+ work_kind=work_kind,
664
+ agent_id=agent_id,
665
+ executor_id=executor_id,
666
+ model_strategy_key=strategy_key,
667
+ aggregate_json=aggregate.model_dump_json(),
668
+ sample_count=aggregate.sample_count,
669
+ recent_count=aggregate.recent_count,
670
+ success_count=aggregate.success_count,
671
+ rework_count=aggregate.rework_count,
672
+ failure_count=aggregate.failure_count,
673
+ rating_count=aggregate.rating_count,
674
+ average_rating=aggregate.average_rating,
675
+ neutral_due_to_insufficient_evidence=(
676
+ aggregate.neutral_due_to_insufficient_evidence
677
+ ),
678
+ reset_at=aggregate.reset_at,
679
+ as_of=aggregate.as_of,
680
+ decay_half_life_days=aggregate.decay_half_life_days,
681
+ weighted_success=aggregate.weighted_success,
682
+ weighted_failure=aggregate.weighted_failure,
683
+ evidence_refs=aggregate.evidence_refs,
684
+ )
685
+ return aggregate
686
+
687
+ @staticmethod
688
+ def _outcome_evidence_from_raw(
689
+ raw: dict,
690
+ fallback: ProjectAgentExecutorOutcomeEvidenceCreate | None = None,
691
+ ) -> ProjectAgentExecutorOutcomeEvidenceRecord:
692
+ model_strategy_json = raw.get('model_strategy_json')
693
+ if model_strategy_json:
694
+ try:
695
+ strategy = ProjectAgentModelStrategy.model_validate_json(model_strategy_json)
696
+ except Exception:
697
+ strategy = ProjectAgentModelStrategy()
698
+ elif fallback:
699
+ strategy = fallback.model_strategy
700
+ else:
701
+ strategy = ProjectAgentModelStrategy()
702
+ strategy_key = raw.get('model_strategy_key')
703
+ if not strategy_key:
704
+ strategy_key = project_agent_model_strategy_key(strategy)
705
+ return ProjectAgentExecutorOutcomeEvidenceRecord(
706
+ personal_space_id=raw.get('personal_space_id') or (fallback.personal_space_id if fallback else ''),
707
+ personal_project_id=raw.get('personal_project_id') or (fallback.personal_project_id if fallback else ''),
708
+ work_kind=raw.get('work_kind') or (fallback.work_kind if fallback else ''),
709
+ agent_id=raw.get('agent_id') or (fallback.agent_id if fallback else ''),
710
+ executor_id=raw.get('executor_id') or (fallback.executor_id if fallback else ''),
711
+ task_id=raw.get('task_id') or (fallback.task_id if fallback else ''),
712
+ run_id=raw.get('run_id') or (fallback.run_id if fallback else None),
713
+ model_strategy=strategy,
714
+ model_strategy_key=strategy_key,
715
+ evidence_kind=raw.get('evidence_kind') or (fallback.evidence_kind if fallback else 'terminal_outcome'),
716
+ source=raw.get('source') or (fallback.source if fallback else 'system_terminal'),
717
+ terminal_outcome=raw.get('terminal_outcome') or (fallback.terminal_outcome if fallback else None),
718
+ rating=raw.get('rating') if raw.get('rating') is not None else (fallback.rating if fallback else None),
719
+ reference_ids=list(raw.get('reference_ids') or (fallback.reference_ids if fallback else [])),
720
+ note=raw.get('note') or (fallback.note if fallback else None),
721
+ idempotency_key=raw.get('idempotency_key') or (fallback.idempotency_key if fallback else 'stored-evidence-key'),
722
+ occurred_at=native_datetime(raw.get('occurred_at')) or (fallback.occurred_at if fallback else now_utc()),
723
+ evidence_id=raw.get('evidence_id') or raw.get('id') or 'stored-evidence',
724
+ ignored=bool(raw.get('ignored', False)),
725
+ ignored_reason=raw.get('ignored_reason'),
726
+ created_at=native_datetime(raw.get('created_at')) or now_utc(),
727
+ )
728
+
729
+ @staticmethod
730
+ def _evidence_signal(
731
+ item: ProjectAgentExecutorOutcomeEvidenceRecord,
732
+ ) -> tuple[str, float]:
733
+ if item.evidence_kind in {'explicit_rating'}:
734
+ return 'rating', 1.0
735
+ if item.evidence_kind in {
736
+ 'terminal_outcome',
737
+ 'explicit_praise',
738
+ 'test_passed',
739
+ 'acceptance_passed',
740
+ }:
741
+ if item.evidence_kind == 'terminal_outcome':
742
+ if item.terminal_outcome in _TERMINAL_NEUTRAL:
743
+ return 'neutral', 0.0
744
+ if item.terminal_outcome == 'failed':
745
+ return 'failure', 1.0
746
+ return 'success', 1.0
747
+ if item.evidence_kind in {'rework_requested'}:
748
+ return 'rework', 1.0
749
+ return 'failure', 1.0
750
+
751
+ record_executor_outcome_evidence = record_project_agent_executor_outcome_evidence
752
+ list_executor_outcome_evidence = list_project_agent_executor_outcome_evidence
753
+ list_executor_outcome_aggregates = list_project_agent_executor_outcome_aggregates
754
+ ignore_executor_outcome_evidence = ignore_project_agent_executor_outcome_evidence
755
+ reset_executor_outcomes = reset_project_agent_executor_outcomes
756
+ aggregate_executor_outcomes = aggregate_project_agent_executor_outcomes