fuli-context 0.11.0 → 0.12.0

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 (169) hide show
  1. package/dist/web/assets/AboutPage-BAD0iPVX.js +1 -0
  2. package/dist/web/assets/AgentConversations-1iGTd4sz.js +2 -0
  3. package/dist/web/assets/AgentHand-D_RafKOM.js +1 -0
  4. package/dist/web/assets/AgentName-B5LKQ09q.js +1 -0
  5. package/dist/web/assets/AgentProfilePage-CioOdkBM.js +1 -0
  6. package/dist/web/assets/AgentsDirectoryPage--1A9nvNA.js +1 -0
  7. package/dist/web/assets/AgentsDirectoryPage-C7OUQH9v.css +1 -0
  8. package/dist/web/assets/ConnectionsPage-Tr2rRVOZ.js +1 -0
  9. package/dist/web/assets/EmployeeRecruitDialog-1QevD4Qv.js +1 -0
  10. package/dist/web/assets/EmployeeWorkbenchPage-B0vQIgwA.js +3 -0
  11. package/dist/web/assets/EmployeeWorkbenchPage-BQes4L1f.css +1 -0
  12. package/dist/web/assets/KnowledgeEditDialog-ClnrfPZi.js +1 -0
  13. package/dist/web/assets/KnowledgeInspector-Te1hJZbx.js +1 -0
  14. package/dist/web/assets/KnowledgeOrganizerPage-CCCrWl6Y.js +1 -0
  15. package/dist/web/assets/KnowledgeOrganizerPage-ccgscrPd.css +1 -0
  16. package/dist/web/assets/KnowledgePage-DGOcBwr8.js +1 -0
  17. package/dist/web/assets/KnowledgeWorkspace-cKn1NCRe.js +4 -0
  18. package/dist/web/assets/NotFoundPage-fetSnp0N.js +1 -0
  19. package/dist/web/assets/OverviewPage-BD4yY2Xz.js +1 -0
  20. package/dist/web/assets/PersonalProfilePage-DGJVP_VB.js +1 -0
  21. package/dist/web/assets/PersonalProjectsPage-D25HYMgD.js +1 -0
  22. package/dist/web/assets/ProjectAgentFirstTask-BcxqmKXQ.js +1 -0
  23. package/dist/web/assets/ProjectAgentsPage-BWDJBESb.js +12 -0
  24. package/dist/web/assets/{ProjectAgentsPage-DNLNjYp-.css → ProjectAgentsPage-DWGkgIsr.css} +1 -1
  25. package/dist/web/assets/ProjectScopePicker-yodM7lF_.js +1 -0
  26. package/dist/web/assets/PublicProjectsPage-CyNxVzW3.js +1 -0
  27. package/dist/web/assets/ReviewPage-Cy0Rt4CO.js +1 -0
  28. package/dist/web/assets/RoundtablesPage-BjVhvwMc.js +1 -0
  29. package/dist/web/assets/SettingsPage-CCZqZtnH.js +1 -0
  30. package/dist/web/assets/SettingsPage-DYz5bTwa.css +1 -0
  31. package/dist/web/assets/SharedJefaReportPage-C56kPciB.js +1 -0
  32. package/dist/web/assets/UiDialog-CvDxg3B1.js +1 -0
  33. package/dist/web/assets/UiDisclosure-D0iMTplF.js +1 -0
  34. package/dist/web/assets/UiSegmented-DIJW6wv3.js +1 -0
  35. package/dist/web/assets/UiSelect-CMR0QqF6.js +1 -0
  36. package/dist/web/assets/WritingTastePage-QgNzssfF.js +1 -0
  37. package/dist/web/assets/agent-pins-BcJ_qr8n.js +1 -0
  38. package/dist/web/assets/agent-work-summary-C-yW7x7A.js +1 -0
  39. package/dist/web/assets/{attention-store-BfxtL9EV.js → attention-store-B1DI8wmd.js} +1 -1
  40. package/dist/web/assets/{catalog-6SN9i3WR.js → catalog-DbXv0_bR.js} +1 -1
  41. package/dist/web/assets/client-CR-W5Wzp.js +1 -0
  42. package/dist/web/assets/console-D7GHOMEC.js +1 -0
  43. package/dist/web/assets/conversation-launcher-settings-Dj2GMzCI.js +1 -0
  44. package/dist/web/assets/{i18n-CCMR4gDS.js → i18n-CVFhT_k7.js} +3 -3
  45. package/dist/web/assets/{identity-C1eN-FSB.js → identity-CM6Dmy00.js} +1 -1
  46. package/dist/web/assets/index-CJ6Jmok9.css +1 -0
  47. package/dist/web/assets/index-CRa3GnYi.js +5 -0
  48. package/dist/web/assets/judgment-types-CJBEyJs1.js +1 -0
  49. package/dist/web/assets/model-WFhQZ3v_.js +1 -0
  50. package/dist/web/assets/{personal-profile-graph-BD1Y6TDS.js → personal-profile-graph-BwCh2M9G.js} +1 -1
  51. package/dist/web/assets/shared-report-CeIvrL9a.js +1 -0
  52. package/dist/web/assets/{agent-work-summary-LeW-Al_S.js → task-evidence-DTiPCs7r.js} +1 -1
  53. package/dist/web/assets/useAgentRoster-DCArHlxK.js +1 -0
  54. package/dist/web/assets/{useApi-CROJJdhE-DlOiu8lP.js → useApi-CROJJdhE-BYhbjaZB.js} +1 -1
  55. package/dist/web/assets/vue-router-DaxrQwX3.js +1 -0
  56. package/dist/web/index.html +16 -12
  57. package/docs/agent-roundtable.md +17 -1
  58. package/docs/employee-agents.md +1 -1
  59. package/docs/judgment-and-autonomy.md +33 -0
  60. package/graph-provider/fuli_graph/ai_review.py +154 -0
  61. package/graph-provider/fuli_graph/common_knowledge.py +12 -0
  62. package/graph-provider/fuli_graph/graph_models.py +14 -0
  63. package/graph-provider/fuli_graph/graph_query.py +217 -0
  64. package/graph-provider/fuli_graph/knowledge_audit.py +6 -0
  65. package/graph-provider/fuli_graph/knowledge_batch_confirmation.py +6 -0
  66. package/graph-provider/fuli_graph/knowledge_management.py +23 -0
  67. package/graph-provider/fuli_graph/knowledge_review.py +383 -4
  68. package/graph-provider/fuli_graph/knowledge_review_models.py +61 -2
  69. package/graph-provider/fuli_graph/personal_global_preferences.py +5 -1
  70. package/graph-provider/fuli_graph/preference_conflicts.py +91 -1
  71. package/graph-provider/fuli_graph/project_agent_context_models.py +5 -1
  72. package/graph-provider/fuli_graph/project_agent_delegations.py +118 -0
  73. package/graph-provider/fuli_graph/project_agent_employee_numbers.py +41 -0
  74. package/graph-provider/fuli_graph/project_agent_memory_routes.py +13 -0
  75. package/graph-provider/fuli_graph/project_agent_models.py +1 -0
  76. package/graph-provider/fuli_graph/project_agent_task_models.py +2 -0
  77. package/graph-provider/fuli_graph/project_agent_team_growth.py +2 -2
  78. package/graph-provider/fuli_graph/project_knowledge.py +41 -0
  79. package/graph-provider/fuli_graph/runtime.py +15 -0
  80. package/graph-provider/fuli_graph/store_agent_verification.py +12 -5
  81. package/graph-provider/fuli_graph/store_knowledge.py +98 -58
  82. package/graph-provider/fuli_graph/store_project_agent_attention.py +1 -1
  83. package/graph-provider/fuli_graph/store_project_agent_context.py +52 -4
  84. package/graph-provider/fuli_graph/store_project_agent_coordination_policy.py +16 -1
  85. package/graph-provider/fuli_graph/store_project_agent_task_activity.py +26 -0
  86. package/graph-provider/fuli_graph/store_project_agent_task_staffing.py +5 -3
  87. package/graph-provider/fuli_graph/store_project_agents.py +31 -5
  88. package/graph-provider/fuli_graph/system_agents.py +23 -2
  89. package/graph-provider/fuli_graph/system_hr_identity.py +19 -5
  90. package/graph-provider/pyproject.toml +1 -1
  91. package/npm-shrinkwrap.json +2 -2
  92. package/package.json +3 -2
  93. package/src/agent-roundtable/service.js +34 -5
  94. package/src/agent-roundtable/wake.js +2 -1
  95. package/src/agent-tools/graph-definitions.js +2 -0
  96. package/src/agent-tools/graph-handlers.js +7 -0
  97. package/src/agent-tools/interface-catalog.js +6 -0
  98. package/src/agent-tools/judgment-definitions.js +12 -0
  99. package/src/agents/claude-code/wake.js +7 -0
  100. package/src/agents/codex/wake.js +7 -0
  101. package/src/agents/identity-receipt.js +20 -2
  102. package/src/employees/catalog/tonborg.json +20 -0
  103. package/src/employees/manifest.js +2 -2
  104. package/src/employees/recruitment.js +20 -1
  105. package/src/graphiti/agent-collaboration.js +8 -2
  106. package/src/graphiti/federated-application.js +11 -2
  107. package/src/graphiti/knowledge-review.js +7 -3
  108. package/src/graphiti/project-agent-mapping.js +4 -0
  109. package/src/graphiti/project-agent-task-entry.js +23 -5
  110. package/src/graphiti/project-agent-team-context.js +4 -1
  111. package/src/graphiti/provider-client.js +9 -0
  112. package/src/graphiti/task-entry-preferences.js +17 -4
  113. package/src/graphiti/task-entry-project-scope.js +67 -0
  114. package/src/http/api-router.js +2 -0
  115. package/src/judgment/acceptance.js +91 -0
  116. package/src/judgment/engine.js +168 -0
  117. package/src/judgment/http.js +24 -0
  118. package/src/judgment/model.js +24 -0
  119. package/src/judgment/routing.js +96 -0
  120. package/src/judgment/scheduler.js +25 -0
  121. package/src/judgment/service.js +66 -0
  122. package/src/judgment/store.js +123 -0
  123. package/src/mcp/session-id.js +1 -0
  124. package/src/mcp/tool-annotations.js +4 -2
  125. package/src/server.js +9 -2
  126. package/dist/web/assets/AboutPage-CKq4IRHd.js +0 -1
  127. package/dist/web/assets/AgentConversations-DfXiFvBN.js +0 -2
  128. package/dist/web/assets/AgentHand-BBM-sKh2.js +0 -1
  129. package/dist/web/assets/AgentName-C_6R80nP.js +0 -1
  130. package/dist/web/assets/AgentProfilePage-Cz-i1fRD.js +0 -1
  131. package/dist/web/assets/AgentsDirectoryPage-BI0EFz6y.js +0 -1
  132. package/dist/web/assets/AgentsDirectoryPage-yRWktiin.css +0 -1
  133. package/dist/web/assets/ConnectionsPage-BoC6kXgs.js +0 -1
  134. package/dist/web/assets/EmployeeRecruitDialog-BVyclQAJ.js +0 -1
  135. package/dist/web/assets/EmployeeWorkbenchPage-D-c_Jm2z.css +0 -1
  136. package/dist/web/assets/EmployeeWorkbenchPage-D2GvYraI.js +0 -3
  137. package/dist/web/assets/KnowledgeEditDialog-a0AKWics.js +0 -1
  138. package/dist/web/assets/KnowledgeInspector-DMg9yJZ2.js +0 -1
  139. package/dist/web/assets/KnowledgeOrganizerPage-BgsHM3Qw.js +0 -1
  140. package/dist/web/assets/KnowledgeOrganizerPage-DeL7Jp34.css +0 -1
  141. package/dist/web/assets/KnowledgePage-kn8MqUCO.js +0 -1
  142. package/dist/web/assets/KnowledgeWorkspace-BtRq9fvC.js +0 -4
  143. package/dist/web/assets/NotFoundPage-D-7jLz3o.js +0 -1
  144. package/dist/web/assets/OverviewPage-BHPc-XxY.js +0 -1
  145. package/dist/web/assets/PersonalProfilePage-C00iV0Kx.js +0 -1
  146. package/dist/web/assets/PersonalProjectsPage-CFNkdHSo.js +0 -1
  147. package/dist/web/assets/ProjectAgentFirstTask-CrCWEYgn.js +0 -1
  148. package/dist/web/assets/ProjectAgentsPage-BzztLMH-.js +0 -12
  149. package/dist/web/assets/ProjectScopePicker-CMxn3txv.js +0 -1
  150. package/dist/web/assets/PublicProjectsPage-DQVtvhOS.js +0 -1
  151. package/dist/web/assets/ReviewPage-BZhvyDWc.js +0 -1
  152. package/dist/web/assets/RoundtablesPage-CVebTXQa.js +0 -1
  153. package/dist/web/assets/SettingsPage-1knvfOW6.js +0 -1
  154. package/dist/web/assets/SettingsPage-BmYkLul4.css +0 -1
  155. package/dist/web/assets/SharedJefaReportPage-BCVq8u0P.js +0 -1
  156. package/dist/web/assets/UiDialog-Da1P10f4.js +0 -1
  157. package/dist/web/assets/UiDisclosure-8wE8YuE4.js +0 -1
  158. package/dist/web/assets/UiSegmented-BKZXc8QF.js +0 -1
  159. package/dist/web/assets/UiSelect-D5Xj5Ec4.js +0 -1
  160. package/dist/web/assets/WritingTastePage-B8-etOBn.js +0 -1
  161. package/dist/web/assets/client-CUTkF3t4.js +0 -1
  162. package/dist/web/assets/console-CG8tdtSs.js +0 -1
  163. package/dist/web/assets/conversation-launcher-settings-CyZkLJg7.js +0 -1
  164. package/dist/web/assets/index-kPB0Cf8Q.css +0 -1
  165. package/dist/web/assets/index-sLDKI6R-.js +0 -5
  166. package/dist/web/assets/model-l-sAqtW_.js +0 -1
  167. package/dist/web/assets/shared-report-B46dxlCR.js +0 -1
  168. package/dist/web/assets/useAgentRoster-DdxoKDEE.js +0 -1
  169. package/dist/web/assets/vue-router-DjqEU4W2.js +0 -1
@@ -1,7 +1,7 @@
1
1
  from datetime import datetime
2
2
  from typing import Literal
3
3
 
4
- from pydantic import Field, model_validator
4
+ from pydantic import Field, field_validator, model_validator
5
5
 
6
6
  from .models import (
7
7
  ConfirmationStatus,
@@ -11,6 +11,7 @@ from .models import (
11
11
  PersonalProfileAspect,
12
12
  PreferenceScope,
13
13
  StrictModel,
14
+ SourceApplication,
14
15
  )
15
16
 
16
17
  KnowledgeReviewScope = Literal[
@@ -23,7 +24,7 @@ KnowledgeReviewScope = Literal[
23
24
  KnowledgeReviewStatus = Literal['active', 'paused', 'completed']
24
25
  KnowledgeReviewDisposition = Literal['paused', 'completed']
25
26
  KnowledgeReviewOutcome = Literal[
26
- 'confirmed', 'updated', 'invalidated', 'deferred', 'delegated_to_ai'
27
+ 'confirmed', 'updated', 'invalidated', 'deferred', 'delegated_to_ai', 'ai_reviewed'
27
28
  ]
28
29
  KnowledgeReviewReason = Literal[
29
30
  'changed_since_last',
@@ -37,6 +38,7 @@ KnowledgeReviewReason = Literal[
37
38
  class KnowledgeReviewStart(StrictModel):
38
39
  personal_space_id: str = Field(min_length=1, max_length=128)
39
40
  scope: KnowledgeReviewScope
41
+ reviewer: Literal['human', 'tonborg'] = 'human'
40
42
  personal_project_id: str | None = Field(default=None, min_length=1, max_length=128)
41
43
 
42
44
  @model_validator(mode='after')
@@ -55,6 +57,7 @@ class KnowledgeReviewRun(StrictModel):
55
57
  scope: KnowledgeReviewScope
56
58
  personal_project_id: str | None = None
57
59
  scope_key: str
60
+ reviewer: Literal['human', 'tonborg'] = 'human'
58
61
  status: KnowledgeReviewStatus
59
62
  previous_completed_at: datetime | None = None
60
63
  review_cutoff_at: datetime
@@ -64,6 +67,25 @@ class KnowledgeReviewRun(StrictModel):
64
67
  resumed: bool = False
65
68
 
66
69
 
70
+ class KnowledgeReviewAIAssessment(StrictModel):
71
+ """The bounded, version-bound result returned by the Tonborg reviewer."""
72
+
73
+ outcome: Literal['approve', 'escalate']
74
+ summary: str = Field(min_length=1, max_length=2000)
75
+ evidence: list[str] = Field(min_length=1, max_length=8)
76
+ confidence: float = Field(ge=0, le=1)
77
+ client: SourceApplication | None = None
78
+ model: str | None = Field(default=None, min_length=1, max_length=256)
79
+ session_id: str | None = Field(default=None, min_length=1, max_length=256)
80
+
81
+ @field_validator('evidence')
82
+ @classmethod
83
+ def validate_evidence(cls, value: list[str]) -> list[str]:
84
+ if any(not item or len(item) > 1000 for item in value):
85
+ raise ValueError('AI evidence entries must contain 1 to 1000 characters')
86
+ return value
87
+
88
+
67
89
  class KnowledgeReviewCandidate(StrictModel):
68
90
  candidate_key: str
69
91
  item_id: str
@@ -90,12 +112,18 @@ class KnowledgeReviewCandidate(StrictModel):
90
112
  last_feedback_kind: KnowledgeFeedbackKind | None = None
91
113
  distinct_session_count: int = Field(ge=0)
92
114
  changed_at: datetime | None = None
115
+ ai_review_evidence_token: str = Field(
116
+ min_length=64,
117
+ max_length=64,
118
+ pattern=r'^[0-9a-f]{64}$',
119
+ )
93
120
 
94
121
 
95
122
  class KnowledgeReviewCandidateRequest(StrictModel):
96
123
  personal_space_id: str = Field(min_length=1, max_length=128)
97
124
  review_id: str = Field(min_length=1, max_length=256)
98
125
  limit: int = Field(default=10, ge=1, le=50)
126
+ offset: int = Field(default=0, ge=0, le=1000000)
99
127
 
100
128
 
101
129
  class KnowledgeReviewCandidatePage(StrictModel):
@@ -115,6 +143,28 @@ class KnowledgeReviewProgress(StrictModel):
115
143
  )
116
144
  outcome: KnowledgeReviewOutcome
117
145
  note: str | None = Field(default=None, min_length=1, max_length=2000)
146
+ ai_review_evidence_token: str | None = Field(
147
+ default=None,
148
+ min_length=64,
149
+ max_length=64,
150
+ pattern=r'^[0-9a-f]{64}$',
151
+ )
152
+ ai_assessment: KnowledgeReviewAIAssessment | None = None
153
+
154
+ @model_validator(mode='after')
155
+ def validate_ai_review_fields(self):
156
+ has_token = self.ai_review_evidence_token is not None
157
+ has_assessment = self.ai_assessment is not None
158
+ if self.outcome == 'ai_reviewed':
159
+ if not has_token or not has_assessment:
160
+ raise ValueError(
161
+ 'ai_reviewed requires an evidence token and assessment'
162
+ )
163
+ elif has_token or has_assessment:
164
+ raise ValueError(
165
+ 'AI assessment fields are only valid for ai_reviewed outcomes'
166
+ )
167
+ return self
118
168
 
119
169
 
120
170
  class KnowledgeReviewDecision(StrictModel):
@@ -125,6 +175,15 @@ class KnowledgeReviewDecision(StrictModel):
125
175
  note: str | None = None
126
176
  created_at: datetime
127
177
  updated_at: datetime
178
+ ai_review_evidence_token: str | None = Field(
179
+ default=None,
180
+ min_length=64,
181
+ max_length=64,
182
+ pattern=r'^[0-9a-f]{64}$',
183
+ )
184
+ ai_assessment: KnowledgeReviewAIAssessment | None = None
185
+ ai_review_scope: KnowledgeReviewScope | None = None
186
+ ai_review_project_id: str | None = Field(default=None, min_length=1, max_length=128)
128
187
 
129
188
 
130
189
  class KnowledgeReviewFinish(StrictModel):
@@ -1116,7 +1116,9 @@ OPTIONAL MATCH (old_assertion:Entity {
1116
1116
  })
1117
1117
  SET preview.used_at = $changed_at
1118
1118
  FOREACH (_ IN CASE WHEN old_assertion IS NULL THEN [] ELSE [1] END |
1119
- SET old_assertion.fuli_invalid_at = $changed_at,
1119
+ SET old_assertion.fuli_ai_review_lock_version =
1120
+ coalesce(old_assertion.fuli_ai_review_lock_version, 0) + 1,
1121
+ old_assertion.fuli_invalid_at = $changed_at,
1120
1122
  old_assertion.expired_at = $changed_at
1121
1123
  )
1122
1124
  SET current.candidate_version = $candidate_version,
@@ -1176,6 +1178,8 @@ FOREACH (_ IN CASE WHEN $global_assertion_id IS NULL THEN [] ELSE [1] END |
1176
1178
  assertion.fuli_distinct_task_count = 0,
1177
1179
  assertion.fuli_usage_generation = 1,
1178
1180
  assertion.fuli_invalid_at = null
1181
+ SET assertion.fuli_ai_review_lock_version =
1182
+ coalesce(assertion.fuli_ai_review_lock_version, 0) + 1
1179
1183
  MERGE (episode:Episodic {uuid: $episode_id})
1180
1184
  ON CREATE SET episode.group_id = $group_id,
1181
1185
  episode.name = 'Personal-global preference human review',
@@ -19,6 +19,18 @@ from .models import (
19
19
  from .provider_values import native_datetime as _native_datetime
20
20
 
21
21
 
22
+ def _item_match(item_kind: str, variable: str, id_parameter: str) -> str:
23
+ if item_kind == 'entity':
24
+ return (
25
+ f'MATCH ({variable}:Entity '
26
+ f'{{uuid: ${id_parameter}, group_id: $group_id}})'
27
+ )
28
+ return (
29
+ f'MATCH ()-[{variable}:RELATES_TO '
30
+ f'{{uuid: ${id_parameter}, group_id: $group_id}}]->()'
31
+ )
32
+
33
+
22
34
  async def defer_preference_conflict(
23
35
  store,
24
36
  actor: dict,
@@ -44,9 +56,42 @@ async def defer_preference_conflict(
44
56
  )
45
57
  _validate_pair_scope(left, right, request)
46
58
  deferred_at = datetime.now(timezone.utc)
59
+ left_match = _item_match(
60
+ request.left_item_kind, 'left', 'left_item_id'
61
+ )
62
+ right_match = _item_match(
63
+ request.right_item_kind, 'right', 'right_item_id'
64
+ )
47
65
  records, _, _ = await store.runtime.driver.execute_query(
48
66
  '''
49
67
  MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})
68
+ '''
69
+ + left_match
70
+ + '\n '
71
+ + right_match
72
+ + '''
73
+ WITH space,
74
+ CASE
75
+ WHEN $left_item_kind + ':' + left.uuid =
76
+ $right_item_kind + ':' + right.uuid
77
+ THEN [{kind: $left_item_kind, item: left}]
78
+ WHEN $left_item_kind + ':' + left.uuid <
79
+ $right_item_kind + ':' + right.uuid
80
+ THEN [
81
+ {kind: $left_item_kind, item: left},
82
+ {kind: $right_item_kind, item: right}
83
+ ]
84
+ ELSE [
85
+ {kind: $right_item_kind, item: right},
86
+ {kind: $left_item_kind, item: left}
87
+ ]
88
+ END AS lock_items
89
+ UNWIND lock_items AS lock_item
90
+ WITH space, lock_item
91
+ ORDER BY lock_item.kind + ':' + lock_item.item.uuid
92
+ SET lock_item.item.fuli_ai_review_lock_version =
93
+ coalesce(lock_item.item.fuli_ai_review_lock_version, 0) + 1
94
+ WITH space, collect(lock_item) AS _locked_items
50
95
  MERGE (conflict:FuliPreferenceConflict {id: $conflict_id})
51
96
  ON CREATE SET conflict.personal_space_id = $space_id,
52
97
  conflict.preference_key = $preference_key,
@@ -70,6 +115,7 @@ async def defer_preference_conflict(
70
115
  RETURN conflict
71
116
  ''',
72
117
  space_id=space['id'],
118
+ group_id=space['group_id'],
73
119
  conflict_id=request.conflict_id,
74
120
  preference_key=request.preference_key,
75
121
  preference_scope=request.preference_scope,
@@ -160,10 +206,12 @@ async def resolve_preference_conflict(
160
206
  return await _complete(
161
207
  store,
162
208
  space['id'],
209
+ space['group_id'],
163
210
  conflict_id,
164
211
  request.resolution,
165
212
  request.reason,
166
213
  request.operation_actor,
214
+ conflict,
167
215
  )
168
216
 
169
217
 
@@ -179,14 +227,16 @@ async def complete_preference_conflict(
179
227
  request.personal_space_id,
180
228
  'maintainer',
181
229
  )
182
- await _read_conflict(store, space['id'], conflict_id)
230
+ conflict = await _read_conflict(store, space['id'], conflict_id)
183
231
  return await _complete(
184
232
  store,
185
233
  space['id'],
234
+ space['group_id'],
186
235
  conflict_id,
187
236
  request.resolution,
188
237
  request.reason,
189
238
  request.operation_actor,
239
+ conflict,
190
240
  )
191
241
 
192
242
 
@@ -305,17 +355,52 @@ async def _split_scope(store, actor, space, conflict, left, right, request):
305
355
  async def _complete(
306
356
  store,
307
357
  space_id,
358
+ group_id,
308
359
  conflict_id,
309
360
  resolution,
310
361
  reason,
311
362
  resolved_by,
363
+ conflict: PreferenceConflictRecord,
312
364
  ):
313
365
  resolved_at = datetime.now(timezone.utc)
366
+ left_match = _item_match(
367
+ conflict.left_item_kind, 'left', 'left_item_id'
368
+ )
369
+ right_match = _item_match(
370
+ conflict.right_item_kind, 'right', 'right_item_id'
371
+ )
314
372
  records, _, _ = await store.runtime.driver.execute_query(
315
373
  '''
316
374
  MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
317
375
  [:HAS_PREFERENCE_CONFLICT]->
318
376
  (conflict:FuliPreferenceConflict {id: $conflict_id})
377
+ '''
378
+ + left_match
379
+ + '\n '
380
+ + right_match
381
+ + '''
382
+ WITH conflict,
383
+ CASE
384
+ WHEN $left_item_kind + ':' + left.uuid =
385
+ $right_item_kind + ':' + right.uuid
386
+ THEN [{kind: $left_item_kind, item: left}]
387
+ WHEN $left_item_kind + ':' + left.uuid <
388
+ $right_item_kind + ':' + right.uuid
389
+ THEN [
390
+ {kind: $left_item_kind, item: left},
391
+ {kind: $right_item_kind, item: right}
392
+ ]
393
+ ELSE [
394
+ {kind: $right_item_kind, item: right},
395
+ {kind: $left_item_kind, item: left}
396
+ ]
397
+ END AS lock_items
398
+ UNWIND lock_items AS lock_item
399
+ WITH conflict, lock_item
400
+ ORDER BY lock_item.kind + ':' + lock_item.item.uuid
401
+ SET lock_item.item.fuli_ai_review_lock_version =
402
+ coalesce(lock_item.item.fuli_ai_review_lock_version, 0) + 1
403
+ WITH conflict, collect(lock_item) AS _locked_items
319
404
  SET conflict.status = 'resolved',
320
405
  conflict.resolution = $resolution,
321
406
  conflict.resolved_by = $resolved_by,
@@ -325,7 +410,12 @@ async def _complete(
325
410
  RETURN conflict
326
411
  ''',
327
412
  space_id=space_id,
413
+ group_id=group_id,
328
414
  conflict_id=conflict_id,
415
+ left_item_id=conflict.left_item_id,
416
+ left_item_kind=conflict.left_item_kind,
417
+ right_item_id=conflict.right_item_id,
418
+ right_item_kind=conflict.right_item_kind,
329
419
  resolution=resolution,
330
420
  resolved_by=resolved_by,
331
421
  reason=reason,
@@ -1,4 +1,4 @@
1
- """Read-only task-entry selection; never submits a task or spawns a worker."""
1
+ """Task-entry selection and optional invocation claim; never spawns a worker."""
2
2
 
3
3
  from typing import Literal
4
4
 
@@ -13,6 +13,8 @@ class ProjectAgentContextRequest(StrictModel):
13
13
  personal_space_id: str = Field(min_length=1, max_length=128)
14
14
  personal_project_id: str = Field(min_length=1, max_length=128)
15
15
  agent_id: str | None = Field(default=None, min_length=1, max_length=128)
16
+ delegation_token: str | None = Field(default=None, min_length=32, max_length=128)
17
+ delegation_session_id: str | None = Field(default=None, min_length=1, max_length=256)
16
18
  session_id: str | None = Field(default=None, min_length=1, max_length=256)
17
19
  turn_id: str | None = Field(default=None, min_length=1, max_length=256)
18
20
  work_kind: str = Field(default='project_context', min_length=1, max_length=128)
@@ -26,4 +28,6 @@ class ProjectAgentContextResolution(StrictModel):
26
28
  match_basis: list[str] = Field(default_factory=list)
27
29
  candidate_count: int = 0
28
30
  agent: ProjectAgentRecord | None = None
31
+ requested_agent_id: str | None = None
32
+ reporting_lead_agent_id: str | None = None
29
33
  worker_started: Literal[False] = False
@@ -0,0 +1,118 @@
1
+ """Short-lived, session-bound capabilities for a real roundtable invocation."""
2
+
3
+ from hashlib import sha256
4
+ from secrets import token_urlsafe
5
+
6
+ from fastapi import HTTPException
7
+ from pydantic import Field
8
+
9
+ from .models import SourceApplication, StrictModel
10
+ from .personal_project_access import authorize_personal_project
11
+
12
+
13
+ class AgentDelegationIssue(StrictModel):
14
+ personal_space_id: str = Field(min_length=1, max_length=128)
15
+ task_context_token: str = Field(min_length=1, max_length=256)
16
+ source_application: SourceApplication
17
+ agent_id: str = Field(min_length=1, max_length=128)
18
+ target_application: SourceApplication
19
+ target_session_id: str | None = Field(default=None, min_length=1, max_length=256)
20
+ lifetime_seconds: int = Field(default=300, ge=30, le=900)
21
+
22
+
23
+ class AgentDelegationCheck(StrictModel):
24
+ personal_space_id: str = Field(min_length=1, max_length=128)
25
+ token: str = Field(min_length=32, max_length=128)
26
+ session_id: str | None = Field(default=None, min_length=1, max_length=256)
27
+
28
+
29
+ def token_hash(token):
30
+ return sha256(token.encode()).hexdigest()
31
+
32
+
33
+ class StoreAgentDelegations:
34
+ async def issue_agent_delegation(self, actor, request):
35
+ self._require_personal()
36
+ space = await self.authorize(actor, request.personal_space_id, 'maintainer')
37
+ context = await self.get_task_context(actor, request.personal_space_id,
38
+ request.task_context_token, request.source_application)
39
+ project_id = context.get('personal_project_id')
40
+ if not project_id or (context.get('checkpoint') or {}).get('phase') == 'complete':
41
+ raise HTTPException(409, 'Delegation requires an active project task')
42
+ await authorize_personal_project(self, actor, space, project_id)
43
+ policy = await self.get_project_agent_coordination_policy(actor, request.personal_space_id, project_id)
44
+ lead = policy.team_lead_agent_id
45
+ sender = context.get('project_agent_id')
46
+ if not lead or not sender or (sender != lead and request.agent_id != lead):
47
+ raise HTTPException(403, 'Members must communicate through the project lead')
48
+ target = await self.get_project_agent(actor, request.personal_space_id, project_id, request.agent_id)
49
+ if (target.profile.status != 'active'
50
+ or request.target_application not in target.profile.allowed_clients):
51
+ raise HTTPException(403, 'The receiving Agent is unavailable in this client')
52
+ token = token_urlsafe(32)
53
+ # Only hashes are stored. A crash leaves a bounded lifetime, never a
54
+ # reusable authority derived from a public message or thread identifier.
55
+ await self.runtime.driver.execute_query(
56
+ '''MATCH (old:FuliAgentDelegation {space_id: $space_id})
57
+ WHERE old.expires_at <= datetime() DELETE old''', space_id=request.personal_space_id)
58
+ await self.runtime.driver.execute_query(
59
+ '''CREATE (:FuliAgentDelegation {token_hash: $token_hash, space_id: $space_id,
60
+ project_id: $project_id, agent_id: $agent_id, source_application: $application,
61
+ session_id: $session_id, sender_token: $sender_token, lead_agent_id: $lead,
62
+ expires_at: datetime() + duration({seconds: $lifetime}), redeemed: false})''',
63
+ token_hash=token_hash(token), space_id=request.personal_space_id, project_id=project_id,
64
+ agent_id=request.agent_id, application=request.target_application,
65
+ session_id=request.target_session_id, sender_token=request.task_context_token,
66
+ lead=lead, lifetime=request.lifetime_seconds)
67
+ return {'token': token}
68
+
69
+ async def redeem_agent_delegation(self, request, lead):
70
+ if not request.delegation_token or not request.delegation_session_id or not request.agent_id:
71
+ return False
72
+ # Lock before reading the session binding: two different sessions cannot
73
+ # both claim a fresh capability. Same-session hook/MCP retries are safe.
74
+ rows, _, _ = await self.runtime.driver.execute_query(
75
+ '''MATCH (:FuliSpace {id: $space_id})-[:CONTAINS_PROJECT]->
76
+ (:FuliPersonalProject {project_id: $project_id})-
77
+ [:HAS_PROJECT_AGENT_COORDINATION_POLICY]->(policy:FuliProjectAgentCoordinationPolicy)
78
+ SET policy._fuli_write_lock = true
79
+ REMOVE policy._fuli_write_lock
80
+ WITH policy WHERE policy.team_lead_agent_id = $lead
81
+ MATCH (grant:FuliAgentDelegation {token_hash: $token_hash, space_id: $space_id,
82
+ project_id: $project_id, agent_id: $agent_id, source_application: $application,
83
+ lead_agent_id: $lead})
84
+ SET grant.write_serial = coalesce(grant.write_serial, 0) + 1
85
+ WITH grant
86
+ WHERE grant.expires_at > datetime()
87
+ AND (grant.session_id IS NULL OR grant.session_id = $session_id)
88
+ MATCH (session:FuliTaskContextSession)-[:HAS_CONTEXT]->(task:FuliTaskContext)
89
+ WHERE task.token = grant.sender_token AND session.current_token = task.token
90
+ AND NOT task.completed AND task.personal_space_id = grant.space_id
91
+ AND task.personal_project_id = grant.project_id
92
+ AND (task.project_agent_id = grant.lead_agent_id OR grant.agent_id = grant.lead_agent_id)
93
+ SET grant.session_id = $session_id, grant.redeemed = true
94
+ RETURN grant.token_hash AS token_hash''',
95
+ token_hash=token_hash(request.delegation_token), space_id=request.personal_space_id,
96
+ project_id=request.personal_project_id, agent_id=request.agent_id,
97
+ application=request.source_application, lead=lead, session_id=request.delegation_session_id)
98
+ return bool(rows)
99
+
100
+ async def verify_agent_delegation(self, actor, request):
101
+ self._require_personal()
102
+ await self.authorize(actor, request.personal_space_id, 'reader')
103
+ rows, _, _ = await self.runtime.driver.execute_query(
104
+ '''MATCH (grant:FuliAgentDelegation {token_hash: $token_hash, space_id: $space_id,
105
+ session_id: $session_id, redeemed: true})
106
+ WHERE grant.expires_at > datetime()
107
+ RETURN grant.token_hash AS token_hash''',
108
+ token_hash=token_hash(request.token), space_id=request.personal_space_id,
109
+ session_id=request.session_id)
110
+ return {'redeemed': bool(rows)}
111
+
112
+ async def revoke_agent_delegation(self, actor, request):
113
+ self._require_personal()
114
+ await self.authorize(actor, request.personal_space_id, 'maintainer')
115
+ await self.runtime.driver.execute_query(
116
+ '''MATCH (grant:FuliAgentDelegation {token_hash: $token_hash, space_id: $space_id})
117
+ DELETE grant''', token_hash=token_hash(request.token), space_id=request.personal_space_id)
118
+ return {'revoked': True}
@@ -0,0 +1,41 @@
1
+ """Space-wide FLA employee numbers, assigned in the identity write transaction.
2
+
3
+ Both identity creation paths take the space lock before touching an Agent.
4
+ The high-water mark survives archives and identity aliases; numbers are never
5
+ reused. Existing identities are backfilled in creation order on upgrade.
6
+ """
7
+
8
+ EMPLOYEE_NUMBER_LOCK = '''
9
+ SET space._employee_number_lock = true
10
+ REMOVE space._employee_number_lock
11
+ WITH space
12
+ '''
13
+
14
+ # Keep outer variables (notably ``agent``) intact. The subquery always returns
15
+ # one row, including when the space has no missing numbers.
16
+ ASSIGN_EMPLOYEE_NUMBERS = '''
17
+ WITH space, agent
18
+ CALL {
19
+ WITH space
20
+ MATCH (space)-[:HAS_PROJECT_AGENT_IDENTITY]->(employee:FuliProjectAgent)
21
+ WITH DISTINCT space, employee
22
+ ORDER BY employee.created_at, employee.agent_id
23
+ WITH space, collect(employee) AS employees
24
+ WITH space, employees,
25
+ [employee IN employees WHERE employee.employee_number IS NULL] AS missing,
26
+ reduce(high = coalesce(space.fla_employee_sequence, 0), employee IN employees |
27
+ CASE WHEN coalesce(employee.employee_number, 0) > high
28
+ THEN employee.employee_number ELSE high END) AS high
29
+ SET space.fla_employee_sequence = high + size(missing)
30
+ FOREACH (index IN range(0, size(missing) - 1) |
31
+ FOREACH (employee IN [missing[index]] |
32
+ SET employee.employee_number = high + index + 1))
33
+ FOREACH (employee IN [item IN employees WHERE item.employee_number_space_id IS NULL] |
34
+ SET employee.employee_number_space_id = space.id)
35
+ RETURN space.fla_employee_sequence AS employee_sequence
36
+ }
37
+ '''
38
+
39
+
40
+ def employee_number_label(number: int | None) -> str | None:
41
+ return f'{number:06d}' if number is not None else None
@@ -15,6 +15,7 @@ from .project_agent_context_models import (
15
15
  from .agent_conversation_routes import register_agent_conversation_routes
16
16
  from .default_project_lead import DefaultProjectLeadRequest
17
17
  from .task_context_routes import register_task_context_routes
18
+ from .project_agent_delegations import AgentDelegationIssue, AgentDelegationCheck
18
19
 
19
20
 
20
21
  def register_project_agent_memory_routes(application, store, Actor):
@@ -26,6 +27,18 @@ def register_project_agent_memory_routes(application, store, Actor):
26
27
  async def resolve_context(request: ProjectAgentContextRequest, actor: Actor):
27
28
  return await store.resolve_project_agent_context(actor, request)
28
29
 
30
+ @application.post('/v1/project-agent-context/delegations')
31
+ async def issue_delegation(request: AgentDelegationIssue, actor: Actor):
32
+ return await store.issue_agent_delegation(actor, request)
33
+
34
+ @application.post('/v1/project-agent-context/delegations/verify')
35
+ async def verify_delegation(request: AgentDelegationCheck, actor: Actor):
36
+ return await store.verify_agent_delegation(actor, request)
37
+
38
+ @application.post('/v1/project-agent-context/delegations/revoke')
39
+ async def revoke_delegation(request: AgentDelegationCheck, actor: Actor):
40
+ return await store.revoke_agent_delegation(actor, request)
41
+
29
42
  @application.post(
30
43
  '/v1/project-agent-context/default-lead', response_model=ProjectAgentContextResolution,
31
44
  )
@@ -277,6 +277,7 @@ class ProjectAgentAssignmentReplaceResult(StrictModel):
277
277
 
278
278
  class ProjectAgentRecord(StrictModel):
279
279
  agent_id: str
280
+ employee_number: str | None = None
280
281
  legacy_agent_ids: list[str] = Field(default_factory=list)
281
282
  personal_space_id: str
282
283
  personal_project_id: str | None = None
@@ -494,6 +494,8 @@ class ProjectAgentTaskRecord(StrictModel):
494
494
 
495
495
  class ProjectAgentTaskActivityCreate(StrictModel):
496
496
  artifact_revision: str | None = Field(default=None, min_length=1, max_length=160)
497
+ expected_verification_attempt_id: str | None = Field(default=None, min_length=1, max_length=256)
498
+ judgment_task_context_token: str | None = Field(default=None, min_length=1, max_length=256)
497
499
  personal_space_id: str = Field(min_length=1, max_length=128)
498
500
  personal_project_id: str = Field(min_length=1, max_length=128)
499
501
  task_id: str = Field(min_length=1, max_length=128)
@@ -33,8 +33,8 @@ async def enroll_recruited_team_member(store, recruitment, profile):
33
33
  policy.team_lead_agent_id IS NULL AS needs_lead,
34
34
  coalesce(policy.team_lead_agent_id = agent.agent_id, false)
35
35
  OR agent.agent_id IN members AS already_member
36
- WITH *, enabled AND NOT already_member
37
- AND (needs_lead OR size(members) < 32) AS changed
36
+ WITH *, NOT already_member
37
+ AND (needs_lead OR (enabled AND size(members) < 32)) AS changed
38
38
  FOREACH (_ IN CASE WHEN changed THEN [1] ELSE [] END |
39
39
  SET policy.team_lead_agent_id = CASE WHEN needs_lead
40
40
  THEN agent.agent_id ELSE policy.team_lead_agent_id END,
@@ -578,11 +578,25 @@ async def _upsert_reference(
578
578
  space['id'], 'knowledge-reference', item_kind, item_id, project_id
579
579
  )
580
580
  changed_at = datetime.now(timezone.utc)
581
+ item_match = (
582
+ 'MATCH (item:Entity {uuid: $item_id, group_id: $group_id})'
583
+ if item_kind == 'entity'
584
+ else (
585
+ 'MATCH ()-[item:RELATES_TO '
586
+ '{uuid: $item_id, group_id: $group_id}]->()'
587
+ )
588
+ )
581
589
  records, _, _ = await store.runtime.driver.execute_query(
582
590
  '''
583
591
  MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})
584
592
  MATCH (space)-[:CONTAINS_PROJECT]->
585
593
  (target:FuliPersonalProject {project_id: $project_id})
594
+ '''
595
+ + item_match
596
+ + '''
597
+ SET item.fuli_ai_review_lock_version =
598
+ coalesce(item.fuli_ai_review_lock_version, 0) + 1
599
+ WITH space, target
586
600
  MERGE (reference:FuliKnowledgeProjectReference {
587
601
  space_id: $space_id,
588
602
  item_kind: $item_kind,
@@ -604,6 +618,7 @@ async def _upsert_reference(
604
618
  space_id=space['id'],
605
619
  item_kind=item_kind,
606
620
  item_id=item_id,
621
+ group_id=space['group_id'],
607
622
  project_id=project_id,
608
623
  reference_id=reference_id,
609
624
  source_project_id=source_project_id,
@@ -635,6 +650,31 @@ async def _upsert_conflict(
635
650
  records, _, _ = await store.runtime.driver.execute_query(
636
651
  '''
637
652
  MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})
653
+ MATCH (item:Entity {uuid: $item_id, group_id: $group_id})
654
+ MATCH (target_item:Entity {
655
+ uuid: $target_item_id,
656
+ group_id: $group_id
657
+ })
658
+ WITH space,
659
+ CASE
660
+ WHEN item.uuid = target_item.uuid
661
+ THEN [{kind: 'entity', item: item}]
662
+ WHEN item.uuid < target_item.uuid
663
+ THEN [
664
+ {kind: 'entity', item: item},
665
+ {kind: 'entity', item: target_item}
666
+ ]
667
+ ELSE [
668
+ {kind: 'entity', item: target_item},
669
+ {kind: 'entity', item: item}
670
+ ]
671
+ END AS lock_items
672
+ UNWIND lock_items AS lock_item
673
+ WITH space, lock_item
674
+ ORDER BY lock_item.kind + ':' + lock_item.item.uuid
675
+ SET lock_item.item.fuli_ai_review_lock_version =
676
+ coalesce(lock_item.item.fuli_ai_review_lock_version, 0) + 1
677
+ WITH space, collect(lock_item) AS _locked_items
638
678
  MERGE (conflict:FuliKnowledgeConflict {id: $conflict_id})
639
679
  ON CREATE SET conflict.created_at = $changed_at
640
680
  SET conflict.space_id = $space_id,
@@ -651,6 +691,7 @@ async def _upsert_conflict(
651
691
  RETURN conflict
652
692
  ''',
653
693
  space_id=space['id'],
694
+ group_id=space['group_id'],
654
695
  conflict_id=conflict_id,
655
696
  item_id=item_id,
656
697
  target_item_id=target_item_id,
@@ -226,6 +226,10 @@ class GraphitiRuntime:
226
226
  'CREATE CONSTRAINT fuli_workflow_review_preview_token IF NOT EXISTS '
227
227
  'FOR (n:FuliWorkflowReviewPreview) REQUIRE n.token_hash IS UNIQUE'
228
228
  )
229
+ await self.driver.execute_query(
230
+ 'CREATE CONSTRAINT fuli_agent_delegation_token IF NOT EXISTS '
231
+ 'FOR (n:FuliAgentDelegation) REQUIRE n.token_hash IS UNIQUE'
232
+ )
229
233
  await self.driver.execute_query(
230
234
  'CREATE CONSTRAINT fuli_workflow_rule_id IF NOT EXISTS '
231
235
  'FOR (n:FuliWorkflowRule) REQUIRE n.id IS UNIQUE'
@@ -285,6 +289,9 @@ class GraphitiRuntime:
285
289
  'FOR (n:FuliConversationPolicy) REQUIRE n.id IS UNIQUE',
286
290
  'CREATE CONSTRAINT fuli_project_agent_id IF NOT EXISTS '
287
291
  'FOR (n:FuliProjectAgent) REQUIRE n.id IS UNIQUE',
292
+ 'CREATE CONSTRAINT fuli_project_agent_employee_number IF NOT EXISTS '
293
+ 'FOR (n:FuliProjectAgent) '
294
+ 'REQUIRE (n.employee_number_space_id, n.employee_number) IS UNIQUE',
288
295
  'CREATE CONSTRAINT fuli_agent_attention_id IF NOT EXISTS '
289
296
  'FOR (n:FuliAgentAttention) REQUIRE n.id IS UNIQUE',
290
297
  'CREATE CONSTRAINT fuli_project_agent_assignment_id IF NOT EXISTS '
@@ -649,6 +656,10 @@ class GraphitiRuntime:
649
656
  OR item.fuli_usage_generation IS NULL
650
657
  OR item.fuli_inheritance_mode IS NULL
651
658
  OR item.fuli_inherited_project_ids IS NULL
659
+ WITH item
660
+ ORDER BY 'entity:' + item.uuid
661
+ SET item.fuli_ai_review_lock_version =
662
+ coalesce(item.fuli_ai_review_lock_version, 0) + 1
652
663
  SET item.fuli_utility_score =
653
664
  coalesce(item.fuli_utility_score, 0.0),
654
665
  item.fuli_confidence_score = coalesce(
@@ -681,6 +692,10 @@ class GraphitiRuntime:
681
692
  OR item.fuli_usage_generation IS NULL
682
693
  OR item.fuli_inheritance_mode IS NULL
683
694
  OR item.fuli_inherited_project_ids IS NULL
695
+ WITH item
696
+ ORDER BY 'relationship:' + item.uuid
697
+ SET item.fuli_ai_review_lock_version =
698
+ coalesce(item.fuli_ai_review_lock_version, 0) + 1
684
699
  SET item.fuli_utility_score =
685
700
  coalesce(item.fuli_utility_score, 0.0),
686
701
  item.fuli_confidence_score = coalesce(