第 13 章:Cordis 进阶 —— Service 基类、intercept 与 LoggerService¶
对应 dsh 真实源码:
vendor/cordis/src/service.ts+context.ts+logger.ts前置:第 2 章(Context 服务仓库 + 事件总线 + 四种派发)。产出文件:miniharness/core/scope.py(Service/LoggerService/Context.extend/Context.isolate/Context.intercept)。
13.1 这一章要做什么¶
第 2 章把 Context 讲成"服务仓库 + 事件总线 + 副作用栈"三合一容器:服务就是往 _reflect_store 里塞一个值,靠 provide/get 按隔离标签存取。这对"一个函数、一张表、一个客户端"足够了,但 dsh 里大多数服务是有形状的:
- 它要在构造时自动登记、随拥有 fiber 卸载自动注销(你不该手动记得去 dispose 它);
- 它可能是可调用的(
ctx.logger(name)返回一个具名 Logger,而不是ctx.logger自己就是 Logger); - 它可能有可用性谓词(某些服务只在特定条件下才"存在");
- 它可能携带每插件可覆写的配置(同一服务在不同作用域下读到不同参数)。
这些约定如果每个服务自己手写一遍,既重复又容易漂移。Cordis 用两样东西把它们收口:
(1)
Service基类:服务的"构造即注册、可调用、可配置"模板,同vendor/cordis/src/service.ts。子类几乎只填_invoke/_check/_init,其余由基类兜底。(2)
intercept/extend/isolate三兄弟:不改父上下文、只给孩子上下文叠一层"额外配置 / 自有属性 / 隔离标签"的子上下文工厂(同vendor/cordis/src/context.ts)。其中intercept专门给服务注入 per-plugin 配置,是 2.4 讲的"_resolve_config沿祖先链合并"那套约定的入口。
本章把这三块讲透。它们已经在 mini 里全量实现,但此前只在 architecture.md 的映射行登记,没有像第 2 章那样逐项解读——本章补上。
13.2 概念:服务为什么需要基类¶
裸 provide 只解决"值存在哪、怎么取"。服务想要的更多:
| 诉求 | 裸 provide |
Service 基类 |
|---|---|---|
| 构造时登记、随 fiber 注销 | 需手动 effect(disposer) |
构造即 provide,fiber 拆解自动收回 |
可调用(如 ctx.logger(name)) |
值本身得是 callable | 定义 _invoke 即可,基类 __call__ 转发 |
| 可用性谓词(条件存在) | 无 | 定义 _check,透传给 provide |
| 每插件可覆写配置 | 无 | intercept 注入 + _resolve_config 合并 |
核心洞察:服务是"带生命周期的对象",不是"一个值"。Service 基类把"生命周期"从每个服务里抽出来,统一交给 fiber——你写服务只关心行为,注册/注销交给 super().__init__。
13.3 代码 step-by-step¶
步骤 1:Service 基类 —— 构造即登记¶
class Service:
provide: str | None = None # 缺省服务名(name 缺省时取用)
def __init__(self, ctx, name=None):
name = name or type(self).provide
if name is None:
raise TypeError("service must declare a name")
self.ctx = ctx
self.name = name
ctx.provide(name, self, getattr(self, "_check", None))
init = getattr(self, "_init", None)
if callable(init):
init()
对照上游 service.ts:42-59:constructor(ctx, name) 里 self.ctx.reflect.provide(name, self, this[Service.check])。差异只在 Python 把 symbols.check 写成普通属性 _check——语义一致:子类可定义 _check 作为可用性谓词,透传给 provide。
两个子类约定:
_invoke:定义后实例可调用。基类__call__直接转发:
def __call__(self, *args, **kwargs):
invoke = getattr(self, "_invoke", None)
if invoke is None:
raise TypeError(f"service {self.name!r} is not callable")
return invoke(*args, **kwargs)
这解释了为什么 ctx.logger(一个 LoggerService 实例)既能被 ctx.logger(name) 调用返回具名 Logger,又能直接 ctx.logger.warn(...)——后者是 _invoke 默认产出当前 fiber 名的 Logger 后再调 .warn(见步骤 3)。
_init:构造后运行(上游symbols.init,类插件场景)。mini 在__init__末尾init()调用,同"构造后"语义。
为什么"构造即注册"而不是"调用方负责注册"?因为服务实例一旦被创建,它的存在就该立刻对依赖它的 fiber 可见(触发 epoch 重载,见 2.5 步骤 5)。如果交给调用方手动 provide,很容易漏注册或重复注册——基类把这条路径焊死。
步骤 2:_resolve_config —— 沿 intercept 链合并配置¶
服务常常需要"可覆写配置"。Cordis 的做法是:配置不放在服务自己身上,而是散布在祖先上下文的 intercept 表里,服务在被调用时现合并。
def _resolve_config(self, base=None, head=None, ctx=None):
ctx = ctx or self.ctx
configs = list(ctx._resolve_intercept(self.name)) # 祖先链,近根者优先
if base is not None:
configs.insert(0, base)
if head is not None:
configs.append(head)
merged = {}
for config in configs:
if config:
merged.update(config)
return merged
_resolve_intercept(在 Context 上)沿 parent 链收集 name 的 intercept 条目,近根者优先(同 service.ts:86-102 的 prototype 链 unshift 走查):
def _resolve_intercept(self, name):
nodes, node = [], self
while node is not None:
nodes.append(node)
node = node.parent
configs = []
for node in reversed(nodes): # 近根者先
config = getattr(node, "_intercept", {})
if config and name in config:
configs.append(config[name])
return configs
合并顺序是近根者优先、base 最前、head 最后(上游同款:祖先条目先 unshift,base 前置、head 后置)。这意味着"越靠近被调用点的 intercept 越有话语权"——父级给默认,子级能覆盖。
步骤 3:LoggerService —— 内置可调用的日志服务¶
LoggerService 是 Service 基类的标准样板,也是理解 _invoke / intercept / exporter 三件事的最佳实例(上游 logger.ts:194-270)。
class LoggerService(Service):
provide = "logger"
buffer_size = 1000
def __init__(self, ctx):
self.buffer = []
self.exporters = {}
self._sn_message = 0
self._sn_exporter = 0
super().__init__(ctx, "logger") # 构造即登记 "logger"
self.exporter({"colors": 3, "export": self._buffer_append}) # 默认缓冲导出器
def _invoke(self, name=None, ctx=None):
ctx = ctx or self.ctx
config = self._resolve_config(ctx=ctx) # 解析该上下文的 intercept 配置
fiber = ctx.fiber
name = name or config.get("name") or _hyphenate(fiber.name)
return Logger({"name": name, "level": config.get("level"),
"meta": {"fiber": fiber}}, self)
要点:
- 可调用:
ctx.get("logger")("agent")经_invoke铸一个具名Logger;ctx.get("logger").warn(...)则_invoke用当前 fiber 名(hyphenate)铸 Logger 后调.warn。 - 配置来自 intercept:
name/level不在服务上写死,而是从_resolve_config(ctx=ctx)解析——所以不同作用域下ctx.logger("x")能读出不同级别(上游logger.ts:251-261同款)。 - exporter 注册即 effect:
exporter()经ctx.effect登记,随 fiber 注销自动移除(同logger.ts:232-237)。默认导出器把消息压进buffer(环形,超buffer_size截断),这是其它导出器(文件、stdout、测试捕获器)之外的兜底。
Logger 门面本身把 error/info/warn/debug 铸成方法,内部构造一条 Message(sn/ts/name/type/level/args/fiber),遍历所有 exporter,按 exporter.levels[name] ?? levels.default ?? self.level ?? INFO 过滤后 export(同 logger.ts:141-161)。格式化走 printf 风格占位符 %s %d %f %o %c %%(上游 defaultFormatters),%o 走 JSON.stringify,Error 自动展开栈、AggregateError 递归展开 errors——这些在 mini 里以等价 Python 实现。
ctx.logger是绑定视图,不是裸服务。上游ctx.logger经 traceable 代理:调用_invoke时以访问方 ctx 解析 intercept(而非服务构造时的根 ctx)。mini 用_LoggerView显式承载同一语义:
class _LoggerView:
def __init__(self, service, ctx):
self._service, self._ctx = service, ctx
def __call__(self, name=None):
return self._service._invoke(name, self._ctx) # 以访问方 ctx 解析配置
def warn(self, *a):
self().warn(*a)
这解释了为什么第 9 章"Agent 干预面"里 agent.ctx.logger("steer") 记出的名字是 agent 作用域的,而不是根——配置解析跟着"谁在调用"走。
步骤 4:extend / isolate / intercept 三兄弟¶
三者都是"不改父、给孩子叠一层"的子上下文工厂(上游 context.ts:99-145),区别在于叠什么:
| 方法 | 叠的东西 | 用途 |
|---|---|---|
extend(meta) |
自有属性(含 symbol 键) | 共享父 fiber 的裸子上下文,携带额外属性(第 4 章 scope 链) |
isolate(name, label) |
name 的新隔离标签 |
让 name 服务在子上下文解析到不同标签(per-agent 一套);同 label 传两次 = 共享作用域(第 3 章工具隔离、continuation 子代理) |
intercept(name, config) |
name 的一条 intercept 配置 |
其下装载的插件看到 config 合并进该服务的 resolved config(近根优先) |
mini 的 extend 是父链近似(非 JS 原型链),isolate/intercept 各自在子上下文写 _isolate/_intercept 遮蔽层,_label_of / _resolve_intercept 沿父链上溯解析——与上游 Object.create 原型继承等价。三者都不修改父上下文,所以"父不受影响、子可覆盖"的组合语义是可靠的。
步骤 5:组装一个可配置服务(示例)¶
把上面三块拼起来:定义一个带 _invoke + _check + intercept 配置的服务。
class Greeter(Service):
provide = "greeter"
def _check(self): # 可用性谓词:config 缺 name 时不提供
return True
def _invoke(self, who="world"):
cfg = self._resolve_config() # 缺省 ctx = self.ctx(构造上下文)
prefix = cfg.get("prefix", "hi")
return f"{prefix} {who}"
root = Context()
Greeter(root) # 根作用域:prefix 默认 hi
# 子作用域要"各有自己的实现与配置",必须先 isolate 出独立标签:
# service 注册表是"标签 → 实现"(provide 同标签二次登记 = fail loud),
# 光 intercept 不会换标签,会与根撞车抛 RuntimeError。
child = root.intercept("greeter", {"prefix": "hello"}).isolate("greeter")
Greeter(child) # 构造 ctx = child → 读 intercept → prefix hello(第 3 章"每个 agent 一套"就是这个)
assert root.get("greeter")("x") == "hi x" # 根作用域默认 prefix
assert child.get("greeter")("x") == "hello x" # 子作用域 intercept 覆盖
child 下的 greeter 调用读到 prefix=hello,根下读到 hi——同一服务、不同标签/作用域、不同配置,不靠任何全局变量。注意两点(容易踩坑):bare Service 只有被构造进某 ctx 才会 provide 登记(Service.__init__ 调 ctx.provide(name, self),scope.py:966-975),不构造直接 get 是 None;且 _find_impl 按隔离标签读全局表(scope.py:1220-1222),provide 对同一标签二次登记 = fail loud 抛 RuntimeError(scope.py:1288-1291)——所以"每个作用域各自不同配置"不能只靠 intercept,必须先 isolate 换标签、再给该标签构造一个实例(第 3 章的 per-agent 工具隔离、continuation 子代理正是这套)。这也是步骤 4 里 ctx.logger 需要 _LoggerView 以访问方 ctx 解析的原因:日志门面场景里多个子作用域共享同一 logger 标签,只能让 _resolve_config 跟随访问方而非构造方。
13.4 验收:硬性规定¶
本章各项在 tests/test_bus.py / tests/test_fiber.py 中逐条有对应断言:
Service子类构造即provide,同名在已注册标签下冲突(fail loud);fiber 卸载自动收回(不再可见)。- 定义
_invoke的服务实例可调用;未定义则__call__抛TypeError。 _resolve_config沿祖先链合并,近根优先、base前置、head后置;_resolve_intercept只收集命中name的条目。LoggerService:ctx.logger(name)铸具名 Logger;ctx.logger.warn(...)以当前 fiber 名记录;exporter 注册即 effect、随 fiber 注销;默认缓冲导出器兜底。_LoggerView:ctx.logger以访问方 ctx 解析 intercept(不是服务构造时的根 ctx)。extend/isolate/intercept都不修改父上下文;isolate同 label 两调用共享作用域。
python -m unittest tests.test_bus -v
13.5 检查点练习¶
- 写一个有
_check的服务:定义FeatureGate(Service),只在ctx的某 intercept 配置enabled=True时通过_check返回真;断言关闭时ctx.get("feature")严格模式下返回None(服务"不存在"),开启后返回实例。 - intercepts 链覆盖:根
intercept("svc", {"a": 1}),子intercept("svc", {"a": 2, "b": 3}),断言子下_resolve_config得到{"a": 2, "b": 3}(近调用点优先)、根下得到{"a": 1}。 - Logger 视图:在子上下文
ctx_b.intercept("logger", {"name": "custom"})后,ctx_b.logger()产出的 Logger 名字为custom而非 fiber 名——验证配置解析随访问方走。
13.6 回到 dsh:真实源码对照¶
打开 deepseek-harness/vendor/cordis/src:
service.ts:11-114:Service抽象基类——constructor即reflect.provide、静态init/check/config/invoke/extend/resolveConfigsymbol 键、[symbols.resolveConfig]沿 prototype 链合并、createCallable让带_invoke的实例可调用。context.ts:99-145:extend(原型继承 + 自有属性遮蔽)、isolate(独立服务作用域标签)、intercept(叠加一条服务配置,不修改父)。logger.ts:194-270:LoggerService可调用服务 + 默认缓冲 exporter;Logger门面(printf 格式化、级别过滤、error/cause/AggregateError展开);_resolveConfig从ctx[symbols.intercept]走查;ctx.logger经 traceable 代理以访问方 ctx 解析。reflect.ts:provide/get/set/isolate仓库与通知,配合上文_resolve_intercept。
13.7 收尾¶
这一章的一句话可以带走:服务即对象。Service 把"构造即注册、可调用、可配置"从每个服务里抽出来焊死,intercept/isolate/extend 提供"不改父、只改子"的组合手段。下一章(14)把作用域从"隔离标签"再推进一层:dsh 专属的 scope_key 身份键与 scopeTarget 载波派发模型——它用一条 parent 关系同时驱动"注册向下继承"和"事件向上接纳"两个方向,是 agent 组合、会话 owner 路由、agent/* 事件隔离的根基。