Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/guide/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions docs/guide/zh-CN/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 不会截断你的指令。
72 changes: 72 additions & 0 deletions src/__tests__/doctor-hermes-soul-budget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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('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));
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);
});
38 changes: 38 additions & 0 deletions src/__tests__/e2e/doctor-hermes-budget.test.ts
Original file line number Diff line number Diff line change
@@ -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);
21 changes: 21 additions & 0 deletions src/doctor-delivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2465,6 +2465,27 @@ 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.
// 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',
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
Expand Down
15 changes: 15 additions & 0 deletions src/hermes-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading