my-pi-agent 0.1.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/README.md +318 -0
- package/package.json +45 -0
- package/pyproject.toml +50 -0
- package/src/my_agent_core/__init__.py +123 -0
- package/src/my_agent_core/agent.py +441 -0
- package/src/my_agent_core/background.py +121 -0
- package/src/my_agent_core/context.py +505 -0
- package/src/my_agent_core/events.py +153 -0
- package/src/my_agent_core/extensions/__init__.py +9 -0
- package/src/my_agent_core/extensions/core.py +197 -0
- package/src/my_agent_core/hooks.py +130 -0
- package/src/my_agent_core/loop.py +709 -0
- package/src/my_agent_core/main.py +134 -0
- package/src/my_agent_core/memory.py +241 -0
- package/src/my_agent_core/message_queue.py +110 -0
- package/src/my_agent_core/plugins.py +212 -0
- package/src/my_agent_core/registry.py +186 -0
- package/src/my_agent_core/session/__init__.py +79 -0
- package/src/my_agent_core/session/entries.py +197 -0
- package/src/my_agent_core/session/jsonl.py +60 -0
- package/src/my_agent_core/session/memory.py +137 -0
- package/src/my_agent_core/session/session.py +400 -0
- package/src/my_agent_core/session/storage.py +245 -0
- package/src/my_agent_core/session/store.py +131 -0
- package/src/my_agent_core/session/tree.py +86 -0
- package/src/my_agent_core/skills.py +149 -0
- package/src/my_agent_core/subagent_tasks.py +170 -0
- package/src/my_agent_core/subagents.py +148 -0
- package/src/my_agent_core/task_store.py +248 -0
- package/src/my_agent_core/tool_history.py +189 -0
- package/src/my_agent_core/tools/__init__.py +5 -0
- package/src/my_agent_core/tools/builtin/__init__.py +5 -0
- package/src/my_agent_core/tools/builtin/task.py +30 -0
- package/src/my_agent_core/tools/builtin/task_tools.py +215 -0
- package/src/my_agent_core/tools/core.py +239 -0
- package/src/my_agent_llm/__init__.py +45 -0
- package/src/my_agent_llm/auth/__init__.py +46 -0
- package/src/my_agent_llm/auth/antigravity.py +209 -0
- package/src/my_agent_llm/auth/manager.py +259 -0
- package/src/my_agent_llm/auth/quota.py +56 -0
- package/src/my_agent_llm/auth/schema.py +94 -0
- package/src/my_agent_llm/client.py +116 -0
- package/src/my_agent_llm/config.py +17 -0
- package/src/my_agent_llm/events.py +84 -0
- package/src/my_agent_llm/models.py +195 -0
- package/src/my_agent_llm/providers/__init__.py +4 -0
- package/src/my_agent_llm/providers/_base.py +94 -0
- package/src/my_agent_llm/providers/anthropic.py +298 -0
- package/src/my_agent_llm/providers/antigravity.py +480 -0
- package/src/my_agent_llm/providers/deepseek.py +196 -0
- package/src/my_agent_llm/providers/openai.py +364 -0
- package/src/my_agent_llm/providers/registry.py +16 -0
- package/src/my_agent_llm/stream.py +218 -0
- package/src/my_coding_agent/__init__.py +66 -0
- package/src/my_coding_agent/agent.py +208 -0
- package/src/my_coding_agent/cli.py +78 -0
- package/src/my_coding_agent/file_reference.py +80 -0
- package/src/my_coding_agent/macro.py +408 -0
- package/src/my_coding_agent/mcp.py +243 -0
- package/src/my_coding_agent/mutation_queue.py +37 -0
- package/src/my_coding_agent/paths.py +119 -0
- package/src/my_coding_agent/permissions.py +84 -0
- package/src/my_coding_agent/prompt.py +54 -0
- package/src/my_coding_agent/rpc_server.py +2817 -0
- package/src/my_coding_agent/settings.py +126 -0
- package/src/my_coding_agent/tools/__init__.py +55 -0
- package/src/my_coding_agent/tools/base.py +58 -0
- package/src/my_coding_agent/tools/bash.py +206 -0
- package/src/my_coding_agent/tools/edit.py +226 -0
- package/src/my_coding_agent/tools/find.py +118 -0
- package/src/my_coding_agent/tools/grep.py +177 -0
- package/src/my_coding_agent/tools/ls.py +112 -0
- package/src/my_coding_agent/tools/read.py +113 -0
- package/src/my_coding_agent/tools/write.py +72 -0
- package/tui/README.md +27 -0
- package/tui/bin/my-agent.js +98 -0
- package/tui/dist/app.d.ts +41 -0
- package/tui/dist/app.js +110 -0
- package/tui/dist/bridge/event-translator.d.ts +92 -0
- package/tui/dist/bridge/event-translator.js +216 -0
- package/tui/dist/bridge/kernel-bridge.d.ts +48 -0
- package/tui/dist/bridge/kernel-bridge.js +132 -0
- package/tui/dist/client.d.ts +63 -0
- package/tui/dist/client.js +239 -0
- package/tui/dist/components/assistant-message.d.ts +19 -0
- package/tui/dist/components/assistant-message.js +90 -0
- package/tui/dist/components/compaction-summary-message.d.ts +19 -0
- package/tui/dist/components/compaction-summary-message.js +46 -0
- package/tui/dist/components/custom-editor.d.ts +18 -0
- package/tui/dist/components/custom-editor.js +56 -0
- package/tui/dist/components/dynamic-border.d.ts +9 -0
- package/tui/dist/components/dynamic-border.js +14 -0
- package/tui/dist/components/footer.d.ts +39 -0
- package/tui/dist/components/footer.js +199 -0
- package/tui/dist/components/header.d.ts +4 -0
- package/tui/dist/components/header.js +21 -0
- package/tui/dist/components/keys.d.ts +5 -0
- package/tui/dist/components/keys.js +12 -0
- package/tui/dist/components/login-selector.d.ts +26 -0
- package/tui/dist/components/login-selector.js +181 -0
- package/tui/dist/components/logout-selector.d.ts +19 -0
- package/tui/dist/components/logout-selector.js +88 -0
- package/tui/dist/components/model-selector.d.ts +40 -0
- package/tui/dist/components/model-selector.js +268 -0
- package/tui/dist/components/session-selector.d.ts +54 -0
- package/tui/dist/components/session-selector.js +393 -0
- package/tui/dist/components/settings-selector.d.ts +24 -0
- package/tui/dist/components/settings-selector.js +146 -0
- package/tui/dist/components/status-indicator.d.ts +25 -0
- package/tui/dist/components/status-indicator.js +60 -0
- package/tui/dist/components/theme-selector.d.ts +14 -0
- package/tui/dist/components/theme-selector.js +77 -0
- package/tui/dist/components/thinking-selector.d.ts +21 -0
- package/tui/dist/components/thinking-selector.js +128 -0
- package/tui/dist/components/tool-execution.d.ts +31 -0
- package/tui/dist/components/tool-execution.js +206 -0
- package/tui/dist/components/tree-selector.d.ts +40 -0
- package/tui/dist/components/tree-selector.js +173 -0
- package/tui/dist/components/user-message-selector.d.ts +21 -0
- package/tui/dist/components/user-message-selector.js +103 -0
- package/tui/dist/components/user-message.d.ts +5 -0
- package/tui/dist/components/user-message.js +15 -0
- package/tui/dist/index.d.ts +11 -0
- package/tui/dist/index.js +11 -0
- package/tui/dist/interactive/chat-viewport.d.ts +19 -0
- package/tui/dist/interactive/chat-viewport.js +41 -0
- package/tui/dist/interactive/components.d.ts +1 -0
- package/tui/dist/interactive/components.js +1 -0
- package/tui/dist/interactive/interactive-mode.d.ts +89 -0
- package/tui/dist/interactive/interactive-mode.js +1625 -0
- package/tui/dist/interactive/theme.d.ts +1 -0
- package/tui/dist/interactive/theme.js +1 -0
- package/tui/dist/interactive/tui-renderer.d.ts +8 -0
- package/tui/dist/interactive/tui-renderer.js +10 -0
- package/tui/dist/protocol.d.ts +78 -0
- package/tui/dist/protocol.js +1 -0
- package/tui/dist/theme/dark.json +54 -0
- package/tui/dist/theme/light.json +71 -0
- package/tui/dist/theme/theme.d.ts +20 -0
- package/tui/dist/theme/theme.js +86 -0
- package/tui/package.json +25 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
# pyright: reportUnusedCallResult=false, reportAttributeAccessIssue=false
|
|
2
|
+
"""Session 管理:树结构会话 + JSONL 文件持久化(纯正 Tau 只追加多态体系,配 my-agent-llm Message)。
|
|
3
|
+
|
|
4
|
+
一个会话 = 一棵树(entry 带 id/parent_id)+ current_id 指针。rewind = 移动指针、
|
|
5
|
+
旧分支保留。文件格式为纯粹的 SessionEntry 流:第 1 行是 SessionInfoEntry,后续每行为 MessageEntry/CompactionEntry 等。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import tempfile
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, cast
|
|
16
|
+
from uuid import uuid4
|
|
17
|
+
|
|
18
|
+
from my_agent_llm import Message
|
|
19
|
+
|
|
20
|
+
from .entries import (
|
|
21
|
+
CompactionEntry,
|
|
22
|
+
MessageEntry,
|
|
23
|
+
SessionEntry,
|
|
24
|
+
SessionHeaderEntry,
|
|
25
|
+
SessionInfoEntry,
|
|
26
|
+
)
|
|
27
|
+
from .jsonl import entry_from_json_line, entry_to_json_line
|
|
28
|
+
from .tree import path_to_entry
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SessionTree:
|
|
32
|
+
"""树:entries(id→entry)+ current_id 指针 + root_id。"""
|
|
33
|
+
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
self.entries: dict[str, SessionEntry] = {}
|
|
36
|
+
self.current_id: str | None = None
|
|
37
|
+
self.root_id: str | None = None
|
|
38
|
+
|
|
39
|
+
def add_entry(self, role: str, content: str, parent_id: str | None = None, **metadata: Any) -> MessageEntry:
|
|
40
|
+
"""追加到 current 下(或指定 parent)。首个 entry 成为根。"""
|
|
41
|
+
if parent_id is None:
|
|
42
|
+
parent_id = self.current_id
|
|
43
|
+
entry = MessageEntry(
|
|
44
|
+
id=uuid4().hex[:8],
|
|
45
|
+
parent_id=parent_id,
|
|
46
|
+
message=Message(
|
|
47
|
+
role=cast(Any, role),
|
|
48
|
+
content=content,
|
|
49
|
+
metadata=metadata or None,
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
self.entries[entry.id] = entry
|
|
53
|
+
self.current_id = entry.id
|
|
54
|
+
if self.root_id is None:
|
|
55
|
+
self.root_id = entry.id
|
|
56
|
+
return entry
|
|
57
|
+
|
|
58
|
+
def get_current_path(self) -> list[SessionEntry]:
|
|
59
|
+
"""根 → current 的路径(Agent 上下文用)。空树返回 []。"""
|
|
60
|
+
if self.current_id is None:
|
|
61
|
+
return []
|
|
62
|
+
return self.get_path_to_entry(self.current_id)
|
|
63
|
+
|
|
64
|
+
def get_path_to_entry(self, entry_id: str) -> list[SessionEntry]:
|
|
65
|
+
"""根 → entry_id 的路径(fork 用)。不存在抛 ValueError。"""
|
|
66
|
+
if entry_id not in self.entries:
|
|
67
|
+
raise ValueError(f"Entry {entry_id} not found")
|
|
68
|
+
try:
|
|
69
|
+
return path_to_entry(self.entries, entry_id)
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
raise ValueError(str(exc)) from exc
|
|
72
|
+
|
|
73
|
+
def rewind(self, entry_id: str) -> None:
|
|
74
|
+
"""移动 current 指针到已有节点;新 entry 将在其下追加(长新枝)。不存在抛 ValueError。"""
|
|
75
|
+
if entry_id not in self.entries:
|
|
76
|
+
raise ValueError(f"Entry {entry_id} not found")
|
|
77
|
+
self.current_id = entry_id
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_jsonl_iter(cls, lines: Iterable[str]) -> SessionTree:
|
|
81
|
+
"""从迭代器恢复树。中途某行损坏抛 ValueError(带行号),尾行由 load 处理。"""
|
|
82
|
+
tree = cls()
|
|
83
|
+
for idx, line in enumerate(lines, start=2): # start=2 因为 line 1 是 session_info
|
|
84
|
+
line = line.strip()
|
|
85
|
+
if not line:
|
|
86
|
+
continue
|
|
87
|
+
try:
|
|
88
|
+
entry = entry_from_json_line(line)
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
raise ValueError(f"Corrupted entry at line {idx}: {exc}") from exc
|
|
91
|
+
tree.entries[entry.id] = entry
|
|
92
|
+
tree.current_id = entry.id
|
|
93
|
+
if tree.root_id is None:
|
|
94
|
+
tree.root_id = entry.id
|
|
95
|
+
return tree
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Session:
|
|
99
|
+
"""一个会话 = 树 + 路径 + JSONL 文件(纯正 Tau 多态 SessionEntry 流)。
|
|
100
|
+
|
|
101
|
+
文件第 1 行是 SessionInfoEntry,后续每行是一个多态 SessionEntry。
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(self, *, path: Path, cwd: str | None = None, metadata: dict | None = None):
|
|
105
|
+
"""新建会话(纯对话,不含 system)。不立即写文件。"""
|
|
106
|
+
self.path = Path(path)
|
|
107
|
+
self.id = f"{datetime.now().strftime('%Y%m%d-%H%M%S')}-{uuid4().hex[:8]}"
|
|
108
|
+
self.created_at = datetime.now().isoformat()
|
|
109
|
+
self.cwd = cwd or str(Path.cwd())
|
|
110
|
+
self.tree = SessionTree()
|
|
111
|
+
self.compaction_floor: str | None = None
|
|
112
|
+
self.metadata = metadata or {} # 额外元数据(子代理:agent_type/parent_session_id),进 SessionInfoEntry
|
|
113
|
+
self._storage: Any | None = None
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def load(cls, path: Path) -> Session:
|
|
117
|
+
"""从 JSONL 文件恢复整棵树。首行非 SessionInfoEntry → ValueError;
|
|
118
|
+
非尾行损坏 → ValueError(带行号);尾行撕裂 → 丢弃该行(宽容兜底)。"""
|
|
119
|
+
path = Path(path).resolve()
|
|
120
|
+
try:
|
|
121
|
+
with open(path, encoding="utf-8") as f:
|
|
122
|
+
lines = list(f)
|
|
123
|
+
except OSError as exc:
|
|
124
|
+
raise ValueError(f"Failed to read session file {path}: {exc}") from exc
|
|
125
|
+
if not lines:
|
|
126
|
+
raise ValueError(f"Session file {path} is empty")
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
first_entry = entry_from_json_line(lines[0])
|
|
130
|
+
except Exception as exc:
|
|
131
|
+
raise ValueError(f"Session file {path}: invalid header/info: {exc}") from exc
|
|
132
|
+
|
|
133
|
+
if not isinstance(first_entry, (SessionInfoEntry, SessionHeaderEntry)):
|
|
134
|
+
raise ValueError(f"Session file {path}: line 1 must be a valid SessionHeaderEntry or SessionInfoEntry")
|
|
135
|
+
|
|
136
|
+
tree_lines = lines[1:]
|
|
137
|
+
if tree_lines and tree_lines[-1].strip():
|
|
138
|
+
try:
|
|
139
|
+
entry_from_json_line(tree_lines[-1])
|
|
140
|
+
except Exception:
|
|
141
|
+
tree_lines = tree_lines[:-1]
|
|
142
|
+
|
|
143
|
+
tree = SessionTree.from_jsonl_iter(tree_lines)
|
|
144
|
+
meta = dict(getattr(first_entry, "metadata", {}) or {})
|
|
145
|
+
if getattr(first_entry, "name", None):
|
|
146
|
+
meta["name"] = first_entry.name
|
|
147
|
+
if getattr(first_entry, "title", None):
|
|
148
|
+
meta["title"] = first_entry.title
|
|
149
|
+
if getattr(first_entry, "parent_session", None):
|
|
150
|
+
meta["parent_session"] = first_entry.parent_session
|
|
151
|
+
meta["parent_session_path"] = first_entry.parent_session
|
|
152
|
+
for e in tree.entries.values():
|
|
153
|
+
if isinstance(e, SessionInfoEntry):
|
|
154
|
+
sub_name = getattr(e, "name", None) or getattr(e, "title", None)
|
|
155
|
+
if sub_name:
|
|
156
|
+
meta["name"] = sub_name
|
|
157
|
+
meta["title"] = sub_name
|
|
158
|
+
|
|
159
|
+
# 优先恢复 current_id / root_id
|
|
160
|
+
cur = meta.get("current_id")
|
|
161
|
+
if isinstance(cur, str) and cur in tree.entries:
|
|
162
|
+
tree.current_id = cur
|
|
163
|
+
root = meta.get("root_id")
|
|
164
|
+
if isinstance(root, str) and root in tree.entries:
|
|
165
|
+
tree.root_id = root
|
|
166
|
+
|
|
167
|
+
session = cls(path=path, cwd=first_entry.cwd)
|
|
168
|
+
session.id = first_entry.id
|
|
169
|
+
session.created_at = (
|
|
170
|
+
datetime.fromtimestamp(first_entry.created_at).isoformat()
|
|
171
|
+
if first_entry.created_at
|
|
172
|
+
else datetime.now().isoformat()
|
|
173
|
+
)
|
|
174
|
+
session.tree = tree
|
|
175
|
+
session.compaction_floor = meta.get("compaction_floor")
|
|
176
|
+
session.metadata = {k: v for k, v in meta.items() if k not in ("current_id", "root_id", "compaction_floor")}
|
|
177
|
+
return session
|
|
178
|
+
|
|
179
|
+
def add_message(self, role: str, content: str, parent_id: str | None = None, **metadata: Any) -> MessageEntry:
|
|
180
|
+
"""加到树 + save()。"""
|
|
181
|
+
entry = self.tree.add_entry(role, content, parent_id, **metadata)
|
|
182
|
+
self.save()
|
|
183
|
+
return entry
|
|
184
|
+
|
|
185
|
+
def get_current_path_messages(self) -> list[Message]:
|
|
186
|
+
"""当前路径 → list[Message](Agent 上下文用)。"""
|
|
187
|
+
return [
|
|
188
|
+
e.message
|
|
189
|
+
if isinstance(e, MessageEntry)
|
|
190
|
+
else Message(
|
|
191
|
+
role=cast(Any, getattr(e, "role", "system")),
|
|
192
|
+
content=getattr(e, "content", ""),
|
|
193
|
+
metadata=getattr(e, "metadata", None),
|
|
194
|
+
)
|
|
195
|
+
for e in self.tree.get_current_path()
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
def add_summary_cache(
|
|
199
|
+
self,
|
|
200
|
+
summary: str,
|
|
201
|
+
*,
|
|
202
|
+
covered_count: int,
|
|
203
|
+
retained_tail: list[dict],
|
|
204
|
+
tokens_before: int,
|
|
205
|
+
summary_usage: dict | None = None,
|
|
206
|
+
summary_model: str | None = None,
|
|
207
|
+
) -> None:
|
|
208
|
+
"""写一条 CompactionEntry(不动 current_id)+ 更新 compaction_floor。"""
|
|
209
|
+
metadata: dict[str, Any] = {
|
|
210
|
+
"retained_tail": retained_tail,
|
|
211
|
+
"covered_count": covered_count,
|
|
212
|
+
"tokens_before": tokens_before,
|
|
213
|
+
}
|
|
214
|
+
if summary_usage is not None:
|
|
215
|
+
metadata["summary_usage"] = summary_usage
|
|
216
|
+
if summary_model is not None:
|
|
217
|
+
metadata["summary_model"] = summary_model
|
|
218
|
+
entry = CompactionEntry(
|
|
219
|
+
parent_id=self.tree.current_id,
|
|
220
|
+
summary=summary,
|
|
221
|
+
replaces_entry_ids=[],
|
|
222
|
+
metadata=metadata,
|
|
223
|
+
)
|
|
224
|
+
self.tree.entries[entry.id] = entry
|
|
225
|
+
self.compaction_floor = self.tree.current_id
|
|
226
|
+
self.save()
|
|
227
|
+
|
|
228
|
+
def get_full_history_messages(self) -> list[Message]:
|
|
229
|
+
"""完整对话历史(排除 CompactionEntry 节点)——宿主看历史、Agent 恢复上下文用。"""
|
|
230
|
+
return [
|
|
231
|
+
e.message
|
|
232
|
+
if isinstance(e, MessageEntry)
|
|
233
|
+
else Message(
|
|
234
|
+
role=cast(Any, getattr(e, "role", "system")),
|
|
235
|
+
content=getattr(e, "content", ""),
|
|
236
|
+
metadata=getattr(e, "metadata", None),
|
|
237
|
+
)
|
|
238
|
+
for e in self.tree.get_current_path()
|
|
239
|
+
if getattr(e, "type", "message") == "message"
|
|
240
|
+
]
|
|
241
|
+
|
|
242
|
+
def get_latest_compaction_cache(self) -> dict | None:
|
|
243
|
+
"""最新一条 CompactionEntry → {summary, covered_count, retained_tail};无则 None。"""
|
|
244
|
+
cache_entries = [
|
|
245
|
+
e
|
|
246
|
+
for e in self.tree.entries.values()
|
|
247
|
+
if isinstance(e, CompactionEntry) or getattr(e, "type", None) == "compaction"
|
|
248
|
+
]
|
|
249
|
+
if not cache_entries:
|
|
250
|
+
return None
|
|
251
|
+
latest = max(cache_entries, key=lambda e: len(self.tree.get_path_to_entry(e.id)))
|
|
252
|
+
md = getattr(latest, "metadata", {}) or {}
|
|
253
|
+
try:
|
|
254
|
+
covered_count = int(md.get("covered_count", 0))
|
|
255
|
+
except (ValueError, TypeError):
|
|
256
|
+
covered_count = 0
|
|
257
|
+
return {
|
|
258
|
+
"summary": getattr(latest, "summary", getattr(latest, "content", "")),
|
|
259
|
+
"covered_count": covered_count,
|
|
260
|
+
"retained_tail": list(md.get("retained_tail", [])),
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
def rewind(self, entry_id: str) -> None:
|
|
264
|
+
"""移动 current 指针(旧分支保留)+ save。压缩后只能回 floor(含)之后,否则 ValueError。"""
|
|
265
|
+
if self.compaction_floor is not None and not self._after_floor(entry_id):
|
|
266
|
+
raise ValueError(
|
|
267
|
+
f"Cannot rewind past compaction floor {self.compaction_floor}: "
|
|
268
|
+
f"entry {entry_id} is prior to compacted history"
|
|
269
|
+
)
|
|
270
|
+
self.tree.rewind(entry_id)
|
|
271
|
+
self.save()
|
|
272
|
+
|
|
273
|
+
def _after_floor(self, entry_id: str) -> bool:
|
|
274
|
+
"""entry_id 是否在 compaction_floor(含)之后长出的节点:等于 floor,或沿 parent 链能走到 floor。"""
|
|
275
|
+
if entry_id == self.compaction_floor:
|
|
276
|
+
return True
|
|
277
|
+
cur = self.tree.entries.get(entry_id)
|
|
278
|
+
while cur is not None and cur.parent_id is not None:
|
|
279
|
+
if cur.parent_id == self.compaction_floor:
|
|
280
|
+
return True
|
|
281
|
+
cur = self.tree.entries.get(cur.parent_id)
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
def save(self) -> None:
|
|
285
|
+
"""原子落盘:临时文件 + fsync + os.replace。写入首条 SessionInfoEntry 与所有多态 SessionEntry。"""
|
|
286
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
287
|
+
meta = dict(self.metadata)
|
|
288
|
+
meta["current_id"] = self.tree.current_id
|
|
289
|
+
meta["root_id"] = self.tree.root_id
|
|
290
|
+
meta["compaction_floor"] = self.compaction_floor
|
|
291
|
+
|
|
292
|
+
created_at_val: float | None = None
|
|
293
|
+
try:
|
|
294
|
+
if isinstance(self.created_at, str):
|
|
295
|
+
created_at_val = datetime.fromisoformat(self.created_at).timestamp()
|
|
296
|
+
elif isinstance(self.created_at, (int, float)):
|
|
297
|
+
created_at_val = float(self.created_at)
|
|
298
|
+
except Exception:
|
|
299
|
+
created_at_val = None
|
|
300
|
+
|
|
301
|
+
name_val = meta.get("name") or meta.get("title")
|
|
302
|
+
info_entry = SessionInfoEntry(
|
|
303
|
+
id=self.id,
|
|
304
|
+
cwd=self.cwd,
|
|
305
|
+
title=name_val,
|
|
306
|
+
name=name_val,
|
|
307
|
+
created_at=created_at_val,
|
|
308
|
+
metadata=meta,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
temp_path: Path | None = None
|
|
312
|
+
try:
|
|
313
|
+
with tempfile.NamedTemporaryFile(
|
|
314
|
+
mode="w",
|
|
315
|
+
encoding="utf-8",
|
|
316
|
+
dir=self.path.parent,
|
|
317
|
+
prefix=f".{self.path.name}.",
|
|
318
|
+
suffix=".tmp",
|
|
319
|
+
delete=False,
|
|
320
|
+
) as f:
|
|
321
|
+
temp_path = Path(f.name)
|
|
322
|
+
f.write(entry_to_json_line(info_entry) + "\n")
|
|
323
|
+
for e in self.tree.entries.values():
|
|
324
|
+
f.write(entry_to_json_line(e) + "\n")
|
|
325
|
+
f.flush()
|
|
326
|
+
os.fsync(f.fileno())
|
|
327
|
+
os.replace(temp_path, self.path)
|
|
328
|
+
except Exception:
|
|
329
|
+
if temp_path is not None:
|
|
330
|
+
temp_path.unlink(missing_ok=True)
|
|
331
|
+
raise
|
|
332
|
+
|
|
333
|
+
def append_entry(self, entry: SessionEntry) -> None:
|
|
334
|
+
"""追加一条任意多态 SessionEntry 并原子持久化。"""
|
|
335
|
+
if isinstance(entry, SessionInfoEntry):
|
|
336
|
+
name_val = getattr(entry, "name", None) or getattr(entry, "title", None)
|
|
337
|
+
if name_val:
|
|
338
|
+
self.metadata["name"] = name_val
|
|
339
|
+
self.metadata["title"] = name_val
|
|
340
|
+
if entry.cwd:
|
|
341
|
+
self.cwd = entry.cwd
|
|
342
|
+
self.tree.entries[entry.id] = entry
|
|
343
|
+
if self.tree.root_id is None:
|
|
344
|
+
self.tree.root_id = entry.id
|
|
345
|
+
self.tree.current_id = entry.id
|
|
346
|
+
self.save()
|
|
347
|
+
|
|
348
|
+
@property
|
|
349
|
+
def store(self):
|
|
350
|
+
"""兼容层:暴露包含 append_entry 的 storage/store 接口。"""
|
|
351
|
+
|
|
352
|
+
class _SessionStoreProxy:
|
|
353
|
+
def __init__(self, session: Session):
|
|
354
|
+
self._session = session
|
|
355
|
+
|
|
356
|
+
def append_entry(self, entry: SessionEntry) -> None:
|
|
357
|
+
self._session.append_entry(entry)
|
|
358
|
+
|
|
359
|
+
return _SessionStoreProxy(self)
|
|
360
|
+
|
|
361
|
+
def reset(self) -> None:
|
|
362
|
+
"""清空树 + 原子重写(纯对话,不含 system)。唯一破坏性操作。"""
|
|
363
|
+
self.tree = SessionTree()
|
|
364
|
+
self.compaction_floor = None
|
|
365
|
+
self.save()
|
|
366
|
+
|
|
367
|
+
def fork(self, entry_id: str, new_path: Path | None = None) -> Session:
|
|
368
|
+
"""从某 entry 分叉为新会话:复制根到 entry 的路径为新会话(新 id/路径,独立演化)。"""
|
|
369
|
+
if entry_id not in self.tree.entries:
|
|
370
|
+
raise ValueError(f"Entry {entry_id} not found")
|
|
371
|
+
if new_path is None:
|
|
372
|
+
sid = f"{datetime.now().strftime('%Y%m%d-%H%M%S')}-{uuid4().hex[:8]}"
|
|
373
|
+
new_path = self.path.parent / f"{sid}.jsonl"
|
|
374
|
+
new_session = Session(path=new_path, cwd=self.cwd, metadata=dict(self.metadata))
|
|
375
|
+
for entry in self.tree.get_path_to_entry(entry_id):
|
|
376
|
+
if isinstance(entry, MessageEntry):
|
|
377
|
+
new_session.add_message(entry.role, entry.content, **entry.metadata)
|
|
378
|
+
return new_session
|
|
379
|
+
|
|
380
|
+
@property
|
|
381
|
+
def storage(self) -> Any:
|
|
382
|
+
"""底层只追加存储对象。"""
|
|
383
|
+
if self._storage is None:
|
|
384
|
+
from my_agent_core.session.storage import JsonlSessionStorage
|
|
385
|
+
|
|
386
|
+
self._storage = JsonlSessionStorage(self.path)
|
|
387
|
+
return self._storage
|
|
388
|
+
|
|
389
|
+
def get_state(self) -> Any:
|
|
390
|
+
"""获取从根到当前 current_id 的 SessionState 状态快照。"""
|
|
391
|
+
from my_agent_core.session.memory import SessionState
|
|
392
|
+
|
|
393
|
+
return SessionState(
|
|
394
|
+
messages=tuple(self.get_current_path_messages()),
|
|
395
|
+
active_leaf_id=self.tree.current_id,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
def get_history(self) -> list[Message]:
|
|
399
|
+
"""获取当前路径的对话历史列表。"""
|
|
400
|
+
return self.get_current_path_messages()
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# pyright: reportUnusedCallResult=false
|
|
2
|
+
"""Locked, append-only session storage implementations and protocols.
|
|
3
|
+
|
|
4
|
+
对齐 Tau (tau_agent.session.storage) 架构设计:
|
|
5
|
+
集中放置 SessionStorage 协议、InMemorySessionStorage 以及 JsonlSessionStorage。
|
|
6
|
+
文件锁底层调用以内聚辅助函数 _lock_file / _unlock_file 放置于文件末尾,
|
|
7
|
+
彻底杜绝模块顶层的条件分支定义,具备工业级跨进程防踩踏与超时保护能力。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import contextlib
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from collections.abc import AsyncIterator, Sequence
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Protocol, runtime_checkable
|
|
20
|
+
|
|
21
|
+
from .entries import SessionEntry
|
|
22
|
+
from .jsonl import (
|
|
23
|
+
SessionJsonlError,
|
|
24
|
+
entry_from_json_line,
|
|
25
|
+
entry_to_json_line,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@runtime_checkable
|
|
30
|
+
class SessionStorage(Protocol):
|
|
31
|
+
"""纯异步只追加会话存储协议。
|
|
32
|
+
|
|
33
|
+
彻底废除全量重写 rewrite_history,保证历史记录发生即不可变。
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
async def append(self, entry: SessionEntry) -> None:
|
|
37
|
+
"""追加单个条目到存储末尾。"""
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
async def append_batch(self, entries: Sequence[SessionEntry]) -> None:
|
|
41
|
+
"""原子追加一批条目到存储末尾。"""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
async def read_all(self) -> list[SessionEntry]:
|
|
45
|
+
"""读取存储中所有按追加时序排列的历史条目。"""
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class InMemorySessionStorage:
|
|
50
|
+
"""纯内存只追加会话存储实现。
|
|
51
|
+
|
|
52
|
+
用于极速单元测试和无持久化需求的会话场景,协程安全。
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, initial_entries: Sequence[SessionEntry] | None = None) -> None:
|
|
56
|
+
self._entries: list[SessionEntry] = (
|
|
57
|
+
list(initial_entries) if initial_entries else []
|
|
58
|
+
)
|
|
59
|
+
self._lock = asyncio.Lock()
|
|
60
|
+
|
|
61
|
+
async def append(self, entry: SessionEntry) -> None:
|
|
62
|
+
async with self._lock:
|
|
63
|
+
self._entries.append(entry)
|
|
64
|
+
|
|
65
|
+
async def append_batch(self, entries: Sequence[SessionEntry]) -> None:
|
|
66
|
+
async with self._lock:
|
|
67
|
+
self._entries.extend(entries)
|
|
68
|
+
|
|
69
|
+
async def read_all(self) -> list[SessionEntry]:
|
|
70
|
+
async with self._lock:
|
|
71
|
+
return list(self._entries)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class JsonlSessionStorage:
|
|
75
|
+
"""基于单文件行级追加的 SessionStorage 实现。
|
|
76
|
+
|
|
77
|
+
支持 `.{name}.lock` 跨进程文件锁,以及异常退出遗留 `.tmp` 碎片的自愈清理。
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, path: Path | str) -> None:
|
|
81
|
+
self.path = Path(path)
|
|
82
|
+
self.lock_path = self.path.parent / f".{self.path.name}.lock"
|
|
83
|
+
self._async_lock = asyncio.Lock()
|
|
84
|
+
self._remove_incomplete_temp()
|
|
85
|
+
|
|
86
|
+
def _remove_incomplete_temp(self) -> list[Path]:
|
|
87
|
+
"""清理异常中断遗留的临时碎片文件。"""
|
|
88
|
+
cleaned: list[Path] = []
|
|
89
|
+
parent = self.path.parent
|
|
90
|
+
if not parent.exists():
|
|
91
|
+
return cleaned
|
|
92
|
+
|
|
93
|
+
patterns = [
|
|
94
|
+
f".{self.path.name}*.tmp",
|
|
95
|
+
f"{self.path.name}*.tmp",
|
|
96
|
+
]
|
|
97
|
+
seen: set[Path] = set()
|
|
98
|
+
for pattern in patterns:
|
|
99
|
+
for tmp_file in parent.glob(pattern):
|
|
100
|
+
if tmp_file in seen or not tmp_file.is_file():
|
|
101
|
+
continue
|
|
102
|
+
seen.add(tmp_file)
|
|
103
|
+
with contextlib.suppress(OSError):
|
|
104
|
+
tmp_file.unlink(missing_ok=True)
|
|
105
|
+
cleaned.append(tmp_file)
|
|
106
|
+
return cleaned
|
|
107
|
+
|
|
108
|
+
@contextlib.asynccontextmanager
|
|
109
|
+
async def _process_lock(self, timeout: float = 10.0) -> AsyncIterator[None]:
|
|
110
|
+
"""基于 `.{name}.lock` 的跨进程文件锁。"""
|
|
111
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
start_time = time.monotonic()
|
|
113
|
+
fd = os.open(str(self.lock_path), os.O_RDWR | os.O_CREAT)
|
|
114
|
+
try:
|
|
115
|
+
acquired = False
|
|
116
|
+
while not acquired:
|
|
117
|
+
try:
|
|
118
|
+
_lock_file(fd)
|
|
119
|
+
acquired = True
|
|
120
|
+
except (BlockingIOError, OSError, PermissionError):
|
|
121
|
+
if time.monotonic() - start_time > timeout:
|
|
122
|
+
raise SessionJsonlError(
|
|
123
|
+
f"Timeout ({timeout}s) waiting for lock on {self.lock_path}"
|
|
124
|
+
) from None
|
|
125
|
+
await asyncio.sleep(0.01)
|
|
126
|
+
try:
|
|
127
|
+
yield
|
|
128
|
+
finally:
|
|
129
|
+
_unlock_file(fd)
|
|
130
|
+
finally:
|
|
131
|
+
os.close(fd)
|
|
132
|
+
|
|
133
|
+
@contextlib.asynccontextmanager
|
|
134
|
+
async def _lock(self, timeout: float = 10.0) -> AsyncIterator[None]:
|
|
135
|
+
"""协程锁与跨进程锁的双重上下文。"""
|
|
136
|
+
async with self._async_lock, self._process_lock(timeout=timeout):
|
|
137
|
+
yield
|
|
138
|
+
|
|
139
|
+
async def append(self, entry: SessionEntry) -> None:
|
|
140
|
+
"""追加单个条目到存储末尾。"""
|
|
141
|
+
async with self._lock():
|
|
142
|
+
self._remove_incomplete_temp()
|
|
143
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
line = entry_to_json_line(entry)
|
|
145
|
+
try:
|
|
146
|
+
with open(self.path, "a", encoding="utf-8") as f:
|
|
147
|
+
f.write(line + "\n")
|
|
148
|
+
f.flush()
|
|
149
|
+
os.fsync(f.fileno())
|
|
150
|
+
except OSError as exc:
|
|
151
|
+
raise SessionJsonlError(
|
|
152
|
+
f"Failed to append to {self.path}: {exc}"
|
|
153
|
+
) from exc
|
|
154
|
+
|
|
155
|
+
async def append_batch(self, entries: Sequence[SessionEntry]) -> None:
|
|
156
|
+
"""原子追加一批条目到存储末尾。"""
|
|
157
|
+
if not entries:
|
|
158
|
+
return
|
|
159
|
+
async with self._lock():
|
|
160
|
+
self._remove_incomplete_temp()
|
|
161
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
lines = [entry_to_json_line(e) for e in entries]
|
|
163
|
+
try:
|
|
164
|
+
with open(self.path, "a", encoding="utf-8") as f:
|
|
165
|
+
for line in lines:
|
|
166
|
+
f.write(line + "\n")
|
|
167
|
+
f.flush()
|
|
168
|
+
os.fsync(f.fileno())
|
|
169
|
+
except OSError as exc:
|
|
170
|
+
raise SessionJsonlError(
|
|
171
|
+
f"Failed to append batch to {self.path}: {exc}"
|
|
172
|
+
) from exc
|
|
173
|
+
|
|
174
|
+
async def read_all(self) -> list[SessionEntry]:
|
|
175
|
+
"""读取存储中所有按追加时序排列的历史条目。"""
|
|
176
|
+
async with self._lock():
|
|
177
|
+
self._remove_incomplete_temp()
|
|
178
|
+
if not self.path.exists():
|
|
179
|
+
return []
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
with open(self.path, encoding="utf-8") as f:
|
|
183
|
+
raw_lines = f.readlines()
|
|
184
|
+
except OSError as exc:
|
|
185
|
+
raise SessionJsonlError(f"Failed to read {self.path}: {exc}") from exc
|
|
186
|
+
|
|
187
|
+
lines = [ln.strip() for ln in raw_lines if ln.strip()]
|
|
188
|
+
if not lines:
|
|
189
|
+
return []
|
|
190
|
+
|
|
191
|
+
entries: list[SessionEntry] = []
|
|
192
|
+
for i, line in enumerate(lines):
|
|
193
|
+
is_last = i == len(lines) - 1
|
|
194
|
+
try:
|
|
195
|
+
entry = entry_from_json_line(line)
|
|
196
|
+
entries.append(entry)
|
|
197
|
+
except SessionJsonlError as exc:
|
|
198
|
+
# 尾行撕裂容忍:如果是最后一行且前面已有有效条目,丢弃该损坏尾行(宽容兜底)
|
|
199
|
+
if _should_tolerate_tail_error(is_last, len(entries)):
|
|
200
|
+
break
|
|
201
|
+
raise exc
|
|
202
|
+
return entries
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ─── 私有辅助函数(对齐 Tau 内聚风格,置于模块末尾) ──────────────────────────
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _should_tolerate_tail_error(is_last: bool, count: int) -> bool:
|
|
209
|
+
"""尾行撕裂容忍:最后一行且前面已有有效条目时为 True。"""
|
|
210
|
+
return is_last and count > 0
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _lock_file(fd: int) -> None:
|
|
214
|
+
"""底层文件锁适配(非阻塞排他锁),内部按需判断操作系统。"""
|
|
215
|
+
if sys.platform == "win32":
|
|
216
|
+
import msvcrt
|
|
217
|
+
|
|
218
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
import fcntl
|
|
222
|
+
|
|
223
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _unlock_file(fd: int) -> None:
|
|
227
|
+
"""底层文件解锁适配。"""
|
|
228
|
+
if sys.platform == "win32":
|
|
229
|
+
import msvcrt
|
|
230
|
+
|
|
231
|
+
with contextlib.suppress(OSError):
|
|
232
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
import fcntl
|
|
236
|
+
|
|
237
|
+
with contextlib.suppress(OSError):
|
|
238
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
__all__ = [
|
|
242
|
+
"SessionStorage",
|
|
243
|
+
"InMemorySessionStorage",
|
|
244
|
+
"JsonlSessionStorage",
|
|
245
|
+
]
|