fuli-context 0.8.0 → 0.8.2
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-7X3LGByg.js +2 -0
- package/dist/web/assets/index-CWMqDq8g.css +1 -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/agent-interface-architecture.md +17 -0
- package/docs/employee-agents.md +14 -1
- package/docs/project-agent-memory.md +34 -1
- 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/knowledge_search.py +8 -16
- 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_routes.py +5 -1
- 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 +15 -15
- package/package.json +4 -3
- package/skills/capturing-session-knowledge/SKILL.md +20 -2
- 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/employee-definitions.js +6 -5
- package/src/agent-tools/graph-definitions.js +19 -35
- 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/employees/service.js +8 -1
- package/src/graphiti/agent-collaboration.js +62 -0
- package/src/graphiti/agent-conversations.js +75 -0
- package/src/graphiti/agent-knowledge-workflows.js +8 -2
- 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 +11 -1
- package/src/graphiti/project-path-context.js +17 -10
- 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 +19 -5
- package/src/mcp/employee-tool-catalog.js +22 -0
- package/src/mcp/session-id.js +4 -0
- package/src/mcp/tool-annotations.js +8 -1
- package/src/mcp/tool-result.js +3 -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
- package/src/mcp/task-context-registry.js +0 -165
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Deterministic quality gate and capability routing, without I/O or model calls.
|
|
2
|
+
|
|
3
|
+
The caller owns trusted evidence collection and persists ordered attempts. A
|
|
4
|
+
selected model is only a recommendation: selection never proves execution or
|
|
5
|
+
successful verification. Artifact revisions must identify the checked artifact.
|
|
6
|
+
"""
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from copy import deepcopy
|
|
9
|
+
from math import isfinite
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
COMPLEXITY_TIERS = {'simple': 1, 'standard': 2, 'complex': 3}
|
|
13
|
+
STRATEGY_TIERS = {'fast': 1, 'balanced': 2, 'deep': 3, 'adaptive': 1}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _tier(value):
|
|
17
|
+
return type(value) is int and 1 <= value <= 3
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _text(value):
|
|
21
|
+
return isinstance(value, str) and bool(value.strip())
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _unique_attempts(attempts):
|
|
25
|
+
seen = {}
|
|
26
|
+
unique = []
|
|
27
|
+
for attempt in attempts:
|
|
28
|
+
if not isinstance(attempt, Mapping):
|
|
29
|
+
raise ValueError('Each verification attempt must be an object')
|
|
30
|
+
identifier = attempt.get('attempt_id')
|
|
31
|
+
refs = attempt.get('evidence_refs')
|
|
32
|
+
if not _text(identifier) or not _text(attempt.get('artifact_revision')):
|
|
33
|
+
raise ValueError('attempt_id and artifact_revision must be nonempty strings')
|
|
34
|
+
if attempt.get('outcome') not in ('pass', 'fail'):
|
|
35
|
+
raise ValueError('Attempt outcome must be pass or fail')
|
|
36
|
+
if not _tier(attempt.get('capability_tier')):
|
|
37
|
+
raise ValueError('Attempt capability_tier must be an observed integer from 1 to 3')
|
|
38
|
+
if not isinstance(refs, (list, tuple)) or not all(_text(ref) for ref in refs):
|
|
39
|
+
raise ValueError('evidence_refs must contain nonempty string references')
|
|
40
|
+
if identifier in seen:
|
|
41
|
+
if seen[identifier] != attempt:
|
|
42
|
+
raise ValueError(f'Conflicting attempt_id: {identifier}')
|
|
43
|
+
continue
|
|
44
|
+
seen[identifier] = attempt
|
|
45
|
+
unique.append(attempt)
|
|
46
|
+
return unique
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _select_model(models, minimum):
|
|
50
|
+
eligible = []
|
|
51
|
+
for model in models:
|
|
52
|
+
if not isinstance(model, Mapping):
|
|
53
|
+
continue
|
|
54
|
+
tier, cost = model.get('capability_tier'), model.get('cost_rank')
|
|
55
|
+
if model.get('available') is not True or not _tier(tier) or tier < minimum:
|
|
56
|
+
continue
|
|
57
|
+
if type(cost) not in (int, float) or cost < 0:
|
|
58
|
+
continue
|
|
59
|
+
if type(cost) is float and not isfinite(cost):
|
|
60
|
+
continue
|
|
61
|
+
if not _text(model.get('provider')) or not _text(model.get('model')):
|
|
62
|
+
continue
|
|
63
|
+
eligible.append(model)
|
|
64
|
+
if not eligible:
|
|
65
|
+
return None
|
|
66
|
+
selected = min(eligible, key=lambda item: (
|
|
67
|
+
item['cost_rank'], item['provider'], item['model'], -item['capability_tier'],
|
|
68
|
+
))
|
|
69
|
+
# Return only routing metadata, detached from the caller's mutable catalog.
|
|
70
|
+
return deepcopy({key: selected[key] for key in (
|
|
71
|
+
'provider', 'model', 'capability_tier', 'cost_rank', 'available',
|
|
72
|
+
)})
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def evaluate_quality_policy(*, complexity, current_strategy, artifact_revision,
|
|
76
|
+
attempts=(), available_models=(), current_capability_tier=None):
|
|
77
|
+
"""Return a quality gate and the cheapest observed model meeting its floor.
|
|
78
|
+
|
|
79
|
+
``current_strategy`` is a mode string or a mapping containing ``mode``.
|
|
80
|
+
Unproven attempts are retained in the count but cannot pass the gate, reset
|
|
81
|
+
failure streaks, or trigger escalation. Distinct evidence-backed failures
|
|
82
|
+
accumulate per capability tier across artifact revisions. A proved pass
|
|
83
|
+
resets its tier's streak, without erasing an already-earned capability floor.
|
|
84
|
+
|
|
85
|
+
Status is one of ``verification_required``, ``passed``, ``needs_human``, or
|
|
86
|
+
``blocked``. Two failures at tier 3 stop automatic selection. A later valid
|
|
87
|
+
tier-3 pass can resolve that gate, for example after human-directed repair.
|
|
88
|
+
``escalation_required`` means the floor rose above the declared current
|
|
89
|
+
capability; it does not mean an upgraded executor has already run.
|
|
90
|
+
"""
|
|
91
|
+
mode = current_strategy.get('mode') if isinstance(current_strategy, Mapping) else current_strategy
|
|
92
|
+
if not isinstance(complexity, str) or complexity not in COMPLEXITY_TIERS:
|
|
93
|
+
raise ValueError('Unknown task complexity')
|
|
94
|
+
if not isinstance(mode, str) or mode not in STRATEGY_TIERS:
|
|
95
|
+
raise ValueError('Unknown model strategy')
|
|
96
|
+
if not _text(artifact_revision):
|
|
97
|
+
raise ValueError('artifact_revision must be a nonempty string')
|
|
98
|
+
if current_capability_tier is not None and not _tier(current_capability_tier):
|
|
99
|
+
raise ValueError('current_capability_tier must be an integer from 1 to 3')
|
|
100
|
+
declared_tier = max(STRATEGY_TIERS[mode], current_capability_tier or 1)
|
|
101
|
+
minimum = max(COMPLEXITY_TIERS[complexity], declared_tier)
|
|
102
|
+
consecutive = {1: 0, 2: 0, 3: 0}
|
|
103
|
+
unique = _unique_attempts(attempts)
|
|
104
|
+
ignored = []
|
|
105
|
+
last_current = None
|
|
106
|
+
escalation_attempt_ids = []
|
|
107
|
+
needs_human = False
|
|
108
|
+
for attempt in unique:
|
|
109
|
+
if not attempt['evidence_refs']:
|
|
110
|
+
ignored.append(attempt['attempt_id'])
|
|
111
|
+
continue
|
|
112
|
+
tier = attempt['capability_tier']
|
|
113
|
+
# Evidence that a higher tier already ran prevents a silent downgrade.
|
|
114
|
+
minimum = max(minimum, tier)
|
|
115
|
+
if attempt['outcome'] == 'fail':
|
|
116
|
+
consecutive[tier] += 1
|
|
117
|
+
if consecutive[tier] == 2:
|
|
118
|
+
minimum = max(minimum, min(3, tier + 1))
|
|
119
|
+
escalation_attempt_ids.append(attempt['attempt_id'])
|
|
120
|
+
if tier == 3:
|
|
121
|
+
needs_human = True
|
|
122
|
+
else:
|
|
123
|
+
consecutive[tier] = 0
|
|
124
|
+
if tier == 3 and attempt['artifact_revision'] == artifact_revision:
|
|
125
|
+
needs_human = False
|
|
126
|
+
if attempt['artifact_revision'] == artifact_revision:
|
|
127
|
+
last_current = attempt
|
|
128
|
+
|
|
129
|
+
result = {
|
|
130
|
+
'status': 'verification_required',
|
|
131
|
+
'minimum_capability_tier': minimum,
|
|
132
|
+
'escalation_required': minimum > declared_tier,
|
|
133
|
+
'selected_model': None,
|
|
134
|
+
'artifact_revision': artifact_revision,
|
|
135
|
+
'verified_attempt_id': None,
|
|
136
|
+
'attempt_count': len(unique),
|
|
137
|
+
'ignored_attempt_ids': ignored,
|
|
138
|
+
'consecutive_failures': consecutive,
|
|
139
|
+
'escalation_attempt_ids': escalation_attempt_ids,
|
|
140
|
+
}
|
|
141
|
+
if needs_human:
|
|
142
|
+
result['status'] = 'needs_human'
|
|
143
|
+
elif (last_current is not None and last_current['outcome'] == 'pass'
|
|
144
|
+
and last_current['capability_tier'] >= minimum):
|
|
145
|
+
result['status'] = 'passed'
|
|
146
|
+
result['verified_attempt_id'] = last_current['attempt_id']
|
|
147
|
+
else:
|
|
148
|
+
result['selected_model'] = _select_model(available_models, minimum)
|
|
149
|
+
if result['selected_model'] is None:
|
|
150
|
+
result['status'] = 'blocked'
|
|
151
|
+
return result
|
|
@@ -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
|
|
@@ -496,21 +496,6 @@ def _item_scope_metadata(
|
|
|
496
496
|
project_scopes: dict[str, dict],
|
|
497
497
|
include_personal_global: bool,
|
|
498
498
|
) -> dict | None:
|
|
499
|
-
if not any(
|
|
500
|
-
field in record
|
|
501
|
-
for field in (
|
|
502
|
-
'profile_aspect',
|
|
503
|
-
'preference_agent_id',
|
|
504
|
-
'assignment_project_id',
|
|
505
|
-
'episode_project_ids',
|
|
506
|
-
'episode_project_agent_ids',
|
|
507
|
-
'reference_project_ids',
|
|
508
|
-
'has_global_episode',
|
|
509
|
-
)
|
|
510
|
-
):
|
|
511
|
-
# Backward-compatible projection for older Provider clients that returned
|
|
512
|
-
# only already-filtered item IDs.
|
|
513
|
-
return _global_scope_metadata()
|
|
514
499
|
profile_aspect = record.get('profile_aspect')
|
|
515
500
|
if profile_aspect:
|
|
516
501
|
preference_scope = record.get('preference_scope') or 'global'
|
|
@@ -592,7 +577,14 @@ def _item_scope_metadata(
|
|
|
592
577
|
),
|
|
593
578
|
)
|
|
594
579
|
return _project_scope_metadata(project_id, scope)
|
|
595
|
-
|
|
580
|
+
# A later project assignment, or an existing project origin, takes
|
|
581
|
+
# precedence over a historical unscoped episode. It is not global access.
|
|
582
|
+
if (
|
|
583
|
+
include_personal_global
|
|
584
|
+
and record.get('has_global_episode')
|
|
585
|
+
and not assignment_project_id
|
|
586
|
+
and not episode_project_ids
|
|
587
|
+
):
|
|
596
588
|
return _global_scope_metadata()
|
|
597
589
|
return None
|
|
598
590
|
|
|
@@ -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)
|
|
@@ -253,13 +253,17 @@ def register_project_agent_routes(
|
|
|
253
253
|
str | None,
|
|
254
254
|
Query(min_length=1, max_length=128),
|
|
255
255
|
] = None,
|
|
256
|
+
include_events: bool = True,
|
|
256
257
|
) -> ProjectAgentTaskRecord:
|
|
257
|
-
|
|
258
|
+
task = await store.get_project_agent_task(
|
|
258
259
|
actor,
|
|
259
260
|
personal_space_id,
|
|
260
261
|
task_id,
|
|
261
262
|
personal_project_id=personal_project_id,
|
|
262
263
|
)
|
|
264
|
+
# Derive execution evidence before omitting history from the response.
|
|
265
|
+
# Never mutate the durable record or erase worker state in compact reads.
|
|
266
|
+
return task if include_events else task.model_copy(update={'events': []})
|
|
263
267
|
|
|
264
268
|
@application.post(
|
|
265
269
|
'/v1/project-agent-tasks/{task_id}/events',
|
|
@@ -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}
|