deepseek-bot 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """DeepSeek Bot:自进化个人智能体运行时(前代号 naga,2026-11 定名)。"""
2
+
3
+ __version__ = "0.2.0"
deepseek_bot/agent.py ADDED
@@ -0,0 +1,412 @@
1
+ """L1 任务回路(M1 批次 2,主文档 §5.1 的最小形态)。
2
+
3
+ 回路:装配上下文(系统提示 + 技能检索)→ LLM 决策 → 工具调用(经
4
+ ctx.perform 入账)→ 结果回填 → 终答 | 步数/预算上限。
5
+ 每步进 TraceRecorder(M1 出口:ReplayBundle 非空);预算逐调用
6
+ consume(prompt+completion 全额——deepseek 推理型模型的 completion 含
7
+ reasoning 消耗)。QuotaExhausted → outcome=aborted。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import itertools
13
+ import json
14
+ import re
15
+ import time
16
+ import uuid
17
+ from typing import Any
18
+
19
+ from pydantic import BaseModel
20
+
21
+ from deepseek_bot import kernel
22
+ from deepseek_bot.branch import SessionBranch
23
+ from deepseek_bot.ledger_store import DurableLedger
24
+ from deepseek_bot.llm import ModelAdapter, ToolSpec, price_cents
25
+ from deepseek_bot.memory import (
26
+ MEMORY_DISTILL_MIN_TOOL_CALLS,
27
+ MemoryStore,
28
+ new_memory_id,
29
+ parse_distilled,
30
+ render_memory_section,
31
+ )
32
+ from deepseek_bot.skills import SKILL_MAX_BYTES, SkillStore
33
+ from deepseek_bot.trace import TraceRecorder
34
+
35
+ SKILL_DISTILL_MIN_TOOL_CALLS = 5 # spec-constants skill_distill_min_tool_calls / H.4
36
+
37
+
38
+ def keywords_of(goal: str) -> list[str]:
39
+ words = [w for w in re.split(r"[^\w]+", goal) if len(w) >= 2]
40
+ cjk = re.findall(r"[\u4e00-\u9fff]", goal)
41
+ return words + [a + b for a, b in itertools.pairwise(cjk)]
42
+
43
+
44
+
45
+ def cluster_of(goal: str) -> str:
46
+ """任务簇(E.2):确定性分簇——CJK 二元组优先、拉丁词(≥3)次之,前 3 个;
47
+ 整句 CJK 长词不入簇(太具体,簇会碎片化)。"""
48
+ keywords = keywords_of(goal)
49
+ cjk_bigrams = [k for k in keywords
50
+ if len(k) == 2 and re.search(r"[\u4e00-\u9fff]", k)]
51
+ latin = [k.lower() for k in re.split(r"[^\w]+", goal)
52
+ if len(k) >= 3 and not re.search(r"[\u4e00-\u9fff]", k)]
53
+ parts = (cjk_bigrams + latin)[:3]
54
+ return "-".join(parts)[:48] or "default"
55
+ SKILL_INJECT_MAX_CHARS = 2000 # 候选 spec-constants skill_inject_max_chars:技能正文注入预算
56
+
57
+
58
+ class AgentResult(BaseModel):
59
+ reply: str | None
60
+ outcome: str # success | aborted
61
+ trace_id: str
62
+ steps: int
63
+ tokens: int
64
+ cost_cents: int
65
+ failure_analysis: str | None = None
66
+
67
+
68
+ def _noop_revert() -> None:
69
+ return None
70
+
71
+
72
+ class AgentLoop:
73
+ """单任务执行体(M1:CLI 直驱;会话/lane 归属批次 3)。"""
74
+
75
+ def __init__(self, *, ledger: DurableLedger, ctx: kernel.Context,
76
+ budget: kernel.Budget, adapter: ModelAdapter,
77
+ tools: list[Any] | None = None, skill_store: SkillStore | None = None,
78
+ session_key: str = "cli:local:owner", model_id: str = "deepseek-v4-flash",
79
+ max_steps: int = 8, trace_id: str | None = None,
80
+ actor_trust: str | None = None,
81
+ max_tokens: int = 8192, trace_parent: str | None = None,
82
+ branch: SessionBranch | None = None,
83
+ forced_skills: list[tuple[str, str]] | None = None,
84
+ meter_skills: bool = True,
85
+ memory_store: MemoryStore | None = None):
86
+ self._ledger = ledger
87
+ self._ctx = ctx
88
+ self._budget = budget
89
+ self._adapter = adapter
90
+ self._tools = {t.name: t for t in (tools or [])}
91
+ self._skills = skill_store
92
+ self._memory = memory_store
93
+ self._session_key = session_key
94
+ self._model_id = model_id
95
+ self._max_steps = max_steps
96
+ self._trace_id = trace_id
97
+ self._actor_trust = actor_trust
98
+ self._max_tokens = max_tokens # 推理模型:reasoning+content 共享上限,须给足
99
+ self._trace_parent = trace_parent
100
+ self._branch = branch
101
+ self._forced = forced_skills or [] # 影子验证:强制注入的候选 (uri, content)
102
+ self._meter_skills = meter_skills # 影子运行不计量(非真实使用)
103
+ self._step_seq = 0
104
+
105
+ # ---- 技能检索(local:{session} + main 双分支;拉丁词 + CJK 二元组) ----
106
+ @staticmethod
107
+ def _keywords(goal: str) -> list[str]:
108
+ words = [w for w in re.split(r"[^\w]+", goal) if len(w) >= 2]
109
+ cjk = re.findall(r"[\u4e00-\u9fff]", goal)
110
+ return words + [a + b for a, b in itertools.pairwise(cjk)]
111
+
112
+ def _select_skills(self, goal: str, limit: int = 3
113
+ ) -> list[tuple[str, str]]:
114
+ """检索命中技能为 (uri, content) 对——正文随系统提示注入(M4a-1:
115
+ 仅列 URI 对模型行为零影响,技能必须真正进入上下文才构成资产)。"""
116
+ if self._skills is None:
117
+ return []
118
+ keywords = keywords_of(goal)
119
+ scored: list[tuple[int, str, str, str]] = [] # (score, uri, cas_id, content)
120
+ seen: set[str] = set()
121
+ for branch in (f"local:{self._session_key}", "main"):
122
+ for cas, name, _, _, _ in self._skills.list(branch=branch):
123
+ if name in seen:
124
+ continue
125
+ got = self._skills.get(name, branch=branch)
126
+ if got is None:
127
+ continue
128
+ meta, content = got
129
+ text = (name + " " + content).lower()
130
+ score = sum(1 for k in keywords if k.lower() in text)
131
+ if score > 0:
132
+ scored.append((score, f"skill://{name}@v{meta.version}", cas, content))
133
+ seen.add(name)
134
+ scored.sort(key=lambda p: (-p[0], p[1]))
135
+ picked = scored[:limit]
136
+ for _, _uri, cas, _body in picked: # 命中即计量(uses/lastUsed,投影确定性更新)
137
+ if self._meter_skills:
138
+ self._ledger.append("skill.use", "root", {"cas_id": cas})
139
+ return [(uri, content) for _, uri, _, content in picked]
140
+
141
+ def _system_prompt(self, skills: list[tuple[str, str]],
142
+ forced: list[tuple[str, str]] | None = None,
143
+ memory_block: str = "") -> str:
144
+ lines = [
145
+ ("你是 DeepSeek Bot 的任务执行智能体。"
146
+ "能直接回答时直接回答;需要外部事实或资料时调用工具。"),
147
+ ("运行时能力(不要否认自己拥有它们,细节用 system_status 查):"
148
+ "定时任务调度器(job_create 可直接建)、两层记忆、技能库、"
149
+ "通知补发队列。"),
150
+ ]
151
+ if memory_block:
152
+ lines += ["", memory_block] # §6.5 读取路径:召回注入(背景上下文)
153
+ sections = [(uri, content) for uri, content in skills]
154
+ if forced:
155
+ sections = forced + sections # 影子验证:候选强制注入(不受检索命中约束)
156
+ if sections:
157
+ lines += ["", "## 可参考技能(按要点执行)"]
158
+ for uri, content in sections:
159
+ body = content[:SKILL_INJECT_MAX_CHARS]
160
+ if len(content) > SKILL_INJECT_MAX_CHARS:
161
+ body += "\n…(截断)"
162
+ lines += [f"### {uri}", body]
163
+ return "\n".join(lines)
164
+
165
+ async def _call_tool(self, tc: dict[str, Any]) -> tuple[Any, bool, int, Any]:
166
+ name = str(tc.get("name", ""))
167
+ tool = self._tools.get(name)
168
+ raw = tc.get("arguments")
169
+ t0 = time.monotonic()
170
+ args: Any = None
171
+ try:
172
+ args = json.loads(raw or "{}")
173
+ if tool is None:
174
+ raise ValueError(f"unknown tool: {name}")
175
+
176
+ async def _apply(tool: Any = tool, args: dict[str, Any] = args) -> Any:
177
+ return await tool.run(args)
178
+
179
+ result: Any = await self._ctx.perform(f"tool:{name}", _apply, _noop_revert)
180
+ ok = True
181
+ except Exception as err: # noqa: BLE001 —— 工具失败=ok=False 步(K.1),
182
+ # 不得杀死任务:网络错/worker 崩溃/参数错一律降级续跑(出口③前提)
183
+ if args is None: # 参数不可解析(#97):截断原始串入轨迹,循环不炸
184
+ args = raw[:200] if isinstance(raw, str) else raw
185
+ result = {"error": f"{type(err).__name__}: {err}"}
186
+ ok = False
187
+ return result, ok, int((time.monotonic() - t0) * 1000), args
188
+
189
+ async def run(self, goal: str) -> AgentResult:
190
+ skills = self._select_skills(goal)
191
+ memories = (self._memory.recall(self._session_key, goal)
192
+ if self._memory is not None else [])
193
+ loaded_uris = [uri for uri, _ in skills] + [uri for uri, _ in self._forced]
194
+ messages: list[dict[str, Any]] = [
195
+ {"role": "system", "content": self._system_prompt(
196
+ skills, self._forced, render_memory_section(memories))},
197
+ {"role": "user", "content": goal},
198
+ ]
199
+ tool_specs = [ToolSpec(name=t.name, description=t.description,
200
+ parameters=t.parameters) for t in self._tools.values()]
201
+ trace_id = self._trace_id or ("tr_" + uuid.uuid4().hex[:24])
202
+ rec = TraceRecorder(self._ledger,
203
+ trace_id=trace_id,
204
+ session_key=self._session_key, inbound={"text": goal},
205
+ skills_loaded=loaded_uris,
206
+ env={"model": self._model_id,
207
+ "channel": self._session_key.split(":", 1)[0],
208
+ "actor_trust": self._actor_trust,
209
+ "memories": [m.cas_id for m in memories]},
210
+ parent_trace=self._trace_parent)
211
+ self._step_seq = 0
212
+ self._tool_steps = 0
213
+ self._tool_log: list[dict[str, Any]] = []
214
+ tokens = 0
215
+ cost = 0
216
+ aborted: str | None = None
217
+ try:
218
+ for _ in range(self._max_steps):
219
+ t0 = time.monotonic()
220
+ resp = await self._adapter.chat(messages, tool_specs or None,
221
+ max_tokens=self._max_tokens)
222
+ ms = int((time.monotonic() - t0) * 1000)
223
+ usage = resp.prompt_tokens + resp.completion_tokens
224
+ self._budget.consume(usage)
225
+ tokens += usage
226
+ cost += price_cents(self._model_id, resp.prompt_tokens, resp.completion_tokens)
227
+ self._step_seq += 1
228
+ rec.step(self._step_seq, "llm", self._model_id, True, ms, usage,
229
+ args={"turn": self._step_seq},
230
+ results={"content": resp.content,
231
+ "tool_calls": [tc["name"] for tc in resp.tool_calls]})
232
+ if resp.tool_calls:
233
+ # 回填必须用 OpenAI 完整格式(id/type/function 嵌套)——简化 dict 会被 422 拒
234
+ messages.append({"role": "assistant", "content": resp.content or "",
235
+ "tool_calls": [
236
+ {"id": tc["id"], "type": "function",
237
+ "function": {"name": tc["name"],
238
+ "arguments": tc["arguments"]}}
239
+ for tc in resp.tool_calls]})
240
+ for tc in resp.tool_calls:
241
+ # 解析在 _call_tool 内(#97):坏 JSON 参数=ok=False 失败步,
242
+ # 模型可看到错误并重试,轨迹/分支照常收尾——循环不再裸解析
243
+ result, ok, tool_ms, args = await self._call_tool(tc)
244
+ self._tool_steps += 1
245
+ self._tool_log.append({"tool": str(tc["name"]), "args": args})
246
+ self._step_seq += 1
247
+ rec.step(self._step_seq, "tool", str(tc["name"]), ok, tool_ms, 0,
248
+ args=args, results=result)
249
+ messages.append({"role": "tool", "tool_call_id": tc["id"],
250
+ "content": json.dumps(result, ensure_ascii=False,
251
+ default=str)})
252
+ continue
253
+ messages.append({"role": "assistant", "content": resp.content or ""})
254
+ reply = resp.content or ""
255
+ # 沉淀判定(H.4:≥5 工具调用才值得记;§6.4 默认局部——local:{session} 分支)
256
+ if self._skills is not None and self._tool_steps >= SKILL_DISTILL_MIN_TOOL_CALLS:
257
+ sed = await self._sediment(goal, reply, rec, trace_id)
258
+ tokens += sed["tokens"]
259
+ cost += sed["cost_cents"]
260
+ if self._memory is not None and self._tool_steps >= MEMORY_DISTILL_MIN_TOOL_CALLS:
261
+ mem = await self._memorize(goal, reply, rec, trace_id)
262
+ tokens += mem["tokens"]
263
+ cost += mem["cost_cents"]
264
+ trace = rec.finish(outcome="success", cost_tokens=tokens,
265
+ cost_cents=cost,
266
+ task_cluster=cluster_of(goal))
267
+ self._settle_branch("commit", trace_id)
268
+ return AgentResult(reply=reply, outcome="success",
269
+ trace_id=str(trace["trace_id"]), steps=self._step_seq,
270
+ tokens=tokens, cost_cents=cost)
271
+ aborted = "步数上限"
272
+ except kernel.QuotaExhausted:
273
+ aborted = "预算耗尽"
274
+ trace = rec.finish(outcome="aborted", cost_tokens=tokens,
275
+ cost_cents=cost, failure_analysis=aborted,
276
+ task_cluster=cluster_of(goal))
277
+ self._settle_branch("discard", trace_id)
278
+ return AgentResult(reply=None, outcome="aborted",
279
+ trace_id=str(trace["trace_id"]), steps=self._step_seq,
280
+ tokens=tokens, cost_cents=cost, failure_analysis=aborted)
281
+
282
+ def _settle_branch(self, action: str, trace_id: str | None) -> None:
283
+ """任务末分支裁决(M3-2):根分支 commit/discard;子分支保持 open,
284
+ 裁决权属父会话(§5.4 人走茶不凉)——父级联时一并处理。"""
285
+ if self._branch is None or self._branch.parent is not None:
286
+ return
287
+ try:
288
+ if action == "commit":
289
+ self._branch.commit(trace_id)
290
+ else:
291
+ self._branch.discard()
292
+ except ValueError:
293
+ pass # 分支已被外部处置(如 owner 介入):尊重现状
294
+
295
+ # ---- 技能沉淀(④ 类蒸馏调用,H.1;§6.4 默认局部) ----
296
+ @staticmethod
297
+ def _slug(text: str, fallback: str) -> str:
298
+ s = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")[:40]
299
+ return s or fallback
300
+
301
+ async def _sediment(self, goal: str, reply: str, rec: TraceRecorder,
302
+ trace_id: str) -> dict[str, int]:
303
+ """蒸馏任务经验为 SKILL.md,提交 local:{session} 分支;返回计量。"""
304
+ t0 = time.monotonic()
305
+ prompt = (
306
+ "根据以下任务执行记录,为\"下次同类任务直接复用\"沉淀一份技能文档。\n"
307
+ "第一行输出技能 ID(格式:id: <短横线小写英文>),随后是 Markdown 正文,"
308
+ "必须包含三个二级标题节:## 何时使用、## 步骤、## 已知陷阱。\n\n"
309
+ f"任务:{goal}\n"
310
+ f"执行记录:{json.dumps(self._tool_log, ensure_ascii=False, default=str)}\n"
311
+ f"最终结果:{reply}")
312
+ try:
313
+ resp = await self._adapter.chat([{"role": "user", "content": prompt}],
314
+ None, max_tokens=self._max_tokens)
315
+ except kernel.QuotaExhausted:
316
+ self._step_seq += 1
317
+ rec.step(self._step_seq, "llm", "skill-distill", False,
318
+ int((time.monotonic() - t0) * 1000), 0,
319
+ args={"goal": goal}, results={"error": "预算耗尽,跳过沉淀"})
320
+ return {"tokens": 0, "cost_cents": 0}
321
+ usage = resp.prompt_tokens + resp.completion_tokens
322
+ try:
323
+ self._budget.consume(usage)
324
+ except kernel.QuotaExhausted:
325
+ return {"tokens": 0, "cost_cents": 0}
326
+ body = (resp.content or "").strip()
327
+ m = re.match(r"^id:\s*([a-z0-9][a-z0-9-]*)\s*\n", body)
328
+ skill_id = m.group(1) if m else self._slug(goal, f"skill-{trace_id[-6:]}")
329
+ if m:
330
+ body = body[m.end():]
331
+ content = f"---\nname: {skill_id}\n---\n{body}\n"
332
+ data = content.encode("utf-8")
333
+ if len(data) > SKILL_MAX_BYTES: # 按字节截断(CJK 多字节;15KB 硬限在 commit 兜底)
334
+ content = data[:SKILL_MAX_BYTES - 1].decode("utf-8", errors="ignore") + "\n"
335
+ ok = True
336
+ try:
337
+ if self._branch is not None:
338
+ # M3-2:沉淀写进会话分支(覆盖层),任务末由 commit 物化/discard 丢弃
339
+ self._branch.put(f"skill:{skill_id}", content.encode("utf-8"))
340
+ else:
341
+ assert self._skills is not None
342
+ self._skills.commit(skill_id, content,
343
+ provenance={"source": "agent", "trace_id": trace_id},
344
+ branch=f"local:{self._session_key}")
345
+ except (ValueError, AssertionError):
346
+ ok = False
347
+ self._step_seq += 1
348
+ rec.step(self._step_seq, "llm", "skill-distill", ok,
349
+ int((time.monotonic() - t0) * 1000), usage,
350
+ args={"goal": goal}, results={"skill": skill_id if ok else None})
351
+ return {"tokens": usage, "cost_cents": price_cents(self._model_id, usage, 0)}
352
+
353
+ # ---- 记忆提炼(④ 类调用 1 次,§6.5 情景层;semantic 仅 owner) ----
354
+ async def _memorize(self, goal: str, reply: str, rec: TraceRecorder,
355
+ trace_id: str) -> dict[str, int]:
356
+ """任务收尾提炼:一次 LLM 调用产出 `[E]` 事件摘要(全信任级)与
357
+ `[S]` 长期事实(仅 owner 采纳);写经会话分支 COW(§6.5 并行语义,
358
+ commit 才物化——aborted 任务本就不走此路径,discard 亦不留记忆)。"""
359
+ t0 = time.monotonic()
360
+ prompt = (
361
+ "根据以下任务执行记录提炼记忆。\n"
362
+ "第一行:[E] <本次任务的一句话事件摘要(做了什么、结果如何,40字内)>\n"
363
+ "随后每行一条:[S] <值得长期记住的用户偏好或事实>(最多3条,没有就不输出)\n"
364
+ "只输出以上行,不要多余解释。\n\n"
365
+ f"任务:{goal}\n"
366
+ f"执行记录:{json.dumps(self._tool_log, ensure_ascii=False, default=str)}\n"
367
+ f"最终结果:{reply}")
368
+ try:
369
+ resp = await self._adapter.chat([{"role": "user", "content": prompt}],
370
+ None, max_tokens=self._max_tokens)
371
+ except kernel.QuotaExhausted:
372
+ self._step_seq += 1
373
+ rec.step(self._step_seq, "llm", "memory-distill", False,
374
+ int((time.monotonic() - t0) * 1000), 0,
375
+ args={"goal": goal}, results={"error": "预算耗尽,跳过提炼"})
376
+ return {"tokens": 0, "cost_cents": 0}
377
+ usage = resp.prompt_tokens + resp.completion_tokens
378
+ try:
379
+ self._budget.consume(usage)
380
+ except kernel.QuotaExhausted:
381
+ return {"tokens": 0, "cost_cents": 0}
382
+ episodic, semantic = parse_distilled((resp.content or "").strip())
383
+ if self._actor_trust not in (None, "owner"):
384
+ semantic = [] # §6.5 写入闸门:非 owner 的 [S] 不采纳(store 兜底)
385
+ written = 0
386
+ if episodic:
387
+ written += self._memory_put("episodic", episodic, trace_id)
388
+ for fact in semantic:
389
+ written += self._memory_put("semantic", fact, trace_id)
390
+ self._step_seq += 1
391
+ rec.step(self._step_seq, "llm", "memory-distill", True,
392
+ int((time.monotonic() - t0) * 1000), usage,
393
+ args={"goal": goal},
394
+ results={"items": written, "episodic": bool(episodic),
395
+ "semantic": len(semantic)})
396
+ return {"tokens": usage,
397
+ "cost_cents": price_cents(self._model_id, usage, 0)}
398
+
399
+ def _memory_put(self, layer: str, content: str, trace_id: str) -> int:
400
+ """单条落地:有分支走 COW(commit 物化),无分支直写(与 _sediment 同构)。"""
401
+ assert self._memory is not None # 调用点已判空
402
+ provenance = {"source": "agent", "trace_id": trace_id}
403
+ try:
404
+ if self._branch is not None:
405
+ self._branch.put(
406
+ f"memory:{layer}:{new_memory_id()}", content.encode("utf-8"))
407
+ return 1
408
+ self._memory.write(layer, self._session_key, content, provenance,
409
+ actor_trust=self._actor_trust)
410
+ return 1
411
+ except (ValueError, OSError):
412
+ return 0 # 闸门/体检/尺寸拒绝:跳过该条,不杀任务
deepseek_bot/branch.py ADDED
@@ -0,0 +1,205 @@
1
+ """Session branches — the in-process COW write-capture overlay (§6.2/K.2, M3-2).
2
+
3
+ One active sessionKey binds one branch: **read global, write local**. The
4
+ overlay stores only the diff against the base (overlay_ops: put/del — a
5
+ branch is a diff, not a copy), so untouched keys cost nothing. Session end is
6
+ an explicit two-way choice (K.2): **commit** (diff persisted to CAS as a
7
+ manifest linked to the trace; sink materializes the writes) or **discard**
8
+ (dropped wholesale, zero global effect — the environment is restored).
9
+
10
+ Kill semantics (§5.4): a killed sub-agent's branch stays **open** — the
11
+ commit/discard decision belongs to the parent session ("人走茶不凉,资产留
12
+ 下"). When the parent commits, open child branches cascade-commit; when the
13
+ parent discards, open child branches are discarded with it (ledger-recorded);
14
+ already-committed assets are unaffected either way.
15
+
16
+ Naga's honest form of K.2: agents never touch the raw filesystem — every
17
+ write goes through the runtime — so write capture is this in-process layer,
18
+ and the ≤10% SLO (R3-4/K.2, 100 concurrent sessions) is measured against it.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import uuid
25
+ from collections.abc import Callable
26
+ from typing import Any
27
+
28
+ # 写捕获 SLO(R3-4/K.2):拦截开销 ≤10%,百并发实测——M3 出口③
29
+ WRITE_CAPTURE_OVERHEAD_SLO = 0.10
30
+
31
+ BRANCH_MANIFEST_SCHEMA = "naga-branch/0"
32
+ # manifest 为裸 dict(K.2 SLO:结算路径不建模型):{schema, branch_id,
33
+ # owner_session, trace_id, ops:[{op, key, value_cas}], parent_branch}
34
+
35
+
36
+ class SessionBranch:
37
+ """一条 COW 经验分支:overlay 覆盖 base(全局视图),只存差异。
38
+
39
+ base_get/base_has:全局只读视图(会话间不共享内存态,仅经全局库通信)。
40
+ sink_put/sink_delete:commit 时的物化回调(如 SkillStore.commit)。
41
+ """
42
+
43
+ def __init__(self, *, owner_session: str,
44
+ base_get: Callable[[str], bytes | None],
45
+ base_has: Callable[[str], bool],
46
+ cas_put: Callable[[bytes], str],
47
+ sink_put: Callable[[str, bytes], Any] | None = None,
48
+ sink_delete: Callable[[str], Any] | None = None,
49
+ parent: SessionBranch | None = None,
50
+ ledger_append: Callable[[str, str, dict[str, Any]], Any] | None = None,
51
+ branch_id: str | None = None):
52
+ self.branch_id = branch_id or "br_" + uuid.uuid4().hex[:12]
53
+ self.owner_session = owner_session
54
+ self._base_get = base_get
55
+ self._base_has = base_has
56
+ self._cas_put = cas_put
57
+ self._sink_put = sink_put
58
+ self._sink_delete = sink_delete
59
+ self._parent = parent
60
+ self._ledger_append = ledger_append
61
+ self._overlay: dict[str, bytes | None] = {} # None = 墓碑(delete)
62
+ self._children: list[SessionBranch] = []
63
+ self._state = "open" # open | committed | discarded
64
+ if parent is not None:
65
+ parent._children.append(self)
66
+
67
+ # ---- 生命周期 ----
68
+
69
+ @property
70
+ def state(self) -> str:
71
+ return self._state
72
+
73
+ @property
74
+ def parent(self) -> SessionBranch | None:
75
+ return self._parent
76
+
77
+ def spawn_child(self, owner_session: str | None = None) -> SessionBranch:
78
+ """派生子分支(同一 base/CAS/sink 上下文,挂父链供级联裁决)。"""
79
+ self._require_open()
80
+ return SessionBranch(
81
+ owner_session=owner_session or self.owner_session,
82
+ base_get=self._base_get, base_has=self._base_has,
83
+ cas_put=self._cas_put, sink_put=self._sink_put,
84
+ sink_delete=self._sink_delete, parent=self,
85
+ ledger_append=self._ledger_append)
86
+
87
+ # ---- COW 读写(读全局写本地) ----
88
+
89
+ def get(self, key: str) -> bytes | None:
90
+ """overlay ∪ base,overlay 胜;墓碑=已删除。"""
91
+ if key in self._overlay:
92
+ return self._overlay[key]
93
+ return self._base_get(key)
94
+
95
+ def has(self, key: str) -> bool:
96
+ if key in self._overlay:
97
+ return self._overlay[key] is not None
98
+ return self._base_has(key)
99
+
100
+ def put(self, key: str, value: bytes) -> None:
101
+ """写捕获(K.2 拦截路径,纯内存——CAS 推迟到 commit):只记差异,
102
+ 不触全局;与全局一致则消除差异。"""
103
+ self._require_open()
104
+ if self._base_get(key) == value:
105
+ self._overlay.pop(key, None) # 与全局一致:不产生差异
106
+ return
107
+ self._overlay[key] = value
108
+
109
+ def delete(self, key: str) -> None:
110
+ self._require_open()
111
+ if not self._base_has(key):
112
+ self._overlay.pop(key, None) # 全局本就无此键:无差异
113
+ return
114
+ self._overlay[key] = None # 墓碑
115
+
116
+ def diff_keys(self) -> list[str]:
117
+ """差异键集合(未动的键零开销——分支=差异而非副本)。"""
118
+ return list(self._overlay.keys())
119
+
120
+ # ---- 会话结束二选一(K.2) ----
121
+
122
+ def commit(self, trace_id: str | None = None) -> str | None:
123
+ """物化 sink(返回 CAS 则 manifest 引用之,杜绝同内容双写)+ manifest
124
+ 入 CAS;级联:open 子分支一并 commit。op 集合由 overlay 终态导出
125
+ (同键多次写=终态一次,天然去重)。"""
126
+ self._require_open()
127
+ cascaded = self._cascade("commit", trace_id)
128
+ ops: list[dict[str, Any]] = []
129
+ for key, value in self._overlay.items():
130
+ if value is None:
131
+ if self._sink_delete is not None:
132
+ self._sink_delete(key)
133
+ ops.append({"op": "del", "key": key})
134
+ continue
135
+ cas: Any = None
136
+ if self._sink_put is not None:
137
+ cas = self._sink_put(key, value) # 物化即持久化:引用其 CAS
138
+ if not isinstance(cas, str) or not cas:
139
+ cas = self._cas_put(value) # 无 sink/无返回:分支自持
140
+ ops.append({"op": "put", "key": key, "value_cas": cas})
141
+ cas_id: str | None = None
142
+ if ops:
143
+ manifest = {"schema": BRANCH_MANIFEST_SCHEMA,
144
+ "branch_id": self.branch_id,
145
+ "owner_session": self.owner_session,
146
+ "trace_id": trace_id, "ops": ops,
147
+ "parent_branch": (self._parent.branch_id
148
+ if self._parent else None)}
149
+ cas_id = self._cas_put(
150
+ json.dumps(manifest, ensure_ascii=False,
151
+ separators=(",", ":")).encode())
152
+ self._state = "committed"
153
+ # commit 不另入账:manifest 已入 CAS、物化自带 skill.commit 事件
154
+ # (审计轨迹完备);fsync 级账本追加会令捕获开销翻倍,违反 K.2 SLO 本意
155
+ del cascaded
156
+ return cas_id
157
+
158
+ def discard(self) -> None:
159
+ """整体丢弃(环境还原);级联:open 子分支陪葬(入账)。"""
160
+ self._require_open()
161
+ cascaded = self._cascade("discard", None)
162
+ self._overlay.clear()
163
+ self._state = "discarded"
164
+ self._record("branch.discarded", {"cascaded_children": cascaded})
165
+
166
+ def _cascade(self, action: str, trace_id: str | None) -> int:
167
+ """§5.4:父 commit → open 子分支级联 commit;父 discard → 陪葬。"""
168
+ count = 0
169
+ for child in self._children:
170
+ if child._state != "open":
171
+ continue
172
+ if action == "commit":
173
+ child.commit(trace_id)
174
+ else:
175
+ child.discard()
176
+ count += 1
177
+ return count
178
+
179
+ def _require_open(self) -> None:
180
+ if self._state != "open":
181
+ raise ValueError(f"branch {self.branch_id} 已 {self._state},不可再操作")
182
+
183
+ def _record(self, kind: str, payload: dict[str, Any]) -> None:
184
+ if self._ledger_append is not None:
185
+ try:
186
+ self._ledger_append(kind, self.owner_session, payload)
187
+ except OSError:
188
+ pass # 分支语义不因账本 IO 抖动中断(事件尽力入账)
189
+
190
+
191
+ class BranchPool:
192
+ """会话→活跃分支的登记表(gateway 每任务建分支;会话级常驻绑定属会话注册表批次)。"""
193
+
194
+ def __init__(self) -> None:
195
+ self._branches: dict[str, SessionBranch] = {}
196
+
197
+ def bind(self, branch: SessionBranch) -> SessionBranch:
198
+ self._branches[branch.owner_session] = branch
199
+ return branch
200
+
201
+ def of(self, owner_session: str) -> SessionBranch | None:
202
+ return self._branches.get(owner_session)
203
+
204
+ def unbind(self, owner_session: str) -> None:
205
+ self._branches.pop(owner_session, None)