mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 06:24:41 +01:00
fix(harness): register OpenClaw skills over its 32 KB whole-read limit as entries pointing at SKILL.full.md (GST-110)
#3555's hybrid layout registered review, investigate, ship and plan-eng-review as OpenClaw skills; OpenClaw serves a registered SKILL.md whole or refuses it, at 32,768 bytes when the model's context window is unknown. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
ad8e12dff7
commit
fca8f02660
2 files changed
+99
-3
No files matched your search
+31
-1
@@ -37,6 +37,15 @@ export const DOT_SKILLS_DIR = '/home/dot/skills';
|
||||
export const ROUTER_DESCRIPTION = 'Use to review a diff or PR, debug a bug or failing test, QA test a site, plan or review architecture, ship a PR, audit security or write release docs. Load first for any coding task: lists every gstack skill and where to read it.';
|
||||
/** OpenClaw hybrid mode: these task skills are listed as skills of their own; the router carries the rest. */
|
||||
export const HYBRID_SKILLS = ['review', 'investigate', 'qa', 'ship', 'plan-eng-review'];
|
||||
/**
|
||||
* OpenClaw serves a registered skill's SKILL.md whole or refuses it ("Skill instructions cannot be partially
|
||||
* served"), with maxBytes from resolveAdaptiveReadMaxBytes: 32,768 (DEFAULT_READ_PAGE_MAX_BYTES) when the model's
|
||||
* context window is unknown, never less (OpenClaw 2026.10.6 dist/agent-tools.read-BIxUGJy-.mjs:168, :189-194, :747,
|
||||
* :798, :819-821). A larger skill is registered as a short entry that points at its full text in OPENCLAW_FULL_SKILL.
|
||||
*/
|
||||
export const OPENCLAW_INSTRUCTION_MAX = 32_768;
|
||||
/** Beside the entry, so the full text's relative paths (sections/, ../gstack-qa/) resolve as before; only a skill folder's SKILL.md is an instruction file (dist/skill-root-discovery-C2QVZC77.mjs:343). */
|
||||
export const OPENCLAW_FULL_SKILL = 'SKILL.full.md';
|
||||
|
||||
export interface Target {
|
||||
name: TargetName;
|
||||
@@ -46,6 +55,8 @@ export interface Target {
|
||||
render: 'host' | 'claude' | null;
|
||||
defaultMode: SkillsMode;
|
||||
modes: SkillsMode[];
|
||||
/** Largest SKILL.md the harness serves whole; a larger skill is registered as an entry pointing at its full text. */
|
||||
instructionMax?: number;
|
||||
/** Destination folder for this target, from its environment, or null when it has no default. */
|
||||
dest: (env: Env, home: string) => string | null;
|
||||
/** Running inside this harness: a session marker or the harness among this process's ancestors. */
|
||||
@@ -65,7 +76,7 @@ const ancestorIs = (ancestry: string[], name: string) => ancestry.some(id => {
|
||||
|
||||
export const TARGETS: Record<TargetName, Target> = {
|
||||
openclaw: {
|
||||
name: 'openclaw', displayName: 'OpenClaw', mechanism: 'skill-folder', render: 'host', defaultMode: 'hybrid', modes: ['router', 'hybrid', 'all'],
|
||||
name: 'openclaw', displayName: 'OpenClaw', mechanism: 'skill-folder', render: 'host', defaultMode: 'hybrid', modes: ['router', 'hybrid', 'all'], instructionMax: OPENCLAW_INSTRUCTION_MAX,
|
||||
dest: (env, home) => path.join(env.OPENCLAW_STATE_DIR || path.join(home, '.openclaw'), 'skills'),
|
||||
inside: (env, anc) => env.OPENCLAW_SHELL === 'exec' || ancestorIs(anc, 'openclaw'),
|
||||
present: (env, home) => onPath('openclaw', env) || fs.existsSync(env.OPENCLAW_STATE_DIR || path.join(home, '.openclaw')),
|
||||
@@ -239,6 +250,19 @@ export function routerSkill(skills: Array<{ name: string; use: string; path: str
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** A registered skill whose full text is over the harness's whole-document read limit: its own frontmatter, and a body that sends the agent to the full text. */
|
||||
export function entrySkill(md: string, fullPath: string, target: Target): string {
|
||||
const end = md.indexOf('\n---', 4);
|
||||
return [
|
||||
md.slice(0, md.indexOf('\n', end + 4) + 1) + '<!-- AUTO-GENERATED by gstack-harness register: rerun it instead of editing -->',
|
||||
'# ' + frontmatterField(md, 'name'), '',
|
||||
'This skill\'s full instructions (' + Buffer.byteLength(md) + ' bytes) are larger than ' + target.displayName + ' serves as a skill file, so they are in a plain file beside this one.'
|
||||
+ ' Read `' + fullPath + '` in full now (every page, if the read is split) and follow it as this skill\'s instructions.'
|
||||
+ ' Paths it gives relative to the installed SKILL.md directory resolve from this folder.',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** The marked context block for the registration's mode: router (a router path), hybrid (a router path and direct skills) or all (no router). */
|
||||
export function contextBlock(routerPath: string | null, direct: string[] = []): string {
|
||||
const lead = "gstack's engineering workflows (code review, QA, debugging, security audit, plan review, shipping) are installed here. ";
|
||||
@@ -364,6 +388,12 @@ export function planPayload(opts: RegisterOptions, dest: string, mode: SkillsMod
|
||||
const listed = (s: SkillEntry) => {
|
||||
const to = path.join(dest, path.basename(s.dir));
|
||||
if (t.render === 'host') copyDir(s.dir, to); else claudeFiles(s, to);
|
||||
const md = path.join(to, 'SKILL.md');
|
||||
const full = files.get(md)!;
|
||||
if (t.instructionMax && full.length > t.instructionMax) {
|
||||
files.set(path.join(to, OPENCLAW_FULL_SKILL), full);
|
||||
files.set(md, Buffer.from(entrySkill(full.toString('utf8'), path.join(to, OPENCLAW_FULL_SKILL), t)));
|
||||
}
|
||||
return t.render === 'host' ? s.name : path.basename(s.dir);
|
||||
};
|
||||
if (mode === 'all') {
|
||||
|
||||
@@ -10,8 +10,8 @@ import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
BLOCK_BEGIN, BLOCK_END, CONTEXT_BLOCK_MAX, DOT_SKILLS_DIR, HARNESS_CODES, HYBRID_SKILLS, HarnessError, ROUTER_DESCRIPTION, ROUTER_DESCRIPTION_MAX, TARGETS,
|
||||
contextBlock, contextScope, detect, listLedgers, readLedger, refresh, register, unregister, type RegisterOptions,
|
||||
BLOCK_BEGIN, BLOCK_END, CONTEXT_BLOCK_MAX, DOT_SKILLS_DIR, HARNESS_CODES, HYBRID_SKILLS, HarnessError, OPENCLAW_FULL_SKILL, OPENCLAW_INSTRUCTION_MAX, ROUTER_DESCRIPTION, ROUTER_DESCRIPTION_MAX, TARGETS,
|
||||
contextBlock, contextScope, detect, listLedgers, readLedger, refresh, register, sourceSkills, unregister, type RegisterOptions,
|
||||
} from '../lib/harness-register';
|
||||
import { QM_PACK_LIMITS, qmRequest, registerQm, removeQmPack } from '../lib/harness-qm';
|
||||
import { cleanupFixtures, cleanupSeed, makeFixture, makeSource, registryRows, runSetup, tree } from './helpers/install-fixture';
|
||||
@@ -386,6 +386,72 @@ describe('GSTA-135: registered skills get used', () => {
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('GST-110: OpenClaw serves every registered skill file whole', () => {
|
||||
/** OpenClaw's registered instruction files: SKILL.md in each non-dot child folder, a folder without one searched below (dist/skill-root-discovery-C2QVZC77.mjs:44, :343-361). */
|
||||
const registered = (dir: string, depth = 0): string[] => depth > 6 ? [] : fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules')
|
||||
.flatMap(e => { const sub = path.join(dir, e.name); return fs.existsSync(path.join(sub, 'SKILL.md')) ? [path.join(sub, 'SKILL.md')] : registered(sub, depth + 1); });
|
||||
const frontmatter = (md: string) => md.slice(0, md.indexOf('\n---', 4) + 4);
|
||||
|
||||
test('hybrid and all: every registered SKILL.md is within the limit; a larger skill is an entry naming its full text, unregistered, beside it; uninstall removes both', () => {
|
||||
const d = tmp();
|
||||
for (const mode of ['hybrid', 'all'] as const) {
|
||||
const skills = path.join(d, mode, 'skills');
|
||||
const r = register(opts(d, 'openclaw', { mode, env: { OPENCLAW_STATE_DIR: path.join(d, mode) }, stateRoot: path.join(d, mode, 'state') }));
|
||||
const mds = registered(skills);
|
||||
expect(mds.length, mode).toBe(mode === 'all' ? r.skills : 1 + HYBRID_SKILLS.length);
|
||||
const entries = mds.filter(md => fs.readFileSync(md, 'utf8').includes(OPENCLAW_FULL_SKILL));
|
||||
if (mode === 'hybrid') expect(entries.map(md => path.basename(path.dirname(md)))).toContain('gstack-review');
|
||||
expect(entries.length, mode).toBeGreaterThan(0);
|
||||
for (const md of mds) expect(fs.statSync(md).size, md).toBeLessThanOrEqual(OPENCLAW_INSTRUCTION_MAX);
|
||||
const ledger = readLedger(r.ledger)!.files.map(x => x.path);
|
||||
for (const md of entries) {
|
||||
const entry = fs.readFileSync(md, 'utf8');
|
||||
const full = /Read \x60([^\x60]+)\x60 in full/.exec(entry)![1];
|
||||
expect(full).toBe(path.join(path.dirname(md), OPENCLAW_FULL_SKILL));
|
||||
expect(mds).not.toContain(full);
|
||||
const text = fs.readFileSync(full, 'utf8');
|
||||
expect(Buffer.byteLength(text)).toBeGreaterThan(OPENCLAW_INSTRUCTION_MAX);
|
||||
expect(frontmatter(entry)).toBe(frontmatter(text));
|
||||
expect(ledger).toContain(full);
|
||||
}
|
||||
unregister(path.join(d, mode, 'state'), ROOT, r.id);
|
||||
expect(files(skills), mode).toEqual([]);
|
||||
}
|
||||
}, 180_000);
|
||||
|
||||
test('Hermes is unchanged: its registration is the Hermes render, byte for byte, with no entry files', () => {
|
||||
const d = tmp();
|
||||
const r = register(opts(d, 'hermes', { env: { HERMES_HOME: path.join(d, 'hermes') } }));
|
||||
const render = sourceSkills(TARGETS.hermes, ROOT, tmp());
|
||||
const want = render.flatMap(s => files(s.dir).map(f => path.join(path.basename(s.dir), f))).sort();
|
||||
expect(files(r.dest)).toEqual(want);
|
||||
for (const s of render) for (const f of files(s.dir)) {
|
||||
expect(fs.readFileSync(path.join(r.dest, path.basename(s.dir), f)).equals(fs.readFileSync(path.join(s.dir, f))), f).toBe(true);
|
||||
}
|
||||
}, 180_000);
|
||||
|
||||
const openclaw = process.env.GSTACK_OPENCLAW_BIN || Bun.which('openclaw');
|
||||
test.skipIf(!openclaw)('a real OpenClaw lists the hybrid entries as its skills and none of the full texts (GSTACK_OPENCLAW_BIN or openclaw on PATH)', () => {
|
||||
const d = tmp();
|
||||
const env = { ...process.env, OPENCLAW_STATE_DIR: path.join(d, 'oc') };
|
||||
const r = register(opts(d, 'openclaw', { env: { OPENCLAW_STATE_DIR: env.OPENCLAW_STATE_DIR } }));
|
||||
const run = (...args: string[]) => {
|
||||
const p = spawnSync(openclaw!, ['skills', ...args, '--json'], { cwd: d, env, encoding: 'utf8', timeout: 120_000 });
|
||||
expect(p.status, p.stderr).toBe(0);
|
||||
return JSON.parse(p.stdout);
|
||||
};
|
||||
const managed = run('list').skills.filter((s: { source: string }) => s.source === 'openclaw-managed').map((s: { name: string }) => s.name).sort();
|
||||
expect(managed).toEqual(['gstack', ...HYBRID_SKILLS].sort());
|
||||
for (const name of managed) {
|
||||
const file = run('info', name).filePath as string;
|
||||
expect(file.startsWith(r.dest + path.sep) && path.basename(file) === 'SKILL.md', file).toBe(true);
|
||||
expect(fs.statSync(file).size, file).toBeLessThanOrEqual(OPENCLAW_INSTRUCTION_MAX);
|
||||
}
|
||||
expect(fs.existsSync(path.join(r.dest, 'gstack-review', OPENCLAW_FULL_SKILL))).toBe(true);
|
||||
}, 180_000);
|
||||
});
|
||||
|
||||
describe('bin/gstack-harness CLI error path', () => {
|
||||
test('a refused command prints its reason code and fix, never a TypeError from the error printer', () => {
|
||||
const d = tmp();
|
||||
|
||||
Reference in new issue
Block a user