fuli-context 0.11.0 → 0.11.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/dist/web/assets/{AboutPage-CKq4IRHd.js → AboutPage-cW9hDghN.js} +1 -1
- package/dist/web/assets/{AgentConversations-DfXiFvBN.js → AgentConversations-CGznTWuV.js} +1 -1
- package/dist/web/assets/{AgentHand-BBM-sKh2.js → AgentHand-DkyF4vgk.js} +1 -1
- package/dist/web/assets/{AgentName-C_6R80nP.js → AgentName-C1mAVr-v.js} +1 -1
- package/dist/web/assets/AgentProfilePage-pKUR0rfj.js +1 -0
- package/dist/web/assets/AgentsDirectoryPage-CUlbT2hD.js +1 -0
- package/dist/web/assets/{ConnectionsPage-BoC6kXgs.js → ConnectionsPage-CuDHIJXu.js} +1 -1
- package/dist/web/assets/{EmployeeRecruitDialog-BVyclQAJ.js → EmployeeRecruitDialog-D9QfGipd.js} +1 -1
- package/dist/web/assets/{EmployeeWorkbenchPage-D2GvYraI.js → EmployeeWorkbenchPage-DAtvGney.js} +1 -1
- package/dist/web/assets/{KnowledgeEditDialog-a0AKWics.js → KnowledgeEditDialog-a-5rasG0.js} +1 -1
- package/dist/web/assets/{KnowledgeInspector-DMg9yJZ2.js → KnowledgeInspector-BGsqjNY2.js} +1 -1
- package/dist/web/assets/{KnowledgeOrganizerPage-BgsHM3Qw.js → KnowledgeOrganizerPage-BndgUsHS.js} +1 -1
- package/dist/web/assets/KnowledgePage-CQweO0YJ.js +1 -0
- package/dist/web/assets/{KnowledgeWorkspace-BtRq9fvC.js → KnowledgeWorkspace-BtUjKTLb.js} +1 -1
- package/dist/web/assets/{NotFoundPage-D-7jLz3o.js → NotFoundPage-BmQTTqQn.js} +1 -1
- package/dist/web/assets/{OverviewPage-BHPc-XxY.js → OverviewPage-C73HyTsM.js} +1 -1
- package/dist/web/assets/{PersonalProfilePage-C00iV0Kx.js → PersonalProfilePage-BMuOrppx.js} +1 -1
- package/dist/web/assets/PersonalProjectsPage-Cq9_kFvs.js +1 -0
- package/dist/web/assets/{ProjectAgentFirstTask-CrCWEYgn.js → ProjectAgentFirstTask-CaHvOjkp.js} +1 -1
- package/dist/web/assets/{ProjectAgentsPage-DNLNjYp-.css → ProjectAgentsPage-DWGkgIsr.css} +1 -1
- package/dist/web/assets/{ProjectAgentsPage-BzztLMH-.js → ProjectAgentsPage-WVcNdzVi.js} +2 -2
- package/dist/web/assets/{ProjectScopePicker-CMxn3txv.js → ProjectScopePicker-zAgiaZKL.js} +1 -1
- package/dist/web/assets/{PublicProjectsPage-DQVtvhOS.js → PublicProjectsPage-DL093f65.js} +1 -1
- package/dist/web/assets/{ReviewPage-BZhvyDWc.js → ReviewPage-D_NIhPx8.js} +1 -1
- package/dist/web/assets/{RoundtablesPage-CVebTXQa.js → RoundtablesPage-CdVVyZE-.js} +1 -1
- package/dist/web/assets/{SettingsPage-1knvfOW6.js → SettingsPage-CbLaau8Q.js} +1 -1
- package/dist/web/assets/{SharedJefaReportPage-BCVq8u0P.js → SharedJefaReportPage-aygd6wvb.js} +1 -1
- package/dist/web/assets/{UiDialog-Da1P10f4.js → UiDialog-Q7fsJUoO.js} +1 -1
- package/dist/web/assets/{UiDisclosure-8wE8YuE4.js → UiDisclosure-BS-muK5j.js} +1 -1
- package/dist/web/assets/{UiSegmented-BKZXc8QF.js → UiSegmented-CT28bxqw.js} +1 -1
- package/dist/web/assets/{UiSelect-D5Xj5Ec4.js → UiSelect-DcGpPQQo.js} +1 -1
- package/dist/web/assets/{WritingTastePage-B8-etOBn.js → WritingTastePage-CYlwAFqV.js} +1 -1
- package/dist/web/assets/{attention-store-BfxtL9EV.js → attention-store-D-qIFRIb.js} +1 -1
- package/dist/web/assets/{catalog-6SN9i3WR.js → catalog-UDv854JN.js} +1 -1
- package/dist/web/assets/{client-CUTkF3t4.js → client-DLS5FZBI.js} +1 -1
- package/dist/web/assets/{console-CG8tdtSs.js → console-CqzwSo7a.js} +1 -1
- package/dist/web/assets/{conversation-launcher-settings-CyZkLJg7.js → conversation-launcher-settings-DRoIz42V.js} +1 -1
- package/dist/web/assets/{i18n-CCMR4gDS.js → i18n-b14l2pQA.js} +2 -2
- package/dist/web/assets/{identity-C1eN-FSB.js → identity-BWbPmZZf.js} +1 -1
- package/dist/web/assets/{index-sLDKI6R-.js → index-D1OHIz4c.js} +3 -3
- package/dist/web/assets/{model-l-sAqtW_.js → model-DupZy8Qb.js} +1 -1
- package/dist/web/assets/{personal-profile-graph-BD1Y6TDS.js → personal-profile-graph-BPTtMJTZ.js} +1 -1
- package/dist/web/assets/{shared-report-B46dxlCR.js → shared-report-BYnLhmCZ.js} +1 -1
- package/dist/web/assets/{useAgentRoster-DdxoKDEE.js → useAgentRoster-ep7SpKv1.js} +1 -1
- package/dist/web/assets/{useApi-CROJJdhE-DlOiu8lP.js → useApi-CROJJdhE-CEQAEuug.js} +1 -1
- package/dist/web/assets/{vue-router-DjqEU4W2.js → vue-router-B6ZjK-Br.js} +1 -1
- package/dist/web/index.html +11 -11
- package/docs/agent-roundtable.md +17 -1
- package/graph-provider/fuli_graph/project_agent_context_models.py +5 -1
- package/graph-provider/fuli_graph/project_agent_delegations.py +118 -0
- package/graph-provider/fuli_graph/project_agent_employee_numbers.py +41 -0
- package/graph-provider/fuli_graph/project_agent_memory_routes.py +13 -0
- package/graph-provider/fuli_graph/project_agent_models.py +1 -0
- package/graph-provider/fuli_graph/project_agent_team_growth.py +2 -2
- package/graph-provider/fuli_graph/runtime.py +7 -0
- package/graph-provider/fuli_graph/store_project_agent_context.py +52 -4
- package/graph-provider/fuli_graph/store_project_agent_coordination_policy.py +16 -1
- package/graph-provider/fuli_graph/store_project_agent_task_staffing.py +3 -2
- package/graph-provider/fuli_graph/store_project_agents.py +31 -5
- package/graph-provider/fuli_graph/system_agents.py +3 -1
- package/graph-provider/pyproject.toml +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/agent-roundtable/service.js +34 -5
- package/src/agent-roundtable/wake.js +2 -1
- package/src/agents/identity-receipt.js +20 -2
- package/src/graphiti/federated-application.js +5 -1
- package/src/graphiti/project-agent-mapping.js +2 -0
- package/src/graphiti/project-agent-task-entry.js +21 -3
- package/src/graphiti/project-agent-team-context.js +4 -1
- package/src/graphiti/provider-client.js +9 -0
- package/src/graphiti/task-entry-preferences.js +12 -4
- package/src/graphiti/task-entry-project-scope.js +67 -0
- package/dist/web/assets/AgentProfilePage-Cz-i1fRD.js +0 -1
- package/dist/web/assets/AgentsDirectoryPage-BI0EFz6y.js +0 -1
- package/dist/web/assets/KnowledgePage-kn8MqUCO.js +0 -1
- package/dist/web/assets/PersonalProjectsPage-CFNkdHSo.js +0 -1
|
@@ -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
|
|
@@ -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 *,
|
|
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,
|
|
@@ -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 '
|
|
@@ -4,16 +4,21 @@ from .personal_project_access import authorize_personal_project
|
|
|
4
4
|
from .project_agent_context_models import ProjectAgentContextResolution
|
|
5
5
|
from .project_agent_task_models import ProjectAgentTaskSubmit
|
|
6
6
|
from .task_context_temporary_owner import temporary_task_owner
|
|
7
|
+
from .project_agent_delegations import StoreAgentDelegations
|
|
7
8
|
|
|
8
9
|
|
|
9
|
-
class StoreProjectAgentContext:
|
|
10
|
+
class StoreProjectAgentContext(StoreAgentDelegations):
|
|
10
11
|
async def resolve_project_agent_context(self, actor, request):
|
|
11
12
|
self._require_personal()
|
|
12
13
|
space = await self.authorize(actor, request.personal_space_id, 'reader')
|
|
13
14
|
await authorize_personal_project(self, actor, space, request.personal_project_id)
|
|
14
|
-
temporary_owner = await temporary_task_owner(self, actor, request)
|
|
15
|
+
temporary_owner = await temporary_task_owner(self, actor, request) if not request.delegation_token else None
|
|
15
16
|
if temporary_owner is not None:
|
|
16
17
|
return temporary_owner
|
|
18
|
+
policy = await self.get_project_agent_coordination_policy(
|
|
19
|
+
actor, request.personal_space_id, request.personal_project_id,
|
|
20
|
+
)
|
|
21
|
+
project_lead = getattr(policy, 'team_lead_agent_id', None)
|
|
17
22
|
owner = None
|
|
18
23
|
if request.session_id and not request.agent_id:
|
|
19
24
|
rows, _, _ = await self.runtime.driver.execute_query(
|
|
@@ -39,7 +44,8 @@ class StoreProjectAgentContext:
|
|
|
39
44
|
idempotency_key='read-only-task-context',
|
|
40
45
|
title='Resolve task context', objective='Select one existing durable role.',
|
|
41
46
|
work_kind=request.work_kind, required_capabilities=request.required_capabilities,
|
|
42
|
-
lead_agent_id=request.agent_id or owner
|
|
47
|
+
lead_agent_id=request.agent_id or (project_lead if owner else None),
|
|
48
|
+
source_application=request.source_application,
|
|
43
49
|
routing_reason='Read-only task-entry context recovery',
|
|
44
50
|
)
|
|
45
51
|
if owner and not request.agent_id:
|
|
@@ -71,16 +77,58 @@ class StoreProjectAgentContext:
|
|
|
71
77
|
'using project_context for continuity only',
|
|
72
78
|
*basis,
|
|
73
79
|
]
|
|
74
|
-
|
|
80
|
+
requested_agent_id = None
|
|
81
|
+
reporting_lead_agent_id = None
|
|
82
|
+
peer_request = bool(request.agent_id and selected and self._is_implicit_peer(selected[0]))
|
|
83
|
+
delegated = bool(request.delegation_token)
|
|
84
|
+
if delegated and (not selected or selected[0]['agent_id'] != request.agent_id):
|
|
85
|
+
return ProjectAgentContextResolution(status='agent_unavailable', reason='delegation_invalid')
|
|
86
|
+
if not peer_request:
|
|
87
|
+
if not project_lead:
|
|
88
|
+
if delegated:
|
|
89
|
+
return ProjectAgentContextResolution(status='agent_unavailable', reason='delegation_invalid')
|
|
90
|
+
return ProjectAgentContextResolution(
|
|
91
|
+
status='unassigned', reason='project_lead_required',
|
|
92
|
+
requested_agent_id=request.agent_id,
|
|
93
|
+
match_basis=['Every project requires one accountable lead before work begins.'],
|
|
94
|
+
)
|
|
95
|
+
if selected and selected[0]['agent_id'] != project_lead:
|
|
96
|
+
# Validate the named specialist first, then route accountability
|
|
97
|
+
# through the lead. Never load the specialist's private memory here.
|
|
98
|
+
rows = await self._assignment_candidates(request.personal_space_id, request.personal_project_id)
|
|
99
|
+
lead = next((row for row in rows if row['agent_id'] == project_lead
|
|
100
|
+
and (delegated or self._agent_client_allowed(row, request.source_application))), None)
|
|
101
|
+
if not lead:
|
|
102
|
+
return ProjectAgentContextResolution(status='agent_unavailable',
|
|
103
|
+
reason='team_lead_unavailable', match_basis=['The configured project lead is unavailable.'])
|
|
104
|
+
if delegated:
|
|
105
|
+
# A real lead-to-member worker invocation keeps the member's
|
|
106
|
+
# isolated identity; its result returns to the project lead.
|
|
107
|
+
reporting_lead_agent_id = project_lead
|
|
108
|
+
reason = 'delegated_member'
|
|
109
|
+
basis = ['This member worker reports to the configured project lead.']
|
|
110
|
+
else:
|
|
111
|
+
requested_agent_id = request.agent_id
|
|
112
|
+
selected = [lead]
|
|
113
|
+
reason = 'project_lead_reporting'
|
|
114
|
+
basis = ['The project lead owns the task; requested members report results to this lead.']
|
|
115
|
+
if owner and selected and owner == selected[0]['agent_id']:
|
|
75
116
|
reason, basis = 'active_task_owner', ['reuse the owner of this host session task']
|
|
76
117
|
agent = await self.get_project_agent(
|
|
77
118
|
actor, request.personal_space_id, request.personal_project_id,
|
|
78
119
|
selected[0]['agent_id'],
|
|
79
120
|
) if selected else None
|
|
121
|
+
# A reporting lead does not execute in the member's client. Acknowledge
|
|
122
|
+
# the capability only after the executing role passes selection, so a
|
|
123
|
+
# failed resolution cannot be accepted as that member's answer.
|
|
124
|
+
if delegated and (not agent or not await self.redeem_agent_delegation(request, project_lead)):
|
|
125
|
+
return ProjectAgentContextResolution(status='agent_unavailable', reason='delegation_invalid')
|
|
80
126
|
return ProjectAgentContextResolution(
|
|
81
127
|
status='ready' if agent else (
|
|
82
128
|
'manual_selection' if reason == 'manual_agent_selection'
|
|
83
129
|
else 'agent_unavailable' if reason == 'agent_unavailable' else 'unassigned'
|
|
84
130
|
),
|
|
85
131
|
agent=agent, reason=reason, match_basis=basis, candidate_count=len(candidates),
|
|
132
|
+
requested_agent_id=requested_agent_id,
|
|
133
|
+
reporting_lead_agent_id=reporting_lead_agent_id,
|
|
86
134
|
)
|
|
@@ -93,6 +93,8 @@ class StoreProjectAgentCoordinationPolicy:
|
|
|
93
93
|
members = (request.team_member_agent_ids if 'team_member_agent_ids' in request.model_fields_set
|
|
94
94
|
else current.team_member_agent_ids)
|
|
95
95
|
auto_grow = current.auto_grow_team if request.auto_grow_team is None else request.auto_grow_team
|
|
96
|
+
if 'team_lead_agent_id' in request.model_fields_set and not lead:
|
|
97
|
+
raise HTTPException(status_code=422, detail='each project requires a lead; select a replacement instead of clearing the lead')
|
|
96
98
|
if members and not lead:
|
|
97
99
|
raise HTTPException(status_code=422, detail='select a team lead before adding members')
|
|
98
100
|
if lead in members:
|
|
@@ -115,9 +117,21 @@ class StoreProjectAgentCoordinationPolicy:
|
|
|
115
117
|
updated_at = now_utc()
|
|
116
118
|
written, _, _ = await self.runtime.driver.execute_query(
|
|
117
119
|
'''
|
|
118
|
-
MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})-
|
|
120
|
+
MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
|
|
119
121
|
[:CONTAINS_PROJECT]->
|
|
120
122
|
(project:FuliPersonalProject {project_id: $personal_project_id})
|
|
123
|
+
OPTIONAL MATCH (space)-[:HAS_PROJECT_AGENT_IDENTITY]->(role:FuliProjectAgent)
|
|
124
|
+
WHERE role.agent_id IN $team_agent_ids
|
|
125
|
+
WITH project, role ORDER BY role.agent_id
|
|
126
|
+
FOREACH (locked IN CASE WHEN role IS NULL THEN [] ELSE [role] END |
|
|
127
|
+
SET locked._task_lifecycle_lock = true
|
|
128
|
+
REMOVE locked._task_lifecycle_lock)
|
|
129
|
+
WITH project, collect(role) AS roles
|
|
130
|
+
WHERE size(roles) = size($team_agent_ids)
|
|
131
|
+
AND all(role IN roles WHERE role.status = 'active' AND EXISTS {
|
|
132
|
+
MATCH (project)-[:HAS_PROJECT_AGENT_ASSIGNMENT]->
|
|
133
|
+
(:FuliProjectAgentAssignment {status: 'active'})-[:ASSIGNED_AGENT]->(role)
|
|
134
|
+
})
|
|
121
135
|
MERGE (policy:FuliProjectAgentCoordinationPolicy {
|
|
122
136
|
policy_id: $policy_id
|
|
123
137
|
})
|
|
@@ -147,6 +161,7 @@ class StoreProjectAgentCoordinationPolicy:
|
|
|
147
161
|
auto_grow_team=auto_grow,
|
|
148
162
|
team_lead_agent_id=lead,
|
|
149
163
|
team_member_agent_ids=members,
|
|
164
|
+
team_agent_ids=([lead] if lead else []) + members,
|
|
150
165
|
expected_updated_at=current.updated_at,
|
|
151
166
|
updated_at=updated_at,
|
|
152
167
|
)
|
|
@@ -54,14 +54,15 @@ class StoreProjectAgentTaskStaffing:
|
|
|
54
54
|
row for row in assignment_rows
|
|
55
55
|
if self._agent_client_allowed(row, request.source_application)
|
|
56
56
|
]
|
|
57
|
-
if request.staffing_intent in {'new_durable', 'temporary'}:
|
|
57
|
+
if request.staffing_intent in {'new_durable', 'temporary', 'default_lead'}:
|
|
58
58
|
return (
|
|
59
59
|
[],
|
|
60
60
|
rows,
|
|
61
61
|
'explicit_temporary_agent'
|
|
62
62
|
if request.staffing_intent == 'temporary'
|
|
63
63
|
else 'explicit_new_agent',
|
|
64
|
-
['
|
|
64
|
+
['project requires an accountable lead' if request.staffing_intent == 'default_lead'
|
|
65
|
+
else 'user explicitly requested recruitment'],
|
|
65
66
|
)
|
|
66
67
|
policy = await self.get_project_agent_coordination_policy(
|
|
67
68
|
actor,
|
|
@@ -5,6 +5,9 @@ from fastapi import HTTPException
|
|
|
5
5
|
|
|
6
6
|
from .personal_project_access import authorize_personal_project
|
|
7
7
|
from .project_agent_access import authorize_project_agent
|
|
8
|
+
from .project_agent_employee_numbers import (
|
|
9
|
+
ASSIGN_EMPLOYEE_NUMBERS, EMPLOYEE_NUMBER_LOCK, employee_number_label,
|
|
10
|
+
)
|
|
8
11
|
from .project_agent_models import (
|
|
9
12
|
ProjectAgentAssignmentCreate,
|
|
10
13
|
ProjectAgentAssignmentEnd,
|
|
@@ -134,6 +137,7 @@ class StoreProjectAgents:
|
|
|
134
137
|
records, _, _ = await self.runtime.driver.execute_query(
|
|
135
138
|
'''
|
|
136
139
|
MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})
|
|
140
|
+
''' + EMPLOYEE_NUMBER_LOCK + '''
|
|
137
141
|
MERGE (agent:FuliProjectAgent {id: $id})
|
|
138
142
|
ON CREATE SET agent.agent_id = $agent_id,
|
|
139
143
|
agent.created_at = $updated_at,
|
|
@@ -143,7 +147,12 @@ class StoreProjectAgents:
|
|
|
143
147
|
SET agent._profile_upsert_lock = true
|
|
144
148
|
REMOVE agent._profile_upsert_lock
|
|
145
149
|
WITH space, agent
|
|
146
|
-
WHERE agent.status IS NULL OR agent.status <> 'archived'
|
|
150
|
+
WHERE (agent.status IS NULL OR agent.status <> 'archived')
|
|
151
|
+
AND ($status = 'active' OR NOT EXISTS {
|
|
152
|
+
MATCH (space)-[:CONTAINS_PROJECT]->(:FuliPersonalProject)-
|
|
153
|
+
[:HAS_PROJECT_AGENT_COORDINATION_POLICY]->(policy:FuliProjectAgentCoordinationPolicy)
|
|
154
|
+
WHERE policy.team_lead_agent_id = agent.agent_id
|
|
155
|
+
})
|
|
147
156
|
SET agent.profile_json = $profile_json,
|
|
148
157
|
agent.name = $name,
|
|
149
158
|
agent.occupation_emoji = $occupation_emoji,
|
|
@@ -159,6 +168,7 @@ class StoreProjectAgents:
|
|
|
159
168
|
agent.recruitment_id, $recruitment_id),
|
|
160
169
|
agent.updated_at = $updated_at
|
|
161
170
|
MERGE (space)-[:HAS_PROJECT_AGENT_IDENTITY]->(agent)
|
|
171
|
+
''' + ASSIGN_EMPLOYEE_NUMBERS + '''
|
|
162
172
|
RETURN agent
|
|
163
173
|
''',
|
|
164
174
|
personal_space_id=request.personal_space_id,
|
|
@@ -182,7 +192,7 @@ class StoreProjectAgents:
|
|
|
182
192
|
raise HTTPException(
|
|
183
193
|
status_code=409,
|
|
184
194
|
detail=(
|
|
185
|
-
'archived
|
|
195
|
+
'archived Agents require a dedicated restore operation; project leads must hand off before deactivation'
|
|
186
196
|
),
|
|
187
197
|
)
|
|
188
198
|
if request.personal_project_id:
|
|
@@ -420,7 +430,12 @@ class StoreProjectAgents:
|
|
|
420
430
|
'awaiting_recruitment', 'queued', 'running',
|
|
421
431
|
'paused', 'blocked', 'awaiting_review'
|
|
422
432
|
]] AS open_tasks
|
|
423
|
-
WHERE agent.status = 'archived' OR size(open_tasks) = 0
|
|
433
|
+
WHERE (agent.status = 'archived' OR size(open_tasks) = 0)
|
|
434
|
+
AND NOT EXISTS {
|
|
435
|
+
MATCH (space)-[:CONTAINS_PROJECT]->(:FuliPersonalProject)-
|
|
436
|
+
[:HAS_PROJECT_AGENT_COORDINATION_POLICY]->(policy:FuliProjectAgentCoordinationPolicy)
|
|
437
|
+
WHERE policy.team_lead_agent_id = agent.agent_id
|
|
438
|
+
}
|
|
424
439
|
SET agent.status = 'archived',
|
|
425
440
|
agent.archive_reason = coalesce(agent.archive_reason, $reason),
|
|
426
441
|
agent.archived_at = coalesce(agent.archived_at, $updated_at),
|
|
@@ -446,7 +461,7 @@ class StoreProjectAgents:
|
|
|
446
461
|
if not records:
|
|
447
462
|
raise HTTPException(
|
|
448
463
|
status_code=409,
|
|
449
|
-
detail='Agent is missing
|
|
464
|
+
detail='Agent is missing, has non-terminal task work, or is a project lead; hand off before archiving',
|
|
450
465
|
)
|
|
451
466
|
return await self.get_project_agent(
|
|
452
467
|
actor,
|
|
@@ -768,6 +783,11 @@ class StoreProjectAgents:
|
|
|
768
783
|
WITH project, assignment, agent
|
|
769
784
|
WHERE assignment.status = 'active'
|
|
770
785
|
AND coalesce(assignment.revision, 0) = $expected_revision
|
|
786
|
+
AND NOT EXISTS {
|
|
787
|
+
MATCH (project)-[:HAS_PROJECT_AGENT_COORDINATION_POLICY]->
|
|
788
|
+
(policy:FuliProjectAgentCoordinationPolicy)
|
|
789
|
+
WHERE policy.team_lead_agent_id = agent.agent_id
|
|
790
|
+
}
|
|
771
791
|
SET assignment.status = 'ended',
|
|
772
792
|
assignment.end_reason = $reason,
|
|
773
793
|
assignment.ended_at = $updated_at,
|
|
@@ -786,7 +806,7 @@ class StoreProjectAgents:
|
|
|
786
806
|
if not records:
|
|
787
807
|
raise HTTPException(
|
|
788
808
|
status_code=409,
|
|
789
|
-
detail='assignment is not active
|
|
809
|
+
detail='assignment is not active, revision is stale, or project leadership requires a handoff',
|
|
790
810
|
)
|
|
791
811
|
row = records[0]
|
|
792
812
|
return self._assignment(
|
|
@@ -851,6 +871,11 @@ class StoreProjectAgents:
|
|
|
851
871
|
REMOVE lifecycle_agent._task_lifecycle_lock
|
|
852
872
|
WITH project, ended, ended_agent, replacement_agent,
|
|
853
873
|
collect(lifecycle_agent) AS locked_agents
|
|
874
|
+
WHERE ended.status <> 'active' OR NOT EXISTS {
|
|
875
|
+
MATCH (project)-[:HAS_PROJECT_AGENT_COORDINATION_POLICY]->
|
|
876
|
+
(policy:FuliProjectAgentCoordinationPolicy)
|
|
877
|
+
WHERE policy.team_lead_agent_id = ended_agent.agent_id
|
|
878
|
+
}
|
|
854
879
|
OPTIONAL MATCH (existing_replacement:FuliProjectAgentAssignment {
|
|
855
880
|
id: $replacement_id
|
|
856
881
|
})
|
|
@@ -1075,6 +1100,7 @@ class StoreProjectAgents:
|
|
|
1075
1100
|
})
|
|
1076
1101
|
return ProjectAgentRecord(
|
|
1077
1102
|
agent_id=raw['agent_id'],
|
|
1103
|
+
employee_number=employee_number_label(raw.get('employee_number')),
|
|
1078
1104
|
legacy_agent_ids=list(raw.get('legacy_agent_ids') or []),
|
|
1079
1105
|
personal_space_id=personal_space_id,
|
|
1080
1106
|
personal_project_id=projection_project_id,
|
|
@@ -8,6 +8,7 @@ original coordinator reference.
|
|
|
8
8
|
import json
|
|
9
9
|
|
|
10
10
|
from .project_agent_models import ProjectAgentProfile
|
|
11
|
+
from .project_agent_employee_numbers import ASSIGN_EMPLOYEE_NUMBERS, EMPLOYEE_NUMBER_LOCK
|
|
11
12
|
from .provider_values import now_utc, stable_uuid
|
|
12
13
|
|
|
13
14
|
SYSTEM_COORDINATOR_AGENT_ID = 'employee.jefa'
|
|
@@ -75,6 +76,7 @@ async def ensure_system_identity(driver, provider_id, space_id, agent_id):
|
|
|
75
76
|
await driver.execute_query(
|
|
76
77
|
'''
|
|
77
78
|
MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})
|
|
79
|
+
''' + EMPLOYEE_NUMBER_LOCK + '''
|
|
78
80
|
MERGE (agent:FuliProjectAgent {id: $id})
|
|
79
81
|
ON CREATE SET agent.agent_id = $agent_id,
|
|
80
82
|
agent.name = $name,
|
|
@@ -90,7 +92,7 @@ async def ensure_system_identity(driver, provider_id, space_id, agent_id):
|
|
|
90
92
|
agent.status = 'active',
|
|
91
93
|
agent.system_managed = true
|
|
92
94
|
MERGE (space)-[:HAS_PROJECT_AGENT_IDENTITY]->(agent)
|
|
93
|
-
''',
|
|
95
|
+
''' + ASSIGN_EMPLOYEE_NUMBERS + ' RETURN agent',
|
|
94
96
|
space_id=space_id,
|
|
95
97
|
id=node_id,
|
|
96
98
|
agent_id=agent_id,
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fuli-context",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "fuli-context",
|
|
9
|
-
"version": "0.11.
|
|
9
|
+
"version": "0.11.1",
|
|
10
10
|
"license": "Apache-2.0",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@a2a-js/sdk": "1.3.0",
|
package/package.json
CHANGED
|
@@ -10,7 +10,7 @@ const NESTED_ASK_LIMIT = 3;
|
|
|
10
10
|
// Agents talk to each other directly: an ask wakes the recipient in its own
|
|
11
11
|
// client and returns its answer, or waits in its inbox for its next task.
|
|
12
12
|
export function createAgentRoundtable({ app, openStore, wake = wakeAgent, idFactory = randomUUID,
|
|
13
|
-
locateCwd = sessionWorkingDirectory, clientAvailable = (client) => Boolean(resolveClientCommand(client)) }) {
|
|
13
|
+
env = process.env, locateCwd = sessionWorkingDirectory, clientAvailable = (client) => Boolean(resolveClientCommand(client)) }) {
|
|
14
14
|
const spaceId = () => app.config.personal.spaceId;
|
|
15
15
|
// Opened on first use: most MCP processes never touch the roundtable.
|
|
16
16
|
let opened = null;
|
|
@@ -35,6 +35,9 @@ export function createAgentRoundtable({ app, openStore, wake = wakeAgent, idFact
|
|
|
35
35
|
const all = await agents();
|
|
36
36
|
const exact = all.find((agent) => agent.agentId === needle || agent.agentId === decodeURIComponent(needle));
|
|
37
37
|
if (exact) return exact;
|
|
38
|
+
const number = needle.match(/^(?:FLA\s*)?(\d{6,})$/i)?.[1];
|
|
39
|
+
const numbered = number && all.find((agent) => agent.employeeNumber === number);
|
|
40
|
+
if (numbered) return numbered;
|
|
38
41
|
const named = all.filter((agent) => [agent.profile.displayName, agent.profile.name]
|
|
39
42
|
.some((name) => name?.toLowerCase() === needle.toLowerCase()));
|
|
40
43
|
if (named.length === 1) return named[0];
|
|
@@ -53,11 +56,12 @@ export function createAgentRoundtable({ app, openStore, wake = wakeAgent, idFact
|
|
|
53
56
|
|
|
54
57
|
async function findAgents({ query = null, personalProjectId = null } = {}) {
|
|
55
58
|
const needle = query?.trim().toLowerCase();
|
|
56
|
-
const matches = (await agents()).filter((agent) => !needle || [agent.agentId,
|
|
59
|
+
const matches = (await agents()).filter((agent) => !needle || [agent.agentId,
|
|
60
|
+
agent.employeeNumber ? `FLA ${agent.employeeNumber}` : null, agent.profile.displayName,
|
|
57
61
|
agent.profile.name, agent.profile.responsibility, ...(agent.profile.capabilities ?? [])]
|
|
58
62
|
.some((value) => value?.toLowerCase().includes(needle)));
|
|
59
63
|
return { agents: await Promise.all(matches.slice(0, 20).map(async (agent) => ({
|
|
60
|
-
agentId: agent.agentId, name: label(agent), responsibility: agent.profile.responsibility,
|
|
64
|
+
agentId: agent.agentId, employeeNumber: agent.employeeNumber, name: label(agent), responsibility: agent.profile.responsibility,
|
|
61
65
|
clients: (agent.profile.allowedClients ?? []).filter((client) => WAKE_CLIENTS.includes(client)),
|
|
62
66
|
recentConversations: await conversations(agent, personalProjectId)
|
|
63
67
|
}))),
|
|
@@ -107,6 +111,7 @@ export function createAgentRoundtable({ app, openStore, wake = wakeAgent, idFact
|
|
|
107
111
|
to: { agentId: target.agentId, name: label(target) } });
|
|
108
112
|
const nested = history.filter((message) => message.kind === 'ask' && message.status === 'delivering').length;
|
|
109
113
|
if (!wait || nested >= NESTED_ASK_LIMIT) return queued(thread, ask, target, nested ? 'nested_limit' : 'not_waiting');
|
|
114
|
+
if (!taskContextToken || !from.projectId) return queued(thread, ask, target, 'task_context_required');
|
|
110
115
|
const attempts = await deliveryTargets(target, conversation, from.projectId, projectPath);
|
|
111
116
|
if (!attempts.length) return queued(thread, ask, target, 'no_client');
|
|
112
117
|
const prompt = wakePrompt({ thread, ask, target, senderName: ask.from_name, senderClient: from.client });
|
|
@@ -117,8 +122,20 @@ export function createAgentRoundtable({ app, openStore, wake = wakeAgent, idFact
|
|
|
117
122
|
if (unusable.has(delivery.client)) continue;
|
|
118
123
|
store.updateMessage(ask.id, { status: 'delivering', toClient: delivery.client, toSession: delivery.sessionId });
|
|
119
124
|
let answer;
|
|
125
|
+
let grant;
|
|
120
126
|
try {
|
|
121
|
-
|
|
127
|
+
grant = await app.personal.issueAgentDelegation({ personal_space_id: spaceId(),
|
|
128
|
+
task_context_token: taskContextToken, source_application: from.client, agent_id: target.agentId,
|
|
129
|
+
target_application: delivery.client,
|
|
130
|
+
// Claude resumes into a fork with a new ID. Only Codex resumes in place.
|
|
131
|
+
target_session_id: delivery.client === 'codex' ? delivery.sessionId : null,
|
|
132
|
+
lifetime_seconds: timeoutMs / 1000 });
|
|
133
|
+
answer = await wake({ client: delivery.client, sessionId: delivery.sessionId, cwd: delivery.cwd,
|
|
134
|
+
timeoutMs, prompt, delegationToken: grant.token });
|
|
135
|
+
const verified = answer.sessionId && await app.personal.verifyAgentDelegation({
|
|
136
|
+
personal_space_id: spaceId(), token: grant.token, session_id: answer.sessionId });
|
|
137
|
+
if (!verified?.redeemed) throw Object.assign(new Error('The receiving session did not restore the Agent'),
|
|
138
|
+
{ code: 'delegation_not_redeemed' });
|
|
122
139
|
} catch (error) {
|
|
123
140
|
failure = error.code ?? 'wake_failed';
|
|
124
141
|
// A missing or logged-out client cannot answer from any session. A conversation
|
|
@@ -127,6 +144,11 @@ export function createAgentRoundtable({ app, openStore, wake = wakeAgent, idFact
|
|
|
127
144
|
// A timeout already used the caller's wait.
|
|
128
145
|
if (failure === 'wake_timeout') break;
|
|
129
146
|
continue;
|
|
147
|
+
} finally {
|
|
148
|
+
// Even failed or timed-out attempts lose their authority. Expiry remains
|
|
149
|
+
// the fail-closed backstop if the Provider is temporarily unreachable.
|
|
150
|
+
if (grant?.token) await app.personal.revokeAgentDelegation({
|
|
151
|
+
personal_space_id: spaceId(), token: grant.token }).catch(() => {});
|
|
130
152
|
}
|
|
131
153
|
const via = `${delivery.client}:${delivery.sessionId ? 'resume' : 'new'}`;
|
|
132
154
|
store.updateMessage(ask.id, { status: 'answered', via });
|
|
@@ -187,7 +209,14 @@ export function createAgentRoundtable({ app, openStore, wake = wakeAgent, idFact
|
|
|
187
209
|
to: { agentId: message.to_agent, name: message.to_name, client: message.to_client } })) };
|
|
188
210
|
}
|
|
189
211
|
|
|
190
|
-
|
|
212
|
+
function taskEntryDelegation({ sessionId }) {
|
|
213
|
+
// The public thread header is presentation only. The Provider validates this
|
|
214
|
+
// process-local capability, including project, recipient, client and session.
|
|
215
|
+
return env.FULI_ROUNDTABLE_DELEGATION
|
|
216
|
+
? { token: env.FULI_ROUNDTABLE_DELEGATION, sessionId: sessionId ?? null } : null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { findAgents, messageAgent, readMessages, replyMessage, threads, thread, taskEntryDelegation,
|
|
191
220
|
pending: (agentId) => store.pendingFor(spaceId(), agentId).map(inboxItem),
|
|
192
221
|
close: () => opened?.close() };
|
|
193
222
|
}
|