fuli-context 0.7.10-beta.7 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/web/assets/{AboutPage-EtWPOSpz.js → AboutPage-BjMmpwJB.js} +1 -1
  2. package/dist/web/assets/AgentHand-BfLYPGjg.js +1 -0
  3. package/dist/web/assets/AgentHand-D9sQu2vc.css +1 -0
  4. package/dist/web/assets/{ConnectionsPage-DXkvMuyK.js → ConnectionsPage-BKGxFDos.js} +1 -1
  5. package/dist/web/assets/{EmployeeRecruitDialog-DONxXnd2.js → EmployeeRecruitDialog-D-W043v4.js} +1 -1
  6. package/dist/web/assets/{EmployeeWorkbenchPage-Cd4hd4bf.js → EmployeeWorkbenchPage-HHQPxdqI.js} +3 -3
  7. package/dist/web/assets/{GrowthLoading-CCYPgE2f.js → GrowthLoading-WXCj8b0r.js} +1 -1
  8. package/dist/web/assets/{KnowledgeEditDialog-BUqMfa0a.js → KnowledgeEditDialog-Bkie08ZA.js} +1 -1
  9. package/dist/web/assets/{KnowledgeInspector-DlVPDGP3.js → KnowledgeInspector-Cez-Z_95.js} +1 -1
  10. package/dist/web/assets/{KnowledgeOrganizerPage-DhthZGKZ.js → KnowledgeOrganizerPage-BYmTPA_8.js} +1 -1
  11. package/dist/web/assets/KnowledgePage-Ck7tIBDn.js +1 -0
  12. package/dist/web/assets/{KnowledgeWorkspace-mVFHQcxr.js → KnowledgeWorkspace-DKij2_B4.js} +1 -1
  13. package/dist/web/assets/{NotFoundPage-CCeEdBy9.js → NotFoundPage-HGV-q-bh.js} +1 -1
  14. package/dist/web/assets/{OverviewPage-CnYWoZ3r.js → OverviewPage-DE29_F_Y.js} +1 -1
  15. package/dist/web/assets/PersonalProfilePage-B95DFc7x.js +1 -0
  16. package/dist/web/assets/{PersonalProfilePage-yQRCBude.css → PersonalProfilePage-L8T5xMEC.css} +1 -1
  17. package/dist/web/assets/PersonalProjectsPage-Bt7tFF5Z.js +1 -0
  18. package/dist/web/assets/ProjectAgentsPage-BioDR9OW.css +1 -0
  19. package/dist/web/assets/ProjectAgentsPage-CiiPNe_G.js +12 -0
  20. package/dist/web/assets/{ProjectScopePicker-BcPtIcv8.js → ProjectScopePicker-Cu5PdxF6.js} +1 -1
  21. package/dist/web/assets/{PublicProjectsPage-BmBHfnud.js → PublicProjectsPage-BY4B3_t3.js} +1 -1
  22. package/dist/web/assets/{ReviewPage-tvt2eqhw.js → ReviewPage-C9vCesNu.js} +1 -1
  23. package/dist/web/assets/{SearchableSelect-BjBDBVuZ.js → SearchableSelect-214TvfYg.js} +1 -1
  24. package/dist/web/assets/{SettingsPage-D9mNzBg-.js → SettingsPage-D9TnzcFc.js} +1 -1
  25. package/dist/web/assets/{SharedJefaReportPage-Dn_HBgaa.js → SharedJefaReportPage-CdSGI_OE.js} +1 -1
  26. package/dist/web/assets/WritingTastePage-Bzg6AvnB.css +1 -0
  27. package/dist/web/assets/WritingTastePage-CUE1dU8f.js +1 -0
  28. package/dist/web/assets/{client-v8XKgQji.js → client-Bl4oiW3E.js} +1 -1
  29. package/dist/web/assets/{console-C9HS-zbd.js → console-BAIVNC-N.js} +1 -1
  30. package/dist/web/assets/{conversation-launcher-settings-DMDCpf_N.js → conversation-launcher-settings-DpRI_Jp2.js} +1 -1
  31. package/dist/web/assets/{i18n-BkJjUsz_.js → i18n-CVFADb_M.js} +2 -2
  32. package/dist/web/assets/{identity-74yGiMzO.js → identity-Dt0WZgqo.js} +1 -1
  33. package/dist/web/assets/index-BUMq5zNh.js +2 -0
  34. package/dist/web/assets/index-C0ci8dnU.css +1 -0
  35. package/dist/web/assets/{model-86tZgY0r.js → model-B8sOxih0.js} +1 -1
  36. package/dist/web/assets/personal-profile-graph-aAqex7ty.js +1 -0
  37. package/dist/web/assets/{shared-report-CLg82y4D.js → shared-report-BQD6l_cS.js} +1 -1
  38. package/dist/web/assets/{useApi-CROJJdhE-BBUO_YEu.js → useApi-CROJJdhE-CuJ16v2S.js} +1 -1
  39. package/dist/web/assets/{useModalDialog-DrLhqtjR.js → useModalDialog-Ddv2XJQi.js} +1 -1
  40. package/dist/web/assets/{vue-router-Cw4QZxM1.js → vue-router-C8KuEZ6a.js} +1 -1
  41. package/dist/web/index.html +11 -11
  42. package/docs/project-agent-memory.md +69 -449
  43. package/graph-provider/fuli_graph/app.py +2 -0
  44. package/graph-provider/fuli_graph/preference_conflicts.py +4 -1
  45. package/graph-provider/fuli_graph/project_agent_coordination_models.py +23 -1
  46. package/graph-provider/fuli_graph/project_agent_memory_models.py +11 -0
  47. package/graph-provider/fuli_graph/project_agent_team_growth.py +56 -0
  48. package/graph-provider/fuli_graph/project_agent_work_log.py +34 -0
  49. package/graph-provider/fuli_graph/store_project_agent_coordination_policy.py +56 -2
  50. package/graph-provider/fuli_graph/store_project_agent_memory.py +3 -0
  51. package/graph-provider/fuli_graph/store_project_agent_task_activity.py +1 -1
  52. package/graph-provider/fuli_graph/store_project_agent_task_recruitment.py +2 -0
  53. package/graph-provider/fuli_graph/store_project_agent_task_staffing.py +17 -2
  54. package/graph-provider/fuli_graph/store_project_agent_tasks.py +1 -0
  55. package/graph-provider/fuli_graph/store_task_contexts.py +11 -1
  56. package/graph-provider/fuli_graph/task_context_adoption.py +65 -0
  57. package/graph-provider/fuli_graph/task_context_models.py +23 -1
  58. package/graph-provider/fuli_graph/task_context_routes.py +5 -1
  59. package/graph-provider/pyproject.toml +1 -1
  60. package/npm-shrinkwrap.json +2 -2
  61. package/package.json +1 -1
  62. package/src/agent-tools/graph-definitions.js +14 -5
  63. package/src/agent-tools/project-agent-definitions.js +1 -0
  64. package/src/agents/codex/lifecycle-hook.js +1 -0
  65. package/src/cli/setup-command.js +1 -1
  66. package/src/graphiti/agent-knowledge-workflows.js +13 -4
  67. package/src/graphiti/project-agent-control-plane.js +9 -0
  68. package/src/graphiti/project-agent-mapping.js +12 -1
  69. package/src/graphiti/project-agent-memory.js +10 -2
  70. package/src/graphiti/project-agent-task-entry.js +4 -1
  71. package/src/graphiti/project-agent-team-context.js +21 -0
  72. package/src/graphiti/project-agent-workflows.js +18 -0
  73. package/src/graphiti/provider-client.js +7 -1
  74. package/src/graphiti/writing-taste-profile-workflow.js +62 -9
  75. package/src/graphiti/writing-taste-profile.js +43 -16
  76. package/src/http/graph-api-router.js +9 -0
  77. package/src/mcp/create-mcp-server.js +6 -4
  78. package/src/mcp/instructions.js +3 -3
  79. package/src/mcp/provider-task-context-registry.js +11 -1
  80. package/src/mcp/task-context-registry.js +2 -1
  81. package/src/mcp/tool-result.js +7 -3
  82. package/dist/web/assets/AgentHand-D5pOgVjJ.js +0 -1
  83. package/dist/web/assets/AgentHand-DvdNtJ3_.css +0 -1
  84. package/dist/web/assets/KnowledgePage-B6RDVxq-.js +0 -1
  85. package/dist/web/assets/PersonalProfilePage-Bz4zpTPC.js +0 -1
  86. package/dist/web/assets/PersonalProjectsPage-2u1o6dvA.js +0 -1
  87. package/dist/web/assets/ProjectAgentsPage-8tci631q.js +0 -11
  88. package/dist/web/assets/ProjectAgentsPage-BN4k3ubu.css +0 -1
  89. package/dist/web/assets/WritingTastePage-BCwimXC-.js +0 -1
  90. package/dist/web/assets/WritingTastePage-tb_pgude.css +0 -1
  91. package/dist/web/assets/index-CwjxeHGk.css +0 -1
  92. package/dist/web/assets/index-Dioqmmvf.js +0 -2
@@ -1,6 +1,6 @@
1
1
  from datetime import datetime
2
2
 
3
- from pydantic import Field
3
+ from pydantic import Field, field_validator, model_validator
4
4
 
5
5
  from .models import StrictModel
6
6
 
@@ -12,6 +12,9 @@ class ProjectAgentCoordinationPolicyRecord(StrictModel):
12
12
  personal_project_id: str
13
13
  ask_before_recruitment: bool = True
14
14
  auto_reuse_previous_agent: bool = True
15
+ auto_grow_team: bool = True
16
+ team_lead_agent_id: str | None = None
17
+ team_member_agent_ids: list[str] = Field(default_factory=list)
15
18
  updated_at: datetime | None = None
16
19
 
17
20
 
@@ -20,3 +23,22 @@ class ProjectAgentCoordinationPolicyUpdate(StrictModel):
20
23
  personal_project_id: str = Field(min_length=1, max_length=128)
21
24
  ask_before_recruitment: bool = True
22
25
  auto_reuse_previous_agent: bool = True
26
+ auto_grow_team: bool | None = None
27
+ team_lead_agent_id: str | None = Field(default=None, min_length=1, max_length=128)
28
+ team_member_agent_ids: list[str] = Field(default_factory=list, max_length=32)
29
+ expected_updated_at: datetime | None = None
30
+
31
+ @field_validator('team_member_agent_ids')
32
+ @classmethod
33
+ def validate_members(cls, value):
34
+ if any(not item.strip() or len(item) > 128 for item in value):
35
+ raise ValueError('team member IDs must contain 1 to 128 characters')
36
+ if len(set(value)) != len(value):
37
+ raise ValueError('team member IDs must be unique')
38
+ return value
39
+
40
+ @model_validator(mode='after')
41
+ def validate_team(self):
42
+ if self.team_lead_agent_id in self.team_member_agent_ids:
43
+ raise ValueError('the team lead cannot also be a team member')
44
+ return self
@@ -65,6 +65,16 @@ class ProjectAgentMemoryRecord(StrictModel):
65
65
  created_at: datetime
66
66
 
67
67
 
68
+ class EmployeeWorkLogRecord(StrictModel):
69
+ task_context_token: str
70
+ source_application: SourceApplication
71
+ session_id: str
72
+ created_at: datetime
73
+ status: Literal['reported', 'completed', 'incomplete', 'failed', 'no_change', 'unreported', 'running']
74
+ summary: str = Field(min_length=1, max_length=2000)
75
+ memory_updated: bool
76
+
77
+
68
78
  class ProjectAgentMemoryView(StrictModel):
69
79
  personal_space_id: str
70
80
  personal_project_id: str
@@ -77,3 +87,4 @@ class ProjectAgentMemoryView(StrictModel):
77
87
  revision: int = Field(default=0, ge=0)
78
88
  current: ProjectAgentMemoryRecord | None = None
79
89
  history: list[ProjectAgentMemoryRecord] = Field(default_factory=list)
90
+ work_log: list[EmployeeWorkLogRecord] = Field(default_factory=list, max_length=6)
@@ -0,0 +1,56 @@
1
+ """Grow a project team inside the recruitment transaction, without changing authority."""
2
+
3
+ from fastapi import HTTPException
4
+
5
+ from .provider_values import now_utc
6
+
7
+
8
+ async def enroll_recruited_team_member(store, recruitment, profile):
9
+ # Temporary workers and system employees do not become permanent teammates.
10
+ if (profile.agent_type != 'durable' or profile.status != 'active'
11
+ or any(cap.startswith('fuli.employee:') for cap in profile.capabilities)):
12
+ return
13
+ space_id = recruitment['personal_space_id']
14
+ project_id = recruitment['personal_project_id']
15
+ rows, _, _ = await store.runtime.driver.execute_query(
16
+ '''
17
+ MATCH (:FuliSpace {id: $space_id})-[:CONTAINS_PROJECT]->
18
+ (project:FuliPersonalProject {project_id: $project_id})
19
+ MATCH (project)-[:HAS_PROJECT_AGENT_ASSIGNMENT]->
20
+ (:FuliProjectAgentAssignment {status: 'active'})-[:ASSIGNED_AGENT]->
21
+ (agent:FuliProjectAgent {agent_id: $agent_id, status: 'active'})
22
+ WITH DISTINCT project, agent
23
+ MERGE (policy:FuliProjectAgentCoordinationPolicy {policy_id: $policy_id})
24
+ ON CREATE SET policy.personal_space_id = $space_id,
25
+ policy.personal_project_id = $project_id,
26
+ policy.created_at = $updated_at
27
+ SET policy._fuli_write_lock = true
28
+ REMOVE policy._fuli_write_lock
29
+ MERGE (project)-[:HAS_PROJECT_AGENT_COORDINATION_POLICY]->(policy)
30
+ WITH policy, agent, coalesce(policy.auto_grow_team, true) AS enabled,
31
+ coalesce(policy.team_member_agent_ids, []) AS members
32
+ WITH policy, agent, members, enabled,
33
+ policy.team_lead_agent_id IS NULL AS needs_lead,
34
+ coalesce(policy.team_lead_agent_id = agent.agent_id, false)
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
38
+ FOREACH (_ IN CASE WHEN changed THEN [1] ELSE [] END |
39
+ SET policy.team_lead_agent_id = CASE WHEN needs_lead
40
+ THEN agent.agent_id ELSE policy.team_lead_agent_id END,
41
+ policy.team_member_agent_ids = CASE WHEN needs_lead
42
+ THEN members ELSE members + agent.agent_id END,
43
+ policy.updated_at = $updated_at
44
+ )
45
+ RETURN enabled AND NOT already_member AND NOT needs_lead
46
+ AND size(members) >= 32 AS full
47
+ ''',
48
+ space_id=space_id, project_id=project_id,
49
+ agent_id=recruitment['proposed_agent_id'],
50
+ policy_id=store._project_agent_coordination_policy_id(space_id, project_id),
51
+ updated_at=now_utc(),
52
+ )
53
+ if not rows:
54
+ raise HTTPException(409, 'recruited Agent has no active project assignment')
55
+ if rows[0]['full']:
56
+ raise HTTPException(409, 'project team is full; change the team before recruiting')
@@ -0,0 +1,34 @@
1
+ """Recover bounded employee work records independently of distilled memory updates."""
2
+
3
+ import json
4
+
5
+
6
+ async def read_employee_work_log(store, space_id, project_id, agent_id):
7
+ rows, _, _ = await store.runtime.driver.execute_query(
8
+ '''
9
+ MATCH (:FuliSpace {id: $space_id})-[:HAS_TASK_CONTEXT_SESSION]->
10
+ (session:FuliTaskContextSession)-[:HAS_CONTEXT]->(task:FuliTaskContext)
11
+ WHERE task.personal_project_id = $project_id AND task.project_agent_id = $agent_id
12
+ RETURN task.record_json AS record_json, task.completed AS completed,
13
+ task.agent_memory_claimed AS memory_updated,
14
+ task.token = session.current_token AS current
15
+ ORDER BY task.created_at DESC, task.token LIMIT 6
16
+ ''', space_id=space_id, project_id=project_id, agent_id=agent_id, routing_='r',
17
+ )
18
+ result = []
19
+ for row in rows:
20
+ record = json.loads(row['record_json'])
21
+ checkpoint = record.get('checkpoint') or {}
22
+ log = checkpoint.get('work_log') or {}
23
+ result.append({
24
+ 'task_context_token': record['token'],
25
+ 'source_application': record['source_application'],
26
+ 'session_id': record['session_id'],
27
+ 'created_at': record['created_at'],
28
+ 'status': (log.get('status', 'unreported') if row['completed']
29
+ else 'running' if row.get('current') else 'incomplete'),
30
+ 'summary': log.get('summary') or checkpoint.get('reason')
31
+ or 'The employee has not submitted a work summary for this task.',
32
+ 'memory_updated': bool(row.get('memory_updated')),
33
+ })
34
+ return result
@@ -1,5 +1,7 @@
1
1
  """Project-local Agent continuity and recruitment authorization."""
2
2
 
3
+ from fastapi import HTTPException
4
+
3
5
  from .personal_project_access import authorize_personal_project
4
6
  from .project_agent_coordination_models import (
5
7
  ProjectAgentCoordinationPolicyRecord,
@@ -9,7 +11,7 @@ from .provider_values import native_datetime, now_utc, stable_uuid
9
11
 
10
12
 
11
13
  class StoreProjectAgentCoordinationPolicy:
12
- """Persist the two user-facing project Agent automation switches."""
14
+ """Persist project automation and a stable team without absorbing peer roles."""
13
15
 
14
16
  async def get_project_agent_coordination_policy(
15
17
  self,
@@ -53,6 +55,9 @@ class StoreProjectAgentCoordinationPolicy:
53
55
  'auto_reuse_previous_agent',
54
56
  True,
55
57
  ),
58
+ team_lead_agent_id=raw.get('team_lead_agent_id'),
59
+ auto_grow_team=raw.get('auto_grow_team', True),
60
+ team_member_agent_ids=raw.get('team_member_agent_ids') or [],
56
61
  updated_at=native_datetime(raw.get('updated_at')),
57
62
  )
58
63
 
@@ -77,8 +82,38 @@ class StoreProjectAgentCoordinationPolicy:
77
82
  request.personal_space_id,
78
83
  request.personal_project_id,
79
84
  )
85
+ current = await self.get_project_agent_coordination_policy(
86
+ actor, request.personal_space_id, request.personal_project_id,
87
+ )
88
+ if ('expected_updated_at' in request.model_fields_set
89
+ and request.expected_updated_at != current.updated_at):
90
+ raise HTTPException(status_code=409, detail='coordination policy changed; reload before saving')
91
+ lead = (request.team_lead_agent_id if 'team_lead_agent_id' in request.model_fields_set
92
+ else current.team_lead_agent_id)
93
+ members = (request.team_member_agent_ids if 'team_member_agent_ids' in request.model_fields_set
94
+ else current.team_member_agent_ids)
95
+ auto_grow = current.auto_grow_team if request.auto_grow_team is None else request.auto_grow_team
96
+ if members and not lead:
97
+ raise HTTPException(status_code=422, detail='select a team lead before adding members')
98
+ if lead in members:
99
+ raise HTTPException(status_code=422, detail='the team lead cannot also be a team member')
100
+ if 'team_lead_agent_id' in request.model_fields_set or 'team_member_agent_ids' in request.model_fields_set:
101
+ for agent_id in ([lead] if lead else []) + members:
102
+ agent = await self.get_project_agent(
103
+ actor, request.personal_space_id, request.personal_project_id, agent_id,
104
+ )
105
+ profile = agent.profile
106
+ active_assignment = any(
107
+ assignment.status == 'active'
108
+ and assignment.personal_project_id == request.personal_project_id
109
+ for assignment in agent.assignments
110
+ )
111
+ if (profile.status != 'active' or not active_assignment
112
+ or profile.agent_type != 'durable'
113
+ or any(cap.startswith('fuli.employee:') for cap in profile.capabilities)):
114
+ raise HTTPException(status_code=422, detail='team roles require active durable project assignments; HR and project managers remain peers')
80
115
  updated_at = now_utc()
81
- await self.runtime.driver.execute_query(
116
+ written, _, _ = await self.runtime.driver.execute_query(
82
117
  '''
83
118
  MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})-
84
119
  [:CONTAINS_PROJECT]->
@@ -89,23 +124,42 @@ class StoreProjectAgentCoordinationPolicy:
89
124
  ON CREATE SET policy.personal_space_id = $personal_space_id,
90
125
  policy.personal_project_id = $personal_project_id,
91
126
  policy.created_at = $updated_at
127
+ SET policy._fuli_write_lock = true
128
+ REMOVE policy._fuli_write_lock
129
+ WITH project, policy
130
+ WHERE ($expected_updated_at IS NULL AND policy.updated_at IS NULL)
131
+ OR (policy.updated_at.epochSeconds = $expected_updated_at.epochSeconds
132
+ AND policy.updated_at.nanosecond = $expected_updated_at.nanosecond)
92
133
  SET policy.ask_before_recruitment = $ask_before_recruitment,
93
134
  policy.auto_reuse_previous_agent = $auto_reuse_previous_agent,
135
+ policy.auto_grow_team = $auto_grow_team,
136
+ policy.team_lead_agent_id = $team_lead_agent_id,
137
+ policy.team_member_agent_ids = $team_member_agent_ids,
94
138
  policy.updated_at = $updated_at
95
139
  MERGE (project)-[:HAS_PROJECT_AGENT_COORDINATION_POLICY]->(policy)
140
+ RETURN policy
96
141
  ''',
97
142
  policy_id=policy_id,
98
143
  personal_space_id=request.personal_space_id,
99
144
  personal_project_id=request.personal_project_id,
100
145
  ask_before_recruitment=request.ask_before_recruitment,
101
146
  auto_reuse_previous_agent=request.auto_reuse_previous_agent,
147
+ auto_grow_team=auto_grow,
148
+ team_lead_agent_id=lead,
149
+ team_member_agent_ids=members,
150
+ expected_updated_at=current.updated_at,
102
151
  updated_at=updated_at,
103
152
  )
153
+ if not written:
154
+ raise HTTPException(status_code=409, detail='coordination policy changed; reload before saving')
104
155
  return ProjectAgentCoordinationPolicyRecord(
105
156
  personal_space_id=request.personal_space_id,
106
157
  personal_project_id=request.personal_project_id,
107
158
  ask_before_recruitment=request.ask_before_recruitment,
108
159
  auto_reuse_previous_agent=request.auto_reuse_previous_agent,
160
+ auto_grow_team=auto_grow,
161
+ team_lead_agent_id=lead,
162
+ team_member_agent_ids=members,
109
163
  updated_at=updated_at,
110
164
  )
111
165
 
@@ -16,6 +16,7 @@ from .project_agent_memory_models import (
16
16
  ProjectAgentMemoryWrite,
17
17
  )
18
18
  from .provider_values import now_utc, stable_uuid
19
+ from .project_agent_work_log import read_employee_work_log
19
20
  from .system_hr_identity import LEGACY_HR_AGENT_ID, SYSTEM_HR_AGENT_ID, resolve_hr_alias
20
21
 
21
22
 
@@ -53,6 +54,8 @@ class StoreProjectAgentMemory:
53
54
  revision=current.revision if current else 0,
54
55
  current=current,
55
56
  history=history,
57
+ work_log=await read_employee_work_log(self, personal_space_id,
58
+ personal_project_id, agent['agent_id']),
56
59
  )
57
60
 
58
61
  async def write_project_agent_memory(
@@ -266,7 +266,7 @@ class StoreProjectAgentTaskActivity:
266
266
  THEN [1] ELSE [] END |
267
267
  SET task.status = $status,
268
268
  task.result_summary = CASE
269
- WHEN $status = 'completed' THEN $summary
269
+ WHEN $status IN ['completed', 'awaiting_review', 'failed', 'blocked', 'cancelled'] THEN $summary
270
270
  ELSE task.result_summary END,
271
271
  task.failure_reason = CASE
272
272
  WHEN $status = 'failed' THEN $summary
@@ -26,6 +26,7 @@ from .project_agent_task_models import (
26
26
  from .provider_values import native_datetime, now_utc, stable_uuid
27
27
  from .store_project_agents import SYSTEM_HR_AGENT_ID
28
28
  from .store_transactions import query_store_transaction
29
+ from .project_agent_team_growth import enroll_recruited_team_member
29
30
 
30
31
 
31
32
  RECRUITMENT_CLAIM_TTL = timedelta(minutes=2)
@@ -1236,6 +1237,7 @@ class StoreProjectAgentTaskRecruitment:
1236
1237
  include_temporary=True,
1237
1238
  )
1238
1239
  selected = next(item for item in assignment_rows if item['agent_id'] == agent_id)
1240
+ await enroll_recruited_team_member(self, raw, profile)
1239
1241
  return selected
1240
1242
 
1241
1243
  async def _get_recruitment(self, personal_space_id, recruitment_id):
@@ -62,6 +62,18 @@ class StoreProjectAgentTaskStaffing:
62
62
  return [], rows, 'manual_agent_selection', [
63
63
  'project policy requires an explicit @Agent selection',
64
64
  ]
65
+ team_lead = getattr(policy, 'team_lead_agent_id', None)
66
+ if team_lead:
67
+ lead = next((row for row in rows if row['agent_id'] == team_lead), None)
68
+ if lead is None:
69
+ return [], rows, 'agent_unavailable', ['configured team lead is unavailable; select or replace the lead']
70
+ member_ids = set(getattr(policy, 'team_member_agent_ids', []))
71
+ members = [row for row in rows if row['agent_id'] in member_ids]
72
+ return [lead], [lead, *members], 'project_team_lead', [
73
+ 'project team lead is the stable conversation owner',
74
+ f'{len(members)} active team member(s) available for explicit workstreams',
75
+ 'HR and project manager remain peer roles',
76
+ ]
65
77
  normalized_work_kind = request.work_kind.casefold()
66
78
  required = {item.casefold() for item in request.required_capabilities}
67
79
  eligible = [row for row in rows if required.issubset({
@@ -506,16 +518,19 @@ class StoreProjectAgentTaskStaffing:
506
518
  request,
507
519
  candidates,
508
520
  participants,
521
+ *,
522
+ team_only=False,
509
523
  ):
510
524
  capacity = len(request.parallel_plan.workstream_boundaries) - len(participants)
511
525
  if capacity <= 0:
512
526
  return []
513
527
  pool = list(candidates)
514
528
  known_ids = {item['agent_id'] for item in pool}
515
- for row in await self._assignment_candidates(
529
+ assignments = [] if team_only else await self._assignment_candidates(
516
530
  request.personal_space_id,
517
531
  request.personal_project_id,
518
- ):
532
+ )
533
+ for row in assignments:
519
534
  if row['agent_id'] not in known_ids:
520
535
  pool.append(row)
521
536
  known_ids.add(row['agent_id'])
@@ -147,6 +147,7 @@ class StoreProjectAgentTasks(
147
147
  request,
148
148
  candidates,
149
149
  participants,
150
+ **({'team_only': True} if reason == 'project_team_lead' else {}),
150
151
  )
151
152
  candidate_ids = {item['agent_id'] for item in candidates}
152
153
  candidates.extend(
@@ -14,9 +14,13 @@ from .project_agent_access import authorize_project_agent
14
14
  from .provider_values import now_utc, stable_uuid
15
15
  from .project_agent_memory_models import ProjectAgentMemoryWrite
16
16
  from .store_transactions import query_store_transaction
17
+ from .task_context_adoption import adopt_task_agent
17
18
 
18
19
 
19
20
  class StoreTaskContexts:
21
+ async def adopt_task_context_agent(self, actor, token, request):
22
+ return await adopt_task_agent(self, actor, token, request)
23
+
20
24
  async def begin_task_context(self, actor, request):
21
25
  self._require_personal()
22
26
  space = await self.authorize(actor, request.personal_space_id, 'maintainer')
@@ -133,6 +137,8 @@ class StoreTaskContexts:
133
137
  record = await self.get_task_context(
134
138
  actor, request.personal_space_id, token, request.source_application,
135
139
  )
140
+ if record.get('work_log_required') and request.work_log is None:
141
+ raise HTTPException(422, 'Employee work summary is required before checkpointing')
136
142
  checkpoint = request.model_dump(mode='json', exclude={
137
143
  'personal_space_id', 'source_application', 'agent_memory',
138
144
  })
@@ -163,12 +169,14 @@ class StoreTaskContexts:
163
169
  WITH task, first_claim OR (
164
170
  task.fingerprint = $fingerprint AND task.disposition = $disposition
165
171
  AND task.reason = $reason
172
+ AND coalesce(task.work_log_json, 'null') = $work_log_json
166
173
  AND (NOT $is_prepare OR memory_claimed = $has_agent_memory)
167
174
  ) AS matches,
168
175
  CASE WHEN first_claim THEN $has_agent_memory ELSE memory_claimed END AS memory_claimed
169
176
  FOREACH (ignored IN CASE WHEN matches AND NOT task.completed THEN [1] ELSE [] END |
170
177
  SET task.fingerprint = $fingerprint, task.record_json = $record_json,
171
178
  task.agent_memory_claimed = memory_claimed,
179
+ task.work_log_json = $work_log_json,
172
180
  task.completed = $completed, task.disposition = $disposition, task.reason = $reason
173
181
  )
174
182
  RETURN matches, task.record_json AS record_json
@@ -178,6 +186,8 @@ class StoreTaskContexts:
178
186
  completed=request.phase == 'complete',
179
187
  is_prepare=request.phase == 'prepare',
180
188
  has_agent_memory=request.agent_memory is not None,
189
+ work_log_json=json.dumps(request.work_log.model_dump() if request.work_log else None,
190
+ sort_keys=True, ensure_ascii=False),
181
191
  memory_checkpoint_id=memory_checkpoint_id,
182
192
  )
183
193
  if not rows or not rows[0]['matches']:
@@ -208,7 +218,7 @@ class StoreTaskContexts:
208
218
  'task_context_token': record['token'],
209
219
  'reason': f'FULI_CHECKPOINT_REQUIRED: {record["token"]} '
210
220
  'Before finishing, call checkpoint_task_knowledge using this task_context_token '
211
- 'with capture_candidates or retain_nothing; include agentMemory when durable '
221
+ 'with capture_candidates or retain_nothing and workLog (summary and truthful status); include agentMemory when durable '
212
222
  'role context changed. Never store raw transcripts or credentials.',
213
223
  }
214
224
 
@@ -0,0 +1,65 @@
1
+ """Claim an initially unassigned task after recruitment, never replace an owner."""
2
+
3
+ import json
4
+
5
+ from fastapi import HTTPException
6
+ from .project_agent_access import authorize_project_agent
7
+
8
+
9
+ async def adopt_task_agent(store, actor, token, request):
10
+ space = await store.authorize(actor, request.personal_space_id, 'maintainer')
11
+ record = await store.get_task_context(actor, request.personal_space_id, token,
12
+ request.source_application)
13
+ if record['personal_project_id'] != request.personal_project_id:
14
+ raise HTTPException(409, 'Task context belongs to another project')
15
+ if record.get('project_agent_id'):
16
+ if record['project_agent_id'] == request.agent_id:
17
+ return record
18
+ raise HTTPException(409, 'Task context already has an employee')
19
+ before = json.dumps(record, ensure_ascii=False)
20
+ agent = await authorize_project_agent(store, actor, space, request.personal_project_id,
21
+ request.agent_id, require_active=True, require_memory=True)
22
+ if request.source_application not in json.loads(agent['profile_json']).get('allowed_clients', []):
23
+ raise HTTPException(403, 'Agent is not allowed in this client')
24
+ memory = await store.get_project_agent_memory(actor, request.personal_space_id,
25
+ request.personal_project_id, request.agent_id)
26
+ record.update(project_agent_id=request.agent_id, memory_revision=memory.revision,
27
+ work_log_required=True)
28
+ rows, _, _ = await store.runtime.driver.execute_query(
29
+ '''
30
+ MATCH (space:FuliSpace {id: $space_id})-[:HAS_TASK_CONTEXT_SESSION]->
31
+ (session:FuliTaskContextSession)-[:HAS_CONTEXT]->(context:FuliTaskContext {token: $token})
32
+ MATCH (space)-[:HAS_PROJECT_AGENT_TASK]->(task:FuliProjectAgentTask {
33
+ task_id: $task_id, personal_project_id: $project_id, lead_agent_id: $agent_id})
34
+ SET session.write_serial = coalesce(session.write_serial, 0) + 1
35
+ WITH context, session, task
36
+ SET task._task_lifecycle_lock = true
37
+ REMOVE task._task_lifecycle_lock
38
+ WITH context, session, task
39
+ MATCH (task)-[:HAS_PARTICIPANT]->(agent:FuliProjectAgent {agent_id: $agent_id, status: 'active'})
40
+ SET agent._task_lifecycle_lock = true
41
+ REMOVE agent._task_lifecycle_lock
42
+ WITH context, session, agent, task
43
+ WHERE context.token = session.current_token AND context.project_agent_id IS NULL
44
+ AND context.fingerprint IS NULL AND NOT context.completed
45
+ AND context.record_json = $before
46
+ AND agent.profile_json = $profile_json
47
+ AND task.status IN ['queued', 'running', 'paused', 'blocked', 'awaiting_recruitment']
48
+ AND EXISTS {
49
+ MATCH (:FuliSpace {id: $space_id})-[:CONTAINS_PROJECT]->
50
+ (:FuliPersonalProject {project_id: $project_id})-[:HAS_PROJECT_AGENT_ASSIGNMENT]->
51
+ (:FuliProjectAgentAssignment {status: 'active'})-[:ASSIGNED_AGENT]->(agent)
52
+ }
53
+ SET context.project_agent_id = agent.agent_id, context.record_json = $record_json
54
+ RETURN context.record_json AS record_json
55
+ ''', space_id=request.personal_space_id, project_id=request.personal_project_id,
56
+ task_id=request.task_id, agent_id=request.agent_id, token=token,
57
+ before=before, record_json=json.dumps(record, ensure_ascii=False), profile_json=agent['profile_json'],
58
+ )
59
+ if not rows:
60
+ latest = await store.get_task_context(actor, request.personal_space_id, token,
61
+ request.source_application)
62
+ if latest.get('project_agent_id') == request.agent_id:
63
+ return latest
64
+ raise HTTPException(409, 'Task employee claim changed, completed, or is not the routed lead')
65
+ return json.loads(rows[0]['record_json'])
@@ -2,7 +2,7 @@
2
2
 
3
3
  from typing import Literal
4
4
 
5
- from pydantic import Field, model_validator
5
+ from pydantic import Field, field_validator, model_validator
6
6
 
7
7
  from .models import SourceApplication, StrictModel
8
8
  from .model_validation import reject_credentials
@@ -19,6 +19,7 @@ class TaskContextBegin(StrictModel):
19
19
  token: str = Field(pattern=r'^fuli-task-[a-zA-Z0-9-]{8,128}$')
20
20
  turn_id: str | None = Field(default=None, min_length=1, max_length=256)
21
21
  memory_revision: int | None = Field(default=None, ge=0)
22
+ work_log_required: bool = False
22
23
 
23
24
  @model_validator(mode='after')
24
25
  def require_project_for_agent(self):
@@ -32,6 +33,26 @@ class TaskContextAgentMemory(StrictModel):
32
33
  memory: ProjectAgentWorkingMemory
33
34
 
34
35
 
36
+ class TaskContextAdoptAgent(StrictModel):
37
+ personal_space_id: str = Field(min_length=1, max_length=128)
38
+ personal_project_id: str = Field(min_length=1, max_length=128)
39
+ source_application: SourceApplication
40
+ task_id: str = Field(min_length=1, max_length=128)
41
+ agent_id: str = Field(min_length=1, max_length=128)
42
+
43
+
44
+ class TaskWorkLog(StrictModel):
45
+ status: Literal['reported', 'completed', 'incomplete', 'failed', 'no_change', 'unreported']
46
+ summary: str = Field(min_length=1, max_length=2000)
47
+
48
+ @field_validator('summary')
49
+ @classmethod
50
+ def require_summary(cls, value):
51
+ if not value.strip():
52
+ raise ValueError('Work summary must not be blank')
53
+ return value.strip()
54
+
55
+
35
56
  class TaskContextCheckpoint(StrictModel):
36
57
  personal_space_id: str = Field(min_length=1, max_length=128)
37
58
  source_application: SourceApplication
@@ -41,6 +62,7 @@ class TaskContextCheckpoint(StrictModel):
41
62
  fingerprint: str = Field(pattern=r'^[a-f0-9]{64}$')
42
63
  capture_status: str | None = Field(default=None, max_length=128)
43
64
  agent_memory: TaskContextAgentMemory | None = None
65
+ work_log: TaskWorkLog | None = None
44
66
 
45
67
  @model_validator(mode='after')
46
68
  def reject_sensitive_values(self):
@@ -3,10 +3,14 @@ from typing import Annotated
3
3
  from fastapi import Query
4
4
 
5
5
  from .models import SourceApplication
6
- from .task_context_models import TaskContextBegin, TaskContextCheckpoint
6
+ from .task_context_models import TaskContextBegin, TaskContextCheckpoint, TaskContextAdoptAgent
7
7
 
8
8
 
9
9
  def register_task_context_routes(application, store, Actor):
10
+ @application.put('/v1/task-contexts/{token}/agent')
11
+ async def adopt_agent(token: str, request: TaskContextAdoptAgent, actor: Actor):
12
+ return await store.adopt_task_context_agent(actor, token, request)
13
+
10
14
  @application.put('/v1/task-contexts')
11
15
  async def begin_context(request: TaskContextBegin, actor: Actor):
12
16
  return await store.begin_task_context(actor, request)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fuli-graph-provider"
3
- version = "0.7.10-beta.7"
3
+ version = "0.8.0"
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.7.10-beta.7",
3
+ "version": "0.8.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "fuli-context",
9
- "version": "0.7.10-beta.7",
9
+ "version": "0.8.0",
10
10
  "license": "Apache-2.0",
11
11
  "dependencies": {
12
12
  "@hono/node-server": "2.0.12",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fuli-context",
3
- "version": "0.7.10-beta.7",
3
+ "version": "0.8.0",
4
4
  "description": "Local-first collaboration relationship graph for AI agents.",
5
5
  "type": "module",
6
6
  "files": [
@@ -98,7 +98,8 @@ const knowledgeConflictResolution = enumSchema([
98
98
  ]);
99
99
  const profileAspect = {
100
100
  type: ['string', 'null'],
101
- enum: ['taste', 'personality', 'judgment_preference', null]
101
+ enum: ['taste', 'personality', 'judgment_preference', null],
102
+ description: 'Use "taste" for durable stylistic preference about the artifact itself, including writing voice, tone, wording, structure, formatting, and visual style; a writing or formatting style rule belongs here, not in judgment_preference. Use "judgment_preference" only for how a decision or process should be made, such as verification, risk, or sequencing rules. Use "personality" only for stable collaboration personality; an Agent-inferred personality item must stay confirmationStatus "pending" until a human confirms it.'
102
103
  };
103
104
  const sourceApplication = {
104
105
  type: ['string', 'null'],
@@ -301,11 +302,15 @@ export const GRAPH_TOOL_DEFINITIONS = [
301
302
  {
302
303
  name: 'checkpoint_task_knowledge',
303
304
  title: 'LIFECYCLE · Finish knowledge review',
304
- description: 'Complete exactly one end-of-task knowledge review. Use capture_candidates only for a small durable batch supported by the task; captured Agent proposals remain pending unless the payload contains valid human or authoritative-source confirmation. Use retain_nothing when the turn produced no reusable knowledge. Never store raw transcripts, guesses, temporary logs, credentials, or disposable output.',
305
+ description: 'Complete exactly one end-of-task knowledge review. An assigned employee must include workLog with a concise result summary and truthful status even with retain_nothing. Include agentMemory with the loaded revision and merged notes when durable role memory changed. Use capture_candidates only for a small durable batch supported by the task; captured Agent proposals remain pending unless the payload contains valid human or authoritative-source confirmation. Use retain_nothing when the turn produced no reusable knowledge. Never store raw transcripts, guesses, temporary logs, credentials, or disposable output.',
305
306
  inputSchema: objectSchema({
306
307
  taskContextToken: id,
307
308
  disposition: enumSchema(['capture_candidates', 'retain_nothing']),
308
309
  reason: boundedString(2000),
310
+ workLog: objectSchema({
311
+ status: enumSchema(['reported', 'completed', 'incomplete', 'failed', 'no_change']),
312
+ summary: boundedString(2000)
313
+ }, ['status', 'summary']),
309
314
  capture: objectSchema(captureEpisodeFields, captureEpisodeRequired),
310
315
  agentMemory: objectSchema({
311
316
  expectedRevision: integerSchema({ minimum: 0 }),
@@ -743,7 +748,7 @@ export const GRAPH_TOOL_DEFINITIONS = [
743
748
  {
744
749
  name: 'coordinate_project_agent_task',
745
750
  title: 'WRITE · Prepare a host-executed Agent team',
746
- description: 'Resolve the exact local project, let the Provider coordinator route one durable task, and assemble an isolated context bundle for every selected lead/collaborator. The result is a truthful host execution plan: Fuli persists identity, staffing, and memory scope, while the calling host must start real workers, report their concrete worker/executor evidence, and finish their lifecycle. This tool never claims that a selected Agent is online or that a worker was started.',
751
+ description: 'Resolve the exact local project, let the Provider coordinator route one durable task, and assemble an isolated context bundle for every selected lead/collaborator. Pass the current taskContextToken so a newly recruited lead can claim an initially unassigned task, including its blocked work. The result is a truthful host execution plan: Fuli persists identity, staffing, and memory scope, while the calling host must start real workers, report their concrete worker/executor evidence, and finish their lifecycle. This tool never claims that a selected Agent is online or that a worker was started.',
747
752
  inputSchema: projectAgentTaskCoordinateInput
748
753
  },
749
754
  {
@@ -815,12 +820,16 @@ export const GRAPH_TOOL_DEFINITIONS = [
815
820
  {
816
821
  name: 'update_project_agent_coordination_policy',
817
822
  title: 'WRITE · Set project Agent continuity policy',
818
- description: 'Persist the exact project switches for asking before recruitment and automatically reusing its previous effective Agent. This changes policy only and never starts a worker.',
823
+ description: 'Persist project collaboration switches and an optional stable team lead with members. HR and employee project managers remain peer roles and cannot be team members. Omitted team fields preserve the current team. Read policy first and send expectedUpdatedAt to prevent overwriting concurrent edits. This changes policy only and never starts a worker.',
819
824
  inputSchema: objectSchema({
820
825
  personalSpaceId: id,
821
826
  personalProjectId: id,
822
827
  askBeforeRecruitment: booleanSchema(),
823
- autoReusePreviousAgent: booleanSchema()
828
+ autoReusePreviousAgent: booleanSchema(),
829
+ autoGrowTeam: booleanSchema(),
830
+ teamLeadAgentId: nullableStringSchema(),
831
+ teamMemberAgentIds: arraySchema(id, { maxItems: 32, uniqueItems: true }),
832
+ expectedUpdatedAt: { ...dateTime, type: ['string', 'null'] }
824
833
  }, [
825
834
  'personalSpaceId', 'personalProjectId',
826
835
  'askBeforeRecruitment', 'autoReusePreviousAgent'
@@ -154,6 +154,7 @@ export const projectAgentTaskSubmitInput = objectSchema({
154
154
  'workKind', 'routingReason'
155
155
  ]);
156
156
  export const projectAgentTaskCoordinateInput = objectSchema({
157
+ taskContextToken: id,
157
158
  projectPath: boundedString(4096),
158
159
  idempotencyKey,
159
160
  title: boundedString(160),