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.
Files changed (77) hide show
  1. package/dist/web/assets/{AboutPage-CKq4IRHd.js → AboutPage-cW9hDghN.js} +1 -1
  2. package/dist/web/assets/{AgentConversations-DfXiFvBN.js → AgentConversations-CGznTWuV.js} +1 -1
  3. package/dist/web/assets/{AgentHand-BBM-sKh2.js → AgentHand-DkyF4vgk.js} +1 -1
  4. package/dist/web/assets/{AgentName-C_6R80nP.js → AgentName-C1mAVr-v.js} +1 -1
  5. package/dist/web/assets/AgentProfilePage-pKUR0rfj.js +1 -0
  6. package/dist/web/assets/AgentsDirectoryPage-CUlbT2hD.js +1 -0
  7. package/dist/web/assets/{ConnectionsPage-BoC6kXgs.js → ConnectionsPage-CuDHIJXu.js} +1 -1
  8. package/dist/web/assets/{EmployeeRecruitDialog-BVyclQAJ.js → EmployeeRecruitDialog-D9QfGipd.js} +1 -1
  9. package/dist/web/assets/{EmployeeWorkbenchPage-D2GvYraI.js → EmployeeWorkbenchPage-DAtvGney.js} +1 -1
  10. package/dist/web/assets/{KnowledgeEditDialog-a0AKWics.js → KnowledgeEditDialog-a-5rasG0.js} +1 -1
  11. package/dist/web/assets/{KnowledgeInspector-DMg9yJZ2.js → KnowledgeInspector-BGsqjNY2.js} +1 -1
  12. package/dist/web/assets/{KnowledgeOrganizerPage-BgsHM3Qw.js → KnowledgeOrganizerPage-BndgUsHS.js} +1 -1
  13. package/dist/web/assets/KnowledgePage-CQweO0YJ.js +1 -0
  14. package/dist/web/assets/{KnowledgeWorkspace-BtRq9fvC.js → KnowledgeWorkspace-BtUjKTLb.js} +1 -1
  15. package/dist/web/assets/{NotFoundPage-D-7jLz3o.js → NotFoundPage-BmQTTqQn.js} +1 -1
  16. package/dist/web/assets/{OverviewPage-BHPc-XxY.js → OverviewPage-C73HyTsM.js} +1 -1
  17. package/dist/web/assets/{PersonalProfilePage-C00iV0Kx.js → PersonalProfilePage-BMuOrppx.js} +1 -1
  18. package/dist/web/assets/PersonalProjectsPage-Cq9_kFvs.js +1 -0
  19. package/dist/web/assets/{ProjectAgentFirstTask-CrCWEYgn.js → ProjectAgentFirstTask-CaHvOjkp.js} +1 -1
  20. package/dist/web/assets/{ProjectAgentsPage-DNLNjYp-.css → ProjectAgentsPage-DWGkgIsr.css} +1 -1
  21. package/dist/web/assets/{ProjectAgentsPage-BzztLMH-.js → ProjectAgentsPage-WVcNdzVi.js} +2 -2
  22. package/dist/web/assets/{ProjectScopePicker-CMxn3txv.js → ProjectScopePicker-zAgiaZKL.js} +1 -1
  23. package/dist/web/assets/{PublicProjectsPage-DQVtvhOS.js → PublicProjectsPage-DL093f65.js} +1 -1
  24. package/dist/web/assets/{ReviewPage-BZhvyDWc.js → ReviewPage-D_NIhPx8.js} +1 -1
  25. package/dist/web/assets/{RoundtablesPage-CVebTXQa.js → RoundtablesPage-CdVVyZE-.js} +1 -1
  26. package/dist/web/assets/{SettingsPage-1knvfOW6.js → SettingsPage-CbLaau8Q.js} +1 -1
  27. package/dist/web/assets/{SharedJefaReportPage-BCVq8u0P.js → SharedJefaReportPage-aygd6wvb.js} +1 -1
  28. package/dist/web/assets/{UiDialog-Da1P10f4.js → UiDialog-Q7fsJUoO.js} +1 -1
  29. package/dist/web/assets/{UiDisclosure-8wE8YuE4.js → UiDisclosure-BS-muK5j.js} +1 -1
  30. package/dist/web/assets/{UiSegmented-BKZXc8QF.js → UiSegmented-CT28bxqw.js} +1 -1
  31. package/dist/web/assets/{UiSelect-D5Xj5Ec4.js → UiSelect-DcGpPQQo.js} +1 -1
  32. package/dist/web/assets/{WritingTastePage-B8-etOBn.js → WritingTastePage-CYlwAFqV.js} +1 -1
  33. package/dist/web/assets/{attention-store-BfxtL9EV.js → attention-store-D-qIFRIb.js} +1 -1
  34. package/dist/web/assets/{catalog-6SN9i3WR.js → catalog-UDv854JN.js} +1 -1
  35. package/dist/web/assets/{client-CUTkF3t4.js → client-DLS5FZBI.js} +1 -1
  36. package/dist/web/assets/{console-CG8tdtSs.js → console-CqzwSo7a.js} +1 -1
  37. package/dist/web/assets/{conversation-launcher-settings-CyZkLJg7.js → conversation-launcher-settings-DRoIz42V.js} +1 -1
  38. package/dist/web/assets/{i18n-CCMR4gDS.js → i18n-b14l2pQA.js} +2 -2
  39. package/dist/web/assets/{identity-C1eN-FSB.js → identity-BWbPmZZf.js} +1 -1
  40. package/dist/web/assets/{index-sLDKI6R-.js → index-D1OHIz4c.js} +3 -3
  41. package/dist/web/assets/{model-l-sAqtW_.js → model-DupZy8Qb.js} +1 -1
  42. package/dist/web/assets/{personal-profile-graph-BD1Y6TDS.js → personal-profile-graph-BPTtMJTZ.js} +1 -1
  43. package/dist/web/assets/{shared-report-B46dxlCR.js → shared-report-BYnLhmCZ.js} +1 -1
  44. package/dist/web/assets/{useAgentRoster-DdxoKDEE.js → useAgentRoster-ep7SpKv1.js} +1 -1
  45. package/dist/web/assets/{useApi-CROJJdhE-DlOiu8lP.js → useApi-CROJJdhE-CEQAEuug.js} +1 -1
  46. package/dist/web/assets/{vue-router-DjqEU4W2.js → vue-router-B6ZjK-Br.js} +1 -1
  47. package/dist/web/index.html +11 -11
  48. package/docs/agent-roundtable.md +17 -1
  49. package/graph-provider/fuli_graph/project_agent_context_models.py +5 -1
  50. package/graph-provider/fuli_graph/project_agent_delegations.py +118 -0
  51. package/graph-provider/fuli_graph/project_agent_employee_numbers.py +41 -0
  52. package/graph-provider/fuli_graph/project_agent_memory_routes.py +13 -0
  53. package/graph-provider/fuli_graph/project_agent_models.py +1 -0
  54. package/graph-provider/fuli_graph/project_agent_team_growth.py +2 -2
  55. package/graph-provider/fuli_graph/runtime.py +7 -0
  56. package/graph-provider/fuli_graph/store_project_agent_context.py +52 -4
  57. package/graph-provider/fuli_graph/store_project_agent_coordination_policy.py +16 -1
  58. package/graph-provider/fuli_graph/store_project_agent_task_staffing.py +3 -2
  59. package/graph-provider/fuli_graph/store_project_agents.py +31 -5
  60. package/graph-provider/fuli_graph/system_agents.py +3 -1
  61. package/graph-provider/pyproject.toml +1 -1
  62. package/npm-shrinkwrap.json +2 -2
  63. package/package.json +1 -1
  64. package/src/agent-roundtable/service.js +34 -5
  65. package/src/agent-roundtable/wake.js +2 -1
  66. package/src/agents/identity-receipt.js +20 -2
  67. package/src/graphiti/federated-application.js +5 -1
  68. package/src/graphiti/project-agent-mapping.js +2 -0
  69. package/src/graphiti/project-agent-task-entry.js +21 -3
  70. package/src/graphiti/project-agent-team-context.js +4 -1
  71. package/src/graphiti/provider-client.js +9 -0
  72. package/src/graphiti/task-entry-preferences.js +12 -4
  73. package/src/graphiti/task-entry-project-scope.js +67 -0
  74. package/dist/web/assets/AgentProfilePage-Cz-i1fRD.js +0 -1
  75. package/dist/web/assets/AgentsDirectoryPage-BI0EFz6y.js +0 -1
  76. package/dist/web/assets/KnowledgePage-kn8MqUCO.js +0 -1
  77. 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 *, enabled AND NOT already_member
37
- AND (needs_lead OR size(members) < 32) AS changed
36
+ WITH *, NOT already_member
37
+ AND (needs_lead OR (enabled AND size(members) < 32)) AS changed
38
38
  FOREACH (_ IN CASE WHEN changed THEN [1] ELSE [] END |
39
39
  SET policy.team_lead_agent_id = CASE WHEN needs_lead
40
40
  THEN agent.agent_id ELSE policy.team_lead_agent_id END,
@@ -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, source_application=request.source_application,
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
- if owner and selected:
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
- ['user explicitly requested recruitment'],
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 Project Agents require a dedicated restore operation'
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 or still has non-terminal task work',
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 or revision is stale',
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,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fuli-graph-provider"
3
- version = "0.11.0"
3
+ version = "0.11.1"
4
4
  description = "Graphiti-backed personal and shared project provider for Fuli."
5
5
  requires-python = ">=3.12"
6
6
  dependencies = [
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "fuli-context",
3
- "version": "0.11.0",
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.0",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fuli-context",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "Local-first collaboration relationship graph for AI agents.",
5
5
  "type": "module",
6
6
  "files": [
@@ -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, agent.profile.displayName,
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
- answer = await wake({ client: delivery.client, sessionId: delivery.sessionId, cwd: delivery.cwd, timeoutMs, prompt });
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
- return { findAgents, messageAgent, readMessages, replyMessage, threads, thread,
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
  }