fuli-context 0.8.0 → 0.8.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 (121) hide show
  1. package/README.md +4 -0
  2. package/README.zh-CN.md +4 -1
  3. package/dist/web/assets/{AboutPage-BjMmpwJB.js → AboutPage-fBq5VFoQ.js} +1 -1
  4. package/dist/web/assets/AgentHand-CMZk5GjY.js +1 -0
  5. package/dist/web/assets/AgentName-Cydd0UI4.css +1 -0
  6. package/dist/web/assets/AgentName-DRZHAIB2.js +1 -0
  7. package/dist/web/assets/AgentProfilePage-BPQEfMhB.js +1 -0
  8. package/dist/web/assets/AgentsDirectoryPage-CKZfrcuD.js +1 -0
  9. package/dist/web/assets/{ConnectionsPage-BKGxFDos.js → ConnectionsPage-COb56qnk.js} +1 -1
  10. package/dist/web/assets/EmployeeRecruitDialog-BLTpjCcK.js +1 -0
  11. package/dist/web/assets/EmployeeWorkbenchPage-CQwZnTA0.css +1 -0
  12. package/dist/web/assets/EmployeeWorkbenchPage-HUd-gne2.js +3 -0
  13. package/dist/web/assets/{GrowthLoading-WXCj8b0r.js → GrowthLoading-Bu_kH-I8.js} +1 -1
  14. package/dist/web/assets/{KnowledgeEditDialog-Bkie08ZA.js → KnowledgeEditDialog-BTwXXRCD.js} +1 -1
  15. package/dist/web/assets/{KnowledgeInspector-Cez-Z_95.js → KnowledgeInspector-Ct5hl2--.js} +1 -1
  16. package/dist/web/assets/{KnowledgeOrganizerPage-BYmTPA_8.js → KnowledgeOrganizerPage-DMC8Hh43.js} +1 -1
  17. package/dist/web/assets/KnowledgePage-uXW1Eovz.js +1 -0
  18. package/dist/web/assets/{KnowledgeWorkspace-DKij2_B4.js → KnowledgeWorkspace-DmHIc0jb.js} +1 -1
  19. package/dist/web/assets/{NotFoundPage-HGV-q-bh.js → NotFoundPage-C0yOR9T9.js} +1 -1
  20. package/dist/web/assets/{OverviewPage-DE29_F_Y.js → OverviewPage-BreX_kb4.js} +1 -1
  21. package/dist/web/assets/{PersonalProfilePage-B95DFc7x.js → PersonalProfilePage-CX1a6_E3.js} +1 -1
  22. package/dist/web/assets/PersonalProjectsPage-Br1S8ETM.js +1 -0
  23. package/dist/web/assets/ProjectAgentsPage-CA04h1Uf.js +13 -0
  24. package/dist/web/assets/ProjectAgentsPage-CZWWV4Kw.css +1 -0
  25. package/dist/web/assets/{ProjectScopePicker-Cu5PdxF6.js → ProjectScopePicker-DVHfXX1f.js} +1 -1
  26. package/dist/web/assets/{PublicProjectsPage-BY4B3_t3.js → PublicProjectsPage-CtctKml1.js} +1 -1
  27. package/dist/web/assets/{ReviewPage-C9vCesNu.js → ReviewPage-DUqaK-do.js} +1 -1
  28. package/dist/web/assets/{SearchableSelect-214TvfYg.js → SearchableSelect-BZtyYmZN.js} +1 -1
  29. package/dist/web/assets/{SettingsPage-D9TnzcFc.js → SettingsPage-CB4ymLr8.js} +1 -1
  30. package/dist/web/assets/{SharedJefaReportPage-CdSGI_OE.js → SharedJefaReportPage-CbFtrDCY.js} +1 -1
  31. package/dist/web/assets/{WritingTastePage-CUE1dU8f.js → WritingTastePage-B7lSzs2z.js} +1 -1
  32. package/dist/web/assets/agent-profile-CdKOh5xw.css +1 -0
  33. package/dist/web/assets/agent-profile.css_vue_type_style_index_0_src_true_lang-C22UdfU0.js +1 -0
  34. package/dist/web/assets/catalog-DU4Kfd-N.js +1 -0
  35. package/dist/web/assets/{client-Bl4oiW3E.js → client-CEHkJJ-5.js} +1 -1
  36. package/dist/web/assets/{console-BAIVNC-N.js → console--CZxc3PW.js} +1 -1
  37. package/dist/web/assets/{conversation-launcher-settings-DpRI_Jp2.js → conversation-launcher-settings-Co1RlTFH.js} +1 -1
  38. package/dist/web/assets/{i18n-CVFADb_M.js → i18n-BtOOoyJd.js} +3 -3
  39. package/dist/web/assets/{identity-Dt0WZgqo.js → identity-CsowvtIm.js} +1 -1
  40. package/dist/web/assets/index-CWMqDq8g.css +1 -0
  41. package/dist/web/assets/index-MKmHNBtc.js +2 -0
  42. package/dist/web/assets/{model-B8sOxih0.js → model-CGpllrds.js} +1 -1
  43. package/dist/web/assets/{personal-profile-graph-aAqex7ty.js → personal-profile-graph-BNTG4PSY.js} +1 -1
  44. package/dist/web/assets/profile-model-BQO4zNs9.js +1 -0
  45. package/dist/web/assets/{shared-report-BQD6l_cS.js → shared-report-BFUJqqQe.js} +1 -1
  46. package/dist/web/assets/task-evidence-BUu7yYIn.js +1 -0
  47. package/dist/web/assets/{useApi-CROJJdhE-CuJ16v2S.js → useApi-CROJJdhE-raNlHRDl.js} +1 -1
  48. package/dist/web/assets/{useModalDialog-Ddv2XJQi.js → useModalDialog-CroGk2Cs.js} +1 -1
  49. package/dist/web/assets/{vue-router-C8KuEZ6a.js → vue-router-JFYBLXB1.js} +1 -1
  50. package/dist/web/index.html +12 -10
  51. package/docs/agent-conversations-and-collaboration.md +115 -0
  52. package/docs/project-agent-memory.md +28 -0
  53. package/graph-provider/fuli_graph/agent_conversation_models.py +72 -0
  54. package/graph-provider/fuli_graph/agent_conversation_routes.py +47 -0
  55. package/graph-provider/fuli_graph/agent_loan_models.py +28 -0
  56. package/graph-provider/fuli_graph/agent_quality_policy.py +151 -0
  57. package/graph-provider/fuli_graph/agent_verification_models.py +22 -0
  58. package/graph-provider/fuli_graph/conversation_context.py +30 -0
  59. package/graph-provider/fuli_graph/project_agent_executor_models.py +3 -0
  60. package/graph-provider/fuli_graph/project_agent_memory_routes.py +2 -0
  61. package/graph-provider/fuli_graph/project_agent_models.py +10 -0
  62. package/graph-provider/fuli_graph/project_agent_task_models.py +4 -0
  63. package/graph-provider/fuli_graph/runtime.py +14 -0
  64. package/graph-provider/fuli_graph/store.py +6 -0
  65. package/graph-provider/fuli_graph/store_agent_conversations.py +206 -0
  66. package/graph-provider/fuli_graph/store_agent_loans.py +146 -0
  67. package/graph-provider/fuli_graph/store_agent_verification.py +111 -0
  68. package/graph-provider/fuli_graph/store_project_agent_executors.py +2 -0
  69. package/graph-provider/fuli_graph/store_project_agent_task_activity.py +6 -0
  70. package/graph-provider/fuli_graph/store_project_agent_tasks.py +20 -8
  71. package/graph-provider/fuli_graph/store_task_contexts.py +9 -0
  72. package/graph-provider/fuli_graph/task_context_routes.py +7 -0
  73. package/graph-provider/pyproject.toml +1 -1
  74. package/npm-shrinkwrap.json +2 -2
  75. package/package.json +2 -1
  76. package/skills/operating-fuli/SKILL.md +4 -0
  77. package/src/agent-tools/agent-collaboration-definitions.js +30 -0
  78. package/src/agent-tools/agent-conversation-definitions.js +21 -0
  79. package/src/agent-tools/graph-definitions.js +17 -33
  80. package/src/agent-tools/graph-handlers.js +17 -1
  81. package/src/agent-tools/interface-catalog.js +2 -0
  82. package/src/agent-tools/project-agent-definitions.js +21 -0
  83. package/src/agent-tools/project-relation-definitions.js +36 -0
  84. package/src/agents/claude-code/conversation-transcript.js +20 -0
  85. package/src/agents/claude-code/lifecycle-hook.js +154 -0
  86. package/src/agents/claude-code/lifecycle-hooks.js +20 -0
  87. package/src/agents/codex/conversation-transcript.js +22 -0
  88. package/src/agents/codex/lifecycle-hook.js +47 -8
  89. package/src/agents/codex/lifecycle-hooks.js +6 -6
  90. package/src/agents/cursor/lifecycle-hook.js +5 -4
  91. package/src/app/agent-request-context.js +6 -1
  92. package/src/app/test-tools.js +26 -0
  93. package/src/conversations/sync-transcript.js +144 -0
  94. package/src/conversations/task-context-view.js +28 -0
  95. package/src/conversations/transcript-guard.js +54 -0
  96. package/src/conversations/transcript-reader.js +113 -0
  97. package/src/graphiti/agent-collaboration.js +62 -0
  98. package/src/graphiti/agent-conversations.js +75 -0
  99. package/src/graphiti/agent-knowledge-workflows.js +7 -1
  100. package/src/graphiti/project-agent-control-plane.js +22 -2
  101. package/src/graphiti/project-agent-mapping.js +19 -1
  102. package/src/graphiti/project-agent-task-entry.js +11 -1
  103. package/src/graphiti/project-agent-workflows.js +1 -0
  104. package/src/graphiti/provider-client.js +17 -0
  105. package/src/graphiti/verification-preference.js +24 -0
  106. package/src/http/graph-api-router.js +17 -0
  107. package/src/mcp/create-mcp-server.js +13 -4
  108. package/src/mcp/session-id.js +4 -0
  109. package/src/mcp/tool-annotations.js +8 -1
  110. package/src/setup/agent-installation-status.js +1 -0
  111. package/src/setup/claude-code-config.js +13 -19
  112. package/dist/web/assets/AgentHand-BfLYPGjg.js +0 -1
  113. package/dist/web/assets/EmployeeRecruitDialog-D-W043v4.js +0 -1
  114. package/dist/web/assets/EmployeeWorkbenchPage-Cz7y-IFD.css +0 -1
  115. package/dist/web/assets/EmployeeWorkbenchPage-HHQPxdqI.js +0 -3
  116. package/dist/web/assets/KnowledgePage-Ck7tIBDn.js +0 -1
  117. package/dist/web/assets/PersonalProjectsPage-Bt7tFF5Z.js +0 -1
  118. package/dist/web/assets/ProjectAgentsPage-BioDR9OW.css +0 -1
  119. package/dist/web/assets/ProjectAgentsPage-CiiPNe_G.js +0 -12
  120. package/dist/web/assets/index-BUMq5zNh.js +0 -2
  121. package/dist/web/assets/index-C0ci8dnU.css +0 -1
@@ -0,0 +1,22 @@
1
+ from typing import Annotated, Literal
2
+ from pydantic import Field
3
+ from .models import StrictModel, SourceApplication
4
+
5
+
6
+ class AgentQualityQuery(StrictModel):
7
+ personal_space_id: str = Field(min_length=1, max_length=128)
8
+ personal_project_id: str = Field(min_length=1, max_length=128)
9
+ task_id: str = Field(min_length=1, max_length=128)
10
+ artifact_revision: str = Field(min_length=1, max_length=160)
11
+
12
+
13
+ class AgentVerification(AgentQualityQuery):
14
+ run_id: str = Field(min_length=1, max_length=128)
15
+ task_context_token: str = Field(min_length=1, max_length=160)
16
+ source_application: SourceApplication
17
+ attempt_id: str = Field(min_length=8, max_length=128)
18
+ outcome: Literal['pass', 'fail']
19
+ evidence_refs: list[Annotated[str, Field(min_length=1, max_length=512, pattern=r'\S')]] = Field(min_length=1, max_length=12)
20
+ executor_id: str = Field(min_length=1, max_length=128)
21
+ provider: str = Field(min_length=1, max_length=128)
22
+ model: str = Field(min_length=1, max_length=256)
@@ -0,0 +1,30 @@
1
+ """Deterministic recovery: no model calls, conservative UTF-8 byte accounting."""
2
+ import json
3
+ from datetime import datetime, timezone
4
+
5
+
6
+ def archived(last_activity, idle_days, *, now=None):
7
+ current = datetime.fromisoformat(now) if now else datetime.now(timezone.utc)
8
+ previous = datetime.fromisoformat(last_activity)
9
+ return (current - previous).total_seconds() >= idle_days * 86400
10
+
11
+
12
+ def pack_context(summary, events, budget):
13
+ result = {'summary': '', 'messages': [], 'truncated': False}
14
+ def size():
15
+ return len(json.dumps(result, ensure_ascii=False, separators=(',', ':')).encode())
16
+ result['summary'] = summary or ''
17
+ # Reserve half for recent messages; Unicode slicing never splits codepoints.
18
+ while len(result['summary'].encode()) > budget // 2:
19
+ result['summary'] = result['summary'][:max(0, len(result['summary']) - 64)]
20
+ result['truncated'] = True
21
+ for event in reversed(events):
22
+ if event['role'] not in ('user', 'assistant') or event.get('kind', 'message') != 'message':
23
+ continue
24
+ item = {'role': event['role'], 'content': event['content']}
25
+ result['messages'].insert(0, item)
26
+ if size() > budget:
27
+ result['messages'].pop(0)
28
+ result['truncated'] = True
29
+ break
30
+ return result
@@ -150,6 +150,8 @@ class ProjectAgentExecutorModelRecord(StrictModel):
150
150
  provider: str = Field(min_length=1, max_length=128)
151
151
  model: str = Field(min_length=1, max_length=256)
152
152
  capabilities: list[str] = Field(default_factory=list, max_length=32)
153
+ capability_tier: int | None = Field(default=None, ge=1, le=3)
154
+ cost_rank: float | None = Field(default=None, ge=0, allow_inf_nan=False)
153
155
  strategy_modes: list[Literal['adaptive', 'fast', 'balanced', 'deep']] = Field(
154
156
  default_factory=list,
155
157
  max_length=4,
@@ -456,6 +458,7 @@ class ProjectAgentExecutorSelection(StrictModel):
456
458
 
457
459
 
458
460
  class ProjectAgentExecutorActualReport(StrictModel):
461
+ artifact_revision: str | None = Field(default=None, min_length=1, max_length=160)
459
462
  personal_space_id: str = Field(min_length=1, max_length=128)
460
463
  personal_project_id: str = Field(min_length=1, max_length=128)
461
464
  task_id: str = Field(min_length=1, max_length=128)
@@ -12,11 +12,13 @@ from .project_agent_memory_models import (
12
12
  from .project_agent_context_models import (
13
13
  ProjectAgentContextRequest, ProjectAgentContextResolution,
14
14
  )
15
+ from .agent_conversation_routes import register_agent_conversation_routes
15
16
  from .task_context_routes import register_task_context_routes
16
17
 
17
18
 
18
19
  def register_project_agent_memory_routes(application, store, Actor):
19
20
  register_task_context_routes(application, store, Actor)
21
+ register_agent_conversation_routes(application, store, Actor)
20
22
  @application.post(
21
23
  '/v1/project-agent-context/resolve', response_model=ProjectAgentContextResolution,
22
24
  )
@@ -88,6 +88,14 @@ class ProjectAgentExecutorPolicy(StrictModel):
88
88
  return self
89
89
 
90
90
 
91
+ class ProjectAgentCharacter(StrictModel):
92
+ """Configured role intent, not a claim about demonstrated performance."""
93
+
94
+ judgment: str = Field(default='', max_length=2048)
95
+ taste: str = Field(default='', max_length=2048)
96
+ personality: str = Field(default='', max_length=2048)
97
+
98
+
91
99
  class ProjectAgentProfile(StrictModel):
92
100
  name: str = Field(min_length=1, max_length=160)
93
101
  display_name: str | None = Field(default=None, min_length=1, max_length=160)
@@ -101,6 +109,8 @@ class ProjectAgentProfile(StrictModel):
101
109
  validation_alias=AliasChoices('occupation_emoji', 'occupationEmoji'),
102
110
  )
103
111
  responsibility: str = Field(min_length=1, max_length=4096)
112
+ character: ProjectAgentCharacter = Field(default_factory=ProjectAgentCharacter)
113
+ expectations: str = Field(default='', max_length=4096)
104
114
  agent_type: ProjectAgentType = 'durable'
105
115
  work_kinds: list[str] = Field(default_factory=list, max_length=32)
106
116
  capabilities: list[str] = Field(default_factory=list, max_length=32)
@@ -141,6 +141,9 @@ class ProjectAgentParallelPlan(StrictModel):
141
141
 
142
142
 
143
143
  class ProjectAgentTaskSubmit(StrictModel):
144
+ # All newly submitted tasks use the quality gate, including legacy public
145
+ # entry points. Existing graph records without this field remain readable.
146
+ verification_required: Literal[True] = True
144
147
  personal_space_id: str = Field(min_length=1, max_length=128)
145
148
  personal_project_id: str = Field(min_length=1, max_length=128)
146
149
  idempotency_key: str = Field(min_length=8, max_length=256)
@@ -486,6 +489,7 @@ class ProjectAgentTaskRecord(StrictModel):
486
489
 
487
490
 
488
491
  class ProjectAgentTaskActivityCreate(StrictModel):
492
+ artifact_revision: str | None = Field(default=None, min_length=1, max_length=160)
489
493
  personal_space_id: str = Field(min_length=1, max_length=128)
490
494
  personal_project_id: str = Field(min_length=1, max_length=128)
491
495
  task_id: str = Field(min_length=1, max_length=128)
@@ -269,6 +269,20 @@ class GraphitiRuntime:
269
269
  # safe for upgraded stores as well as fresh databases.
270
270
  await self._migrate_project_agent_executor_learning_buckets()
271
271
  for query in (
272
+ 'CREATE CONSTRAINT fuli_agent_verification_id IF NOT EXISTS '
273
+ 'FOR (n:FuliAgentVerification) REQUIRE n.id IS UNIQUE',
274
+ 'CREATE CONSTRAINT fuli_agent_loan_id IF NOT EXISTS '
275
+ 'FOR (n:FuliAgentLoan) REQUIRE n.id IS UNIQUE',
276
+ 'CREATE CONSTRAINT fuli_transcript_cursor_id IF NOT EXISTS '
277
+ 'FOR (n:FuliTranscriptCursor) REQUIRE n.id IS UNIQUE',
278
+ 'CREATE CONSTRAINT fuli_conversation_id IF NOT EXISTS '
279
+ 'FOR (n:FuliConversation) REQUIRE n.id IS UNIQUE',
280
+ 'CREATE CONSTRAINT fuli_conversation_event IF NOT EXISTS '
281
+ 'FOR (n:FuliConversationEvent) REQUIRE n.id IS UNIQUE',
282
+ 'CREATE CONSTRAINT fuli_conversation_binding IF NOT EXISTS '
283
+ 'FOR (n:FuliConversationBinding) REQUIRE n.id IS UNIQUE',
284
+ 'CREATE CONSTRAINT fuli_conversation_policy IF NOT EXISTS '
285
+ 'FOR (n:FuliConversationPolicy) REQUIRE n.id IS UNIQUE',
272
286
  'CREATE CONSTRAINT fuli_project_agent_id IF NOT EXISTS '
273
287
  'FOR (n:FuliProjectAgent) REQUIRE n.id IS UNIQUE',
274
288
  'CREATE CONSTRAINT fuli_agent_attention_id IF NOT EXISTS '
@@ -2,6 +2,9 @@ from .config import Settings
2
2
  from .provider_values import graphiti_group_id, native_datetime
3
3
  from .runtime import GraphitiRuntime
4
4
  from .store_identity import StoreIdentity
5
+ from .store_agent_conversations import StoreAgentConversations
6
+ from .store_agent_loans import StoreAgentLoans
7
+ from .store_agent_verification import StoreAgentVerification
5
8
  from .store_knowledge import StoreKnowledge
6
9
  from .store_project_agent_executors import StoreProjectAgentExecutors
7
10
  from .store_project_agent_memory import StoreProjectAgentMemory
@@ -17,6 +20,9 @@ from .store_task_contexts import StoreTaskContexts
17
20
 
18
21
  class GraphStore(
19
22
  StoreIdentity,
23
+ StoreAgentConversations,
24
+ StoreAgentLoans,
25
+ StoreAgentVerification,
20
26
  StoreProjects,
21
27
  StoreProjectAgents,
22
28
  StoreProjectAgentAttention,
@@ -0,0 +1,206 @@
1
+ """Transactional conversation journal in the private graph; no LLM/embeddings."""
2
+ import base64
3
+ import gzip
4
+ import hashlib
5
+ import json
6
+ from fastapi import HTTPException
7
+ from .agent_conversation_models import ConversationPolicy
8
+ from .conversation_context import archived, pack_context
9
+ from .project_agent_access import authorize_project_agent
10
+ from .provider_values import now_utc, stable_uuid
11
+ from .store_transactions import query_store_transaction
12
+
13
+
14
+ def encode_event(event):
15
+ raw = json.dumps(event.model_dump(), ensure_ascii=False, sort_keys=True).encode()
16
+ return hashlib.sha256(raw).hexdigest(), base64.b64encode(gzip.compress(raw, mtime=0)).decode()
17
+
18
+
19
+ def decode_event(row):
20
+ return {**json.loads(gzip.decompress(base64.b64decode(row['payload']))), 'sequence': row['sequence']}
21
+
22
+
23
+ class StoreAgentConversations:
24
+ async def _conversation_scope(self, actor, request, *, write=False):
25
+ self._require_personal()
26
+ space = await self.authorize(actor, request.personal_space_id, 'maintainer' if write else 'reader')
27
+ agent = await authorize_project_agent(self, actor, space, request.personal_project_id,
28
+ request.agent_id, require_active=write, require_memory=True)
29
+ if request.source_application is not None and request.source_application not in json.loads(agent['profile_json']).get('allowed_clients', []):
30
+ raise HTTPException(403, 'Agent is not allowed in this client')
31
+ return stable_uuid('conversation-scope', space['id'], request.personal_project_id, agent['agent_id'])
32
+
33
+ async def _conversation_task(self, actor, request):
34
+ await self.runtime.driver.execute_query(
35
+ 'MATCH (s:FuliTaskContextSession {id: $id}) SET s.conversation_serial=coalesce(s.conversation_serial,0)+1',
36
+ id=self._task_session_id(request.personal_space_id, request.source_application, request.session_id))
37
+ task = await self.get_task_context(actor, request.personal_space_id,
38
+ request.task_context_token, request.source_application)
39
+ if (task['session_id'], task['personal_project_id'], task['project_agent_id']) != (
40
+ request.session_id, request.personal_project_id, request.agent_id):
41
+ raise HTTPException(409, 'Conversation does not match the current task Agent and session')
42
+
43
+ async def _conversation_policy(self, scope):
44
+ rows, _, _ = await self.runtime.driver.execute_query(
45
+ 'MATCH (p:FuliConversationPolicy {id: $scope}) RETURN p.policy AS policy', scope=scope)
46
+ return ConversationPolicy.model_validate_json(rows[0]['policy']) if rows else ConversationPolicy()
47
+
48
+ async def write_conversation_policy(self, actor, request):
49
+ scope = await self._conversation_scope(actor, request, write=True)
50
+ await self.runtime.driver.execute_query(
51
+ 'MERGE (p:FuliConversationPolicy {id: $scope}) SET p.policy=$policy',
52
+ scope=scope, policy=request.policy.model_dump_json())
53
+ return request.policy.model_dump()
54
+
55
+ async def query_conversations(self, actor, request):
56
+ scope = await self._conversation_scope(actor, request)
57
+ policy = await self._conversation_policy(scope)
58
+ if request.mode == 'policy':
59
+ return policy.model_dump()
60
+ if request.mode == 'session':
61
+ rows, _, _ = await self.runtime.driver.execute_query(
62
+ 'OPTIONAL MATCH (b:FuliConversationBinding {id: $id}) '
63
+ 'OPTIONAL MATCH (s:FuliTranscriptCursor {id: $stream}) '
64
+ 'RETURN b.conversation_id AS conversation_id, coalesce(s.cursor,0) AS cursor, '
65
+ 'coalesce(s.initialized, s.cursor > 0, false) AS initialized',
66
+ stream=stable_uuid(request.personal_space_id, request.source_application, request.session_id or ''),
67
+ id=stable_uuid(scope, request.source_application, request.session_id or ''))
68
+ return dict(rows[0]) if rows else {'conversation_id': None, 'cursor': 0, 'initialized': False}
69
+ if request.mode == 'list':
70
+ rows, _, _ = await self.runtime.driver.execute_query(
71
+ 'MATCH (c:FuliConversation {scope: $scope}) RETURN c ORDER BY c.last_activity DESC, c.id LIMIT $limit',
72
+ scope=scope, limit=request.limit)
73
+ return {'conversations': [self._conversation_view(dict(r['c']), policy) for r in rows]}
74
+ rows, _, _ = await self.runtime.driver.execute_query(
75
+ 'MATCH (c:FuliConversation {scope: $scope, id: $id}) RETURN c', scope=scope, id=request.conversation_id)
76
+ if not rows:
77
+ raise HTTPException(404, 'Conversation not found in this Agent and project')
78
+ record = dict(rows[0]['c'])
79
+ view = self._conversation_view(record, policy)
80
+ if request.mode == 'context' and view['archived']:
81
+ return {**view, 'context': pack_context(record.get('summary', ''), [], policy.context_budget)}
82
+ order = 'DESC' if request.mode == 'context' else 'ASC'
83
+ events, _, _ = await self.runtime.driver.execute_query(
84
+ 'MATCH (c:FuliConversation {id: $id})-[:HAS_EVENT]->(e:FuliConversationEvent) '
85
+ 'WHERE e.sequence > $after RETURN e.payload AS payload, e.sequence AS sequence '
86
+ f'ORDER BY e.sequence {order} LIMIT $limit', id=record['id'], after=request.after,
87
+ limit=20 if request.mode == 'context' else request.limit)
88
+ decoded = []
89
+ total = 0
90
+ for event in events:
91
+ value = decode_event(event)
92
+ total += len(json.dumps(value, ensure_ascii=False).encode())
93
+ if total > 512 * 1024 and decoded:
94
+ break
95
+ decoded.append(value)
96
+ if request.mode == 'context':
97
+ return {**view, 'context': pack_context(record.get('summary', ''), list(reversed(decoded)), policy.context_budget)}
98
+ cursor = decoded[-1]['sequence'] if decoded else request.after
99
+ return {**view, 'events': decoded, 'next_cursor': cursor, 'has_more': cursor < record['revision']}
100
+
101
+ def _conversation_view(self, record, policy):
102
+ return {key: record.get(key) for key in ('id', 'summary', 'status', 'revision', 'last_activity')} | {
103
+ 'archived': archived(record['last_activity'], policy.idle_days),
104
+ 'policy': policy.model_dump(), 'raw_retained': True}
105
+
106
+ async def append_conversation(self, actor, request):
107
+ if request.initialize_cursor:
108
+ raise HTTPException(422, 'Initialize transcripts only at a verified task boundary')
109
+ async with query_store_transaction(self) as store:
110
+ scope = await store._conversation_scope(actor, request, write=True)
111
+ await store._conversation_task(actor, request)
112
+ policy = await store._conversation_policy(scope)
113
+ if not policy.enabled:
114
+ return {'status': 'capture_disabled'}
115
+ stream = stable_uuid(request.personal_space_id, request.source_application, request.session_id)
116
+ streams, _, _ = await store.runtime.driver.execute_query(
117
+ 'MERGE (s:FuliTranscriptCursor {id: $id}) ON CREATE SET s.cursor=0, s.owner_scope=$scope '
118
+ 'SET s.serial=coalesce(s.serial,0)+1 RETURN s.cursor AS cursor, s.owner_scope AS owner_scope, '
119
+ 'coalesce(s.initialized, s.cursor > 0, false) AS initialized', id=stream, scope=scope)
120
+ binding = stable_uuid(scope, request.source_application, request.session_id)
121
+ rows, _, _ = await store.runtime.driver.execute_query(
122
+ 'MERGE (b:FuliConversationBinding {id: $id}) '
123
+ 'ON CREATE SET b.conversation_id=$conversation_id, b.cursor=0 '
124
+ 'SET b.serial=coalesce(b.serial,0)+1 RETURN b.conversation_id AS id, b.cursor AS cursor',
125
+ id=binding, conversation_id=stable_uuid('conversation', binding))
126
+ conversation_id = rows[0]['id']
127
+ cursor = streams[0]['cursor']
128
+ if request.expected_cursor is not None and not streams[0]['initialized']:
129
+ raise HTTPException(409, 'Transcript must start at a verified task boundary')
130
+ if request.expected_cursor is not None and streams[0]['owner_scope'] not in (None, scope):
131
+ raise HTTPException(409, 'Transcript scope changed without a verified prompt boundary; do not import the old tail')
132
+ if request.expected_cursor is not None and cursor != request.expected_cursor:
133
+ raise HTTPException(409, 'Transcript cursor changed; reload before retrying')
134
+ now = now_utc().isoformat()
135
+ await store.runtime.driver.execute_query(
136
+ 'MERGE (c:FuliConversation {id: $id}) ON CREATE SET c.scope=$scope, c.revision=0, '
137
+ "c.last_activity=$now, c.summary='', c.status='reported' "
138
+ 'SET c.serial=coalesce(c.serial,0)+1', id=conversation_id, scope=scope, now=now)
139
+ new_summary = False
140
+ for event in request.events:
141
+ digest, payload = encode_event(event)
142
+ event_id = stable_uuid(conversation_id, request.source_application, request.session_id, event.event_id)
143
+ existing, _, _ = await store.runtime.driver.execute_query(
144
+ 'MATCH (e:FuliConversationEvent {id: $id}) RETURN e.digest AS digest', id=event_id)
145
+ if existing:
146
+ if existing[0]['digest'] != digest:
147
+ raise HTTPException(409, 'Conversation event ID has different content')
148
+ continue
149
+ if event.kind == 'checkpoint':
150
+ new_summary = True
151
+ await store.runtime.driver.execute_query(
152
+ 'MATCH (c:FuliConversation {id: $id}) SET c.revision=c.revision+1, c.last_activity=$now '
153
+ 'CREATE (e:FuliConversationEvent {id: $event_id, digest: $digest, payload: $payload, '
154
+ 'sequence: c.revision, source_application: $source, task_context_token: $task}) '
155
+ 'CREATE (c)-[:HAS_EVENT]->(e)', id=conversation_id, event_id=event_id,
156
+ digest=digest, payload=payload, now=now, source=request.source_application,
157
+ task=request.task_context_token)
158
+ if request.summary is not None and new_summary:
159
+ await store.runtime.driver.execute_query(
160
+ 'MATCH (c:FuliConversation {id: $id}) SET c.summary=$summary, c.status=$status',
161
+ id=conversation_id, summary=request.summary, status=request.status or 'reported')
162
+ if request.cursor is not None:
163
+ await store.runtime.driver.execute_query(
164
+ 'MATCH (s:FuliTranscriptCursor {id: $id}) SET s.cursor=$cursor, s.owner_scope=$scope', id=stream, cursor=request.cursor, scope=scope)
165
+ rows, _, _ = await store.runtime.driver.execute_query(
166
+ 'MATCH (c:FuliConversation {id: $id}) RETURN c.revision AS revision', id=conversation_id)
167
+ return {'status': 'saved', 'conversation_id': conversation_id, 'revision': rows[0]['revision']}
168
+
169
+ async def resume_conversation(self, actor, request):
170
+ async with query_store_transaction(self) as store:
171
+ scope = await store._conversation_scope(actor, request, write=True)
172
+ await store._conversation_task(actor, request)
173
+ rows, _, _ = await store.runtime.driver.execute_query(
174
+ 'MATCH (c:FuliConversation {id: $id, scope: $scope}) RETURN c.id AS id',
175
+ id=request.conversation_id, scope=scope)
176
+ if not rows:
177
+ raise HTTPException(404, 'Conversation not found in this Agent and project')
178
+ await store.runtime.driver.execute_query(
179
+ 'MERGE (b:FuliConversationBinding {id: $id}) ON CREATE SET b.cursor=0 '
180
+ 'SET b.conversation_id=$conversation_id', id=stable_uuid(scope, request.source_application, request.session_id),
181
+ conversation_id=request.conversation_id)
182
+ return {'status': 'resumed', 'conversation_id': request.conversation_id}
183
+
184
+ async def claim_conversation_boundary(self, actor, request):
185
+ if request.events or request.cursor is None or (not request.initialize_cursor and request.cursor != request.expected_cursor):
186
+ raise HTTPException(422, 'A scope boundary cannot import events or skip bytes')
187
+ async with query_store_transaction(self) as store:
188
+ scope = await store._conversation_scope(actor, request, write=True)
189
+ await store._conversation_task(actor, request)
190
+ stream = stable_uuid(request.personal_space_id, request.source_application, request.session_id)
191
+ if request.initialize_cursor:
192
+ # First hook sync has no journal entry yet. Create only after authorization;
193
+ # a failed CAS rolls this back in the same transaction.
194
+ await store.runtime.driver.execute_query(
195
+ 'MERGE (s:FuliTranscriptCursor {id:$id}) '
196
+ 'ON CREATE SET s.cursor=0, s.initialized=false', id=stream)
197
+ rows, _, _ = await store.runtime.driver.execute_query(
198
+ 'MATCH (s:FuliTranscriptCursor {id:$id}) SET s.serial=coalesce(s.serial,0)+1 '
199
+ 'WITH s WHERE s.cursor=$expected AND '
200
+ '($initialize=false OR (s.cursor=0 AND coalesce(s.initialized,false)=false)) '
201
+ 'SET s.owner_scope=$scope, s.cursor=$cursor, s.initialized=true RETURN s.cursor AS cursor',
202
+ id=stream,
203
+ cursor=request.cursor, expected=request.expected_cursor, initialize=request.initialize_cursor, scope=scope)
204
+ if not rows:
205
+ raise HTTPException(409, 'Transcript cursor changed before scope boundary')
206
+ return {'status': 'bound', 'cursor': request.cursor}
@@ -0,0 +1,146 @@
1
+ import json
2
+ from fastapi import HTTPException
3
+ from .personal_project_access import authorize_personal_project
4
+ from .project_agent_access import authorize_project_agent
5
+ from .project_agent_models import ProjectAgentAssignmentCreate, ProjectAgentAssignmentEnd
6
+ from .provider_values import now_utc, stable_uuid
7
+ from .store_transactions import query_store_transaction
8
+
9
+
10
+ class StoreAgentLoans:
11
+ async def _loan_lead(self, actor, request):
12
+ self._require_personal()
13
+ space = await self.authorize(actor, request.personal_space_id, 'maintainer')
14
+ task = await self.get_task_context(actor, request.personal_space_id,
15
+ request.task_context_token, request.source_application)
16
+ policy = await self.get_project_agent_coordination_policy(actor,
17
+ request.personal_space_id, task['personal_project_id'])
18
+ if not policy.team_lead_agent_id or task['project_agent_id'] != policy.team_lead_agent_id:
19
+ raise HTTPException(403, 'Ask the current project team lead to coordinate this loan')
20
+ lead = await authorize_project_agent(self, actor, space, task['personal_project_id'],
21
+ task['project_agent_id'], require_active=True)
22
+ if request.source_application not in json.loads(lead['profile_json']).get('allowed_clients', []):
23
+ raise HTTPException(403, 'Team lead is not allowed in this client')
24
+ return task
25
+
26
+ async def list_agent_loans(self, actor, request):
27
+ self._require_personal()
28
+ space = await self.authorize(actor, request.personal_space_id, 'reader')
29
+ await authorize_personal_project(self, actor, space, request.personal_project_id)
30
+ rows, _, _ = await self.runtime.driver.execute_query(
31
+ 'MATCH (l:FuliAgentLoan {space: $space}) WHERE l.source_project=$project OR l.target_project=$project '
32
+ 'RETURN l.record_json AS record_json ORDER BY l.updated_at DESC LIMIT 30',
33
+ space=request.personal_space_id, project=request.personal_project_id)
34
+ return {'loans': [json.loads(row['record_json']) for row in rows]}
35
+
36
+ async def request_agent_loan(self, actor, request):
37
+ async with query_store_transaction(self) as store:
38
+ task = await store._loan_lead(actor, request)
39
+ if task['personal_project_id'] == request.source_project_id:
40
+ raise HTTPException(422, 'Use an existing project assignment for a local specialist')
41
+ space = await store.authorize(actor, request.personal_space_id, 'maintainer')
42
+ specialist = await authorize_project_agent(store, actor, space, request.source_project_id,
43
+ request.specialist_id, require_active=True, require_memory=True)
44
+ source_policy = await store.get_project_agent_coordination_policy(actor,
45
+ request.personal_space_id, request.source_project_id)
46
+ if not source_policy.team_lead_agent_id:
47
+ raise HTTPException(409, 'The source project needs a team lead before a loan')
48
+ if request.specialist_id == source_policy.team_lead_agent_id:
49
+ raise HTTPException(409, 'Keep project team leads available; request a specialist member')
50
+ profile = json.loads(specialist['profile_json'])
51
+ if request.source_application not in profile.get('allowed_clients', []):
52
+ raise HTTPException(403, 'Specialist is not allowed in the destination client')
53
+ rows, _, _ = await store.runtime.driver.execute_query(
54
+ 'MATCH (t:FuliProjectAgentTask {task_id:$task, personal_space_id:$space, personal_project_id:$project}) '
55
+ 'SET t.loan_serial=coalesce(t.loan_serial,0)+1 WITH t '
56
+ "WHERE NOT t.status IN ['completed','cancelled','failed'] RETURN t.work_kind AS work_kind",
57
+ task=request.target_task_id, space=request.personal_space_id, project=task['personal_project_id'])
58
+ if not rows:
59
+ raise HTTPException(404, 'Active destination task not found')
60
+ loan_id = stable_uuid('agent-loan', request.personal_space_id, request.idempotency_key)
61
+ record = dict(loan_id=loan_id, personal_space_id=request.personal_space_id,
62
+ source_project_id=request.source_project_id, target_project_id=task['personal_project_id'],
63
+ source_lead_id=source_policy.team_lead_agent_id, target_lead_id=task['project_agent_id'],
64
+ specialist_id=request.specialist_id, target_task_id=request.target_task_id,
65
+ objective=request.objective, work_kind=rows[0]['work_kind'], status='requested',
66
+ source_application=request.source_application, assignment_id=None)
67
+ fingerprint = store._payload_hash(request)
68
+ result, _, _ = await store.runtime.driver.execute_query(
69
+ 'MERGE (l:FuliAgentLoan {id:$id}) ON CREATE SET l.space=$space, l.source_project=$source, '
70
+ 'l.target_project=$target, l.task_id=$task, l.fingerprint=$fingerprint, '
71
+ 'l.record_json=$record, l.updated_at=$now RETURN l.record_json AS record_json, l.fingerprint AS fingerprint',
72
+ id=loan_id, space=request.personal_space_id, source=request.source_project_id,
73
+ target=task['personal_project_id'], task=request.target_task_id, fingerprint=fingerprint,
74
+ record=json.dumps(record), now=now_utc().isoformat())
75
+ if result[0]['fingerprint'] != fingerprint:
76
+ raise HTTPException(409, 'Loan idempotency key has different input')
77
+ return json.loads(result[0]['record_json'])
78
+
79
+ async def decide_agent_loan(self, actor, request):
80
+ async with query_store_transaction(self) as store:
81
+ task = await store._loan_lead(actor, request)
82
+ rows, _, _ = await store.runtime.driver.execute_query(
83
+ 'MATCH (l:FuliAgentLoan {id:$id, space:$space}) '
84
+ 'MATCH (t:FuliProjectAgentTask {task_id:l.task_id, personal_space_id:$space}) '
85
+ 'SET t.loan_serial=coalesce(t.loan_serial,0)+1 WITH l,t '
86
+ 'SET l.serial=coalesce(l.serial,0)+1 RETURN l.record_json AS record_json, t.status AS task_status', id=request.loan_id, space=request.personal_space_id)
87
+ if not rows:
88
+ raise HTTPException(404, 'Loan not found')
89
+ record = json.loads(rows[0]['record_json'])
90
+ source_lead = task['personal_project_id'] == record['source_project_id'] and task['project_agent_id'] == record['source_lead_id']
91
+ target_lead = task['personal_project_id'] == record['target_project_id'] and task['project_agent_id'] == record['target_lead_id']
92
+ if not source_lead and not (request.decision == 'close' and target_lead):
93
+ raise HTTPException(403, 'The source team lead must decide this loan')
94
+ if request.decision == 'close':
95
+ return await store._close_agent_loan(actor, record)
96
+ if request.decision == 'approve' and rows[0]['task_status'] in ('completed', 'cancelled', 'failed'):
97
+ raise HTTPException(409, 'Destination task has ended')
98
+ expected = 'approved' if request.decision == 'approve' else 'rejected'
99
+ if record['status'] == expected:
100
+ return record
101
+ if record['status'] != 'requested':
102
+ raise HTTPException(409, 'Loan already decided')
103
+ if request.decision == 'approve':
104
+ space = await store.authorize(actor, request.personal_space_id, 'maintainer')
105
+ specialist = await authorize_project_agent(store, actor, space, record['source_project_id'],
106
+ record['specialist_id'], require_active=True, require_memory=True)
107
+ if record['source_application'] not in json.loads(specialist['profile_json']).get('allowed_clients', []):
108
+ raise HTTPException(403, 'Specialist is no longer allowed in the destination client')
109
+ assignment = await store.create_project_agent_assignment(actor, ProjectAgentAssignmentCreate(
110
+ personal_space_id=record['personal_space_id'], personal_project_id=record['target_project_id'],
111
+ agent_id=record['specialist_id'], idempotency_key=f"loan:{record['loan_id']}",
112
+ responsibility=record['objective'], work_kinds=[record['work_kind']],
113
+ reason='Task-scoped specialist loan approved by both project leads',
114
+ source_application=record['source_application']))
115
+ record['assignment_id'] = assignment.assignment_id
116
+ record['status'] = expected
117
+ await store._save_agent_loan(record)
118
+ return record
119
+
120
+ async def _save_agent_loan(self, record):
121
+ await self.runtime.driver.execute_query('MATCH (l:FuliAgentLoan {id:$id}) SET l.record_json=$record, l.updated_at=$now',
122
+ id=record['loan_id'], record=json.dumps(record), now=now_utc().isoformat())
123
+
124
+ async def _close_agent_loan(self, actor, record):
125
+ if record['status'] == 'closed':
126
+ return record
127
+ if record.get('assignment_id') and record['status'] == 'approved':
128
+ assignments, _, _ = await self.runtime.driver.execute_query(
129
+ 'MATCH (a:FuliProjectAgentAssignment {assignment_id:$id}) RETURN a.status AS status, a.revision AS revision',
130
+ id=record['assignment_id'])
131
+ if assignments and assignments[0]['status'] == 'active':
132
+ await self.end_project_agent_assignment(actor, ProjectAgentAssignmentEnd(
133
+ personal_space_id=record['personal_space_id'], personal_project_id=record['target_project_id'],
134
+ assignment_id=record['assignment_id'], expected_revision=assignments[0]['revision'], reason='Specialist loan finished'))
135
+ record['status'] = 'closed'
136
+ await self._save_agent_loan(record)
137
+ return record
138
+
139
+ async def close_task_agent_loans(self, actor, space_id, task_id):
140
+ rows, _, _ = await self.runtime.driver.execute_query(
141
+ 'MATCH (l:FuliAgentLoan {space:$space, task_id:$task}) SET l.serial=coalesce(l.serial,0)+1 '
142
+ 'RETURN l.record_json AS record_json', space=space_id, task=task_id)
143
+ for row in rows:
144
+ record = json.loads(row['record_json'])
145
+ if record['status'] in ('requested', 'approved'):
146
+ await self._close_agent_loan(actor, record)
@@ -0,0 +1,111 @@
1
+ import json
2
+ from fastapi import HTTPException
3
+ from .agent_quality_policy import evaluate_quality_policy
4
+ from .personal_project_access import authorize_personal_project
5
+ from .project_agent_access import authorize_project_agent
6
+ from .project_agent_models import ProjectAgentModelStrategy
7
+ from .provider_values import now_utc, stable_uuid
8
+ from .store_transactions import query_store_transaction
9
+
10
+
11
+ class StoreAgentVerification:
12
+ async def _quality_task(self, actor, request):
13
+ self._require_personal()
14
+ space = await self.authorize(actor, request.personal_space_id, 'maintainer')
15
+ await authorize_personal_project(self, actor, space, request.personal_project_id)
16
+ rows, _, _ = await self.runtime.driver.execute_query(
17
+ 'MATCH (t:FuliProjectAgentTask {task_id:$task, personal_space_id:$space, personal_project_id:$project}) RETURN t',
18
+ task=request.task_id, space=request.personal_space_id, project=request.personal_project_id)
19
+ if not rows:
20
+ raise HTTPException(404, 'Quality gate task not found')
21
+ return dict(rows[0]['t'])
22
+
23
+ async def _quality_models(self, actor, request, task):
24
+ executors = await self.list_project_agent_executors(actor, request.personal_space_id, available_only=True)
25
+ models = []
26
+ strategy = ProjectAgentModelStrategy.model_validate_json(task.get('effective_model_strategy_json') or '{}')
27
+ policy = json.loads(task.get('executor_policy_json') or '{}')
28
+ locked = policy.get('locked_executor_ids', []) if policy.get('mode') == 'locked' else None
29
+ for executor in executors:
30
+ if locked is not None and executor.executor_id not in locked:
31
+ continue
32
+ if (executor.permission_status != 'authorized' or executor.preflight_status != 'passed'
33
+ or executor.registration_status != 'registered' or not executor.workspace_permission
34
+ or executor.health_status == 'unhealthy'
35
+ or (executor.health_required and executor.health_status != 'healthy')):
36
+ continue
37
+ for model in executor.available_models:
38
+ if self._select_model([model], strategy) is not None:
39
+ models.append({**model.model_dump(), 'executor_id': executor.executor_id})
40
+ return models
41
+
42
+ async def query_agent_quality(self, actor, request):
43
+ task = await self._quality_task(actor, request)
44
+ rows, _, _ = await self.runtime.driver.execute_query(
45
+ 'MATCH (t:FuliProjectAgentTask {task_id:$task, personal_space_id:$space})-[:HAS_VERIFICATION]->(v:FuliAgentVerification) '
46
+ 'RETURN v.record_json AS record_json ORDER BY v.sequence', task=request.task_id, space=request.personal_space_id)
47
+ models = await self._quality_models(actor, request, task)
48
+ result = evaluate_quality_policy(complexity=task.get('complexity') or 'standard',
49
+ current_strategy=json.loads(task.get('effective_model_strategy_json') or '{"mode":"adaptive"}'),
50
+ artifact_revision=request.artifact_revision,
51
+ attempts=[json.loads(row['record_json']) for row in rows], available_models=models)
52
+ selected = result['selected_model']
53
+ if selected:
54
+ selected['executor_id'] = next(model['executor_id'] for model in models
55
+ if all(model.get(key) == selected[key] for key in ('provider', 'model', 'capability_tier', 'cost_rank')))
56
+ return {**result, 'evidence_authority': 'host_reported_test_evidence',
57
+ 'execution_started': False, 'guidance': 'Only complete the task after passed for the current artifact revision. Blocked means no authorized preflighted model proves the required capability tier. Model selection does not prove execution.'}
58
+
59
+ async def record_agent_verification(self, actor, request):
60
+ async with query_store_transaction(self) as store:
61
+ task = await store._quality_task(actor, request)
62
+ context = await store.get_task_context(actor, request.personal_space_id,
63
+ request.task_context_token, request.source_application)
64
+ if context['personal_project_id'] != request.personal_project_id or not context['project_agent_id']:
65
+ raise HTTPException(403, 'Verifier needs a current Agent in the task project')
66
+ space = await store.authorize(actor, request.personal_space_id, 'maintainer')
67
+ verifier = await authorize_project_agent(store, actor, space, request.personal_project_id,
68
+ context['project_agent_id'], require_active=True)
69
+ if request.source_application not in json.loads(verifier['profile_json']).get('allowed_clients', []):
70
+ raise HTTPException(403, 'Verifier is not allowed in this client')
71
+ await store.runtime.driver.execute_query(
72
+ 'MATCH (t:FuliProjectAgentTask {task_id:$task, personal_space_id:$space}) '
73
+ 'SET t.verification_serial=coalesce(t.verification_serial,0)+1',
74
+ task=request.task_id, space=request.personal_space_id)
75
+ models = await store._quality_models(actor, request, task)
76
+ observed = next((model for model in models if model['executor_id'] == request.executor_id
77
+ and model['provider'] == request.provider and model['model'] == request.model), None)
78
+ if not observed or not observed.get('capability_tier'):
79
+ raise HTTPException(409, 'Verification model needs preflighted capability metadata')
80
+ # A host must report a real execution, not just the configured model.
81
+ execution, _, _ = await store.runtime.driver.execute_query(
82
+ 'MATCH (t:FuliProjectAgentTask {task_id:$task, personal_space_id:$space})-[:HAS_EXECUTOR_OBSERVATION]->'
83
+ '(o:FuliProjectAgentExecutorObservation {agent_id:$agent, executor_id:$executor, provider:$provider, model:$model, run_id:$run, artifact_revision:$revision}) RETURN o LIMIT 1',
84
+ task=request.task_id, space=request.personal_space_id, agent=context['project_agent_id'],
85
+ executor=request.executor_id, provider=request.provider, model=request.model, run=request.run_id, revision=request.artifact_revision)
86
+ if not execution:
87
+ raise HTTPException(409, 'Report the verifier actual executor run before recording verification')
88
+ used, _, _ = await store.runtime.driver.execute_query(
89
+ 'MATCH (t:FuliProjectAgentTask {task_id:$task, personal_space_id:$space})-[:HAS_VERIFICATION]->(v:FuliAgentVerification {run_id:$run}) '
90
+ 'RETURN v.attempt_id AS attempt_id', task=request.task_id, space=request.personal_space_id, run=request.run_id)
91
+ if used and used[0]['attempt_id'] != request.attempt_id:
92
+ raise HTTPException(409, 'An execution run cannot count as two verification attempts')
93
+ identifier = stable_uuid('verification', request.personal_space_id, request.task_id, request.attempt_id)
94
+ record = dict(attempt_id=request.attempt_id, outcome=request.outcome, artifact_revision=request.artifact_revision,
95
+ evidence_refs=request.evidence_refs, capability_tier=observed['capability_tier'], verifier_id=context['project_agent_id'])
96
+ fingerprint = store._payload_hash(request)
97
+ rows, _, _ = await store.runtime.driver.execute_query(
98
+ 'MATCH (t:FuliProjectAgentTask {task_id:$task, personal_space_id:$space}) '
99
+ 'MERGE (v:FuliAgentVerification {id:$id}) ON CREATE SET v.record_json=$record, v.fingerprint=$fingerprint, '
100
+ 'v.sequence=t.verification_serial, v.created_at=$now, v.run_id=$run, v.attempt_id=$attempt '
101
+ 'MERGE (t)-[:HAS_VERIFICATION]->(v) RETURN v.fingerprint AS fingerprint',
102
+ task=request.task_id, space=request.personal_space_id, id=identifier,
103
+ record=json.dumps(record), fingerprint=fingerprint, now=now_utc().isoformat(), run=request.run_id, attempt=request.attempt_id)
104
+ if rows[0]['fingerprint'] != fingerprint:
105
+ raise HTTPException(409, 'Verification attempt ID has different evidence')
106
+ result = await store.query_agent_quality(actor, request)
107
+ await store.runtime.driver.execute_query(
108
+ 'MATCH (t:FuliProjectAgentTask {task_id:$task, personal_space_id:$space}) '
109
+ 'SET t.quality_gate=$status, t.verified_artifact_revision=$revision',
110
+ task=request.task_id, space=request.personal_space_id, status=result['status'], revision=request.artifact_revision)
111
+ return result