27-P1. 虚拟引用寻址 — 需求与设计文档
所属: 27 Graph Agent 架构 的 Phase P1("虚拟引用") 版本: v0.2(设计阶段,未实现;因 P0 完工同步更新) 日期: 2026-08-15 前置: P0 地基已全部完工并提交(commit
8421f55)——seedGlobalAgents()/seedTemplates()/seedCompanyAgents()/verifyCounts()四步全部实现,system_agents.legacy_path可用,org_graph_nodes已经把现有全部公司雇佣关系回填完毕(不再是空表)。P0 实施过程中发现一个直接影响 P1 设计的问题:system/agents/与system/templates/存在 6 个同名但内容可能不同步的 id,详见 27 号文档 §7 与下文 §4.6——本版本已针对这个问题修正了查表逻辑。
一、目标与非目标
目标(本阶段要交付什么)
按 27 号文档 P1 行的原始定义:
停止文件拷贝,改为引用。改造
AgentManager.getAgentConfig()+PromptBuilder.build()支持(agentId, companyId)寻址;company-service.hire()改为创建 org_graph 引用节点而非_copyTemplate()。
经过对现有代码的走查(见第三节),这句话在真正落地时需要收窄——不是"完全停止拷贝",而是只让 agent.md 这一份文件摆脱物理拷贝,理由见第四节的关键约束分析。
非目标(本阶段明确不做)
- 不做
soul.md/identity.md/tools.md/.claude/skills/的去重——原因是运行时 CLI 进程按cwd相对路径读取它们(证据见 §3.3),贸然去重会在 runtime 直接读不到文件。这些文件的去重留给后续阶段(等这些模板文件内部的相对引用被统一改写为绝对共享路径之后再做)。 - 不做 Org Graph 前端展示、
company.json.agents[]的废弃——org_graph_nodes在 P1 只是追加写入,不替换任何现有读取路径。 - 不做
memory-service.js/heartbeat-service.js/task-service.js/agent-chat.js里重复的id.includes(':')寻址逻辑的统一收编——P1 刻意不碰这些文件,把 blast radius 限制在agent-manager.js+prompt-builder.js+company-service.js三个文件内(见第八节)。 - 不做存量公司数据迁移——旧公司(P1 上线前已
hire()过的 agent)永远走原有的物理拷贝路径,不强制转换。 - 不做
system/agents/与system/templates/之间 6 个同名 id 的内容合并决策——这是 27 号文档 §7 抛出的产品问题(哪份文件才是权威版本),P1 只做到"绕开风险、不读错文件",不做"解决冲突"。见 §4.6。
二、背景:为什么只能先做 agent.md
27 号文档 §1 列的四个问题里,P1 直接对应的是第 1 条"状态碎片化":
同一角色在 10 家公司名下就有 10 份互不相干的
agent.md/memory.md。模板更新后,已雇佣的实例不会同步。
但 memory.md 是运行时持续写入的私有状态(每家公司的 Agent 记忆本来就该不同),不在去重范围内——27 号文档自己在 §3.2 也是这么定义 scope=public 的 memory.md(跨公司共享的是"能力"记忆,不是任意私有工作记忆)。真正符合"应该共享但目前被拷贝成 N 份"定义的,是角色定义文件:agent.md/soul.md/identity.md/tools.md。
P1 要解决的具体问题:Innovator 把某个角色的 agent.md evolve 到 v1.4,但已经雇佣过 v1.1 版本的 5 家公司,实例文件永远停在 v1.1,除非手动逐个覆盖。
三、现状代码走查(决定设计边界的关键证据)
3.1 寻址入口:AgentManager.getAgentConfig()
server/src/core/agent-manager.js:124-202。对 "companyId:role" 格式的地址,走的是"动态兜底"分支(第 131-191 行),不是 this.config.agents 静态配置——company-hired 的 agent 从来没有进入过静态配置,每次都靠这个分支现场拼出 agentConfig:
agentConfig = {
type: 'orchestrator', protocol: 'claude-cli', ...,
prompt: path.join(agentDir, 'agent.md'), // ← P1 要改的就是这一行的来源
workDir: agentDir, // ← 这一行必须保持不变(见 3.3)
...
};
拼出来的结果会缓存进 this.config.agents[agentName](第 187-190 行),下一步 PromptBuilder.build() 直接读这个缓存,不会重新解析地址。这意味着 P1 的核心改动点只有一个:这个分支怎么决定 prompt 字段指向哪。
3.2 PromptBuilder.build() 不需要感知寻址
server/src/core/prompt-builder.js:130-177。它读的是 this.config.agents[agentName].prompt,对这个路径来自哪里完全无感。也就是说:只要 agent-manager.js 把 prompt 字段指对地方,PromptBuilder 本身理论上不用改——除非要把"公共 agent.md + 私有 company-context"拼成两段(见 3.4)。
3.3 关键约束:workDir 决定了哪些文件必须留在私有目录
system/templates/qa-judge/agent.md:483-489:"参考文档"一节用的是相对路径:
- `soul.md` - 判官人格与思维方式
- `identity.md` - 身份信息与核心原则
- `../../workspace/innovator/qa_judge_ideal_model.md` - 理想能力模型
Claude CLI 子进程以 workDir(= agentDir,私有目录)为 cwd 启动,之后自己用文件工具按相对路径读取 soul.md。如果这些文件不物理存在于私有目录,agent 运行时直接读不到。 这就排除了"把整个 agent 目录指向共享位置"这个最简单的方案——那样会导致同一个共享目录被多家公司的 CLI 进程并发写入/读取,且 Claude CLI 自身的项目级状态(存在 ~/.claude/projects/<按路径 hash>/)也会被多家公司共用同一路径而串数据。结论:workDir 必须继续指向私有目录,不能改。
3.4 隐藏耦合:agent.md 今天不是纯共享文件,会被写脏
server/services/company-service.js:687-704(_injectCompanyContext 第 2 步):hire() 复制模板后,直接把公司归属信息追加进复制出来的 agent.md:
const companyContext = ['', '---', '', '## 所属公司', `- **公司ID**: ${companyId}`, ...].join('\n');
fs.writeFileSync(agentMdPath, content + companyContext, 'utf-8');
这意味着如果简单粗暴地让 prompt 直接指向共享 legacy_path/agent.md,会丢失这段"我是哪家公司的员工"的上下文——而这段上下文对 Agent 的行为是必需的(决定它向谁汇报、服务于什么使命)。P1 必须把这段追加内容从"写死进文件"改成"运行时拼接",这正好对应 27 号文档 §3.6 说的"PromptBuilder 装配公共定义 + 私有语境"。
user.md 不受影响:它是整份重写(company-service.js:707-729),不是追加共享内容,本来就是纯私有文件,P1 不用动它。
四、设计方案
4.1 数据流总览
hire(companyId, role)
│
├─ 查 system_agents 表: SELECT * WHERE id = role AND source_kind = 'template'
│ (注意:只匹配 'template',不匹配 'global_agent'——原因见 §4.6)
│
├─ 命中
│ → 引用模式:
│ · 私有目录仍创建(config.json / soul.md / identity.md / tools.md /
│ heartbeat.md / plan.md / todo.md / cron.json / .claude/ / skills/
│ 照今天原样拷贝 —— 不变)
│ · 不拷贝 agent.md
│ · config.json 新增字段 systemAgentRef = role
│ · 新写 company-context.md(取代原本追加进 agent.md 的那段内容)
│ · INSERT org_graph_nodes(company_id, system_agent_id, role_name)
│ (幂等,见 4.4 的唯一索引)
│
└─ 未命中(system_agents 里没有 source_kind='template' 的这一行——
可能是新模板还没跑 seed,也可能是 §4.6 那 6 个命名冲突 id 之一)
→ 回退到今天的 _copyTemplate() 全量拷贝(含 agent.md),行为零变化
→ console.warn 提示"运行 npm run graph:seed 以启用引用模式,或该 id 存在命名空间冲突见 27 号文档 §7"
4.2 AgentManager.getAgentConfig() 改动(agent-manager.js:131-191)
在现有动态兜底分支里,agentDir 存在之后,新增一步:读 agentDir/config.json 的 systemAgentRef 字段。
if (fs.existsSync(agentDir)) {
const config = readConfigIfExists(agentDir); // 已有的 _readAgentConfig 等价逻辑
const ref = config?.systemAgentRef;
const sharedAgentMdPath = ref ? lookupSharedAgentMd(ref) : null; // 见 4.3
agentConfig = {
type: 'orchestrator', protocol: 'claude-cli', ...,
prompt: sharedAgentMdPath || path.join(agentDir, 'agent.md'), // 引用模式 vs legacy 二选一
companyContextPath: ref ? path.join(agentDir, 'company-context.md') : null, // 新增,供 PromptBuilder 用
workDir: agentDir, // 不变
...
};
}
lookupSharedAgentMd(systemAgentId):
SELECT legacy_path FROM system_agents WHERE id = ? AND source_kind = 'template'
source_kind 过滤是硬性要求,不是可选优化——见 §4.6。查到行后拼 path.join(ROOT, legacy_path, 'agent.md');查不到则整体走 legacy 全量拷贝分支。这个查询函数放在新文件 server/services/graph-service.js(P1 自己的模块,不改动 P0 owner 的 graph-db.js/graph-seed-service.js,避免并行开发冲突),内部调用 graph-db.js 导出的 getDb()。
4.3 PromptBuilder.build() 改动(prompt-builder.js:130-177)
agentConfig.companyContextPath 非空时,在 getAgentPrompt() 读完共享 agent.md 之后,再读一份 company-context.md 并作为独立 part 拼进去,位置对应今天"agent.md 正文 + 追加的所属公司段"的顺序:
if (isSkillBased) {
...
} else {
...
const agentPrompt = this.getAgentPrompt(agentName); // 现在可能来自共享路径
if (agentPrompt) parts.push(agentPrompt);
if (agent.companyContextPath && fs.existsSync(agent.companyContextPath)) {
parts.push(this.loadFile(agent.companyContextPath)); // 新增
}
}
对 legacy 拷贝模式的 agent(companyContextPath 为 null),这个分支不生效,行为完全不变。
4.4 company-service.hire() 改动(company-service.js:189-230, 621-648, 667-739)
_copyTemplate()新增一个引用模式变体(暂命名_provisionReferenceAgent()):复制TEMPLATE_FILES时跳过agent.md,其余不变。_injectCompanyContext()引用模式下:第 2 步(追加进 agent.md)改成写一个新文件company-context.md(内容就是原来那段## 所属公司文本 +opts.customAgent),不再碰agent.md(因为它现在压根不在私有目录里)。第 1、3、4 步(config.json / user.md / soul.md 自定义)完全不变。hire()成功后,除了今天已有的company.agents.push(roleName)+_saveCompany()(继续保留,不替换——很多地方还在读company.json.agents[]),额外调用graph-service.upsertOrgGraphNode(companyId, roleName, roleName)。这一步失败不应该让hire()整体失败(try/catch + warn),因为 P1 阶段 org_graph_nodes 只是审计记录,不是唯一真相源。
4.5 schema 微调:org_graph_nodes 唯一索引
现有 graph-db.js(P0 产物)的 org_graph_nodes 没有约束同一 (company_id, role_name) 不能重复插入。P1 需要在 hire() 幂等重跑时不产生重复行。这是加法,不改 P0 已有的表结构定义,用独立语句追加:
CREATE UNIQUE INDEX IF NOT EXISTS idx_org_graph_nodes_company_role
ON org_graph_nodes(company_id, role_name);
放在 graph-service.js 里首次调用时执行(CREATE ... IF NOT EXISTS 幂等),不需要动 graph-db.js。加之前建议先跑一次 SELECT company_id, role_name, COUNT(*) FROM org_graph_nodes GROUP BY 1,2 HAVING COUNT(*) > 1 确认 seedCompanyAgents() 回填的历史数据里没有已存在的重复行(按其去重逻辑推断不应该有,但加唯一索引前验证一次成本很低)。
4.6 关键风险:agent-id 命名空间冲突(真实存在,不是假设)
27 号文档 §7 记录了一个 P0 实施时才暴露的事实:agent-creator / digester / evolver / evolver-youtube-crawler / hunter / innovator 这 6 个 id,在 system/agents/ 和 system/templates/ 下同时存在,物理上是两份可能已经不同步的文件。seedTemplates() 遇到冲突时让 global_agent 行赢(跳过 template 行的写入),保证 system_agents.id 唯一性不被破坏——但这只是保护了数据库,没有回答"该读哪份文件"。
这不是纸上谈兵:data/companies/idearun/agents/ 里已经真实雇佣了 evolver-youtube-crawler,正是冲突名单里的一个。而 company-service.hire() 的模板查找永远只看 TEMPLATES_DIR(company-service.js:199,path.join(TEMPLATES_DIR, roleName)),从来不看 system/agents/。
如果 P1 的查表逻辑写成 WHERE id = role(不加 source_kind 过滤),对 evolver-youtube-crawler 这类 id 会查到 global_agent 行,legacy_path 指向 system/agents/evolver-youtube-crawler/——但 idearun 当初实际雇佣时拷贝的是 system/templates/evolver-youtube-crawler/agent.md。这两份文件如果内容不同,P1 上线后 idearun 的 evolver-youtube-crawler 在下一次调用时会突然读到一份它从未被雇佣时使用过的 agent.md——对用户来说是一次无法预期的、未经确认的角色定义漂移。
修正:§4.1/§4.2 已经把查表条件改成 WHERE id = role AND source_kind = 'template'。对这 6 个冲突 id,这个条件永远查不到行(因为 seedTemplates() 跳过了它们),于是自动落进"未命中 → legacy 全量拷贝"分支——即这 6 个 id 在 P1 阶段天然被排除在引用模式之外,不会有虚拟引用行为,直到 27 号文档 §7 提出的"该以哪份文件为准"这个产品问题被人工决定之后,才应该考虑把它们纳入。
五、与 P0(已完工)的衔接点
P0 已经把 seedCompanyAgents() 也做完了:org_graph_nodes 现在不是空表,已经为所有现存公司雇佣关系(含 idearun 的 6 个角色)各建了一行。这对 P1 意味着:
- P1 的
hire()改动(§4.4)在写入新的org_graph_nodes行时,不能假设自己是"第一个写入者"——历史数据已经在那,§4.5 的唯一索引就是为了让新写入和历史数据共存时不产生重复行。 - P1 不需要(也不应该)对
seedCompanyAgents()已经创建的source_kind='company_hired'行做任何处理——那些是"公司自己培养、没有对应模板"的角色,天然不满足 §4.1 的source_kind='template'命中条件,会正确地继续走 legacy 路径。 - 唯一需要在 P1 动工前处理的衔接项就是 §4.6 的命名冲突排除逻辑——这一点已经通过查表条件的过滤自动满足,不需要额外的迁移或人工介入。
六、验收标准
沿用 27 号文档给出的验收标准,细化为可直接验证的检查项:
- 对一个已在
system_agents表中的模板角色(如qa-judge)执行hire(companyId, 'qa-judge'):- 私有目录下没有
agent.md文件 - 私有目录下有
company-context.md,内容包含公司 ID/名称/使命 config.json含systemAgentRef: "qa-judge"org_graph_nodes表新增一行(companyId, 'qa-judge', 'qa-judge')
- 私有目录下没有
- 对该 agent 调用
callAgent():buildFullPrompt()产出的最终 prompt 中,agent.md 正文来自system/templates/qa-judge/agent.md(与共享文件内容一致,改共享文件后无需重新 hire 即可生效),且包含 company-context.md 的所属公司段落。 - 对一个不在
system_agents表中的模板角色执行hire():退回到今天的行为(完整拷贝含agent.md),并在日志里看到 reseed 提示。 - 找一家 P1 上线前已经
hire()过的旧公司(纯目录拷贝,无systemAgentRef):callAgent()行为不变,getAgentConfig()/PromptBuilder.build()都不进入新分支。 - 重复对同一
(companyId, role)调用hire():第二次直接命中already_exists短路(这一步逻辑不变),org_graph_nodes不产生重复行。 - 命名冲突回归用例(对应 §4.6):用一家新测试公司(不是
idearun——它是 P1 上线前的存量雇佣,天然走 legacy 分支,测不出这个问题)对角色名evolver-youtube-crawler执行一次全新的hire()。断言:hire()内部的system_agents查表查不到行(因为唯一的 template 行被 seed 时跳过了),因此整个走 legacy 全量拷贝分支(含agent.md),而不是误把global_agent行的legacy_path(system/agents/evolver-youtube-crawler/)当成引用来源。这条用例失败就意味着 §4.6 的source_kind='template'过滤条件被写错了。
七、影响面 / Blast Radius
P1 改动限定在三个文件 + 一个新文件:
| 文件 | 改动类型 |
|---|---|
server/src/core/agent-manager.js |
修改:动态兜底分支的 prompt 来源判断 |
server/src/core/prompt-builder.js |
修改:build() 增加 companyContextPath 拼接分支 |
server/services/company-service.js |
修改:hire() / _copyTemplate() / _injectCompanyContext() 分叉出引用模式路径 |
server/services/graph-service.js |
新增:lookupSharedAgentMd() / upsertOrgGraphNode() |
明确不改,但依赖同一批私有目录路径的下游——之所以不改是因为引用模式下私有目录的物理位置、config.json、memory/、.claude/ 全部保持原样,这些文件仍然照常存在:
| 文件 | 为什么不受影响 |
|---|---|
server/services/memory-service.js |
只读 data/companies/{id}/agents/{role}/memory/,P1 不改这个目录的存在 |
server/services/heartbeat-service.js |
同上,读 config.json/heartbeat.md,两者仍照常私有存在 |
server/services/task-service.js |
同上 |
server/routes/agent-chat.js |
同上,且 agentName.includes(':') 的解析方式完全不变 |
如果后续阶段要把 soul.md/identity.md/tools.md 也去重,才需要重新评估这四个文件。
八、测试计划
- 单元测试:
graph-service.js的upsertOrgGraphNode幂等性、lookupSharedAgentMd对存在/不存在两种情况的返回。 - 集成测试(复用
server/scripts/test-company-e2e.js的模式):hire()引用模式全流程 →callAgent()→ 断言最终 prompt 内容分段正确。 - 回归测试:跑一遍现有
server/tests/**中涉及company-service.js/agent-manager.js的用例,确认 legacy 拷贝模式路径零回归。
九、Open Questions(留给实现阶段核实)
- SKILL.md 模式的 agent:
PromptBuilder.build()里isSkillBased(promptPath.endsWith('SKILL.md'))分支目前直接把 SKILL.md 当唯一 prompt 来源,不叠加 protocol。当前梳理的模板样本(qa-judge等)都是agent.md模式,没有验证 SKILL.md 模式模板是否也存在同样的"追加公司上下文"问题。实现前需要抽查system/templates/*里是不是有prompt指向.claude/skills/*/SKILL.md的模板。 opts.customAgent的语义:今天允许调用方在hire()时传一段自定义文本追加进agent.md。P1 把它挪进company-context.md后,语义上从"追加进角色定义"变成"追加进公司专属上下文",需要确认现有调用方(如果有)不依赖它出现在agent.md本身的物理路径里。- 共享
agent.md被 Evolver 重写时的一致性:P1 只解决"读"的问题(新 hire 读到最新版)。如果 Evolver 正在原地重写system/templates/{role}/agent.md的同时有 agent 正在被调用读取,会不会读到半写状态?这个问题在 P0 之前就存在(模板文件本来就会被 Evolver 直接改写),P1 没有让它变得更差,但既然现在读路径变得"更热"(被更多公司共享读取),值得在实现时评估要不要加一层读写锁或原子替换(写临时文件 + rename)。 - 6 个命名冲突 id 何时纳入引用模式:§4.6 的过滤条件让
agent-creator/digester/evolver/evolver-youtube-crawler/hunter/innovator这 6 个 id 在 P1 阶段永远走 legacy 分支。这是刻意的临时状态,不是长期方案——一旦 27 号文档 §7 提出的"哪份文件权威"问题有了产品决策,需要回来把这 6 个 id 的处理方式补上(可能是人工合并文件、也可能是确认两份本来就该是不同东西、重新命名去冲突)。这一项应该在排 P2(Org Graph)之前找时间处理掉,因为 P2 要让 CEO/HR agent 直接在 org graph 上招聘,到时候这 6 个 id 的歧义会更早暴露给用户。