agent-locator.js
12.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
/**
* Agent 目录寻址的**唯一**推导实现(28 号设计 §2.5 / §5.1)。
*
* 为什么单独一个模块:改造前这套路径拼接在 6 个文件里各写了一份
* (agent-manager 执行寻址、agent-chat 会话读写、memory-service 记忆、heartbeat 心跳、
* agent-transcript 日志回溯、graph-engine 的 resolveAgentName)。它们算的是同一个
* 目录,但只有 agent-manager 那份实现了 P1 的 systemAgentRef 分支 —— 也就是说
* 「同一个 agent 在执行时用共享本体、在会话读写时用私有目录」这种分叉,是靠六份
* 手抄的字符串拼接来维持一致的。company-paths.js 的注释已经记过一次这个教训:
* **写的一侧与读的一侧必须算出同一个字符串,算不出同一个就是索引指向空气且不报错**
* —— 目录还在、代码也没抛,只是两边说的不是一个地方。六份手抄比两份更没救。
*
* 布局(§4.1):
* system/agents/{aid}/ 本体,只读,全系统唯一一份
* system/agents/{aid}/{cid}/ 公司态(长期:company-context.md / user.md / memory / conversations)
* system/agents/{aid}/{cid}/{taskId}/ 任务态(CLI 的 cwd:logs / scratch / result.json)
*
* 公司态与本体自带的子目录**同层**,所以公司 id 不能撞 RESERVED_SUBDIRS —— 见下。
*
* 注意本模块只负责 `system/` 这一侧。**产物**(公司 workspace / run artifacts / task 交付物)
* 归 company-paths.js 推导,两个根故意分开:产物有且只有一个家(§5.4)。
*/
const fs = require('fs');
const path = require('path');
const REPO_ROOT = path.resolve(__dirname, '../..');
const AGENTS_ROOT = path.join(REPO_ROOT, 'system', 'agents');
/**
* 不属于任何公司的调用(master / agent-creator / innovator-* 等)落这个保留公司 id。
* 用保留值而不是「省掉这一层」:否则「有没有公司」就是两条深度不同的代码路径,
* 而深度不同意味着 agent.md 里的相对引用(`../../../soul.md`)要分两种写法。
*/
const GLOBAL_COMPANY_ID = '_global';
/** taskId 缺省的落点,语义同 company-paths.taskDirRel 的 `_no-task`:宁可全归到一个显眼的坑里 */
const NO_TASK_ID = '_no-task';
/**
* 本体自带的、会和公司 id 撞名的深度-2 目录名。
*
* 这份名单是**扫每个本体目录下真实存在的子目录**得来的,不是照抄设计稿:
* 实测多出了 `code` / `system`(scrapling_crawler、weibo-scraper 这类带代码的本体),
* 设计稿列的 `knowledge` / `uploads` / `data` 眼下没有实例但仍收进来 —— 它们是
* agent-chat 的文件端点会创建的目录名,晚一点出现而已。
* 下划线 / 点开头的(`__pycache__`、`.claude`)由 isReservedCompanyId 的前缀规则覆盖。
*/
const RESERVED_SUBDIRS = new Set([
'agent_data', 'code', 'conversations', 'data', 'docs', 'knowledge',
'logs', 'memory', 'system', 'tasks', 'train', 'uploads', 'workspace',
]);
/** 本体目录:只读,全系统唯一一份 */
function bodyDir(agentId) {
return path.join(AGENTS_ROOT, agentId);
}
/** 公司态目录:不传 companyId 走保留值 `_global` */
function companyDir(agentId, companyId) {
return path.join(bodyDir(agentId), companyId || GLOBAL_COMPANY_ID);
}
/** 任务态目录 = CLI 的 cwd */
function taskDir(agentId, companyId, taskId) {
return path.join(companyDir(agentId, companyId), taskId || NO_TASK_ID);
}
/**
* 「本体定义齐全」的判据(28 批次 5-23:agent-creator 的产出契约)。
*
* 为什么需要这份名单:新本体是 `_base` 骨架的拷贝 + agent-creator 一次
* **fire-and-forget** 的异步填充。填没填、填全没填全,改造前**没有任何一处会问** ——
* §2.4 的 org-coder 就是这么变成空定义的:目录在、config.json 在、库里也有行,
* 只有 agent.md 还是通用 Base Agent 文本,跑起来静默跑偏且不报错。
*
* 三个文件对应提示词装配的三条真实到达路径,缺一条就是一种静默降级:
* agent.md PromptBuilder 唯一必装的那份(§2.6)
* soul.md agent 运行时按相对路径自读的人格定义
* .claude/skills/main_skill/SKILL.md CLI 侧唯一会被自动发现的技能入口
* 非空判据用「去掉空白后还有内容」而不是「文件存在」:脚手架会先建空文件占位。
*
* ⚠️ **SKILL.md 是「存在才查」,不是必需件 —— 这条是按仓库实测现状定的,不是拍脑袋放宽。**
* 实测(2026-08-18,28 三轮复验):`system/agents/` 下 56 个带 agent.md 的本体里,
* 只有 2 个(claude-youtube-skills / digester-youtube-crawler)带
* `.claude/skills/main_skill/SKILL.md`。也就是说这份文件从来不是本体的组成部分,
* 而是「这个 agent 顺带发布了一个技能」的可选产物 —— 把它算进必需件,等于宣布
* **54/56 的在库本体都没资格上岗**。闸门只作用于 draft / training 两个状态,所以大多数
* 本体只是侥幸没被卡;真被卡死的是 `code-planner`(config.json 写着 status='training',
* seedAgents 原样入库),它是 p2-verify 在跑的现役 work 层本体,而**任何新建的公司
* 都再也雇不到它** —— 老公司只是靠 company_agents 已有行走 `already_exists` 分支绕过去了。
*
* 所以判据拆成两档:
* 必需(REQUIRED):agent.md + soul.md —— 缺失 / 空白 / 仍带哨兵都算缺;
* 可选(OPTIONAL):SKILL.md —— **不存在不算缺**;存在则必须是填过的(空白或仍带哨兵算缺)。
* 「存在才查」而不是「整条删掉」:`_base` 骨架里这份是带哨兵的,删掉判据就等于允许
* 「拷完骨架、只把 agent.md/soul.md 填了,SKILL.md 还是通用骨架」的本体上岗,
* 而 CLI 会把那份骨架技能真的当技能装配进去。
*/
const REQUIRED_BODY_DEFINITION_FILES = ['agent.md', 'soul.md'];
const OPTIONAL_BODY_DEFINITION_FILES = [path.join('.claude', 'skills', 'main_skill', 'SKILL.md')];
/** 两档合并后的完整清单,顺序即报「缺什么」时的顺序(必需在前) */
const BODY_DEFINITION_FILES = [...REQUIRED_BODY_DEFINITION_FILES, ...OPTIONAL_BODY_DEFINITION_FILES];
/**
* `_base` 骨架三份定义文件里都埋着这一行;**它还在 = 这份定义是通用骨架,没被填过**。
*
* 为什么非要这个标记:补齐 `_base`(让它真带上 soul.md + SKILL.md,见 base-skeleton-complete)
* 与「非空即齐全」的判据凑在一起,会把闸门整个抵消 —— 骨架一复制完三份文件就都非空,
* `promoteDraftIfComplete` 当场转正,新角色**刚拷完通用模板就上岗**,身上零角色特化,
* 第二家公司还会把它当成一份已就绪的可复用本体直接雇走。那正是 §2.4 的 org-coder
* 失败形态,且污染面从 1 家公司放大成全系统。
*
* 为什么是文件内的标记而不是 DB 列 / config 标志位:判据的真相来源必须**只有文件内容本身**。
* agent-creator 是一次 fire-and-forget 的自调用,没有「写完了」这个回调点可挂;
* 它无论用什么方式写文件,只要真写了就自然把这行覆盖掉,不会出现「文件填好了但标志位忘了改」
* 或反过来的不一致 —— 而那种不一致恰恰是没人会去查的那一种。
*/
const BASE_SKELETON_SENTINEL = '<!-- LEMONBOSS_BASE_SKELETON -->';
/**
* 这份本体还缺哪些定义文件 —— 按**本体目录**判,不经过 agentId 推导。
*
* 单独开这个入口是给 graph-seed-service 用的:它的扫描根是 `GRAPH_SEED_ROOT`
* (单测把它指向临时目录),而 bodyDir() 恒指向真实仓库。用 agentId 版本的话,
* 夹具里的 `org-planner` 会去查真仓库里的同名本体 —— 判据算的不是同一个目录,
* 而且不报错。
*
* 「缺」有三种形态,对上岗资格是同一回事:**读不到**(必需件才算)、**空白**
* (脚手架建了占位文件)、**仍带哨兵**(从 `_base` 拷来还没被 agent-creator 填过)。
*
* @param {string} dir 本体目录的绝对路径
* @returns {string[]} 缺失的相对路径,空数组 = 齐全,可以从 draft/training 升 active
*/
function missingBodyDefinitionIn(dir) {
return BODY_DEFINITION_FILES.filter((rel) => {
let text;
try {
text = fs.readFileSync(path.join(dir, rel), 'utf-8');
} catch {
// 读不到(不存在 / 是目录 / 无权限):必需件算缺,可选件不算 —— 见 BODY_DEFINITION_FILES 的实测理由
return REQUIRED_BODY_DEFINITION_FILES.includes(rel);
}
return text.trim() === '' || text.includes(BASE_SKELETON_SENTINEL);
});
}
/** missingBodyDefinitionIn 的 agentId 版(真实仓库的 system/agents/{id}) */
function missingBodyDefinition(agentId) {
return missingBodyDefinitionIn(bodyDir(agentId));
}
/**
* 寻址名 → `{ companyId, agentId }`。`"cid:aid"` 拆开,没有冒号则 companyId 为 null。
*
* 按**第一个**冒号切(而不是 split(':') 取前两段):多余的冒号留给 agentId,
* 至少不会把地址后半截默默吞掉 —— 吞掉的话寻址会落到一个存在但不对的目录上。
*/
function parseAddress(addr) {
const s = addr == null ? '' : String(addr);
const i = s.indexOf(':');
if (i < 0) return { companyId: null, agentId: s };
return { companyId: s.slice(0, i) || null, agentId: s.slice(i + 1) };
}
/** `{ companyId, agentId }` → 寻址名;没有公司时返回裸 agentId */
function formatAddress(companyId, agentId) {
return companyId ? `${companyId}:${agentId}` : String(agentId);
}
/**
* 这个 id 能不能拿来当公司 id(§5.1)。
* 命中本体自带的子目录名,公司态目录就会盖住 agent 自己的 memory / logs;
* `_` / `.` 前缀是保留给 `_global` / `_base` / `.claude` 这类非公司项的。
*/
function isReservedCompanyId(id) {
if (!id) return false;
const s = String(id);
return s.startsWith('_') || s.startsWith('.') || RESERVED_SUBDIRS.has(s);
}
/**
* 公司私有 agent 目录 `data/companies/{cid}/agents/{aid}` 的历史落点。
*
* 批次 4 的 M2 已经把这 17 个目录搬空并删掉,寻址链路上**不再有任何一处会走到它**。
* 保留这一个函数是因为 agent-transcript 还要靠它反推 Claude CLI 的 projects 目录名
* (历史 transcript 是按当年的 cwd 落的盘,改不了),那处重写是批次 4-20 的事。
*/
function legacyCompanyAgentDir(companyId, agentId) {
return path.join(REPO_ROOT, 'data', 'companies', String(companyId), 'agents', String(agentId));
}
/**
* 长期状态(memory / conversations)的落点,按寻址名算 —— **写侧与读侧唯一的那一份实现**。
*
* 归属是 `(agentId, companyId)`:同一个本体在两家公司各记各的,不共享(§一「非目标」第一条);
* 没有公司的调用(master / agent-creator)落保留值 `_global`。
*/
function companyStateDir(addr) {
const { companyId, agentId } = parseAddress(addr);
return companyDir(agentId, companyId);
}
/**
* 执行路径(agent-manager)专用寻址:地址恒为 `cid:aid`,`aid` 恒指向本体。
*
* 批次 4 之前这里还有一条「公司私有目录还在就按老布局走」的分支;M2 清空 `agents/` 之后
* 它恒不命中,连同 `systemAgentRef` 引用模式一起删掉了 —— 本体只有一份,不需要挑。
*
* @returns {{ dir: string, agentsRoot: string, agentId: string, companyId: ?string }}
* agentsRoot 是 dir 的父目录,供 scoped skill 遍历用(在哪找 agent,就在哪找它的 skill)
*/
function resolveAgentDir(addr) {
const { companyId, agentId } = parseAddress(addr);
return { dir: bodyDir(agentId), agentsRoot: AGENTS_ROOT, agentId, companyId };
}
module.exports = {
REPO_ROOT,
AGENTS_ROOT,
GLOBAL_COMPANY_ID,
NO_TASK_ID,
RESERVED_SUBDIRS,
BODY_DEFINITION_FILES,
REQUIRED_BODY_DEFINITION_FILES,
OPTIONAL_BODY_DEFINITION_FILES,
BASE_SKELETON_SENTINEL,
missingBodyDefinition,
missingBodyDefinitionIn,
bodyDir,
companyDir,
taskDir,
parseAddress,
formatAddress,
isReservedCompanyId,
companyStateDir,
legacyCompanyAgentDir,
resolveAgentDir,
};