@gralkor/openclaw 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +32 -0
- package/README.md +77 -0
- package/config.yaml +16 -0
- package/dist/config.d.ts +33 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +49 -0
- package/dist/config.js.map +1 -0
- package/dist/ctx-to-messages.d.ts +36 -0
- package/dist/ctx-to-messages.d.ts.map +1 -0
- package/dist/ctx-to-messages.js +120 -0
- package/dist/ctx-to-messages.js.map +1 -0
- package/dist/ctx-to-turn.d.ts +32 -0
- package/dist/ctx-to-turn.d.ts.map +1 -0
- package/dist/ctx-to-turn.js +55 -0
- package/dist/ctx-to-turn.js.map +1 -0
- package/dist/gralkor/client/http.d.ts +55 -0
- package/dist/gralkor/client/http.d.ts.map +1 -0
- package/dist/gralkor/client/http.js +150 -0
- package/dist/gralkor/client/http.js.map +1 -0
- package/dist/gralkor/client/in-memory.d.ts +38 -0
- package/dist/gralkor/client/in-memory.d.ts.map +1 -0
- package/dist/gralkor/client/in-memory.js +72 -0
- package/dist/gralkor/client/in-memory.js.map +1 -0
- package/dist/gralkor/client.d.ts +64 -0
- package/dist/gralkor/client.d.ts.map +1 -0
- package/dist/gralkor/client.js +32 -0
- package/dist/gralkor/client.js.map +1 -0
- package/dist/gralkor/config.d.ts +33 -0
- package/dist/gralkor/config.d.ts.map +1 -0
- package/dist/gralkor/config.js +58 -0
- package/dist/gralkor/config.js.map +1 -0
- package/dist/gralkor/connection.d.ts +20 -0
- package/dist/gralkor/connection.d.ts.map +1 -0
- package/dist/gralkor/connection.js +31 -0
- package/dist/gralkor/connection.js.map +1 -0
- package/dist/gralkor/index.d.ts +11 -0
- package/dist/gralkor/index.d.ts.map +1 -0
- package/dist/gralkor/index.js +6 -0
- package/dist/gralkor/index.js.map +1 -0
- package/dist/gralkor/server-env.d.ts +11 -0
- package/dist/gralkor/server-env.d.ts.map +1 -0
- package/dist/gralkor/server-env.js +26 -0
- package/dist/gralkor/server-env.js.map +1 -0
- package/dist/gralkor/server-manager.d.ts +58 -0
- package/dist/gralkor/server-manager.d.ts.map +1 -0
- package/dist/gralkor/server-manager.js +390 -0
- package/dist/gralkor/server-manager.js.map +1 -0
- package/dist/gralkor/testing.d.ts +10 -0
- package/dist/gralkor/testing.d.ts.map +1 -0
- package/dist/gralkor/testing.js +10 -0
- package/dist/gralkor/testing.js.map +1 -0
- package/dist/hooks/agent-end.d.ts +25 -0
- package/dist/hooks/agent-end.d.ts.map +1 -0
- package/dist/hooks/agent-end.js +51 -0
- package/dist/hooks/agent-end.js.map +1 -0
- package/dist/hooks/before-prompt-build.d.ts +12 -0
- package/dist/hooks/before-prompt-build.d.ts.map +1 -0
- package/dist/hooks/before-prompt-build.js +15 -0
- package/dist/hooks/before-prompt-build.js.map +1 -0
- package/dist/hooks/session-end.d.ts +18 -0
- package/dist/hooks/session-end.d.ts.map +1 -0
- package/dist/hooks/session-end.js +19 -0
- package/dist/hooks/session-end.js.map +1 -0
- package/dist/index.d.ts +130 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +133 -0
- package/dist/index.js.map +1 -0
- package/dist/native-indexer.d.ts +43 -0
- package/dist/native-indexer.d.ts.map +1 -0
- package/dist/native-indexer.js +107 -0
- package/dist/native-indexer.js.map +1 -0
- package/dist/register.d.ts +25 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +184 -0
- package/dist/register.js.map +1 -0
- package/dist/session-map.d.ts +13 -0
- package/dist/session-map.d.ts.map +1 -0
- package/dist/session-map.js +32 -0
- package/dist/session-map.js.map +1 -0
- package/dist/tools/memory-add.d.ts +15 -0
- package/dist/tools/memory-add.d.ts.map +1 -0
- package/dist/tools/memory-add.js +15 -0
- package/dist/tools/memory-add.js.map +1 -0
- package/dist/tools/memory-build-communities.d.ts +19 -0
- package/dist/tools/memory-build-communities.d.ts.map +1 -0
- package/dist/tools/memory-build-communities.js +18 -0
- package/dist/tools/memory-build-communities.js.map +1 -0
- package/dist/tools/memory-build-indices.d.ts +12 -0
- package/dist/tools/memory-build-indices.d.ts.map +1 -0
- package/dist/tools/memory-build-indices.js +11 -0
- package/dist/tools/memory-build-indices.js.map +1 -0
- package/dist/tools/memory-search.d.ts +20 -0
- package/dist/tools/memory-search.d.ts.map +1 -0
- package/dist/tools/memory-search.js +18 -0
- package/dist/tools/memory-search.js.map +1 -0
- package/dist/types.d.ts +62 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/openclaw.plugin.json +130 -0
- package/package.json +75 -0
- package/server/server/.python-version +1 -0
- package/server/server/main.py +902 -0
- package/server/server/pipelines/__init__.py +0 -0
- package/server/server/pipelines/capture_buffer.py +170 -0
- package/server/server/pipelines/distill.py +122 -0
- package/server/server/pipelines/formatting.py +48 -0
- package/server/server/pipelines/interpret.py +165 -0
- package/server/server/pipelines/messages.py +13 -0
- package/server/server/pyproject.toml +19 -0
- package/server/server/pytest.ini +4 -0
- package/server/server/requirements-dev.txt +3 -0
- package/server/server/requirements.txt +5 -0
- package/server/server/uv.lock +1162 -0
- package/server/wheels/falkordblite-0.9.0-py3-none-manylinux_2_36_aarch64.whl +0 -0
|
File without changes
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Awaitable, Callable
|
|
7
|
+
|
|
8
|
+
from google.genai.errors import APIError as _GenaiAPIError
|
|
9
|
+
from graphiti_core.llm_client.errors import RateLimitError as _GraphitiRateLimitError
|
|
10
|
+
|
|
11
|
+
from .messages import Message
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
DEFAULT_RETRY_DELAYS: tuple[float, ...] = (1.0, 2.0, 4.0)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Exceptions raised by the Vertex-upstream path. These are NOT retried
|
|
21
|
+
# at the buffer layer — see gralkor/TEST_TREES.md > Retry ownership:
|
|
22
|
+
# 429 retry is owned by /recall only, and retrying upstream failures
|
|
23
|
+
# here would amplify load on an already-struggling upstream without
|
|
24
|
+
# a meaningful chance of success. `APIError` covers the SDK's
|
|
25
|
+
# 408/429/5xx/4xx path; graphiti's `RateLimitError` is what its
|
|
26
|
+
# GeminiClient raises on rate-limit classification.
|
|
27
|
+
_UPSTREAM_ERROR: tuple[type[BaseException], ...] = (
|
|
28
|
+
_GenaiAPIError,
|
|
29
|
+
_GraphitiRateLimitError,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CaptureClientError(Exception):
|
|
34
|
+
"""Raised when a downstream client returned a non-retryable 4xx response."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
FlushCallback = Callable[[str, str, list[list[Message]]], Awaitable[None]]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class _Entry:
|
|
42
|
+
group_id: str
|
|
43
|
+
agent_name: str
|
|
44
|
+
turns: list[list[Message]] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CaptureBuffer:
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
flush_callback: FlushCallback,
|
|
51
|
+
retry_delays: tuple[float, ...] = DEFAULT_RETRY_DELAYS,
|
|
52
|
+
) -> None:
|
|
53
|
+
self._entries: dict[str, _Entry] = {}
|
|
54
|
+
self._flush_callback = flush_callback
|
|
55
|
+
self._retry_delays = retry_delays
|
|
56
|
+
self._pending_flushes: set[asyncio.Task[None]] = set()
|
|
57
|
+
|
|
58
|
+
def append(
|
|
59
|
+
self,
|
|
60
|
+
session_id: str,
|
|
61
|
+
group_id: str,
|
|
62
|
+
agent_name: str,
|
|
63
|
+
messages: list[Message],
|
|
64
|
+
) -> None:
|
|
65
|
+
if agent_name is None or not str(agent_name).strip():
|
|
66
|
+
raise ValueError("agent_name is required and must be non-blank")
|
|
67
|
+
entry = self._entries.get(session_id)
|
|
68
|
+
if entry is None:
|
|
69
|
+
entry = _Entry(group_id=group_id, agent_name=agent_name)
|
|
70
|
+
self._entries[session_id] = entry
|
|
71
|
+
else:
|
|
72
|
+
if entry.group_id != group_id:
|
|
73
|
+
raise ValueError(
|
|
74
|
+
f"session_id {session_id!r} already bound to group_id "
|
|
75
|
+
f"{entry.group_id!r}; refusing to rebind to {group_id!r}"
|
|
76
|
+
)
|
|
77
|
+
if entry.agent_name != agent_name:
|
|
78
|
+
raise ValueError(
|
|
79
|
+
f"session_id {session_id!r} already bound to agent_name "
|
|
80
|
+
f"{entry.agent_name!r}; refusing to rebind to {agent_name!r}"
|
|
81
|
+
)
|
|
82
|
+
entry.turns.append(list(messages))
|
|
83
|
+
|
|
84
|
+
def turns_for(self, session_id: str) -> list[list[Message]]:
|
|
85
|
+
entry = self._entries.get(session_id)
|
|
86
|
+
return [list(turn) for turn in entry.turns] if entry is not None else []
|
|
87
|
+
|
|
88
|
+
async def _flush_with_retry(
|
|
89
|
+
self,
|
|
90
|
+
session_id: str,
|
|
91
|
+
group_id: str,
|
|
92
|
+
agent_name: str,
|
|
93
|
+
turns: list[list[Message]],
|
|
94
|
+
) -> None:
|
|
95
|
+
attempt = 0
|
|
96
|
+
while True:
|
|
97
|
+
try:
|
|
98
|
+
await self._flush_callback(group_id, agent_name, turns)
|
|
99
|
+
return
|
|
100
|
+
except CaptureClientError as err:
|
|
101
|
+
logger.error(
|
|
102
|
+
"capture dropped (4xx) session=%s group=%s turns=%d err=%s",
|
|
103
|
+
session_id,
|
|
104
|
+
group_id,
|
|
105
|
+
len(turns),
|
|
106
|
+
err,
|
|
107
|
+
)
|
|
108
|
+
return
|
|
109
|
+
except _UPSTREAM_ERROR as err:
|
|
110
|
+
logger.error(
|
|
111
|
+
"capture dropped (upstream error — see Retry ownership) "
|
|
112
|
+
"session=%s group=%s turns=%d err=%s",
|
|
113
|
+
session_id,
|
|
114
|
+
group_id,
|
|
115
|
+
len(turns),
|
|
116
|
+
err,
|
|
117
|
+
)
|
|
118
|
+
return
|
|
119
|
+
except Exception as err:
|
|
120
|
+
if attempt >= len(self._retry_delays):
|
|
121
|
+
logger.error(
|
|
122
|
+
"capture exhausted session=%s group=%s turns=%d err=%s",
|
|
123
|
+
session_id,
|
|
124
|
+
group_id,
|
|
125
|
+
len(turns),
|
|
126
|
+
err,
|
|
127
|
+
)
|
|
128
|
+
return
|
|
129
|
+
logger.warning(
|
|
130
|
+
"capture retry session=%s group=%s attempt=%d err=%s",
|
|
131
|
+
session_id,
|
|
132
|
+
group_id,
|
|
133
|
+
attempt + 1,
|
|
134
|
+
err,
|
|
135
|
+
)
|
|
136
|
+
await asyncio.sleep(self._retry_delays[attempt])
|
|
137
|
+
attempt += 1
|
|
138
|
+
|
|
139
|
+
def flush(self, session_id: str) -> None:
|
|
140
|
+
entry = self._entries.pop(session_id, None)
|
|
141
|
+
if entry is None:
|
|
142
|
+
return
|
|
143
|
+
task = asyncio.create_task(
|
|
144
|
+
self._flush_with_retry(session_id, entry.group_id, entry.agent_name, entry.turns)
|
|
145
|
+
)
|
|
146
|
+
self._pending_flushes.add(task)
|
|
147
|
+
task.add_done_callback(self._pending_flushes.discard)
|
|
148
|
+
|
|
149
|
+
async def flush_all(self) -> None:
|
|
150
|
+
for session_id in list(self._entries.keys()):
|
|
151
|
+
entry = self._entries.pop(session_id, None)
|
|
152
|
+
if entry is None:
|
|
153
|
+
continue
|
|
154
|
+
if entry.turns:
|
|
155
|
+
task = asyncio.create_task(
|
|
156
|
+
self._flush_with_retry(
|
|
157
|
+
session_id, entry.group_id, entry.agent_name, entry.turns
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
self._pending_flushes.add(task)
|
|
161
|
+
task.add_done_callback(self._pending_flushes.discard)
|
|
162
|
+
if self._pending_flushes:
|
|
163
|
+
await asyncio.gather(*self._pending_flushes, return_exceptions=True)
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def pending_count(self) -> int:
|
|
167
|
+
return sum(len(entry.turns) for entry in self._entries.values())
|
|
168
|
+
|
|
169
|
+
def has(self, session_id: str) -> bool:
|
|
170
|
+
return session_id in self._entries
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from .messages import Message
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from graphiti_core.llm_client import LLMClient
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
DISTILL_SYSTEM_PROMPT = (
|
|
19
|
+
"You are a distillery for agentic thought and action. You will be given an agent's actions "
|
|
20
|
+
"during a turn, alongside the user's request and the agent's response for context. Write one "
|
|
21
|
+
"to two sentences in first person past tense capturing the reasoning, decisions, and actions "
|
|
22
|
+
"that drove the outcome — including dead ends and intermediary steps, not just the final "
|
|
23
|
+
"response. When the agent searched memory, do not restate the recalled facts — note only "
|
|
24
|
+
"that memory was consulted and what the agent concluded. Output only the distilled text."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DistillResult(BaseModel):
|
|
29
|
+
behaviour: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _require_agent_name(agent_name: str) -> None:
|
|
33
|
+
if agent_name is None or not str(agent_name).strip():
|
|
34
|
+
raise ValueError("agent_name is required and must be non-blank")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _render_label(role: str, agent_name: str) -> str:
|
|
38
|
+
if role == "user":
|
|
39
|
+
return "User"
|
|
40
|
+
if role == "assistant":
|
|
41
|
+
return agent_name
|
|
42
|
+
if role == "behaviour":
|
|
43
|
+
return agent_name
|
|
44
|
+
return role.capitalize()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _build_distill_input(messages: list[Message], agent_name: str) -> str:
|
|
48
|
+
has_behaviour = any(m.role == "behaviour" and m.content.strip() for m in messages)
|
|
49
|
+
if not has_behaviour:
|
|
50
|
+
return ""
|
|
51
|
+
|
|
52
|
+
lines: list[str] = []
|
|
53
|
+
for msg in messages:
|
|
54
|
+
text = msg.content.strip()
|
|
55
|
+
if not text:
|
|
56
|
+
continue
|
|
57
|
+
lines.append(f"{_render_label(msg.role, agent_name)}: {text}")
|
|
58
|
+
return "\n".join(lines)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def _distill_one(llm_client: "LLMClient", thinking: str) -> str:
|
|
62
|
+
from graphiti_core.prompts.models import Message as LLMMessage
|
|
63
|
+
|
|
64
|
+
prompt = [
|
|
65
|
+
LLMMessage(role="system", content=DISTILL_SYSTEM_PROMPT),
|
|
66
|
+
LLMMessage(role="user", content=thinking),
|
|
67
|
+
]
|
|
68
|
+
response = await llm_client.generate_response(
|
|
69
|
+
prompt,
|
|
70
|
+
response_model=DistillResult,
|
|
71
|
+
max_tokens=150,
|
|
72
|
+
)
|
|
73
|
+
if isinstance(response, dict):
|
|
74
|
+
return (response.get("behaviour") or "").strip()
|
|
75
|
+
return ""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def safe_distill(llm_client: "LLMClient", thinking: str) -> str:
|
|
79
|
+
if not thinking.strip():
|
|
80
|
+
return ""
|
|
81
|
+
try:
|
|
82
|
+
return await _distill_one(llm_client, thinking)
|
|
83
|
+
except Exception as err:
|
|
84
|
+
logger.warning("behaviour distillation failed: %s", err)
|
|
85
|
+
return ""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def format_transcript(
|
|
89
|
+
turns: list[list[Message]],
|
|
90
|
+
llm_client: "LLMClient | None",
|
|
91
|
+
agent_name: str,
|
|
92
|
+
) -> str:
|
|
93
|
+
_require_agent_name(agent_name)
|
|
94
|
+
|
|
95
|
+
distill_inputs = [(i, _build_distill_input(turn, agent_name)) for i, turn in enumerate(turns)]
|
|
96
|
+
distill_inputs = [(i, text) for i, text in distill_inputs if text]
|
|
97
|
+
|
|
98
|
+
summaries: dict[int, str] = {}
|
|
99
|
+
if distill_inputs and llm_client is not None:
|
|
100
|
+
results = await asyncio.gather(
|
|
101
|
+
*(safe_distill(llm_client, text) for _, text in distill_inputs)
|
|
102
|
+
)
|
|
103
|
+
for (i, _), summary in zip(distill_inputs, results):
|
|
104
|
+
if summary:
|
|
105
|
+
summaries[i] = summary
|
|
106
|
+
|
|
107
|
+
lines: list[str] = []
|
|
108
|
+
for i, turn in enumerate(turns):
|
|
109
|
+
user_texts = [m.content.strip() for m in turn if m.role == "user" and m.content.strip()]
|
|
110
|
+
for text in user_texts:
|
|
111
|
+
lines.append(f"User: {text}")
|
|
112
|
+
|
|
113
|
+
summary = summaries.get(i)
|
|
114
|
+
if summary:
|
|
115
|
+
lines.append(f"{agent_name}: (behaviour: {summary})")
|
|
116
|
+
|
|
117
|
+
answer_texts = [
|
|
118
|
+
m.content.strip() for m in turn if m.role == "assistant" and m.content.strip()
|
|
119
|
+
]
|
|
120
|
+
for text in answer_texts:
|
|
121
|
+
lines.append(f"{agent_name}: {text}")
|
|
122
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
_FRACTIONAL_SECONDS = re.compile(r"\.\d+")
|
|
8
|
+
_TRAILING_Z = re.compile(r"Z$")
|
|
9
|
+
_TZ_OFFSET = re.compile(r"([+-])(\d{2}):(\d{2})$")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def format_timestamp(ts: str) -> str:
|
|
13
|
+
s = _FRACTIONAL_SECONDS.sub("", ts)
|
|
14
|
+
s = _TRAILING_Z.sub("+0", s)
|
|
15
|
+
|
|
16
|
+
def _compact(match: re.Match[str]) -> str:
|
|
17
|
+
sign, hours_str, minutes_str = match.group(1), match.group(2), match.group(3)
|
|
18
|
+
hours = str(int(hours_str))
|
|
19
|
+
if minutes_str == "00":
|
|
20
|
+
return f"{sign}{hours}"
|
|
21
|
+
return f"{sign}{hours}:{minutes_str}"
|
|
22
|
+
|
|
23
|
+
return _TZ_OFFSET.sub(_compact, s)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def format_fact(fact: dict[str, Any]) -> str:
|
|
27
|
+
parts = [f"- {fact['fact']}"]
|
|
28
|
+
if fact.get("created_at"):
|
|
29
|
+
parts.append(f" (created {format_timestamp(fact['created_at'])})")
|
|
30
|
+
if fact.get("valid_at"):
|
|
31
|
+
parts.append(f" (valid from {format_timestamp(fact['valid_at'])})")
|
|
32
|
+
if fact.get("invalid_at"):
|
|
33
|
+
parts.append(f" (invalid since {format_timestamp(fact['invalid_at'])})")
|
|
34
|
+
if fact.get("expired_at"):
|
|
35
|
+
parts.append(f" (expired {format_timestamp(fact['expired_at'])})")
|
|
36
|
+
return "".join(parts)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def format_facts(facts: list[dict[str, Any]]) -> str:
|
|
40
|
+
if not facts:
|
|
41
|
+
return "No graph facts found."
|
|
42
|
+
lines = "\n".join(format_fact(f) for f in facts)
|
|
43
|
+
return f"Facts:\n{lines}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def format_node(node: dict[str, Any]) -> str:
|
|
47
|
+
summary = node.get("summary") or "(no summary)"
|
|
48
|
+
return f"- {node['name']}: {summary}"
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from .messages import Message
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from graphiti_core.llm_client import LLMClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
INTERPRET_SYSTEM_PROMPT = (
|
|
14
|
+
"You are reviewing recalled memory facts for an agent mid-conversation. "
|
|
15
|
+
"Each input fact is one line beginning with '- ' and may carry one or more "
|
|
16
|
+
"timestamp parentheticals such as '(created …)', '(valid from …)', "
|
|
17
|
+
"'(invalid since …)', '(expired …)'.\n\n"
|
|
18
|
+
"Return only the facts that bear on the current task. For each one, produce "
|
|
19
|
+
"a single string built from two parts joined by ' — ':\n"
|
|
20
|
+
" 1. The original fact copied verbatim WITHOUT the leading '- '. Preserve "
|
|
21
|
+
"every timestamp parenthetical exactly as given. Do not paraphrase, "
|
|
22
|
+
"summarise, merge facts, drop timestamps, or reformat them.\n"
|
|
23
|
+
" 2. One short sentence explaining why this fact is relevant to the "
|
|
24
|
+
"current task.\n\n"
|
|
25
|
+
"Example output entry: "
|
|
26
|
+
"'Alice works at Acme (valid from 2024-01-01) (expired 2025-06-01) — "
|
|
27
|
+
"confirms her former employer, which the user just asked about.'\n\n"
|
|
28
|
+
"Skip facts with no bearing on the current task. If no facts are relevant, "
|
|
29
|
+
"return an empty list. Do not return prose, prefixes, bullets, numbering, "
|
|
30
|
+
"or any wrapping object — only the list of strings in the schema."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
INTERPRET_TOKEN_BUDGET = 250_000
|
|
34
|
+
_CHARS_PER_TOKEN = 4
|
|
35
|
+
INTERPRET_CHAR_BUDGET = INTERPRET_TOKEN_BUDGET * _CHARS_PER_TOKEN
|
|
36
|
+
|
|
37
|
+
DEFAULT_OUTPUT_TOKEN_BUDGET = 2000
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class InterpretParseFailed(Exception):
|
|
41
|
+
"""Raised when the LLM's interpret response cannot be parsed against the
|
|
42
|
+
InterpretResult schema — typically because output_token_budget was too
|
|
43
|
+
small and the response truncated mid-list. Distinct from generic
|
|
44
|
+
RuntimeError so callers can catch parse failure specifically."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, message: str, raw_response: object = None) -> None:
|
|
47
|
+
super().__init__(message)
|
|
48
|
+
self.raw_response = raw_response
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class InterpretResult(BaseModel):
|
|
52
|
+
relevantFacts: list[str] = Field(
|
|
53
|
+
description=(
|
|
54
|
+
"List of relevant facts. Each entry is the original fact line "
|
|
55
|
+
"copied verbatim (without the leading '- ', preserving every "
|
|
56
|
+
"timestamp parenthetical such as '(valid from …)', '(invalid "
|
|
57
|
+
"since …)', '(expired …)', '(created …)'), followed by ' — ' "
|
|
58
|
+
"and one short sentence explaining why this fact is relevant. "
|
|
59
|
+
"Empty list if nothing is relevant. No prose, no bullets, no "
|
|
60
|
+
"numbering."
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _require_agent_name(agent_name: str) -> None:
|
|
66
|
+
if agent_name is None or not str(agent_name).strip():
|
|
67
|
+
raise ValueError("agent_name is required and must be non-blank")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _require_positive_int(name: str, value: object) -> int:
|
|
71
|
+
if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
|
|
72
|
+
raise ValueError(f"{name} must be a positive integer, got {value!r}")
|
|
73
|
+
return value
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _render_label(role: str, agent_name: str) -> str:
|
|
77
|
+
if role == "user":
|
|
78
|
+
return "User"
|
|
79
|
+
if role == "assistant":
|
|
80
|
+
return agent_name
|
|
81
|
+
if role == "behaviour":
|
|
82
|
+
return agent_name
|
|
83
|
+
return role.capitalize()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _budget_instruction(output_token_budget: int) -> str:
|
|
87
|
+
return (
|
|
88
|
+
f"Respond within {output_token_budget} tokens. Keep each relevance "
|
|
89
|
+
f"reason short so the full list fits."
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def build_interpretation_context(
|
|
94
|
+
messages: list[Message],
|
|
95
|
+
facts_text: str,
|
|
96
|
+
agent_name: str,
|
|
97
|
+
char_budget: int = INTERPRET_CHAR_BUDGET,
|
|
98
|
+
) -> str:
|
|
99
|
+
_require_agent_name(agent_name)
|
|
100
|
+
lines: list[str] = []
|
|
101
|
+
for msg in messages:
|
|
102
|
+
text = msg.content.strip()
|
|
103
|
+
if not text:
|
|
104
|
+
continue
|
|
105
|
+
if msg.role == "behaviour":
|
|
106
|
+
lines.append(f"{agent_name}: (behaviour: {text})")
|
|
107
|
+
else:
|
|
108
|
+
lines.append(f"{_render_label(msg.role, agent_name)}: {text}")
|
|
109
|
+
|
|
110
|
+
budget = char_budget
|
|
111
|
+
trimmed: list[str] = []
|
|
112
|
+
for line in reversed(lines):
|
|
113
|
+
if budget <= 0:
|
|
114
|
+
break
|
|
115
|
+
trimmed.insert(0, line)
|
|
116
|
+
budget -= len(line)
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
"Conversation context:\n"
|
|
120
|
+
+ "\n".join(trimmed)
|
|
121
|
+
+ "\n\nMemory facts to interpret:\n"
|
|
122
|
+
+ facts_text
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def interpret_facts(
|
|
127
|
+
messages: list[Message],
|
|
128
|
+
facts_text: str,
|
|
129
|
+
llm_client: "LLMClient",
|
|
130
|
+
agent_name: str,
|
|
131
|
+
output_token_budget: int = DEFAULT_OUTPUT_TOKEN_BUDGET,
|
|
132
|
+
) -> list[str]:
|
|
133
|
+
_require_agent_name(agent_name)
|
|
134
|
+
_require_positive_int("output_token_budget", output_token_budget)
|
|
135
|
+
if llm_client is None:
|
|
136
|
+
raise RuntimeError(
|
|
137
|
+
"interpret_facts: llm_client is required (configure an LLM provider API key)"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
from graphiti_core.prompts.models import Message as LLMMessage
|
|
141
|
+
|
|
142
|
+
context = build_interpretation_context(messages, facts_text, agent_name)
|
|
143
|
+
context_with_budget = context + "\n\n" + _budget_instruction(output_token_budget)
|
|
144
|
+
prompt = [
|
|
145
|
+
LLMMessage(role="system", content=INTERPRET_SYSTEM_PROMPT),
|
|
146
|
+
LLMMessage(role="user", content=context_with_budget),
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
response = await llm_client.generate_response(
|
|
150
|
+
prompt,
|
|
151
|
+
response_model=InterpretResult,
|
|
152
|
+
max_tokens=output_token_budget,
|
|
153
|
+
)
|
|
154
|
+
if not isinstance(response, dict):
|
|
155
|
+
raise InterpretParseFailed(
|
|
156
|
+
"interpret_facts: response was not a dict (schema mismatch or truncation)",
|
|
157
|
+
raw_response=response,
|
|
158
|
+
)
|
|
159
|
+
raw = response.get("relevantFacts")
|
|
160
|
+
if not isinstance(raw, list):
|
|
161
|
+
raise InterpretParseFailed(
|
|
162
|
+
"interpret_facts: relevantFacts missing or not a list (schema mismatch or truncation)",
|
|
163
|
+
raw_response=response,
|
|
164
|
+
)
|
|
165
|
+
return [str(item).strip() for item in raw if str(item).strip()]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "gralkor-server"
|
|
3
|
+
version = "0.0.0"
|
|
4
|
+
requires-python = ">=3.12"
|
|
5
|
+
dependencies = [
|
|
6
|
+
"graphiti-core[falkordb,anthropic,google-genai,groq]>=0.28.2",
|
|
7
|
+
"falkordblite",
|
|
8
|
+
"fastapi>=0.115",
|
|
9
|
+
"uvicorn>=0.34",
|
|
10
|
+
"pyyaml>=6.0",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[dependency-groups]
|
|
14
|
+
dev = [
|
|
15
|
+
"pytest>=8.0",
|
|
16
|
+
"pytest-asyncio>=0.24",
|
|
17
|
+
"pytest-spec>=6.0",
|
|
18
|
+
"httpx>=0.27",
|
|
19
|
+
]
|