fuli-context 0.7.6 → 0.7.7

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 (146) hide show
  1. package/README.md +69 -13
  2. package/README.zh-CN.md +60 -14
  3. package/dist/web/assets/{AboutPage-sLV9WChi.js → AboutPage-BcZeFcHe.js} +1 -1
  4. package/dist/web/assets/{ConnectionsPage-BWn4ywGJ.js → ConnectionsPage-xAQYl1SQ.js} +1 -1
  5. package/dist/web/assets/EmployeeRecruitDialog-CbPAmhy7.js +1 -0
  6. package/dist/web/assets/EmployeeRecruitDialog-CfFlyKSh.css +1 -0
  7. package/dist/web/assets/EmployeeWorkbenchPage-BQvTLoc8.css +1 -0
  8. package/dist/web/assets/EmployeeWorkbenchPage-DF3EL0Ui.js +1 -0
  9. package/dist/web/assets/KnowledgeEditDialog-UnnBDt0I.js +1 -0
  10. package/dist/web/assets/{KnowledgeEditDialog-DWUV_NPp.css → KnowledgeEditDialog-iv-dgUeD.css} +1 -1
  11. package/dist/web/assets/{KnowledgeInspector-CRQWOaos.js → KnowledgeInspector-BqPJH5Rr.js} +1 -1
  12. package/dist/web/assets/{KnowledgeOrganizerPage-Dfj3vIpU.js → KnowledgeOrganizerPage-CyvRflXM.js} +1 -1
  13. package/dist/web/assets/KnowledgePage-59eczTqe.js +1 -0
  14. package/dist/web/assets/{KnowledgeWorkspace-ChEB5Bwk.js → KnowledgeWorkspace-CfIjhOog.js} +1 -1
  15. package/dist/web/assets/{OverviewPage-Cd66M0RT.js → OverviewPage-Xyfm5xkZ.js} +1 -1
  16. package/dist/web/assets/{PersonalProfilePage-RCFHL0Wz.js → PersonalProfilePage-DB9mByB3.js} +1 -1
  17. package/dist/web/assets/PersonalProjectsPage-_-YXVi5O.js +1 -0
  18. package/dist/web/assets/ProjectAgentsPage-C3vW6wmk.css +1 -0
  19. package/dist/web/assets/ProjectAgentsPage-DeeQByPX.js +11 -0
  20. package/dist/web/assets/{PublicProjectsPage-mstPEjHe.js → PublicProjectsPage-BEZ1YPs3.js} +1 -1
  21. package/dist/web/assets/{ReviewPage-6Y1SmfZy.js → ReviewPage-CQYGwFBH.js} +1 -1
  22. package/dist/web/assets/SearchableSelect-D27H0MoP.js +1 -0
  23. package/dist/web/assets/{SettingsPage--LdTMUm7.js → SettingsPage-BlEZ03Yi.js} +1 -1
  24. package/dist/web/assets/{WritingTastePage-CIpKswvs.js → WritingTastePage-DvRt_nmP.js} +1 -1
  25. package/dist/web/assets/catalog-DFvIaHVS.js +1 -0
  26. package/dist/web/assets/{console-BT1P2Db4.js → console-tOxOl8Vq.js} +1 -1
  27. package/dist/web/assets/conversation-launcher-settings-DuFSpPAo.js +1 -0
  28. package/dist/web/assets/{i18n-js6gfc7j.js → i18n-CgBHZthI.js} +3 -3
  29. package/dist/web/assets/{identity-CfDfanpa.js → identity-CFo_Uw1H.js} +1 -1
  30. package/dist/web/assets/index-CJc5ppXH.css +1 -0
  31. package/dist/web/assets/index-yCi3cz2l.js +2 -0
  32. package/dist/web/assets/{model-DJkLAJsg.js → model-BtnVr7fX.js} +1 -1
  33. package/dist/web/assets/{useApi-CROJJdhE-BdSti7Y2.js → useApi-CROJJdhE-B6MHm_Av.js} +1 -1
  34. package/dist/web/assets/{vue-router-Kk67EaCP.js → vue-router-CzHm-y-x.js} +1 -1
  35. package/dist/web/index.html +7 -6
  36. package/docs/employee-agents.md +99 -0
  37. package/docs/project-agent-memory.md +479 -0
  38. package/graph-provider/fuli_graph/knowledge_audit_models.py +171 -0
  39. package/graph-provider/fuli_graph/model_base.py +5 -0
  40. package/graph-provider/fuli_graph/model_validation.py +32 -0
  41. package/graph-provider/fuli_graph/models.py +26 -170
  42. package/graph-provider/fuli_graph/project_agent_context_models.py +29 -0
  43. package/graph-provider/fuli_graph/project_agent_executor_models.py +35 -7
  44. package/graph-provider/fuli_graph/project_agent_memory_models.py +79 -0
  45. package/graph-provider/fuli_graph/project_agent_memory_routes.py +46 -0
  46. package/graph-provider/fuli_graph/project_agent_models.py +9 -0
  47. package/graph-provider/fuli_graph/project_agent_recruitment_values.py +48 -0
  48. package/graph-provider/fuli_graph/project_agent_routes.py +21 -1
  49. package/graph-provider/fuli_graph/project_agent_task_models.py +217 -29
  50. package/graph-provider/fuli_graph/runtime.py +37 -1
  51. package/graph-provider/fuli_graph/store.py +6 -0
  52. package/graph-provider/fuli_graph/store_project_agent_context.py +65 -0
  53. package/graph-provider/fuli_graph/store_project_agent_executor_learning.py +175 -11
  54. package/graph-provider/fuli_graph/store_project_agent_executor_routing.py +33 -10
  55. package/graph-provider/fuli_graph/store_project_agent_executors.py +181 -14
  56. package/graph-provider/fuli_graph/store_project_agent_memory.py +174 -0
  57. package/graph-provider/fuli_graph/store_project_agent_task_activity.py +423 -0
  58. package/graph-provider/fuli_graph/store_project_agent_task_persistence.py +152 -0
  59. package/graph-provider/fuli_graph/store_project_agent_task_reads.py +187 -21
  60. package/graph-provider/fuli_graph/store_project_agent_task_recruitment.py +748 -162
  61. package/graph-provider/fuli_graph/store_project_agent_task_staffing.py +230 -18
  62. package/graph-provider/fuli_graph/store_project_agent_tasks.py +261 -468
  63. package/graph-provider/fuli_graph/store_project_agents.py +214 -46
  64. package/graph-provider/fuli_graph/store_task_contexts.py +217 -0
  65. package/graph-provider/fuli_graph/store_transactions.py +40 -0
  66. package/graph-provider/fuli_graph/task_context_models.py +50 -0
  67. package/graph-provider/fuli_graph/task_context_routes.py +31 -0
  68. package/graph-provider/fuli_graph/workflow_observation_models.py +1 -1
  69. package/graph-provider/pyproject.toml +1 -1
  70. package/npm-shrinkwrap.json +8 -8
  71. package/package.json +5 -2
  72. package/skills/capturing-session-knowledge/SKILL.md +18 -6
  73. package/src/adaptive-runtime/executor-pool.js +46 -5
  74. package/src/adaptive-runtime/graph-services.js +10 -3
  75. package/src/adaptive-runtime/lease-client.js +66 -14
  76. package/src/adaptive-runtime/runtime-broker.js +24 -9
  77. package/src/agent-tools/employee-definitions.js +43 -0
  78. package/src/agent-tools/graph-definitions.js +25 -6
  79. package/src/agent-tools/graph-handlers.js +11 -0
  80. package/src/agent-tools/project-agent-definitions.js +35 -4
  81. package/src/agent-tools/project-agent-memory-definitions.js +36 -0
  82. package/src/agent-tools.js +6 -2
  83. package/src/agents/codex/lifecycle-hook.js +107 -0
  84. package/src/agents/codex/lifecycle-hooks.js +231 -0
  85. package/src/agents/cursor/lifecycle-hook.js +133 -0
  86. package/src/agents/cursor/lifecycle-hooks.js +50 -0
  87. package/src/agents/lifecycle-message.js +19 -0
  88. package/src/app/agent-request-context.js +23 -0
  89. package/src/cli/command-registry.js +2 -0
  90. package/src/cli/employee-command.js +17 -0
  91. package/src/cli/remote-mcp-command.js +282 -0
  92. package/src/cli/setup-command.js +5 -0
  93. package/src/cli.js +10 -0
  94. package/src/employees/catalog/jefa.json +21 -0
  95. package/src/employees/install-package.js +77 -0
  96. package/src/employees/management-policy.js +88 -0
  97. package/src/employees/manifest.js +53 -0
  98. package/src/employees/package-registry.js +107 -0
  99. package/src/employees/project-selection.js +50 -0
  100. package/src/employees/recruitment.js +199 -0
  101. package/src/employees/service.js +83 -0
  102. package/src/employees/task-entry.js +39 -0
  103. package/src/graphiti/agent-knowledge-workflows.js +135 -25
  104. package/src/graphiti/federated-application.js +78 -35
  105. package/src/graphiti/project-agent-control-plane.js +20 -3
  106. package/src/graphiti/project-agent-mapping.js +155 -5
  107. package/src/graphiti/project-agent-memory.js +115 -0
  108. package/src/graphiti/project-agent-task-entry.js +126 -0
  109. package/src/graphiti/project-agent-workflows.js +30 -14
  110. package/src/graphiti/provider-client.js +66 -4
  111. package/src/http/api-router.js +25 -5
  112. package/src/http/employee-api-router.js +64 -0
  113. package/src/http/error-mapping.js +4 -0
  114. package/src/http/graph-api-router.js +2 -0
  115. package/src/http/request-policy.js +6 -3
  116. package/src/http/static-handler.js +4 -3
  117. package/src/mcp/create-mcp-server.js +98 -37
  118. package/src/mcp/direct-call.js +38 -0
  119. package/src/mcp/instructions.js +8 -7
  120. package/src/mcp/provider-task-context-registry.js +74 -0
  121. package/src/mcp/remote-http-server.js +761 -0
  122. package/src/mcp/session-id.js +29 -3
  123. package/src/mcp/task-context-registry.js +88 -29
  124. package/src/mcp/tool-annotations.js +10 -2
  125. package/src/mcp/tool-result.js +8 -3
  126. package/src/mcp-server.js +20 -8
  127. package/src/native-runtime/runtime.js +58 -8
  128. package/src/server.js +76 -28
  129. package/src/setup/agent-installation-status.js +38 -6
  130. package/src/setup/agents.js +20 -8
  131. package/src/setup/claude-code-config.js +55 -13
  132. package/src/setup/codex-bootstrap.js +11 -10
  133. package/src/setup/codex-config.js +74 -7
  134. package/src/setup/config-backup.js +18 -2
  135. package/src/setup/cursor-config.js +22 -5
  136. package/src/setup/setup.js +30 -22
  137. package/src/storage/json-file.js +11 -3
  138. package/dist/web/assets/KnowledgeEditDialog-CZ5ziPrD.js +0 -1
  139. package/dist/web/assets/KnowledgePage-BRYC9S5Y.js +0 -1
  140. package/dist/web/assets/PersonalProjectsPage-DEh8XWby.js +0 -1
  141. package/dist/web/assets/ProjectAgentsPage-Crh7PrXa.css +0 -1
  142. package/dist/web/assets/ProjectAgentsPage-DPnaH-TR.js +0 -11
  143. package/dist/web/assets/SearchableSelect-D3HM8YF8.js +0 -1
  144. package/dist/web/assets/conversation-launcher-settings-DrJEotdf.js +0 -1
  145. package/dist/web/assets/index-BnH5mcZx.css +0 -1
  146. package/dist/web/assets/index-GqJf1xqg.js +0 -2
@@ -0,0 +1,65 @@
1
+ """Resolve a durable role for the already-running caller, without execution."""
2
+
3
+ from .personal_project_access import authorize_personal_project
4
+ from .project_agent_context_models import ProjectAgentContextResolution
5
+ from .project_agent_task_models import ProjectAgentTaskSubmit
6
+
7
+
8
+ class StoreProjectAgentContext:
9
+ async def resolve_project_agent_context(self, actor, request):
10
+ self._require_personal()
11
+ space = await self.authorize(actor, request.personal_space_id, 'reader')
12
+ await authorize_personal_project(self, actor, space, request.personal_project_id)
13
+ owner = None
14
+ if request.session_id and not request.agent_id:
15
+ rows, _, _ = await self.runtime.driver.execute_query(
16
+ '''
17
+ MATCH (session:FuliTaskContextSession {id: $session_id})-[:HAS_CONTEXT]->
18
+ (context:FuliTaskContext {personal_space_id: $space_id,
19
+ personal_project_id: $project_id, completed: false})
20
+ WHERE session.current_token = context.token
21
+ AND ($turn_id IS NULL OR context.turn_id = $turn_id)
22
+ RETURN context.project_agent_id AS agent_id
23
+ ''', session_id=self._task_session_id(request.personal_space_id,
24
+ request.source_application, request.session_id),
25
+ space_id=request.personal_space_id, project_id=request.personal_project_id,
26
+ turn_id=request.turn_id,
27
+ routing_='r',
28
+ )
29
+ owner = rows[0]['agent_id'] if rows else None
30
+ # Reuse the task router's staffing policy. These fixed values and the
31
+ # request are not persisted; raw user prompts never enter this API.
32
+ selection = ProjectAgentTaskSubmit(
33
+ personal_space_id=request.personal_space_id,
34
+ personal_project_id=request.personal_project_id,
35
+ idempotency_key='read-only-task-context',
36
+ title='Resolve task context', objective='Select one existing durable role.',
37
+ work_kind=request.work_kind, required_capabilities=request.required_capabilities,
38
+ lead_agent_id=request.agent_id or owner, source_application=request.source_application,
39
+ routing_reason='Read-only task-entry context recovery',
40
+ )
41
+ selected, candidates, reason, basis = await self._select_agents(actor, space, selection)
42
+ if not selected and reason == 'no_match' and not request.required_capabilities:
43
+ selected, candidates, reason, basis = await self._select_agents(
44
+ actor, space, selection.model_copy(update={'work_kind': 'project_context'}),
45
+ )
46
+ if selected:
47
+ reason = 'project_context_fallback'
48
+ basis = [
49
+ f'no role matched requested work kind: {request.work_kind}; '
50
+ 'using project_context for continuity only',
51
+ *basis,
52
+ ]
53
+ if owner and selected:
54
+ reason, basis = 'active_task_owner', ['reuse the owner of this host session task']
55
+ agent = await self.get_project_agent(
56
+ actor, request.personal_space_id, request.personal_project_id,
57
+ selected[0]['agent_id'],
58
+ ) if selected else None
59
+ return ProjectAgentContextResolution(
60
+ status='ready' if agent else (
61
+ 'manual_selection' if reason == 'manual_agent_selection'
62
+ else 'agent_unavailable' if reason == 'agent_unavailable' else 'unassigned'
63
+ ),
64
+ agent=agent, reason=reason, match_basis=basis, candidate_count=len(candidates),
65
+ )
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import math
7
+ from contextlib import asynccontextmanager
7
8
  from datetime import datetime
8
9
 
9
10
  from fastapi import HTTPException
@@ -20,6 +21,7 @@ from .project_agent_executor_models import (
20
21
  )
21
22
  from .project_agent_models import ProjectAgentModelStrategy
22
23
  from .provider_values import native_datetime, now_utc, stable_uuid
24
+ from .store_transactions import query_store_transaction
23
25
 
24
26
 
25
27
  _TERMINAL_NEUTRAL = {'cancelled'}
@@ -58,6 +60,36 @@ def project_agent_executor_outcome_bucket_id(
58
60
  class StoreProjectAgentExecutorLearning:
59
61
  """Persist explainable evidence without overriding explicit user policy."""
60
62
 
63
+ @staticmethod
64
+ def _learning_scope(request):
65
+ return {key: getattr(request, key) for key in (
66
+ 'personal_space_id', 'personal_project_id', 'work_kind',
67
+ 'agent_id', 'executor_id', 'model_strategy')}
68
+
69
+ @asynccontextmanager
70
+ async def _learning_bucket_transaction(self, **scope):
71
+ """Serialize a bucket before reading its evidence and commit together."""
72
+ strategy_key = project_agent_model_strategy_key(scope['model_strategy'])
73
+ bucket_id = project_agent_executor_outcome_bucket_id(
74
+ self.settings.provider_id, scope['personal_space_id'],
75
+ scope['personal_project_id'], scope['work_kind'], scope['agent_id'],
76
+ scope['executor_id'], strategy_key, bucket_kind='aggregate')
77
+ async with query_store_transaction(self) as scoped:
78
+ await scoped.runtime.driver.execute_query('''
79
+ MERGE (bucket:FuliProjectAgentExecutorOutcomeAggregate {id: $bucket_id})
80
+ ON CREATE SET bucket.aggregate_id = $bucket_id,
81
+ bucket.personal_space_id = $personal_space_id,
82
+ bucket.personal_project_id = $personal_project_id,
83
+ bucket.work_kind = $work_kind,
84
+ bucket.agent_id = $agent_id,
85
+ bucket.executor_id = $executor_id,
86
+ bucket.model_strategy_key = $model_strategy_key
87
+ SET bucket._write_lock = true
88
+ REMOVE bucket._write_lock
89
+ ''', bucket_id=bucket_id, model_strategy_key=strategy_key,
90
+ **{key: value for key, value in scope.items() if key != 'model_strategy'})
91
+ yield scoped
92
+
61
93
  async def record_project_agent_executor_outcome_evidence(
62
94
  self,
63
95
  actor: dict,
@@ -71,6 +103,77 @@ class StoreProjectAgentExecutorLearning:
71
103
  space,
72
104
  request.personal_project_id,
73
105
  )
106
+ async with query_store_transaction(self) as scoped:
107
+ # Relationship writes below also lock the task. Match the activity
108
+ # path's task -> bucket order instead of introducing its inverse.
109
+ await scoped.runtime.driver.execute_query('''
110
+ MATCH (task:FuliProjectAgentTask {
111
+ personal_space_id: $personal_space_id,
112
+ personal_project_id: $personal_project_id, task_id: $task_id
113
+ })
114
+ SET task._outcome_write_lock = true
115
+ REMOVE task._outcome_write_lock
116
+ ''', **{key: getattr(request, key) for key in (
117
+ 'personal_space_id', 'personal_project_id', 'task_id')})
118
+ await scoped._validate_outcome_execution(request)
119
+ async with scoped._learning_bucket_transaction(**self._learning_scope(request)) as bucket:
120
+ return await bucket._record_outcome_evidence(actor, request)
121
+
122
+ async def _validate_outcome_execution(self, request):
123
+ """Bind credit to recorded work, not a caller-chosen learning bucket.
124
+
125
+ External test/user references remain caller-supplied assertions. Only
126
+ system terminal evidence is derived from a persisted task event.
127
+ Historical execution remains usable after an executor is revoked.
128
+ """
129
+ rows, _, _ = await self.runtime.driver.execute_query('''
130
+ MATCH (task:FuliProjectAgentTask {
131
+ personal_space_id: $personal_space_id,
132
+ personal_project_id: $personal_project_id, task_id: $task_id
133
+ })-[:HAS_PARTICIPANT]->(agent:FuliProjectAgent {agent_id: $agent_id})
134
+ MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})
135
+ -[:HAS_PROJECT_AGENT_IDENTITY]->(agent)
136
+ OPTIONAL MATCH (task)-[:HAS_EXECUTOR_OBSERVATION]->
137
+ (observation:FuliProjectAgentExecutorObservation {
138
+ agent_id: $agent_id, executor_id: $executor_id
139
+ })
140
+ WHERE $run_id IS NULL OR observation.run_id = $run_id
141
+ WITH task, collect(DISTINCT observation) AS observations
142
+ OPTIONAL MATCH (task)-[:HAS_TASK_EVENT]->(event:FuliProjectAgentTaskEvent {
143
+ event_id: $run_id, agent_id: $agent_id, actual_executor_id: $executor_id
144
+ })
145
+ RETURN task, observations, event
146
+ ''', **{key: getattr(request, key) for key in (
147
+ 'personal_space_id', 'personal_project_id', 'task_id',
148
+ 'agent_id', 'executor_id', 'run_id')}, routing_='r')
149
+ if not rows:
150
+ raise HTTPException(status_code=409, detail='outcome Agent is not a task participant')
151
+ row = rows[0]
152
+ task = dict(row['task'])
153
+ strategy_key = project_agent_model_strategy_key(request.model_strategy)
154
+
155
+ def matches_strategy(raw):
156
+ strategy = ProjectAgentModelStrategy.model_validate_json(raw or '{}')
157
+ return project_agent_model_strategy_key(strategy) == strategy_key
158
+
159
+ if (task.get('work_kind') != request.work_kind
160
+ or not matches_strategy(task.get('effective_model_strategy_json'))):
161
+ raise HTTPException(status_code=409, detail='outcome work kind or strategy does not match the task')
162
+ observations = [dict(value) for value in row.get('observations') or []]
163
+ if not any(matches_strategy(value.get('model_strategy_json')) for value in observations):
164
+ raise HTTPException(status_code=409, detail='outcome requires a matching recorded execution')
165
+ if request.evidence_kind == 'terminal_outcome':
166
+ event = dict(row.get('event') or {})
167
+ terminal_status = event.get('status')
168
+ if terminal_status not in {'completed', 'failed', 'cancelled'}:
169
+ terminal_status = event.get('worker_status')
170
+ if (not event or terminal_status != request.terminal_outcome
171
+ or request.reference_ids != [request.run_id]
172
+ or request.idempotency_key != f'terminal:{request.run_id}'
173
+ or request.occurred_at != native_datetime(event.get('created_at'))):
174
+ raise HTTPException(status_code=409, detail='terminal outcome must match its canonical task event')
175
+
176
+ async def _record_outcome_evidence(self, actor, request):
74
177
  evidence_id = stable_uuid(
75
178
  self.settings.provider_id,
76
179
  request.personal_space_id,
@@ -96,6 +199,7 @@ class StoreProjectAgentExecutorLearning:
96
199
  })
97
200
  ON CREATE SET evidence.evidence_id = $evidence_id,
98
201
  evidence.payload_hash = $payload_hash,
202
+ evidence.idempotency_key = $idempotency_key,
99
203
  evidence.personal_space_id = $personal_space_id,
100
204
  evidence.personal_project_id = $personal_project_id,
101
205
  evidence.work_kind = $work_kind,
@@ -126,6 +230,7 @@ class StoreProjectAgentExecutorLearning:
126
230
  run_id=request.run_id,
127
231
  evidence_id=evidence_id,
128
232
  payload_hash=payload_hash,
233
+ idempotency_key=request.idempotency_key,
129
234
  model_strategy_json=request.model_strategy.model_dump_json(),
130
235
  model_strategy_key=strategy_key,
131
236
  evidence_kind=request.evidence_kind,
@@ -314,6 +419,21 @@ class StoreProjectAgentExecutorLearning:
314
419
  space,
315
420
  request.personal_project_id,
316
421
  )
422
+ records, _, _ = await self.runtime.driver.execute_query('''
423
+ MATCH (evidence:FuliProjectAgentExecutorOutcomeEvidence {
424
+ personal_space_id: $personal_space_id, personal_project_id: $personal_project_id,
425
+ agent_id: $agent_id, evidence_id: $evidence_id
426
+ })
427
+ RETURN evidence
428
+ ''', **{key: getattr(request, key) for key in (
429
+ 'personal_space_id', 'personal_project_id', 'agent_id', 'evidence_id')})
430
+ if not records:
431
+ raise HTTPException(status_code=409, detail='outcome evidence is missing')
432
+ evidence = self._outcome_evidence_from_raw(dict(records[0]['evidence']))
433
+ async with self._learning_bucket_transaction(**self._learning_scope(evidence)) as scoped:
434
+ return await scoped._ignore_outcome_evidence(actor, request)
435
+
436
+ async def _ignore_outcome_evidence(self, actor, request):
317
437
  records, _, _ = await self.runtime.driver.execute_query(
318
438
  '''
319
439
  MATCH (evidence:FuliProjectAgentExecutorOutcomeEvidence {
@@ -323,11 +443,15 @@ class StoreProjectAgentExecutorLearning:
323
443
  evidence_id: $evidence_id
324
444
  })
325
445
  WHERE evidence.ignore_idempotency_key IS NULL
326
- OR evidence.ignore_idempotency_key = $idempotency_key
446
+ OR (evidence.ignore_idempotency_key = $idempotency_key
447
+ AND (evidence.ignore_payload_hash = $payload_hash
448
+ OR (evidence.ignore_payload_hash IS NULL
449
+ AND evidence.ignored_reason = $reason)))
327
450
  SET evidence.ignored = true,
328
451
  evidence.ignored_reason = $reason,
329
452
  evidence.ignore_idempotency_key = $idempotency_key,
330
- evidence.ignored_at = $updated_at
453
+ evidence.ignore_payload_hash = $payload_hash,
454
+ evidence.ignored_at = coalesce(evidence.ignored_at, $updated_at)
331
455
  RETURN evidence
332
456
  ''',
333
457
  personal_space_id=request.personal_space_id,
@@ -336,6 +460,7 @@ class StoreProjectAgentExecutorLearning:
336
460
  evidence_id=request.evidence_id,
337
461
  reason=request.reason,
338
462
  idempotency_key=request.idempotency_key,
463
+ payload_hash=self._payload_hash(request),
339
464
  updated_at=now_utc(),
340
465
  )
341
466
  if not records:
@@ -371,6 +496,10 @@ class StoreProjectAgentExecutorLearning:
371
496
  space,
372
497
  request.personal_project_id,
373
498
  )
499
+ async with self._learning_bucket_transaction(**self._learning_scope(request)) as scoped:
500
+ return await scoped._reset_outcomes(actor, request)
501
+
502
+ async def _reset_outcomes(self, actor, request):
374
503
  strategy_key = project_agent_model_strategy_key(request.model_strategy)
375
504
  reset_id = project_agent_executor_outcome_bucket_id(
376
505
  self.settings.provider_id,
@@ -383,6 +512,12 @@ class StoreProjectAgentExecutorLearning:
383
512
  bucket_kind='reset',
384
513
  )
385
514
  payload_hash = self._payload_hash(request)
515
+ previous, _, _ = await self.runtime.driver.execute_query('''
516
+ MATCH (reset:FuliProjectAgentExecutorOutcomeReset {id: $reset_id})
517
+ RETURN reset.reset_at AS previous_reset_at
518
+ ''', reset_id=reset_id, routing_='r')
519
+ previous_time = native_datetime(previous[0]['previous_reset_at']) if previous else None
520
+ replace_unknown_timezone = previous_time is not None and previous_time.utcoffset() is None
386
521
  records, _, _ = await self.runtime.driver.execute_query(
387
522
  '''
388
523
  MERGE (reset:FuliProjectAgentExecutorOutcomeReset {
@@ -402,7 +537,8 @@ class StoreProjectAgentExecutorLearning:
402
537
  OR reset.payload_hash = $payload_hash
403
538
  OR (
404
539
  reset.idempotency_key <> $idempotency_key
405
- AND (reset.reset_at IS NULL OR reset.reset_at < $reset_at)
540
+ AND (reset.reset_at IS NULL OR $replace_unknown_timezone
541
+ OR reset.reset_at < $reset_at)
406
542
  ) AS accepted
407
543
  WHERE accepted
408
544
  SET reset.reset_at = $reset_at,
@@ -424,6 +560,7 @@ class StoreProjectAgentExecutorLearning:
424
560
  model_strategy_key=strategy_key,
425
561
  payload_hash=payload_hash,
426
562
  reset_at=request.reset_at,
563
+ replace_unknown_timezone=replace_unknown_timezone,
427
564
  reason=request.reason,
428
565
  idempotency_key=request.idempotency_key,
429
566
  )
@@ -466,6 +603,19 @@ class StoreProjectAgentExecutorLearning:
466
603
  if decay_half_life_days <= 0:
467
604
  raise HTTPException(status_code=422, detail='decay half-life must be positive')
468
605
  strategy = model_strategy or ProjectAgentModelStrategy()
606
+ async with self._learning_bucket_transaction(
607
+ personal_space_id=personal_space_id, personal_project_id=personal_project_id,
608
+ work_kind=work_kind, executor_id=executor_id, agent_id=agent_id,
609
+ model_strategy=strategy,
610
+ ) as scoped:
611
+ return await scoped._aggregate_outcomes(
612
+ personal_space_id=personal_space_id, personal_project_id=personal_project_id,
613
+ work_kind=work_kind, executor_id=executor_id, agent_id=agent_id,
614
+ strategy=strategy, as_of=as_of, decay_half_life_days=decay_half_life_days)
615
+
616
+ async def _aggregate_outcomes(self, *, personal_space_id, personal_project_id,
617
+ work_kind, executor_id, agent_id, strategy,
618
+ as_of, decay_half_life_days):
469
619
  strategy_key = project_agent_model_strategy_key(strategy)
470
620
  reset_id = project_agent_executor_outcome_bucket_id(
471
621
  self.settings.provider_id,
@@ -518,13 +668,18 @@ class StoreProjectAgentExecutorLearning:
518
668
  routing_='r',
519
669
  )
520
670
  current = as_of or now_utc()
671
+ if current.utcoffset() is None:
672
+ raise HTTPException(status_code=422, detail='aggregation time requires a timezone')
673
+ validation_warnings = []
521
674
  reset_at = None
522
675
  for row in reset_records:
523
676
  reset_raw = row.get('reset')
524
677
  if not reset_raw:
525
678
  continue
526
679
  candidate = native_datetime(dict(reset_raw).get('reset_at'))
527
- if candidate and (reset_at is None or candidate > reset_at):
680
+ if candidate and candidate.utcoffset() is None:
681
+ validation_warnings.append('reset_timestamp_timezone_missing')
682
+ if candidate and (reset_at is None or (not validation_warnings and candidate > reset_at)):
528
683
  reset_at = candidate
529
684
  evidence_rows = []
530
685
  for row in records:
@@ -546,12 +701,20 @@ class StoreProjectAgentExecutorLearning:
546
701
  raw_key = project_agent_model_strategy_key(evidence_strategy)
547
702
  if raw_key == strategy_key:
548
703
  evidence_rows.append(raw)
704
+ if (not raw.get('ignored')
705
+ and native_datetime(raw.get('occurred_at')).utcoffset() is None):
706
+ validation_warnings.append(
707
+ f'evidence_timestamp_timezone_missing:{raw.get("evidence_id") or raw.get("id")}')
708
+ # Older requests accepted local datetimes. Never guess their timezone or
709
+ # let one invalid value prevent subsequent ignore/reset repairs. Expose
710
+ # the history, but keep the entire bucket neutral until it is usable.
549
711
  evidence = [
550
712
  self._outcome_evidence_from_raw(raw)
551
713
  for raw in evidence_rows
552
714
  if not raw.get('ignored')
553
715
  and (
554
- reset_at is None
716
+ validation_warnings
717
+ or reset_at is None
555
718
  or native_datetime(raw.get('occurred_at')) > reset_at
556
719
  )
557
720
  ]
@@ -566,16 +729,16 @@ class StoreProjectAgentExecutorLearning:
566
729
  evidence_refs: set[str] = set()
567
730
  for item in evidence:
568
731
  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:
732
+ age = None if validation_warnings else max(0.0, (current - occurred).total_seconds() / 86400)
733
+ weight = 0.0 if age is None else math.pow(0.5, age / decay_half_life_days)
734
+ if age is not None and age <= _DEFAULT_RECENT_DAYS:
572
735
  recent_count += 1
573
736
  evidence_refs.update(item.reference_ids)
574
737
  if item.task_id:
575
738
  evidence_refs.add(f'task:{item.task_id}')
576
739
  if item.run_id:
577
740
  evidence_refs.add(f'run:{item.run_id}')
578
- signal, value = self._evidence_signal(item)
741
+ signal, value = ('neutral', 0.0) if validation_warnings else self._evidence_signal(item)
579
742
  if signal == 'success':
580
743
  success_count += 1
581
744
  weighted_success += weight * value
@@ -616,8 +779,8 @@ class StoreProjectAgentExecutorLearning:
616
779
  failure_count=failure_count,
617
780
  rating_count=len(rating_values),
618
781
  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,
782
+ neutral_due_to_insufficient_evidence=bool(validation_warnings) or sample_count < _MINIMUM_EVIDENCE_SAMPLES,
783
+ ignored=bool(validation_warnings),
621
784
  reset_at=reset_at,
622
785
  as_of=current,
623
786
  decay_half_life_days=decay_half_life_days,
@@ -625,6 +788,7 @@ class StoreProjectAgentExecutorLearning:
625
788
  weighted_failure=weighted_failure,
626
789
  evidence_refs=sorted(evidence_refs),
627
790
  evidence_contributions=contributions,
791
+ validation_warnings=validation_warnings,
628
792
  )
629
793
  await self.runtime.driver.execute_query(
630
794
  '''
@@ -13,6 +13,7 @@ from .project_agent_executor_models import (
13
13
  ProjectAgentExecutorRoutingRuleRecord,
14
14
  ProjectAgentExecutorScope,
15
15
  ProjectAgentExecutorSelection,
16
+ project_agent_capability_key,
16
17
  project_agent_model_strategy_key,
17
18
  )
18
19
  from .project_agent_models import ProjectAgentExecutorPolicy, ProjectAgentModelStrategy
@@ -20,8 +21,6 @@ from .provider_values import now_utc, stable_uuid
20
21
 
21
22
 
22
23
  _RULE_SCOPE_RANK = {'global': 0, 'space': 1, 'project': 2, 'task': 3}
23
-
24
-
25
24
  class StoreProjectAgentExecutorRouting:
26
25
  """Resolve registered executors without owning directory mutations."""
27
26
 
@@ -66,7 +65,7 @@ class StoreProjectAgentExecutorRouting:
66
65
  MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
67
66
  [:HAS_PROJECT_AGENT_IDENTITY]->
68
67
  (agent:FuliProjectAgent {agent_id: $agent_id})
69
- OPTIONAL MATCH (space)-[:CONTAINS_PROJECT]->
68
+ MATCH (space)-[:CONTAINS_PROJECT]->
70
69
  (:FuliPersonalProject {project_id: $personal_project_id})-
71
70
  [:HAS_PROJECT_AGENT_ASSIGNMENT]->
72
71
  (assignment:FuliProjectAgentAssignment)-[:ASSIGNED_AGENT]->(agent)
@@ -84,9 +83,28 @@ class StoreProjectAgentExecutorRouting:
84
83
  routing_='r',
85
84
  )
86
85
  if not agent_rows:
87
- raise HTTPException(status_code=404, detail='Agent not found')
86
+ raise HTTPException(
87
+ status_code=404,
88
+ detail='active project Agent assignment not found',
89
+ )
88
90
  agent_raw = dict(agent_rows[0].get('agent') or {})
89
91
  assignment_raw = dict(agent_rows[0].get('assignment') or {})
92
+ if (
93
+ not assignment_raw
94
+ or assignment_raw.get('status') != 'active'
95
+ or (
96
+ assignment_id is None
97
+ and work_kind not in (assignment_raw.get('work_kinds') or [])
98
+ )
99
+ or (
100
+ assignment_id is not None
101
+ and assignment_raw.get('assignment_id') != assignment_id
102
+ )
103
+ ):
104
+ raise HTTPException(
105
+ status_code=404,
106
+ detail='active project Agent assignment not found',
107
+ )
90
108
  agent_policy = self._policy_from_agent(agent_raw)
91
109
  assignment_policy = self._policy_from_assignment(assignment_raw)
92
110
  override = task_override if task_override is not None else executor_policy
@@ -139,7 +157,9 @@ class StoreProjectAgentExecutorRouting:
139
157
  work_kind=work_kind,
140
158
  routing_='r',
141
159
  )
142
- requested_capabilities = {item.casefold() for item in required}
160
+ requested_capabilities = {
161
+ project_agent_capability_key(item) for item in required
162
+ }
143
163
  rules = [
144
164
  rule
145
165
  for rule in (
@@ -148,7 +168,8 @@ class StoreProjectAgentExecutorRouting:
148
168
  if row.get('rule')
149
169
  )
150
170
  if {
151
- item.casefold() for item in rule.required_capabilities
171
+ project_agent_capability_key(item)
172
+ for item in rule.required_capabilities
152
173
  }.issubset(requested_capabilities)
153
174
  ]
154
175
  top_scope = max((_RULE_SCOPE_RANK[item.scope] for item in rules), default=None)
@@ -551,11 +572,11 @@ class StoreProjectAgentExecutorRouting:
551
572
  if executor.preflight_status != 'passed':
552
573
  return f'preflight status is {executor.preflight_status}'
553
574
  available_capabilities = {
554
- item.casefold() for item in executor.capabilities
575
+ project_agent_capability_key(item) for item in executor.capabilities
555
576
  }
556
577
  missing = [
557
578
  item for item in required_capabilities
558
- if item.casefold() not in available_capabilities
579
+ if project_agent_capability_key(item) not in available_capabilities
559
580
  ]
560
581
  if missing:
561
582
  return f'missing capabilities: {", ".join(missing)}'
@@ -589,9 +610,11 @@ class StoreProjectAgentExecutorRouting:
589
610
  or strategy.reasoning_effort in model.reasoning_efforts
590
611
  or strategy.reasoning_effort == 'default'
591
612
  )
592
- capabilities = {item.casefold() for item in model.capabilities}
613
+ capabilities = {
614
+ project_agent_capability_key(item) for item in model.capabilities
615
+ }
593
616
  hints_ok = all(
594
- hint.casefold() in capabilities
617
+ project_agent_capability_key(hint) in capabilities
595
618
  for hint in strategy.capability_hints
596
619
  )
597
620
  if mode_ok and effort_ok and hints_ok: