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.
- package/README.md +6 -5
- package/README.zh-CN.md +5 -4
- package/dist/web/assets/AboutPage-Dized8Uz.js +1 -0
- package/dist/web/assets/AgentConversations-Ccy41mEA.js +2 -0
- package/dist/web/assets/AgentHand-UzVhbKUC.js +1 -0
- package/dist/web/assets/AgentName-DJpvkub2.js +1 -0
- package/dist/web/assets/AgentProfilePage-CFViQ8kj.js +1 -0
- package/dist/web/assets/AgentsDirectoryPage-CBFTg_IE.js +1 -0
- package/dist/web/assets/AgentsDirectoryPage-Cv-vxw2C.css +1 -0
- package/dist/web/assets/ConnectionsPage-uXcabab_.js +1 -0
- package/dist/web/assets/EmployeeRecruitDialog-D8Hvhc-M.js +1 -0
- package/dist/web/assets/EmployeeWorkbenchPage-BQes4L1f.css +1 -0
- package/dist/web/assets/EmployeeWorkbenchPage-TBl_HNj6.js +3 -0
- package/dist/web/assets/KnowledgeEditDialog-HmiqLC-Y.js +1 -0
- package/dist/web/assets/KnowledgeInspector-Cj2yK4Qh.js +1 -0
- package/dist/web/assets/KnowledgeOrganizerPage-DT3BROZm.js +1 -0
- package/dist/web/assets/KnowledgeOrganizerPage-ccgscrPd.css +1 -0
- package/dist/web/assets/KnowledgePage-C4J4VueL.js +1 -0
- package/dist/web/assets/KnowledgeWorkspace-DA_dqVRG.js +4 -0
- package/dist/web/assets/NotFoundPage-x_Yd59mm.js +1 -0
- package/dist/web/assets/OverviewPage-3eZjQ26v.js +1 -0
- package/dist/web/assets/OverviewPage-8LBN8Shi.css +1 -0
- package/dist/web/assets/PersonalProfilePage-CDQk096L.js +1 -0
- package/dist/web/assets/PersonalProjectsPage-CJxp4Wyz.js +1 -0
- package/dist/web/assets/ProjectAgentFirstTask-BkRJ0zwO.js +1 -0
- package/dist/web/assets/ProjectAgentsPage-BuNNqSEy.js +12 -0
- package/dist/web/assets/ProjectScopePicker-CiDxM2Yx.js +1 -0
- package/dist/web/assets/PublicProjectsPage-ByOT559r.js +1 -0
- package/dist/web/assets/ReviewPage-C0Vdr9uq.js +1 -0
- package/dist/web/assets/RoundtablesPage-Drou-B81.css +1 -0
- package/dist/web/assets/RoundtablesPage-L3ShEWS3.js +1 -0
- package/dist/web/assets/SettingsPage-BE8pWbI6.css +1 -0
- package/dist/web/assets/SettingsPage-McYuinGz.js +1 -0
- package/dist/web/assets/SharedJefaReportPage-Bg6lIk3E.js +1 -0
- package/dist/web/assets/UiButton-x8hiHNRM.js +1 -0
- package/dist/web/assets/UiDialog-mjletK0y.js +1 -0
- package/dist/web/assets/UiDisclosure-_IxeiexW.js +1 -0
- package/dist/web/assets/UiSegmented-WFfz9Evl.js +1 -0
- package/dist/web/assets/UiSelect-K29jnV-1.js +1 -0
- package/dist/web/assets/WritingTastePage-DCULB-d2.js +1 -0
- package/dist/web/assets/agent-pins-DS4mNsNt.js +1 -0
- package/dist/web/assets/agent-profile-Bh3MJGvb.css +1 -0
- package/dist/web/assets/agent-work-summary-C-yW7x7A.js +1 -0
- package/dist/web/assets/{attention-store-D-qIFRIb.js → attention-store-CA5y8j1R.js} +1 -1
- package/dist/web/assets/catalog-D0VEfOAV.js +1 -0
- package/dist/web/assets/client-DnwnUBQV.js +1 -0
- package/dist/web/assets/console-Cw7TzOSC.js +1 -0
- package/dist/web/assets/conversation-launcher-settings-Yc13viwr.js +1 -0
- package/dist/web/assets/{i18n-b14l2pQA.js → i18n-oJFlt8hW.js} +3 -3
- package/dist/web/assets/{identity-BWbPmZZf.js → identity-DSGS4CZc.js} +1 -1
- package/dist/web/assets/index-BfHCcUL2.css +1 -0
- package/dist/web/assets/index-DO10_fLc.js +5 -0
- package/dist/web/assets/judgment-types-DR931cZ7.js +1 -0
- package/dist/web/assets/model-BJ-UF4cs.js +1 -0
- package/dist/web/assets/{personal-profile-graph-BPTtMJTZ.js → personal-profile-graph-SOK0BR0w.js} +1 -1
- package/dist/web/assets/project-team-api-C_0qJk_B.js +1 -0
- package/dist/web/assets/shared-report-CiX64ZiD.js +1 -0
- package/dist/web/assets/{agent-work-summary-LeW-Al_S.js → task-evidence-DTiPCs7r.js} +1 -1
- package/dist/web/assets/useAgentRoster-B84mzZIg.js +1 -0
- package/dist/web/assets/{useApi-CROJJdhE-CEQAEuug.js → useApi-CROJJdhE-7gt0QyQ5.js} +1 -1
- package/dist/web/assets/vue-router-CCvPGaLD.js +1 -0
- package/dist/web/index.html +17 -12
- package/dist/web/licenses/ant-design-icons.txt +22 -0
- package/docs/agent-conversations-and-collaboration.md +3 -3
- package/docs/agent-roundtable.md +33 -0
- package/docs/client-connections.md +86 -0
- package/docs/employee-agents.md +3 -3
- package/docs/judgment-and-autonomy.md +33 -0
- package/docs/project-agent-memory.md +6 -6
- package/graph-provider/fuli_graph/ai_review.py +154 -0
- package/graph-provider/fuli_graph/common_knowledge.py +12 -0
- package/graph-provider/fuli_graph/default_project_lead.py +1 -1
- package/graph-provider/fuli_graph/graph_models.py +14 -0
- package/graph-provider/fuli_graph/graph_query.py +217 -0
- package/graph-provider/fuli_graph/knowledge_audit.py +6 -0
- package/graph-provider/fuli_graph/knowledge_batch_confirmation.py +6 -0
- package/graph-provider/fuli_graph/knowledge_management.py +23 -0
- package/graph-provider/fuli_graph/knowledge_review.py +383 -4
- package/graph-provider/fuli_graph/knowledge_review_models.py +61 -2
- package/graph-provider/fuli_graph/personal_global_preferences.py +5 -1
- package/graph-provider/fuli_graph/preference_conflicts.py +91 -1
- package/graph-provider/fuli_graph/project_agent_delegations.py +70 -2
- package/graph-provider/fuli_graph/project_agent_memory_routes.py +18 -1
- package/graph-provider/fuli_graph/project_agent_remote_origins.py +154 -0
- package/graph-provider/fuli_graph/project_agent_task_models.py +3 -1
- package/graph-provider/fuli_graph/project_knowledge.py +41 -0
- package/graph-provider/fuli_graph/runtime.py +15 -0
- package/graph-provider/fuli_graph/store_agent_verification.py +12 -5
- package/graph-provider/fuli_graph/store_knowledge.py +98 -58
- package/graph-provider/fuli_graph/store_project_agent_attention.py +1 -1
- package/graph-provider/fuli_graph/store_project_agent_context.py +3 -2
- package/graph-provider/fuli_graph/store_project_agent_coordination_policy.py +1 -1
- package/graph-provider/fuli_graph/store_project_agent_task_activity.py +26 -0
- package/graph-provider/fuli_graph/store_project_agent_task_reads.py +1 -1
- package/graph-provider/fuli_graph/store_project_agent_task_recruitment.py +5 -5
- package/graph-provider/fuli_graph/store_project_agent_task_staffing.py +4 -3
- package/graph-provider/fuli_graph/store_project_agent_tasks.py +2 -2
- package/graph-provider/fuli_graph/store_project_agents.py +6 -6
- package/graph-provider/fuli_graph/system_agents.py +31 -2
- package/graph-provider/fuli_graph/system_hr_identity.py +21 -7
- package/graph-provider/pyproject.toml +2 -1
- package/npm-shrinkwrap.json +280 -5
- package/package.json +7 -2
- package/skills/operating-fuli/SKILL.md +3 -3
- package/src/agent-peer/coordinator-http.js +117 -0
- package/src/agent-peer/coordinator-service.js +258 -0
- package/src/agent-peer/coordinator-store.js +95 -0
- package/src/agent-peer/device-identity.js +70 -0
- package/src/agent-peer/http.js +47 -0
- package/src/agent-peer/node-store.js +127 -0
- package/src/agent-peer/origin-proof.js +56 -0
- package/src/agent-peer/peer-port.js +140 -0
- package/src/agent-peer/peer-runtime.js +298 -0
- package/src/agent-peer/peer-transport.js +78 -0
- package/src/agent-peer/protocol.js +161 -0
- package/src/agent-peer/runner.js +251 -0
- package/src/agent-roundtable/http.js +17 -3
- package/src/agent-roundtable/service.js +272 -43
- package/src/agent-roundtable/tools.js +15 -1
- package/src/agent-roundtable/wake.js +12 -3
- package/src/agent-tools/graph-definitions.js +16 -5
- package/src/agent-tools/graph-handlers.js +8 -0
- package/src/agent-tools/interface-catalog.js +16 -1
- package/src/agent-tools/judgment-definitions.js +12 -0
- package/src/agent-tools/project-agent-definitions.js +1 -1
- package/src/agents/claude-code/wake.js +7 -0
- package/src/agents/codex/wake.js +7 -0
- package/src/agents/identity-receipt.js +1 -1
- package/src/cli/command-registry.js +3 -0
- package/src/cli/connect-command.js +28 -0
- package/src/cli/mcp-command.js +99 -0
- package/src/cli/peer-command.js +100 -0
- package/src/cli/setup-agent-selection.js +2 -2
- package/src/cli/setup-command.js +7 -2
- package/src/cli.js +16 -0
- package/src/employees/catalog/bole.json +2 -2
- package/src/employees/catalog/tonborg.json +20 -0
- package/src/employees/manifest.js +2 -2
- package/src/employees/recruitment.js +20 -1
- package/src/graphiti/agent-collaboration.js +8 -2
- package/src/graphiti/federated-application.js +9 -1
- package/src/graphiti/knowledge-review.js +7 -3
- package/src/graphiti/project-agent-control-plane.js +7 -0
- package/src/graphiti/project-agent-mapping.js +2 -0
- package/src/graphiti/project-agent-task-entry.js +4 -4
- package/src/graphiti/project-agent-team-context.js +16 -18
- package/src/graphiti/project-team-view.js +82 -0
- package/src/graphiti/provider-client.js +9 -0
- package/src/graphiti/task-entry-preferences.js +6 -1
- package/src/http/api-router.js +12 -1
- package/src/http/graph-api-router.js +7 -0
- package/src/judgment/acceptance.js +91 -0
- package/src/judgment/engine.js +168 -0
- package/src/judgment/http.js +24 -0
- package/src/judgment/model.js +24 -0
- package/src/judgment/routing.js +96 -0
- package/src/judgment/scheduler.js +25 -0
- package/src/judgment/service.js +66 -0
- package/src/judgment/store.js +123 -0
- package/src/mcp/session-id.js +1 -0
- package/src/mcp/tool-annotations.js +8 -5
- package/src/mcp-server.js +2 -100
- package/src/server.js +22 -3
- package/src/setup/client-connection.js +16 -0
- package/src/system/system-service.js +5 -0
- package/dist/web/assets/AboutPage-cW9hDghN.js +0 -1
- package/dist/web/assets/AgentConversations-CGznTWuV.js +0 -2
- package/dist/web/assets/AgentHand-DkyF4vgk.js +0 -1
- package/dist/web/assets/AgentName-C1mAVr-v.js +0 -1
- package/dist/web/assets/AgentProfilePage-pKUR0rfj.js +0 -1
- package/dist/web/assets/AgentsDirectoryPage-CUlbT2hD.js +0 -1
- package/dist/web/assets/AgentsDirectoryPage-yRWktiin.css +0 -1
- package/dist/web/assets/ConnectionsPage-CuDHIJXu.js +0 -1
- package/dist/web/assets/EmployeeRecruitDialog-D9QfGipd.js +0 -1
- package/dist/web/assets/EmployeeWorkbenchPage-D-c_Jm2z.css +0 -1
- package/dist/web/assets/EmployeeWorkbenchPage-DAtvGney.js +0 -3
- package/dist/web/assets/KnowledgeEditDialog-a-5rasG0.js +0 -1
- package/dist/web/assets/KnowledgeInspector-BGsqjNY2.js +0 -1
- package/dist/web/assets/KnowledgeOrganizerPage-BndgUsHS.js +0 -1
- package/dist/web/assets/KnowledgeOrganizerPage-DeL7Jp34.css +0 -1
- package/dist/web/assets/KnowledgePage-CQweO0YJ.js +0 -1
- package/dist/web/assets/KnowledgeWorkspace-BtUjKTLb.js +0 -4
- package/dist/web/assets/NotFoundPage-BmQTTqQn.js +0 -1
- package/dist/web/assets/OverviewPage-By-fUzSG.css +0 -1
- package/dist/web/assets/OverviewPage-C73HyTsM.js +0 -1
- package/dist/web/assets/PersonalProfilePage-BMuOrppx.js +0 -1
- package/dist/web/assets/PersonalProjectsPage-Cq9_kFvs.js +0 -1
- package/dist/web/assets/ProjectAgentFirstTask-CaHvOjkp.js +0 -1
- package/dist/web/assets/ProjectAgentsPage-WVcNdzVi.js +0 -12
- package/dist/web/assets/ProjectScopePicker-zAgiaZKL.js +0 -1
- package/dist/web/assets/PublicProjectsPage-DL093f65.js +0 -1
- package/dist/web/assets/ReviewPage-D_NIhPx8.js +0 -1
- package/dist/web/assets/RoundtablesPage-Cb0R4GaM.css +0 -1
- package/dist/web/assets/RoundtablesPage-CdVVyZE-.js +0 -1
- package/dist/web/assets/SettingsPage-BmYkLul4.css +0 -1
- package/dist/web/assets/SettingsPage-CbLaau8Q.js +0 -1
- package/dist/web/assets/SharedJefaReportPage-aygd6wvb.js +0 -1
- package/dist/web/assets/UiDialog-Q7fsJUoO.js +0 -1
- package/dist/web/assets/UiDisclosure-BS-muK5j.js +0 -1
- package/dist/web/assets/UiSegmented-CT28bxqw.js +0 -1
- package/dist/web/assets/UiSelect-DcGpPQQo.js +0 -1
- package/dist/web/assets/WritingTastePage-CYlwAFqV.js +0 -1
- package/dist/web/assets/agent-profile-BZj3ezWe.css +0 -1
- package/dist/web/assets/catalog-UDv854JN.js +0 -1
- package/dist/web/assets/client-DLS5FZBI.js +0 -1
- package/dist/web/assets/console-CqzwSo7a.js +0 -1
- package/dist/web/assets/conversation-launcher-settings-DRoIz42V.js +0 -1
- package/dist/web/assets/index-D1OHIz4c.js +0 -5
- package/dist/web/assets/index-kPB0Cf8Q.css +0 -1
- package/dist/web/assets/model-DupZy8Qb.js +0 -1
- package/dist/web/assets/shared-report-BYnLhmCZ.js +0 -1
- package/dist/web/assets/useAgentRoster-ep7SpKv1.js +0 -1
- 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.
|
|
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
|
|
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
|
|
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
|
|
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)
|