From b7725efb7c88ee66236129cf04cd6842697c2336 Mon Sep 17 00:00:00 2001 From: Nathan Shan Date: Fri, 23 Oct 2026 18:22:40 +0800 Subject: [PATCH 1/2] fix(doctor): check the complete Hermes SOUL read budget - Measure member-authored text and every managed block together, using Python Unicode code points after trimming whitespace as Hermes does. - Honor an explicit context_file_max_chars cap and report malformed YAML; treat the model-dependent 20,000-character default floor as an informational warning. - Keep instructions intact and explain how to shorten the complete file or review the configured cap, with matching English and Chinese diagnostic guidance. - Verify 276 focused tests, TypeScript, lint and build; the real doctor CLI rejects an oversized fixture, preserves its contents and passes the budget check after shortening. --- docs/guide/diagnostics.md | 2 + docs/guide/zh-CN/diagnostics.md | 2 + .../doctor-hermes-soul-budget.test.ts | 67 +++++++++++++++++++ .../e2e/doctor-hermes-budget.test.ts | 38 +++++++++++ src/doctor-delivery.ts | 19 ++++++ src/hermes-config.ts | 15 +++++ 6 files changed, 143 insertions(+) create mode 100644 src/__tests__/doctor-hermes-soul-budget.test.ts create mode 100644 src/__tests__/e2e/doctor-hermes-budget.test.ts diff --git a/docs/guide/diagnostics.md b/docs/guide/diagnostics.md index 9d93b5de8..df5ab95d3 100644 --- a/docs/guide/diagnostics.md +++ b/docs/guide/diagnostics.md @@ -36,3 +36,5 @@ updatePolicy: skip # auto, prompt, or skip For OpenClaw's default workspace, `doctor` checks the whole `AGENTS.md`, including personal text, against `agents.defaults.bootstrapMaxChars` and `bootstrapTotalMaxChars` in the resolved `openclaw.json`. Their defaults are 20,000 and 60,000 characters. Shorten the file or review these limits if it would be truncated or skipped; `pull` does not truncate your instructions. Per-agent overrides require checking that agent's configuration separately. OpenCode hooks use the Node executable and TeamAI entry recorded by `teamai hooks inject` or `teamai pull`, so a service does not need `teamai` on PATH. After moving or reinstalling Node or TeamAI, run either command again and restart OpenCode. + +`doctor` checks the whole Hermes `SOUL.md`, including personal text. An explicit `context_file_max_chars` in Hermes' `config.yaml` is a hard cap. Without it, the cap depends on the selected model; files over the 20,000-character minimum produce an informational warning. Shorten the file or review the cap; `pull` does not truncate your instructions. diff --git a/docs/guide/zh-CN/diagnostics.md b/docs/guide/zh-CN/diagnostics.md index 5516bdb88..d59039c4c 100644 --- a/docs/guide/zh-CN/diagnostics.md +++ b/docs/guide/zh-CN/diagnostics.md @@ -36,3 +36,5 @@ updatePolicy: skip # auto / prompt / skip,优先于团队设置 对于 OpenClaw 的默认工作区,`doctor` 会根据实际 `openclaw.json` 的 `agents.defaults.bootstrapMaxChars` 和 `bootstrapTotalMaxChars` 检查整个 `AGENTS.md`,包括个人内容。两项默认分别为 20,000 和 60,000 字符。如果会截断或跳过,请缩短文件或检查上限;`pull` 不会截断你的指令。单独智能体的覆盖配置需要另行检查。 OpenCode hooks 使用 `teamai hooks inject` 或 `teamai pull` 记录的 Node 可执行文件和 TeamAI 入口,因此服务的 PATH 无需包含 `teamai`。移动或重新安装 Node 或 TeamAI 后,重新运行其中一个命令并重启 OpenCode。 + +`doctor` 会检查 Hermes 的整个 `SOUL.md`,包括个人内容。Hermes 的 `config.yaml` 中显式设置的 `context_file_max_chars` 是明确上限;未设置时,上限随模型变化,超过最低 20,000 字符会产生提示性警告。请缩短文件或检查上限;`pull` 不会截断你的指令。 diff --git a/src/__tests__/doctor-hermes-soul-budget.test.ts b/src/__tests__/doctor-hermes-soul-budget.test.ts new file mode 100644 index 000000000..ee6345ec1 --- /dev/null +++ b/src/__tests__/doctor-hermes-soul-budget.test.ts @@ -0,0 +1,67 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { buildInstructionDeliveryChecks } from '../doctor-delivery.js'; +import { TeamaiConfigSchema, type LocalConfig, TEAMAI_RULES_START, TEAMAI_RULES_END } from '../types.js'; + +let home = ''; +afterEach(() => { vi.unstubAllEnvs(); if (home) fs.rmSync(home, { recursive: true, force: true }); }); + +beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-hermes-budget-')); + const hermes = path.join(home, '.hermes'); + fs.mkdirSync(hermes); + const repo = path.join(home, 'repo'); + fs.mkdirSync(repo); + vi.stubEnv('HOME', home); + vi.stubEnv('USERPROFILE', home); + vi.stubEnv('HERMES_HOME', hermes); + fs.writeFileSync(path.join(hermes, 'config.yaml'), 'context_file_max_chars: 20000\n'); + +}); + +const block = `${TEAMAI_RULES_START}\nTeam rule.\n${TEAMAI_RULES_END}`; +async function budget(text: string, trailing = '') { + fs.writeFileSync(path.join(home, '.hermes', 'SOUL.md'), text + block + trailing); + const localConfig: LocalConfig = { repo: { localPath: path.join(home, 'repo'), remote: 'fixture' }, username: 'test', additionalRoles: [], scope: 'user', enabledAgents: ['hermes'] }; + const teamConfig = TeamaiConfigSchema.parse({ team: 'test', repo: 'fixture' }); + const checks = await buildInstructionDeliveryChecks({ teamConfig, localConfig } as never); + const budget = checks.find(check => check.name === 'Hermes SOUL.md fits the context-file read budget'); + return budget; +} + +it('checks member text as well as the managed block', async () => { + const check = await budget('x'.repeat(20000)); + expect(await check!.check()).toBe(false); + expect(check!.informational).toBe(false); + expect(check!.fix).toContain('context_file_max_chars'); +}); +it('uses Python code points and strips surrounding whitespace at the exact boundary', async () => { + const text = '😀'.repeat(400); + fs.writeFileSync(path.join(home, '.hermes', 'config.yaml'), `context_file_max_chars: ${Array.from(text + block).length}\n`); + expect(await (await budget(text, '\n '))!.check()).toBe(true); + expect(await (await budget(text + 'x'))!.check()).toBe(false); +}); +it('warns informationally about the model-dependent default floor', async () => { + fs.rmSync(path.join(home, '.hermes', 'config.yaml')); + const check = await budget('x'.repeat(20000)); + expect(await check!.check()).toBe(false); + expect(check!.informational).toBe(true); + expect(check!.fix).toContain('selected model'); +}); +it('does not claim a known budget when YAML is malformed', async () => { + fs.writeFileSync(path.join(home, '.hermes', 'config.yaml'), 'broken: ['); + const check = await budget(''); + expect(await check!.check()).toBe(false); + expect(check!.informational).toBe(false); +}); + +it('does not add a budget check to an unmanaged file', async () => { + await budget(''); + fs.writeFileSync(path.join(home, '.hermes', 'SOUL.md'), 'personal instructions'); + const localConfig: LocalConfig = { repo: { localPath: path.join(home, 'repo'), remote: 'fixture' }, username: 'test', additionalRoles: [], scope: 'user', enabledAgents: ['hermes'] }; + const teamConfig = TeamaiConfigSchema.parse({ team: 'test', repo: 'fixture' }); + const checks = await buildInstructionDeliveryChecks({ teamConfig, localConfig } as never); + expect(checks.some(check => check.name === 'Hermes SOUL.md fits the context-file read budget')).toBe(false); +}); diff --git a/src/__tests__/e2e/doctor-hermes-budget.test.ts b/src/__tests__/e2e/doctor-hermes-budget.test.ts new file mode 100644 index 000000000..f4e00ff6e --- /dev/null +++ b/src/__tests__/e2e/doctor-hermes-budget.test.ts @@ -0,0 +1,38 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { expect, it } from 'vitest'; +import YAML from 'yaml'; +import { TEAMAI_RULES_START, TEAMAI_RULES_END } from '../../types.js'; + +const CLI = fileURLToPath(new URL('../../../dist/index.js', import.meta.url)); +it('reports whole-file truncation through the real doctor CLI and clears it after shortening', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-hermes-budget-e2e-')); + const home = path.join(root, 'home'); + const repo = path.join(root, 'repo'); + const write = (file: string, content: string) => { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, content); }; + const env = { ...process.env, HOME: home, USERPROFILE: home, FORCE_COLOR: '0', HERMES_HOME: path.join(home, '.hermes') }; + try { + write(path.join(repo, 'teamai.yaml'), YAML.stringify({ team: 'fixture', repo: 'https://example.invalid/team.git', provider: 'git' })); + write(path.join(home, '.teamai', 'config.yaml'), YAML.stringify({ repo: { localPath: repo, remote: 'https://example.invalid/team.git', kind: 'git' }, username: 'fixture', scope: 'user', enabledAgents: ['hermes'], updatePolicy: 'skip' })); + write(path.join(home, '.hermes', 'config.yaml'), 'context_file_max_chars: 1000\n'); + const file = path.join(home, '.hermes', 'SOUL.md'); + const block = `\n${TEAMAI_RULES_START}\nTeam rules.\n${TEAMAI_RULES_END}`; + const run = () => { + const result = spawnSync(process.execPath, [CLI, 'doctor', '--json'], { cwd: home, env, encoding: 'utf8', timeout: 45000 }); + expect(result.error).toBeUndefined(); + const report = JSON.parse(result.stdout) as { checks: { name: string; ok: boolean; fix: string }[] }; + const check = report.checks.find(check => check.name === 'Hermes SOUL.md fits the context-file read budget'); + expect(check, result.stdout + result.stderr).toBeDefined(); + return check!; + }; + write(file, 'x'.repeat(1000) + block); + expect(run().ok).toBe(false); + expect(run().fix).toContain('context_file_max_chars'); + expect(fs.readFileSync(file, 'utf8')).toBe('x'.repeat(1000) + block); + write(file, block); + expect(run().ok).toBe(true); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}, 120000); diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index de773f67f..1746d51ff 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -2465,6 +2465,25 @@ export async function buildInstructionDeliveryChecks(ctx: DoctorContext): Promis }); } + const hermesSoul = targets.find((target) => target.tools.includes('hermes')); + if (hermesSoul && await holdsInstructionBlocks(hermesSoul.path)) { + const { hermesContextFileBudget, getHermesConfigPath } = await import('./hermes-config.js'); + const budget = await hermesContextFileBudget(); + // Hermes counts Python Unicode code points after stripping whitespace. + const length = Array.from((await readFileSafe(hermesSoul.path) ?? '').trim()).length; + checks.push({ + name: 'Hermes SOUL.md fits the context-file read budget', + source: 'local', + informational: !budget.explicit && !budget.error, + check: async () => !budget.error && length <= budget.limit, + fix: budget.error + ? `${getHermesConfigPath()} cannot be parsed, so the SOUL.md read budget is unknown: ${budget.error}. Fix its YAML and run teamai doctor again.` + : `${hermesSoul.path} has ${length} characters including member text and all TeamAI blocks, over the ` + + `${budget.limit}-character ${budget.explicit ? 'context_file_max_chars cap, so Hermes truncates it' : 'minimum default context-file cap; Hermes may truncate it unless the selected model raises the cap'}. ` + + `Shorten the complete file or set context_file_max_chars in ${getHermesConfigPath()} to a suitable limit. Teamai does not truncate your instructions.`, + }); + } + const opencodeFile = teamConfig.toolPaths.opencode === undefined ? undefined : ownOf(await locate('opencode', 'blocks', installOf(teamConfig, localConfig)))?.path; // Only a file holding the blocks needs listing; pull registers it once it diff --git a/src/hermes-config.ts b/src/hermes-config.ts index ce37f08d8..a180d8eff 100644 --- a/src/hermes-config.ts +++ b/src/hermes-config.ts @@ -18,6 +18,21 @@ export function getHermesSoulPath(): string { return path.join(getHermesHome(), 'SOUL.md'); } +/** Hermes uses an explicit cap, or a model-dependent cap with a 20,000-character floor. */ +export async function hermesContextFileBudget(): Promise<{ limit: number; explicit: boolean; error?: string }> { + const raw = await readFileSafe(getHermesConfigPath()); + try { + const config = raw ? YAML.parse(raw) as { context_file_max_chars?: unknown } | null : null; + const value = config?.context_file_max_chars; + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return { limit: Math.trunc(value), explicit: true }; + } + return { limit: 20_000, explicit: false }; + } catch (error) { + return { limit: 20_000, explicit: false, error: error instanceof Error ? error.message : String(error) }; + } +} + /** * Read the Hermes config.yaml as a YAML Document, preserving comments and * formatting. Returns an empty Document when the file is missing or empty. From 290662f7f2ff5d36c8542e2fb8dbb3ff126f896a Mon Sep 17 00:00:00 2001 From: Nathan Shan Date: Sun, 25 Oct 2026 01:14:39 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(hermes):=20=E6=8C=89=20Python=20?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=E7=BB=9F=E8=AE=A1=E4=B8=8A=E4=B8=8B=E6=96=87?= =?UTF-8?q?=E8=BE=B9=E7=95=8C=E7=A9=BA=E7=99=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 对齐 Python str.strip 的 Unicode 空白集合,正确剥离 C0 分隔符和 NEL,同时保留 BOM。 - 添加精确上限用例,修复前复现失败,修复后通过;保留主分支 OpenClaw 诊断说明和定位接口。 - 22 个相关单测和真实 CLI 预算诊断用例通过,构建、类型检查和 lint 通过。 --- src/__tests__/doctor-hermes-soul-budget.test.ts | 5 +++++ src/doctor-delivery.ts | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/__tests__/doctor-hermes-soul-budget.test.ts b/src/__tests__/doctor-hermes-soul-budget.test.ts index ee6345ec1..2241d8f28 100644 --- a/src/__tests__/doctor-hermes-soul-budget.test.ts +++ b/src/__tests__/doctor-hermes-soul-budget.test.ts @@ -43,6 +43,11 @@ it('uses Python code points and strips surrounding whitespace at the exact bound expect(await (await budget(text, '\n '))!.check()).toBe(true); expect(await (await budget(text + 'x'))!.check()).toBe(false); }); +it('matches Python strip for C0 and NEL whitespace, but keeps BOM', async () => { + fs.writeFileSync(path.join(home, '.hermes', 'config.yaml'), `context_file_max_chars: ${Array.from(block).length}\n`); + expect(await (await budget('\u0085\u001c\u001f', '\u0085\u001c\u001f'))!.check()).toBe(true); + expect(await (await budget('\ufeff'))!.check()).toBe(false); +}); it('warns informationally about the model-dependent default floor', async () => { fs.rmSync(path.join(home, '.hermes', 'config.yaml')); const check = await budget('x'.repeat(20000)); diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index 1746d51ff..68031a9bc 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -2470,7 +2470,9 @@ export async function buildInstructionDeliveryChecks(ctx: DoctorContext): Promis const { hermesContextFileBudget, getHermesConfigPath } = await import('./hermes-config.js'); const budget = await hermesContextFileBudget(); // Hermes counts Python Unicode code points after stripping whitespace. - const length = Array.from((await readFileSafe(hermesSoul.path) ?? '').trim()).length; + // Python str.strip includes C0 separators and NEL, and excludes BOM. + // eslint-disable-next-line no-control-regex + const length = Array.from((await readFileSafe(hermesSoul.path) ?? '').replace(/^[\u0009-\u000d\u001c-\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+|[\u0009-\u000d\u001c-\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+$/gu, '')).length; checks.push({ name: 'Hermes SOUL.md fits the context-file read budget', source: 'local',