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.
- package/README.md +4 -0
- package/README.zh-CN.md +4 -1
- package/dist/web/assets/{AboutPage-BjMmpwJB.js → AboutPage-fBq5VFoQ.js} +1 -1
- package/dist/web/assets/AgentHand-CMZk5GjY.js +1 -0
- package/dist/web/assets/AgentName-Cydd0UI4.css +1 -0
- package/dist/web/assets/AgentName-DRZHAIB2.js +1 -0
- package/dist/web/assets/AgentProfilePage-BPQEfMhB.js +1 -0
- package/dist/web/assets/AgentsDirectoryPage-CKZfrcuD.js +1 -0
- package/dist/web/assets/{ConnectionsPage-BKGxFDos.js → ConnectionsPage-COb56qnk.js} +1 -1
- package/dist/web/assets/EmployeeRecruitDialog-BLTpjCcK.js +1 -0
- package/dist/web/assets/EmployeeWorkbenchPage-CQwZnTA0.css +1 -0
- package/dist/web/assets/EmployeeWorkbenchPage-HUd-gne2.js +3 -0
- package/dist/web/assets/{GrowthLoading-WXCj8b0r.js → GrowthLoading-Bu_kH-I8.js} +1 -1
- package/dist/web/assets/{KnowledgeEditDialog-Bkie08ZA.js → KnowledgeEditDialog-BTwXXRCD.js} +1 -1
- package/dist/web/assets/{KnowledgeInspector-Cez-Z_95.js → KnowledgeInspector-Ct5hl2--.js} +1 -1
- package/dist/web/assets/{KnowledgeOrganizerPage-BYmTPA_8.js → KnowledgeOrganizerPage-DMC8Hh43.js} +1 -1
- package/dist/web/assets/KnowledgePage-uXW1Eovz.js +1 -0
- package/dist/web/assets/{KnowledgeWorkspace-DKij2_B4.js → KnowledgeWorkspace-DmHIc0jb.js} +1 -1
- package/dist/web/assets/{NotFoundPage-HGV-q-bh.js → NotFoundPage-C0yOR9T9.js} +1 -1
- package/dist/web/assets/{OverviewPage-DE29_F_Y.js → OverviewPage-BreX_kb4.js} +1 -1
- package/dist/web/assets/{PersonalProfilePage-B95DFc7x.js → PersonalProfilePage-CX1a6_E3.js} +1 -1
- package/dist/web/assets/PersonalProjectsPage-Br1S8ETM.js +1 -0
- package/dist/web/assets/ProjectAgentsPage-CA04h1Uf.js +13 -0
- package/dist/web/assets/ProjectAgentsPage-CZWWV4Kw.css +1 -0
- package/dist/web/assets/{ProjectScopePicker-Cu5PdxF6.js → ProjectScopePicker-DVHfXX1f.js} +1 -1
- package/dist/web/assets/{PublicProjectsPage-BY4B3_t3.js → PublicProjectsPage-CtctKml1.js} +1 -1
- package/dist/web/assets/{ReviewPage-C9vCesNu.js → ReviewPage-DUqaK-do.js} +1 -1
- package/dist/web/assets/{SearchableSelect-214TvfYg.js → SearchableSelect-BZtyYmZN.js} +1 -1
- package/dist/web/assets/{SettingsPage-D9TnzcFc.js → SettingsPage-CB4ymLr8.js} +1 -1
- package/dist/web/assets/{SharedJefaReportPage-CdSGI_OE.js → SharedJefaReportPage-CbFtrDCY.js} +1 -1
- package/dist/web/assets/{WritingTastePage-CUE1dU8f.js → WritingTastePage-B7lSzs2z.js} +1 -1
- package/dist/web/assets/agent-profile-CdKOh5xw.css +1 -0
- package/dist/web/assets/agent-profile.css_vue_type_style_index_0_src_true_lang-C22UdfU0.js +1 -0
- package/dist/web/assets/catalog-DU4Kfd-N.js +1 -0
- package/dist/web/assets/{client-Bl4oiW3E.js → client-CEHkJJ-5.js} +1 -1
- package/dist/web/assets/{console-BAIVNC-N.js → console--CZxc3PW.js} +1 -1
- package/dist/web/assets/{conversation-launcher-settings-DpRI_Jp2.js → conversation-launcher-settings-Co1RlTFH.js} +1 -1
- package/dist/web/assets/{i18n-CVFADb_M.js → i18n-BtOOoyJd.js} +3 -3
- package/dist/web/assets/{identity-Dt0WZgqo.js → identity-CsowvtIm.js} +1 -1
- package/dist/web/assets/index-CWMqDq8g.css +1 -0
- package/dist/web/assets/index-MKmHNBtc.js +2 -0
- package/dist/web/assets/{model-B8sOxih0.js → model-CGpllrds.js} +1 -1
- package/dist/web/assets/{personal-profile-graph-aAqex7ty.js → personal-profile-graph-BNTG4PSY.js} +1 -1
- package/dist/web/assets/profile-model-BQO4zNs9.js +1 -0
- package/dist/web/assets/{shared-report-BQD6l_cS.js → shared-report-BFUJqqQe.js} +1 -1
- package/dist/web/assets/task-evidence-BUu7yYIn.js +1 -0
- package/dist/web/assets/{useApi-CROJJdhE-CuJ16v2S.js → useApi-CROJJdhE-raNlHRDl.js} +1 -1
- package/dist/web/assets/{useModalDialog-Ddv2XJQi.js → useModalDialog-CroGk2Cs.js} +1 -1
- package/dist/web/assets/{vue-router-C8KuEZ6a.js → vue-router-JFYBLXB1.js} +1 -1
- package/dist/web/index.html +12 -10
- package/docs/agent-conversations-and-collaboration.md +115 -0
- package/docs/project-agent-memory.md +28 -0
- package/graph-provider/fuli_graph/agent_conversation_models.py +72 -0
- package/graph-provider/fuli_graph/agent_conversation_routes.py +47 -0
- package/graph-provider/fuli_graph/agent_loan_models.py +28 -0
- package/graph-provider/fuli_graph/agent_quality_policy.py +151 -0
- package/graph-provider/fuli_graph/agent_verification_models.py +22 -0
- package/graph-provider/fuli_graph/conversation_context.py +30 -0
- package/graph-provider/fuli_graph/project_agent_executor_models.py +3 -0
- package/graph-provider/fuli_graph/project_agent_memory_routes.py +2 -0
- package/graph-provider/fuli_graph/project_agent_models.py +10 -0
- package/graph-provider/fuli_graph/project_agent_task_models.py +4 -0
- package/graph-provider/fuli_graph/runtime.py +14 -0
- package/graph-provider/fuli_graph/store.py +6 -0
- package/graph-provider/fuli_graph/store_agent_conversations.py +206 -0
- package/graph-provider/fuli_graph/store_agent_loans.py +146 -0
- package/graph-provider/fuli_graph/store_agent_verification.py +111 -0
- package/graph-provider/fuli_graph/store_project_agent_executors.py +2 -0
- package/graph-provider/fuli_graph/store_project_agent_task_activity.py +6 -0
- package/graph-provider/fuli_graph/store_project_agent_tasks.py +20 -8
- package/graph-provider/fuli_graph/store_task_contexts.py +9 -0
- package/graph-provider/fuli_graph/task_context_routes.py +7 -0
- package/graph-provider/pyproject.toml +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/skills/operating-fuli/SKILL.md +4 -0
- package/src/agent-tools/agent-collaboration-definitions.js +30 -0
- package/src/agent-tools/agent-conversation-definitions.js +21 -0
- package/src/agent-tools/graph-definitions.js +17 -33
- package/src/agent-tools/graph-handlers.js +17 -1
- package/src/agent-tools/interface-catalog.js +2 -0
- package/src/agent-tools/project-agent-definitions.js +21 -0
- package/src/agent-tools/project-relation-definitions.js +36 -0
- package/src/agents/claude-code/conversation-transcript.js +20 -0
- package/src/agents/claude-code/lifecycle-hook.js +154 -0
- package/src/agents/claude-code/lifecycle-hooks.js +20 -0
- package/src/agents/codex/conversation-transcript.js +22 -0
- package/src/agents/codex/lifecycle-hook.js +47 -8
- package/src/agents/codex/lifecycle-hooks.js +6 -6
- package/src/agents/cursor/lifecycle-hook.js +5 -4
- package/src/app/agent-request-context.js +6 -1
- package/src/app/test-tools.js +26 -0
- package/src/conversations/sync-transcript.js +144 -0
- package/src/conversations/task-context-view.js +28 -0
- package/src/conversations/transcript-guard.js +54 -0
- package/src/conversations/transcript-reader.js +113 -0
- package/src/graphiti/agent-collaboration.js +62 -0
- package/src/graphiti/agent-conversations.js +75 -0
- package/src/graphiti/agent-knowledge-workflows.js +7 -1
- package/src/graphiti/project-agent-control-plane.js +22 -2
- package/src/graphiti/project-agent-mapping.js +19 -1
- package/src/graphiti/project-agent-task-entry.js +11 -1
- package/src/graphiti/project-agent-workflows.js +1 -0
- package/src/graphiti/provider-client.js +17 -0
- package/src/graphiti/verification-preference.js +24 -0
- package/src/http/graph-api-router.js +17 -0
- package/src/mcp/create-mcp-server.js +13 -4
- package/src/mcp/session-id.js +4 -0
- package/src/mcp/tool-annotations.js +8 -1
- package/src/setup/agent-installation-status.js +1 -0
- package/src/setup/claude-code-config.js +13 -19
- package/dist/web/assets/AgentHand-BfLYPGjg.js +0 -1
- package/dist/web/assets/EmployeeRecruitDialog-D-W043v4.js +0 -1
- package/dist/web/assets/EmployeeWorkbenchPage-Cz7y-IFD.css +0 -1
- package/dist/web/assets/EmployeeWorkbenchPage-HHQPxdqI.js +0 -3
- package/dist/web/assets/KnowledgePage-Ck7tIBDn.js +0 -1
- package/dist/web/assets/PersonalProjectsPage-Bt7tFF5Z.js +0 -1
- package/dist/web/assets/ProjectAgentsPage-BioDR9OW.css +0 -1
- package/dist/web/assets/ProjectAgentsPage-CiiPNe_G.js +0 -12
- package/dist/web/assets/index-BUMq5zNh.js +0 -2
- 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
|