fuli-context 0.11.1 → 0.12.1

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 (213) hide show
  1. package/README.md +6 -5
  2. package/README.zh-CN.md +5 -4
  3. package/dist/web/assets/AboutPage-Dized8Uz.js +1 -0
  4. package/dist/web/assets/AgentConversations-Ccy41mEA.js +2 -0
  5. package/dist/web/assets/AgentHand-UzVhbKUC.js +1 -0
  6. package/dist/web/assets/AgentName-DJpvkub2.js +1 -0
  7. package/dist/web/assets/AgentProfilePage-CFViQ8kj.js +1 -0
  8. package/dist/web/assets/AgentsDirectoryPage-CBFTg_IE.js +1 -0
  9. package/dist/web/assets/AgentsDirectoryPage-Cv-vxw2C.css +1 -0
  10. package/dist/web/assets/ConnectionsPage-uXcabab_.js +1 -0
  11. package/dist/web/assets/EmployeeRecruitDialog-D8Hvhc-M.js +1 -0
  12. package/dist/web/assets/EmployeeWorkbenchPage-BQes4L1f.css +1 -0
  13. package/dist/web/assets/EmployeeWorkbenchPage-TBl_HNj6.js +3 -0
  14. package/dist/web/assets/KnowledgeEditDialog-HmiqLC-Y.js +1 -0
  15. package/dist/web/assets/KnowledgeInspector-Cj2yK4Qh.js +1 -0
  16. package/dist/web/assets/KnowledgeOrganizerPage-DT3BROZm.js +1 -0
  17. package/dist/web/assets/KnowledgeOrganizerPage-ccgscrPd.css +1 -0
  18. package/dist/web/assets/KnowledgePage-C4J4VueL.js +1 -0
  19. package/dist/web/assets/KnowledgeWorkspace-DA_dqVRG.js +4 -0
  20. package/dist/web/assets/NotFoundPage-x_Yd59mm.js +1 -0
  21. package/dist/web/assets/OverviewPage-3eZjQ26v.js +1 -0
  22. package/dist/web/assets/OverviewPage-8LBN8Shi.css +1 -0
  23. package/dist/web/assets/PersonalProfilePage-CDQk096L.js +1 -0
  24. package/dist/web/assets/PersonalProjectsPage-CJxp4Wyz.js +1 -0
  25. package/dist/web/assets/ProjectAgentFirstTask-BkRJ0zwO.js +1 -0
  26. package/dist/web/assets/ProjectAgentsPage-BuNNqSEy.js +12 -0
  27. package/dist/web/assets/ProjectScopePicker-CiDxM2Yx.js +1 -0
  28. package/dist/web/assets/PublicProjectsPage-ByOT559r.js +1 -0
  29. package/dist/web/assets/ReviewPage-C0Vdr9uq.js +1 -0
  30. package/dist/web/assets/RoundtablesPage-Drou-B81.css +1 -0
  31. package/dist/web/assets/RoundtablesPage-L3ShEWS3.js +1 -0
  32. package/dist/web/assets/SettingsPage-BE8pWbI6.css +1 -0
  33. package/dist/web/assets/SettingsPage-McYuinGz.js +1 -0
  34. package/dist/web/assets/SharedJefaReportPage-Bg6lIk3E.js +1 -0
  35. package/dist/web/assets/UiButton-x8hiHNRM.js +1 -0
  36. package/dist/web/assets/UiDialog-mjletK0y.js +1 -0
  37. package/dist/web/assets/UiDisclosure-_IxeiexW.js +1 -0
  38. package/dist/web/assets/UiSegmented-WFfz9Evl.js +1 -0
  39. package/dist/web/assets/UiSelect-K29jnV-1.js +1 -0
  40. package/dist/web/assets/WritingTastePage-DCULB-d2.js +1 -0
  41. package/dist/web/assets/agent-pins-DS4mNsNt.js +1 -0
  42. package/dist/web/assets/agent-profile-Bh3MJGvb.css +1 -0
  43. package/dist/web/assets/agent-work-summary-C-yW7x7A.js +1 -0
  44. package/dist/web/assets/{attention-store-D-qIFRIb.js → attention-store-CA5y8j1R.js} +1 -1
  45. package/dist/web/assets/catalog-D0VEfOAV.js +1 -0
  46. package/dist/web/assets/client-DnwnUBQV.js +1 -0
  47. package/dist/web/assets/console-Cw7TzOSC.js +1 -0
  48. package/dist/web/assets/conversation-launcher-settings-Yc13viwr.js +1 -0
  49. package/dist/web/assets/{i18n-b14l2pQA.js → i18n-oJFlt8hW.js} +3 -3
  50. package/dist/web/assets/{identity-BWbPmZZf.js → identity-DSGS4CZc.js} +1 -1
  51. package/dist/web/assets/index-BfHCcUL2.css +1 -0
  52. package/dist/web/assets/index-DO10_fLc.js +5 -0
  53. package/dist/web/assets/judgment-types-DR931cZ7.js +1 -0
  54. package/dist/web/assets/model-BJ-UF4cs.js +1 -0
  55. package/dist/web/assets/{personal-profile-graph-BPTtMJTZ.js → personal-profile-graph-SOK0BR0w.js} +1 -1
  56. package/dist/web/assets/project-team-api-C_0qJk_B.js +1 -0
  57. package/dist/web/assets/shared-report-CiX64ZiD.js +1 -0
  58. package/dist/web/assets/{agent-work-summary-LeW-Al_S.js → task-evidence-DTiPCs7r.js} +1 -1
  59. package/dist/web/assets/useAgentRoster-B84mzZIg.js +1 -0
  60. package/dist/web/assets/{useApi-CROJJdhE-CEQAEuug.js → useApi-CROJJdhE-7gt0QyQ5.js} +1 -1
  61. package/dist/web/assets/vue-router-CCvPGaLD.js +1 -0
  62. package/dist/web/index.html +17 -12
  63. package/dist/web/licenses/ant-design-icons.txt +22 -0
  64. package/docs/agent-conversations-and-collaboration.md +3 -3
  65. package/docs/agent-roundtable.md +33 -0
  66. package/docs/client-connections.md +86 -0
  67. package/docs/employee-agents.md +3 -3
  68. package/docs/judgment-and-autonomy.md +33 -0
  69. package/docs/project-agent-memory.md +6 -6
  70. package/graph-provider/fuli_graph/ai_review.py +154 -0
  71. package/graph-provider/fuli_graph/common_knowledge.py +12 -0
  72. package/graph-provider/fuli_graph/default_project_lead.py +1 -1
  73. package/graph-provider/fuli_graph/graph_models.py +14 -0
  74. package/graph-provider/fuli_graph/graph_query.py +217 -0
  75. package/graph-provider/fuli_graph/knowledge_audit.py +6 -0
  76. package/graph-provider/fuli_graph/knowledge_batch_confirmation.py +6 -0
  77. package/graph-provider/fuli_graph/knowledge_management.py +23 -0
  78. package/graph-provider/fuli_graph/knowledge_review.py +383 -4
  79. package/graph-provider/fuli_graph/knowledge_review_models.py +61 -2
  80. package/graph-provider/fuli_graph/personal_global_preferences.py +5 -1
  81. package/graph-provider/fuli_graph/preference_conflicts.py +91 -1
  82. package/graph-provider/fuli_graph/project_agent_delegations.py +70 -2
  83. package/graph-provider/fuli_graph/project_agent_memory_routes.py +18 -1
  84. package/graph-provider/fuli_graph/project_agent_remote_origins.py +154 -0
  85. package/graph-provider/fuli_graph/project_agent_task_models.py +3 -1
  86. package/graph-provider/fuli_graph/project_knowledge.py +41 -0
  87. package/graph-provider/fuli_graph/runtime.py +15 -0
  88. package/graph-provider/fuli_graph/store_agent_verification.py +12 -5
  89. package/graph-provider/fuli_graph/store_knowledge.py +98 -58
  90. package/graph-provider/fuli_graph/store_project_agent_attention.py +1 -1
  91. package/graph-provider/fuli_graph/store_project_agent_context.py +3 -2
  92. package/graph-provider/fuli_graph/store_project_agent_coordination_policy.py +1 -1
  93. package/graph-provider/fuli_graph/store_project_agent_task_activity.py +26 -0
  94. package/graph-provider/fuli_graph/store_project_agent_task_reads.py +1 -1
  95. package/graph-provider/fuli_graph/store_project_agent_task_recruitment.py +5 -5
  96. package/graph-provider/fuli_graph/store_project_agent_task_staffing.py +4 -3
  97. package/graph-provider/fuli_graph/store_project_agent_tasks.py +2 -2
  98. package/graph-provider/fuli_graph/store_project_agents.py +6 -6
  99. package/graph-provider/fuli_graph/system_agents.py +31 -2
  100. package/graph-provider/fuli_graph/system_hr_identity.py +21 -7
  101. package/graph-provider/pyproject.toml +2 -1
  102. package/npm-shrinkwrap.json +280 -5
  103. package/package.json +7 -2
  104. package/skills/operating-fuli/SKILL.md +3 -3
  105. package/src/agent-peer/coordinator-http.js +117 -0
  106. package/src/agent-peer/coordinator-service.js +258 -0
  107. package/src/agent-peer/coordinator-store.js +95 -0
  108. package/src/agent-peer/device-identity.js +70 -0
  109. package/src/agent-peer/http.js +47 -0
  110. package/src/agent-peer/node-store.js +127 -0
  111. package/src/agent-peer/origin-proof.js +56 -0
  112. package/src/agent-peer/peer-port.js +140 -0
  113. package/src/agent-peer/peer-runtime.js +298 -0
  114. package/src/agent-peer/peer-transport.js +78 -0
  115. package/src/agent-peer/protocol.js +161 -0
  116. package/src/agent-peer/runner.js +251 -0
  117. package/src/agent-roundtable/http.js +17 -3
  118. package/src/agent-roundtable/service.js +272 -43
  119. package/src/agent-roundtable/tools.js +15 -1
  120. package/src/agent-roundtable/wake.js +12 -3
  121. package/src/agent-tools/graph-definitions.js +16 -5
  122. package/src/agent-tools/graph-handlers.js +8 -0
  123. package/src/agent-tools/interface-catalog.js +16 -1
  124. package/src/agent-tools/judgment-definitions.js +12 -0
  125. package/src/agent-tools/project-agent-definitions.js +1 -1
  126. package/src/agents/claude-code/wake.js +7 -0
  127. package/src/agents/codex/wake.js +7 -0
  128. package/src/agents/identity-receipt.js +1 -1
  129. package/src/cli/command-registry.js +3 -0
  130. package/src/cli/connect-command.js +28 -0
  131. package/src/cli/mcp-command.js +99 -0
  132. package/src/cli/peer-command.js +100 -0
  133. package/src/cli/setup-agent-selection.js +2 -2
  134. package/src/cli/setup-command.js +7 -2
  135. package/src/cli.js +16 -0
  136. package/src/employees/catalog/bole.json +2 -2
  137. package/src/employees/catalog/tonborg.json +20 -0
  138. package/src/employees/manifest.js +2 -2
  139. package/src/employees/recruitment.js +20 -1
  140. package/src/graphiti/agent-collaboration.js +8 -2
  141. package/src/graphiti/federated-application.js +9 -1
  142. package/src/graphiti/knowledge-review.js +7 -3
  143. package/src/graphiti/project-agent-control-plane.js +7 -0
  144. package/src/graphiti/project-agent-mapping.js +2 -0
  145. package/src/graphiti/project-agent-task-entry.js +4 -4
  146. package/src/graphiti/project-agent-team-context.js +16 -18
  147. package/src/graphiti/project-team-view.js +82 -0
  148. package/src/graphiti/provider-client.js +9 -0
  149. package/src/graphiti/task-entry-preferences.js +6 -1
  150. package/src/http/api-router.js +12 -1
  151. package/src/http/graph-api-router.js +7 -0
  152. package/src/judgment/acceptance.js +91 -0
  153. package/src/judgment/engine.js +168 -0
  154. package/src/judgment/http.js +24 -0
  155. package/src/judgment/model.js +24 -0
  156. package/src/judgment/routing.js +96 -0
  157. package/src/judgment/scheduler.js +25 -0
  158. package/src/judgment/service.js +66 -0
  159. package/src/judgment/store.js +123 -0
  160. package/src/mcp/session-id.js +1 -0
  161. package/src/mcp/tool-annotations.js +8 -5
  162. package/src/mcp-server.js +2 -100
  163. package/src/server.js +22 -3
  164. package/src/setup/client-connection.js +16 -0
  165. package/src/system/system-service.js +5 -0
  166. package/dist/web/assets/AboutPage-cW9hDghN.js +0 -1
  167. package/dist/web/assets/AgentConversations-CGznTWuV.js +0 -2
  168. package/dist/web/assets/AgentHand-DkyF4vgk.js +0 -1
  169. package/dist/web/assets/AgentName-C1mAVr-v.js +0 -1
  170. package/dist/web/assets/AgentProfilePage-pKUR0rfj.js +0 -1
  171. package/dist/web/assets/AgentsDirectoryPage-CUlbT2hD.js +0 -1
  172. package/dist/web/assets/AgentsDirectoryPage-yRWktiin.css +0 -1
  173. package/dist/web/assets/ConnectionsPage-CuDHIJXu.js +0 -1
  174. package/dist/web/assets/EmployeeRecruitDialog-D9QfGipd.js +0 -1
  175. package/dist/web/assets/EmployeeWorkbenchPage-D-c_Jm2z.css +0 -1
  176. package/dist/web/assets/EmployeeWorkbenchPage-DAtvGney.js +0 -3
  177. package/dist/web/assets/KnowledgeEditDialog-a-5rasG0.js +0 -1
  178. package/dist/web/assets/KnowledgeInspector-BGsqjNY2.js +0 -1
  179. package/dist/web/assets/KnowledgeOrganizerPage-BndgUsHS.js +0 -1
  180. package/dist/web/assets/KnowledgeOrganizerPage-DeL7Jp34.css +0 -1
  181. package/dist/web/assets/KnowledgePage-CQweO0YJ.js +0 -1
  182. package/dist/web/assets/KnowledgeWorkspace-BtUjKTLb.js +0 -4
  183. package/dist/web/assets/NotFoundPage-BmQTTqQn.js +0 -1
  184. package/dist/web/assets/OverviewPage-By-fUzSG.css +0 -1
  185. package/dist/web/assets/OverviewPage-C73HyTsM.js +0 -1
  186. package/dist/web/assets/PersonalProfilePage-BMuOrppx.js +0 -1
  187. package/dist/web/assets/PersonalProjectsPage-Cq9_kFvs.js +0 -1
  188. package/dist/web/assets/ProjectAgentFirstTask-CaHvOjkp.js +0 -1
  189. package/dist/web/assets/ProjectAgentsPage-WVcNdzVi.js +0 -12
  190. package/dist/web/assets/ProjectScopePicker-zAgiaZKL.js +0 -1
  191. package/dist/web/assets/PublicProjectsPage-DL093f65.js +0 -1
  192. package/dist/web/assets/ReviewPage-D_NIhPx8.js +0 -1
  193. package/dist/web/assets/RoundtablesPage-Cb0R4GaM.css +0 -1
  194. package/dist/web/assets/RoundtablesPage-CdVVyZE-.js +0 -1
  195. package/dist/web/assets/SettingsPage-BmYkLul4.css +0 -1
  196. package/dist/web/assets/SettingsPage-CbLaau8Q.js +0 -1
  197. package/dist/web/assets/SharedJefaReportPage-aygd6wvb.js +0 -1
  198. package/dist/web/assets/UiDialog-Q7fsJUoO.js +0 -1
  199. package/dist/web/assets/UiDisclosure-BS-muK5j.js +0 -1
  200. package/dist/web/assets/UiSegmented-CT28bxqw.js +0 -1
  201. package/dist/web/assets/UiSelect-DcGpPQQo.js +0 -1
  202. package/dist/web/assets/WritingTastePage-CYlwAFqV.js +0 -1
  203. package/dist/web/assets/agent-profile-BZj3ezWe.css +0 -1
  204. package/dist/web/assets/catalog-UDv854JN.js +0 -1
  205. package/dist/web/assets/client-DLS5FZBI.js +0 -1
  206. package/dist/web/assets/console-CqzwSo7a.js +0 -1
  207. package/dist/web/assets/conversation-launcher-settings-DRoIz42V.js +0 -1
  208. package/dist/web/assets/index-D1OHIz4c.js +0 -5
  209. package/dist/web/assets/index-kPB0Cf8Q.css +0 -1
  210. package/dist/web/assets/model-DupZy8Qb.js +0 -1
  211. package/dist/web/assets/shared-report-BYnLhmCZ.js +0 -1
  212. package/dist/web/assets/useAgentRoster-ep7SpKv1.js +0 -1
  213. package/dist/web/assets/vue-router-B6ZjK-Br.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,
@@ -20,6 +20,23 @@ class AgentDelegationIssue(StrictModel):
20
20
  lifetime_seconds: int = Field(default=300, ge=30, le=900)
21
21
 
22
22
 
23
+ class AgentRemoteDelegationIssue(StrictModel):
24
+ """One attempt of a signed ask from another paired device.
25
+
26
+ The local runner has already verified the device signature and the owner's
27
+ share. This authority still decides project, receiver, client and lifetime.
28
+ """
29
+ personal_space_id: str = Field(min_length=1, max_length=128)
30
+ personal_project_id: str = Field(min_length=1, max_length=128)
31
+ agent_id: str = Field(min_length=1, max_length=128)
32
+ target_application: SourceApplication
33
+ target_session_id: str | None = Field(default=None, min_length=1, max_length=256)
34
+ lifetime_seconds: int = Field(default=300, ge=30, le=900)
35
+ origin_node: str = Field(pattern=r'^[0-9a-f]{64}$')
36
+ origin_message_id: str = Field(min_length=1, max_length=128, pattern=r'^[A-Za-z0-9][A-Za-z0-9._:-]*$')
37
+ origin_attempt_id: str = Field(min_length=1, max_length=128, pattern=r'^[A-Za-z0-9][A-Za-z0-9._:-]*$')
38
+
39
+
23
40
  class AgentDelegationCheck(StrictModel):
24
41
  personal_space_id: str = Field(min_length=1, max_length=128)
25
42
  token: str = Field(min_length=32, max_length=128)
@@ -66,6 +83,51 @@ class StoreAgentDelegations:
66
83
  lead=lead, lifetime=request.lifetime_seconds)
67
84
  return {'token': token}
68
85
 
86
+ async def issue_remote_agent_delegation(self, actor, request):
87
+ self._require_personal()
88
+ space = await self.authorize(actor, request.personal_space_id, 'maintainer')
89
+ await authorize_personal_project(self, actor, space, request.personal_project_id)
90
+ policy = await self.get_project_agent_coordination_policy(
91
+ actor, request.personal_space_id, request.personal_project_id)
92
+ lead = policy.team_lead_agent_id
93
+ # Remote asks keep the receiving project's accountable lead: in this
94
+ # version only that lead can be woken, never a member directly.
95
+ if not lead or request.agent_id != lead:
96
+ raise HTTPException(403, 'Remote devices can only ask the project lead')
97
+ target = await self.get_project_agent(
98
+ actor, request.personal_space_id, request.personal_project_id, request.agent_id)
99
+ if (target.profile.status != 'active'
100
+ or request.target_application not in target.profile.allowed_clients):
101
+ raise HTTPException(403, 'The receiving Agent is unavailable in this client')
102
+ token = token_urlsafe(32)
103
+ await self.runtime.driver.execute_query(
104
+ '''MATCH (old:FuliAgentDelegation {space_id: $space_id})
105
+ WHERE old.expires_at <= datetime() DELETE old''', space_id=request.personal_space_id)
106
+ # The attempt record is durable history and is never deleted, so one
107
+ # claimed attempt can obtain authority at most once.
108
+ rows, _, _ = await self.runtime.driver.execute_query(
109
+ '''MERGE (attempt:FuliRemoteDelegationAttempt {space_id: $space_id, origin_node: $origin_node,
110
+ origin_message_id: $message_id, origin_attempt_id: $attempt_id})
111
+ ON CREATE SET attempt.issued_at = datetime(), attempt.project_id = $project_id,
112
+ attempt.agent_id = $agent_id, attempt._created = true
113
+ WITH attempt, coalesce(attempt._created, false) AS created
114
+ REMOVE attempt._created
115
+ WITH attempt, created WHERE created
116
+ CREATE (:FuliAgentDelegation {token_hash: $token_hash, space_id: $space_id,
117
+ project_id: $project_id, agent_id: $agent_id, source_application: $application,
118
+ session_id: $session_id, sender_token: null, lead_agent_id: $lead, origin: 'remote',
119
+ origin_node: $origin_node, origin_message_id: $message_id, origin_attempt_id: $attempt_id,
120
+ expires_at: datetime() + duration({seconds: $lifetime}), redeemed: false})
121
+ RETURN attempt.issued_at AS issued_at''',
122
+ space_id=request.personal_space_id, origin_node=request.origin_node,
123
+ message_id=request.origin_message_id, attempt_id=request.origin_attempt_id,
124
+ project_id=request.personal_project_id, agent_id=request.agent_id,
125
+ token_hash=token_hash(token), application=request.target_application,
126
+ session_id=request.target_session_id, lead=lead, lifetime=request.lifetime_seconds)
127
+ if not rows:
128
+ raise HTTPException(409, 'This remote delivery attempt already received authority')
129
+ return {'token': token}
130
+
69
131
  async def redeem_agent_delegation(self, request, lead):
70
132
  if not request.delegation_token or not request.delegation_session_id or not request.agent_id:
71
133
  return False
@@ -85,11 +147,17 @@ class StoreAgentDelegations:
85
147
  WITH grant
86
148
  WHERE grant.expires_at > datetime()
87
149
  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
150
+ OPTIONAL MATCH (session:FuliTaskContextSession)-[:HAS_CONTEXT]->(task:FuliTaskContext)
151
+ WHERE grant.origin IS NULL
152
+ AND task.token = grant.sender_token AND session.current_token = task.token
90
153
  AND NOT task.completed AND task.personal_space_id = grant.space_id
91
154
  AND task.personal_project_id = grant.project_id
92
155
  AND (task.project_agent_id = grant.lead_agent_id OR grant.agent_id = grant.lead_agent_id)
156
+ WITH grant, count(task) AS live_sender
157
+ // A local grant needs its live sender task; a remote grant has no
158
+ // local sender and may only ever restore the current lead.
159
+ WHERE (grant.origin IS NULL AND live_sender > 0)
160
+ OR (grant.origin = 'remote' AND grant.agent_id = grant.lead_agent_id)
93
161
  SET grant.session_id = $session_id, grant.redeemed = true
94
162
  RETURN grant.token_hash AS token_hash''',
95
163
  token_hash=token_hash(request.delegation_token), space_id=request.personal_space_id,
@@ -15,7 +15,10 @@ 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
+ from .project_agent_delegations import (
19
+ AgentDelegationCheck, AgentDelegationIssue, AgentRemoteDelegationIssue,
20
+ )
21
+ from .project_agent_remote_origins import AgentRemoteOriginIssue
19
22
 
20
23
 
21
24
  def register_project_agent_memory_routes(application, store, Actor):
@@ -31,6 +34,20 @@ def register_project_agent_memory_routes(application, store, Actor):
31
34
  async def issue_delegation(request: AgentDelegationIssue, actor: Actor):
32
35
  return await store.issue_agent_delegation(actor, request)
33
36
 
37
+ @application.post('/v1/project-agent-context/remote-delegations')
38
+ async def issue_remote_delegation(request: AgentRemoteDelegationIssue, actor: Actor):
39
+ return await store.issue_remote_agent_delegation(actor, request)
40
+
41
+ @application.post('/v1/project-agent-context/remote-origins')
42
+ async def issue_remote_origin(request: AgentRemoteOriginIssue, actor: Actor):
43
+ return await store.issue_remote_agent_origin(actor, request)
44
+
45
+ @application.get('/v1/project-agent-context/remote-origin-authority')
46
+ async def remote_origin_authority(
47
+ actor: Actor, personal_space_id: Annotated[str, Query(min_length=1, max_length=128)],
48
+ ):
49
+ return await store.remote_origin_authority(actor, personal_space_id)
50
+
34
51
  @application.post('/v1/project-agent-context/delegations/verify')
35
52
  async def verify_delegation(request: AgentDelegationCheck, actor: Actor):
36
53
  return await store.verify_agent_delegation(actor, request)
@@ -0,0 +1,154 @@
1
+ """Origin proof for asks this installation sends to Agents on other devices.
2
+
3
+ A device signature only proves which installation sent a message. The
4
+ receiving device also needs proof that this authority, not merely the device,
5
+ let the current project lead's live task send exactly that message. This
6
+ authority signs that statement with a key that never leaves it; the receiver
7
+ checks it with the public key it bound when the owners paired the devices.
8
+ """
9
+
10
+ import json
11
+ from base64 import urlsafe_b64decode, urlsafe_b64encode
12
+ from datetime import datetime, timedelta, timezone
13
+ from hashlib import sha256
14
+
15
+ from fastapi import HTTPException
16
+ from nacl.signing import SigningKey
17
+ from neo4j.exceptions import ConstraintError
18
+ from pydantic import Field
19
+
20
+ from .models import SourceApplication, StrictModel
21
+ from .personal_project_access import authorize_personal_project
22
+
23
+ OPAQUE = r'^[A-Za-z0-9][A-Za-z0-9._:-]*$'
24
+ DIGEST = r'^[0-9a-f]{64}$'
25
+ CANONICAL_TIME = r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$'
26
+ PROOF_VERSION = 'fuli-remote-origin/1'
27
+ MAX_LIFETIME = timedelta(minutes=30)
28
+
29
+
30
+ class AgentRemoteOriginIssue(StrictModel):
31
+ personal_space_id: str = Field(min_length=1, max_length=128)
32
+ task_context_token: str = Field(min_length=1, max_length=256)
33
+ source_application: SourceApplication
34
+ message_id: str = Field(min_length=1, max_length=128, pattern=OPAQUE)
35
+ digest: str = Field(pattern=DIGEST)
36
+ target_node: str = Field(pattern=DIGEST)
37
+ binding_id: str = Field(min_length=1, max_length=128, pattern=OPAQUE)
38
+ expires_at: str = Field(pattern=CANONICAL_TIME)
39
+
40
+
41
+ def _encode(value):
42
+ return urlsafe_b64encode(value).rstrip(b'=').decode()
43
+
44
+
45
+ def _decode(value):
46
+ return urlsafe_b64decode(value + '=' * (-len(value) % 4))
47
+
48
+
49
+ def canonical_time(value):
50
+ return value.astimezone(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.') + f'{value.microsecond // 1000:03d}Z'
51
+
52
+
53
+ class StoreRemoteAgentOrigins:
54
+ async def remote_origin_authority(self, actor, personal_space_id):
55
+ """Public half of this space's origin key; created once and never rotated silently."""
56
+ self._require_personal()
57
+ await self.authorize(actor, personal_space_id, 'maintainer')
58
+ key = await self._remote_origin_key(personal_space_id)
59
+ return {'algorithm': 'Ed25519', 'key_id': key['key_id'], 'public_key': key['public_key']}
60
+
61
+ async def _remote_origin_key(self, space_id):
62
+ candidate = SigningKey.generate()
63
+ public = bytes(candidate.verify_key)
64
+ try:
65
+ rows, _, _ = await self.runtime.driver.execute_query(
66
+ '''MERGE (key:FuliRemoteOriginKey {space_id: $space_id})
67
+ ON CREATE SET key.private_key = $private_key, key.public_key = $public_key,
68
+ key.key_id = $key_id, key.created_at = datetime()
69
+ RETURN key.private_key AS private_key, key.public_key AS public_key, key.key_id AS key_id''',
70
+ space_id=space_id, private_key=_encode(bytes(candidate)),
71
+ public_key=_encode(public), key_id=sha256(public).hexdigest()[:32])
72
+ except ConstraintError:
73
+ # A concurrent first use created the key; everyone must use that one.
74
+ rows, _, _ = await self.runtime.driver.execute_query(
75
+ '''MATCH (key:FuliRemoteOriginKey {space_id: $space_id})
76
+ RETURN key.private_key AS private_key, key.public_key AS public_key, key.key_id AS key_id''',
77
+ space_id=space_id)
78
+ return rows[0]
79
+
80
+ async def issue_remote_agent_origin(self, actor, request):
81
+ self._require_personal()
82
+ space = await self.authorize(actor, request.personal_space_id, 'maintainer')
83
+ expires = datetime.fromisoformat(request.expires_at.replace('Z', '+00:00'))
84
+ now = datetime.now(timezone.utc)
85
+ if expires <= now or expires - now > MAX_LIFETIME:
86
+ raise HTTPException(422, 'A remote ask needs a lifetime of at most 30 minutes')
87
+ context = await self.get_task_context(actor, request.personal_space_id,
88
+ request.task_context_token, request.source_application)
89
+ agent_id = context.get('project_agent_id')
90
+ project_id = context.get('personal_project_id')
91
+ if not agent_id or not project_id or (context.get('checkpoint') or {}).get('phase') == 'complete':
92
+ raise HTTPException(409, 'A remote ask requires an active project task of a FULI Agent')
93
+ await authorize_personal_project(self, actor, space, project_id)
94
+ policy = await self.get_project_agent_coordination_policy(actor, request.personal_space_id, project_id)
95
+ # Members reach other devices through their own lead, never directly.
96
+ if not policy.team_lead_agent_id or agent_id != policy.team_lead_agent_id:
97
+ raise HTTPException(403, 'Only the project lead can ask Agents on other devices')
98
+ agent = await self.get_project_agent(actor, request.personal_space_id, project_id, agent_id)
99
+ if agent.profile.status != 'active':
100
+ raise HTTPException(403, 'The sending Agent is not active')
101
+ task_hash = sha256(request.task_context_token.encode()).hexdigest()
102
+ # The checks above can be overtaken by a new task turn or a lead handoff.
103
+ # Those writers lock the session, the role and the policy; this takes the
104
+ # same locks and re-checks under them before recording the origin.
105
+ rows, _, _ = await self.runtime.driver.execute_query(
106
+ '''MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})-[:HAS_TASK_CONTEXT_SESSION]->
107
+ (session:FuliTaskContextSession)-[:HAS_CONTEXT]->(task:FuliTaskContext {token: $token})
108
+ SET session._fuli_write_lock = true REMOVE session._fuli_write_lock
109
+ WITH space, session, task
110
+ MATCH (space)-[:CONTAINS_PROJECT]->(project:FuliPersonalProject {project_id: $project_id})
111
+ MATCH (space)-[:HAS_PROJECT_AGENT_IDENTITY]->(role:FuliProjectAgent {agent_id: $agent_id})
112
+ SET role._task_lifecycle_lock = true REMOVE role._task_lifecycle_lock
113
+ WITH space, session, task, project, role
114
+ MATCH (project)-[:HAS_PROJECT_AGENT_COORDINATION_POLICY]->(policy:FuliProjectAgentCoordinationPolicy)
115
+ SET policy._fuli_write_lock = true REMOVE policy._fuli_write_lock
116
+ WITH session.current_token = task.token AND NOT coalesce(task.completed, false)
117
+ AND task.personal_project_id = $project_id AND task.project_agent_id = $agent_id AS live_task,
118
+ policy.team_lead_agent_id = $agent_id AS lead,
119
+ role.status = 'active' AND EXISTS {
120
+ MATCH (project)-[:HAS_PROJECT_AGENT_ASSIGNMENT]->
121
+ (:FuliProjectAgentAssignment {status: 'active'})-[:ASSIGNED_AGENT]->(role)
122
+ } AS assigned
123
+ WITH live_task, lead, assigned, live_task AND lead AND assigned AS allowed
124
+ FOREACH (ignored IN CASE WHEN allowed THEN [1] ELSE [] END |
125
+ MERGE (origin:FuliRemoteAgentOrigin {space_id: $space_id, message_id: $message_id})
126
+ ON CREATE SET origin.digest = $digest, origin.agent_id = $agent_id, origin.project_id = $project_id,
127
+ origin.target_node = $target_node, origin.binding_id = $binding_id, origin.task_hash = $task_hash,
128
+ origin.issued_at = datetime(), origin.expires_at = datetime($expires_at))
129
+ WITH live_task, lead, assigned, allowed
130
+ OPTIONAL MATCH (origin:FuliRemoteAgentOrigin {space_id: $space_id, message_id: $message_id})
131
+ WITH live_task, lead, assigned,
132
+ allowed AND origin.digest = $digest AND origin.agent_id = $agent_id AND origin.task_hash = $task_hash
133
+ AND origin.target_node = $target_node AND origin.binding_id = $binding_id AS same
134
+ RETURN live_task, lead, assigned, same''',
135
+ space_id=request.personal_space_id, token=request.task_context_token, message_id=request.message_id,
136
+ digest=request.digest, agent_id=agent_id, project_id=project_id, target_node=request.target_node,
137
+ binding_id=request.binding_id, task_hash=task_hash, expires_at=request.expires_at)
138
+ row = rows[0] if rows else None
139
+ if not row or not row['live_task']:
140
+ raise HTTPException(404, 'The sending task is no longer current')
141
+ if not row['lead'] or not row['assigned']:
142
+ raise HTTPException(403, 'Only the project lead can ask Agents on other devices')
143
+ if not row['same']:
144
+ raise HTTPException(409, 'This message ID already belongs to a different remote ask')
145
+ key = await self._remote_origin_key(request.personal_space_id)
146
+ payload = json.dumps({
147
+ 'version': PROOF_VERSION, 'key_id': key['key_id'], 'space_id': request.personal_space_id,
148
+ 'project_id': project_id, 'agent_id': agent_id, 'task_hash': task_hash,
149
+ 'message_id': request.message_id, 'digest': request.digest, 'target_node': request.target_node,
150
+ 'binding_id': request.binding_id, 'issued_at': canonical_time(now), 'expires_at': request.expires_at,
151
+ }, sort_keys=True, separators=(',', ':')).encode()
152
+ signature = SigningKey(_decode(key['private_key'])).sign(payload).signature
153
+ return {'agent_id': agent_id, 'agent_name': agent.profile.display_name or agent.profile.name or agent_id,
154
+ 'proof': {'payload': _encode(payload), 'signature': _encode(signature)}}
@@ -21,7 +21,7 @@ ProjectAgentTaskDuration = Literal['ongoing', 'one_off']
21
21
  ProjectAgentStaffingIntent = Literal[
22
22
  'reuse_preferred',
23
23
  # Like reuse_preferred, but a project without a team lead hires its first
24
- # lead through HR without waiting for confirmation.
24
+ # lead through AR without waiting for confirmation.
25
25
  'default_lead',
26
26
  'new_durable',
27
27
  'temporary',
@@ -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)