Skip to content

Port 类型归属与消费视图 #116

Description

@phantom5099

背景

接口声明已经放在调用方(agent/deps.ts 自己声明 14 个 Port),但 Port 的签名里直接引用了叶子模块的类型,于是"契约模块"反过来 import 了"实现模块"。

端口签名引用的类型就是抽象的一部分;它定义在实现包里,抽象就依赖了细节。后果是具体的:叶子改一个字段,agent/deps.ts 必须跟着改;任何替代实现(mock / 远程 executor)都被迫先 import tools/ 才能满足端口。倒置只剩名义。

同一时间,core/ 作为词汇层混装了三类东西:

  • 通用词汇error.tsAgentError / ErrorCode)、result.tspath.ts 的路径运算 —— 不指向任何功能,任何模块都可依赖;
  • 领域词汇types.tsProfileName / TodoItem / Message / ToolCall,全部指向具体功能;
  • 不是词汇workspace.tsexport class WorkspaceService extends Effect.Service,带 init / loadConfig 实现,还 import { loadConfig } from '@codingcode/infra/config'(跨包)。

具体问题

1. 契约引用叶子类型

agent/deps.ts 的 14 个 Port 中 10 个引用了叶子模块的类型:

Port 泄漏类型 来源
ToolExecutorPort ToolResultUnionToolLookup tools/port.ts:6:11
ToolCatalogPortMcpPort ToolDefinition tools/types.ts
SessionPort SessionStoreStateUserEventAssistantEventToolResultEvent session/types.ts
ApprovalPort ApprovalDecisionPermissionMode approval/types.ts
HookPort HookDecision;另 emit(point: string)HookPoint 放宽成了 string hooks/types.ts
SkillPort Skill skills/types.ts
ContextPortMemoryPortLlmPort LLMClient llm/client.ts

2. 一个概念多份定义

  • ToolResultUniontools/port.ts:6tools/tools.ts:8 各一份;ToolLookup 两份且不一致(tools/port.ts:11ToolDefinition<any> vs tools/tools.ts:13ToolDefinition)。
  • ToolOutcomecore/frame.ts:30)与 ToolResultUnion 同构、判别名不同(status vs type),迫使 agent/agent.ts:334-338 写手工映射。
  • ToolCallRequestapproval/types.ts:23)已存在,但 approval/port.ts:7agent/deps.tsApprovalPort 仍把同一对象各内联一遍。

3. 端口窄化:一个被误判的机制

早期版本判断"要把 SessionService(18 方法)收窄到 SessionPort(8 方法),适配器是唯一手段,所以删不得"。这不成立 —— 本仓自己就给出了反例:

session/port.ts:79-92 的两个消费视图用的是零成本手段:

// direct/sessions.ts 实际使用的消费视图,编译期锁定真实耦合面
export type SessionStorePort = Pick<SessionShape, 'create' | 'load' | ... >;

// direct/agent-runtime.ts 与 direct/settings.ts 只读/只写权限模式
export type SessionStatePort = Pick<SessionShape, 'load' | 'setPermissionMode'>;

零 Tag、零适配器、零运行时开销,编译后完全擦除。

准确的区分是:

想窄化的对象 手段 成本
消费者可见的方法面 Pick<Shape, …> 类型别名 0(纯类型)
Effect 的 R 通道 必须新建 Tag —— R 通道由 Tag 决定 每对 1 个 Tag + 1 个适配器

而 R 通道窄化在本项目中没有消费者agent/port.ts 对外的 runTurn,其 R 已是 never(由 AgentWithDeps provide 掉)。于是 agent/deps.ts 这 14 个 Tag 的净作用只剩"agent.ts 内部的名义 R 标注",换来的却是 layer.ts:43-110 的 12 个只做 .bind 的适配器(AgentDepsAdapter 合并 12 个)、12 份与宽契约手工同步的类型定义,以及第 4、5 节两笔真实损失。

补充一条边界:ToolExecutorPort(1 方法)对 ToolExecutorShape(1 方法)只差 opts 的必填性,是零收益裁剪的极端例;同时它也说明签名收紧(可选参数改必填)无法用 Pick 表达,需要写一个手写窄类型别名 —— 仍然是纯类型、零 Tag。

4. 窄化切掉了模块的自我管理面

HookShapehooks/port.ts:6-13)的 6 个方法混装两种性质的成员:

方法 消费者
服务面 emit / emitDecision / disposeSession agent、approval、tools、subagent
自我管理面 register / registerDecision / reloadUserHooks 配置变更事件、模块初始化

HookPort 按"agent 需要哪几个"切出来,只留服务面。代价是实打实的:

reloadUserHooks(projectPath)用户 YAML hook 的唯一装载入口hooks/hooks.ts:182,内部经 resolveHookConfigs~/.codingcode/hooks.yaml<cwd>/.codingcode/hooks.yaml,写入 hooksByProject)。而 agent/agent.ts:52-54 的项目准备只剩两件:

52:      rules.evictProjectRules(normalizedCwd);
54:      yield* mcp.syncConnections(normalizedCwd)...
      // reloadUserHooks 无对应调用

搬迁前的对照物还在旧产物里:dist/runtime/project-runtime.jsProjectRuntimeService.prepareProject 是完整三件套 evictProjectRules / reloadUserHooks / syncConnections

后果:reloadUserHooks 在整个 src/ 里没有调用路径;hooksByProject 零写入者;emit / emitDecision 遍历的 handler 集合恒为空 —— 用户 YAML hook 当前不生效

结论(窄化的边界条件):按"消费者视角"切接口,会把被切模块的自我管理职责一起切掉。窄化之前必须先按"服务面 / 自我管理面"分栏,自我管理面不参与窄化。

5. 宽契约把测试逼向 as any

test/approval/pipeline.test.ts:9-20mockHookService 声明了 10 个方法,其中 attachSessionHooks / disableHook / enableHook / disposeProject HookShape(6 方法)里根本不存在(旧版残留)。结果 :32:33:37 各一处 as any

宽接口不给替身一个"必须满足的最小面",类型检查就只能关掉 —— 而 as any 同时把"漏写方法"从编译错降级为运行时 TypeError。

6. 其他越界项

  • McpPort.listProjectMcpToolsmcp/port.ts:11)返回裸值 ToolDefinition[],同 tag 其余方法都返回 Effect → 不可测试。
  • subagent/port.ts:11-12 内联 import('../core/types.js').ProfileNameimport('../approval/types.js').PermissionMode
  • client/contracts.ts:18typeof AVAILABLE_PROFILESagent/profile.js 引运行时值派生类型。
  • session/port.ts:79-92SessionStorePortSessionStatePort 机制正确(Pick),但落点方向反了 —— 消费视图应由消费者声明,不该由叶子导出。

解决思路

类型定义的落点由两层判据决定,而不是"被谁调用":

判据一 —— 这个概念是否指向某个功能模块?

  • 不指向(错误、结果容器、路径运算)→ 通用件core/
  • 指向(MCP、权限、会话、工具、LLM…)→ 领域件 → 判据二

判据二 —— 引用它的模块数 / 包数

  • 仅 1 个 src 领域引用 → 回该领域叶子
  • 只出现在某调用方接口签名里 → 内联进调用方
  • ≥2 个 src 领域,或 ≥1 个跨包 → 共享包

判据二只作用于领域件:AgentErrorResult 这类无领域归属的类型不参与引用面计算,天然留在 core/,被几个模块引用都不改变这一点。

目标分层与四条硬规则:

目录 允许依赖
L0 通用 core/ 无 import/import type/node 内置
L1 共享包 contracts/(新增) 仅 L0
L2 实现 tools/hooks/session/approval/llm/mcp/ L0 + L1
L3 组合根 layer.tsagent/tool-catalog.tsagent/tool-env.ts 全部

R1 契约不得 import 实现 | R2 实现必须依赖契约模块,但不得依赖消费者模块 | R3 core/ 零内部依赖 | R4 一个概念只允许一处类型定义。

R2 不能写成"实现不得 import 契约"——VS Code(workbench/services/storage/browser/storageService.ts:17platform/storage/common/storage.js)与 NestJS(packages/core/nest-application.ts:30@nestjs/common)都是实现 import 契约,方向正是"细节依赖抽象"。(Effect 属另一形态:接口与运行时 token 同处顶层 Clock.ts,实现下沉 internal/,见参考来源。)会出现"实现不得 import 契约"这个反表述,主因是契约被放进了消费者模块内部 —— 本项目现在就是这样(L1 在 agent/deps.ts),代价已在第 3、4、5 节显形。

组合根落点说明:装配 Layer(agent/tool-catalog.tsagent/tool-env.ts就地保留在 agent/,不新建 src/composition/ 目录。它们的职责是"把若干服务折叠成一个 provide",属于组合根性质,但不值得为它单开一个顶层目录 —— 目录数量本身不是收益。

边界:承载实现机制形状的类型(z.ZodTypeAny、SDK client、Effect 的 R 通道)必须留在叶子 —— 搬进共享层会让抽象反过来依赖机制库,那是另一种倒置。契约只暴露窄的纯数据描述。

具体方案

落点分配

落点 准入 接收的类型
core/(保留) 不指向任何功能模块 AgentErrorErrorCodeResultnormalizePathencodeProjectPathgetProjectBaseDircomputePaths
src/contracts/(新增) 领域件,≥2 个 src 领域或跨包 PermissionModeApprovalDecisionPLAN_ALLOWED_TOOLSProfileNameTokenUsageTodoItemMessageToolCallToolDescriptionMcpServerConfigMcpStatusMcpToolSpecUserHookConfigHookPointHookDecisionSessionEvent 家族/SessionStoreStateSessionIndexUITurnFrameBodyFrameToolOutcomeLLMClientSelectableModelToolLookupToolResultToolRunnerSkillApiErrorAlreadyExistsErrorNotFoundErrorCheckpointDiff
各领域叶子 领域件,仅本领域引用 ToolCallRequestisPermissionModeapproval/LLMRequestLLMResponseLLMStreamPartllm/FrameErrorResponseMetaisTurnEndtoolOutcomeOfagent/RollbackEventSessionMetaEventUserEventsessionJsonlPathFromCwdsession/estimateTokens 组→context/ToolExecCtxtools/encodeFrameserver/decodeFrameclient/parseWorkspaceArgsisGlobalCwdworkspace/
调用方 仅出现在调用方签名 ApprovalRequest → 内联 agent/deps.ts

执行步骤

步骤 内容
S1 类型落点重划 core/ 只留通用件;workspace.ts(实现 + 跨包引 infra/config)与 util.ts(token 估算指向 LLM)移出;error.ts 的 HTTP 类移入共享包;删 path.tssetProjectBaseDirsetProjectPlansBaseDir(测试钩子式可变全局)与零引用的 ModelInfo;新增 src/contracts/ 接收上表领域共享件,单引用领域件退回各自领域,ApprovalRequest 内联 agent/deps.ts。同步改完所有引用点,不留 re-export 垫片
S2 端口简化 14 个 Port 的类型来源改用上表落点(删掉全部叶子 import);并删掉这 14 个 Context.Taglayer.ts:43-110 的 12 个适配器 —— 可见面改用类型别名表达(agent/deps.ts 保留为纯类型文件),取服务时写 const hooks: AgentHooks = yield* HookService,R 通道留给宽 Tag,由 Layer.provide(HookLayer) 直接消掉,不需要 wiring。同时:HookPort.emit(point: string) 收回 HookPointMcpPort.listProjectMcpTools 改为返回 Effect唯一例外:若某方法的返回值需真实转换(如 recordUserUserEvent 收窄为 turnId: number),该处必须保留 Tag + 适配器 —— 值映射是 Pick 表达不了的能力
S3 去重 tools/tools.ts 的重复 ToolResultUnionToolLookup;共享层定义 ToolOutcome + ToolResult = { id, name } & ToolOutcome;判别名统一为 status,删掉 agent.ts:334-338 的手工映射(RuntimeEvent.tool_result 字段与帧格式零变化)
S4 装配归位 agent/tool-catalog.tsagent/tool-env.ts 就地留在 agent/McpPort 的真实映射(把 MCP 工具喂给 catalog)保留在装配层,不再是 .bind 直通
S5 引用收口与断言 更新 package.json exports 与 desktop/tui 的跨包引用(唯一值导入 ApiError 一并处理);session/port.ts:79-92 的消费视图移到消费者内声明;新增架构断言:core/ 不得出现跨领域 import、contracts/ 不得 import 领域实现、每条相对 import 必须能在仓库内找到落点
S6 接回断链 在 S2 之后,reloadUserHooks 已有调用位置(HookPort 不再切掉自我管理面),在 agent.ts 的项目准备处按 evictProjectRulesreloadUserHookssyncConnections 顺序接回,并确认 emit 用的是本轮新注册表

参考来源

社区与官方

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions