@bevel-software/platform-core-backend 0.34.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent-guide/access-control.md +146 -300
- package/agent-guide/conventions.md +23 -25
- package/agent-guide/directory-structure.md +72 -120
- package/agent-guide/drafts-and-change-requests.md +92 -0
- package/agent-guide/for-git-users.md +47 -0
- package/agent-guide/glossary.md +36 -0
- package/agent-guide/how-agents-find-things.md +49 -0
- package/agent-guide/html-views.md +16 -11
- package/agent-guide/new-knowledge-base.md +9 -8
- package/agent-guide/skills.md +67 -42
- package/agent-guide/start-here.md +65 -0
- package/agent-guide/tool-manuals.md +132 -154
- package/agent-guide/tools.md +67 -0
- package/agent-guide/where-a-new-file-goes.md +45 -28
- package/dist/core/create-core-server.js +1 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +6 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +7 -4
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +22 -0
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +3 -1
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +75 -0
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +23 -4
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/index.d.ts +1 -1
- package/dist/modules/access/index.d.ts.map +1 -1
- package/dist/modules/agent-guide/agent-guide.d.ts +47 -7
- package/dist/modules/agent-guide/agent-guide.d.ts.map +1 -1
- package/dist/modules/agent-guide/agent-guide.js +103 -13
- package/dist/modules/agent-guide/agent-guide.js.map +1 -1
- package/dist/modules/agent-guide/agent-guide.tools.d.ts +6 -2
- package/dist/modules/agent-guide/agent-guide.tools.d.ts.map +1 -1
- package/dist/modules/agent-guide/agent-guide.tools.js +45 -31
- package/dist/modules/agent-guide/agent-guide.tools.js.map +1 -1
- package/dist/modules/agent-guide/guide-budget.d.ts +48 -0
- package/dist/modules/agent-guide/guide-budget.d.ts.map +1 -0
- package/dist/modules/agent-guide/guide-budget.js +67 -0
- package/dist/modules/agent-guide/guide-budget.js.map +1 -0
- package/dist/modules/agent-guide/index.d.ts +2 -1
- package/dist/modules/agent-guide/index.d.ts.map +1 -1
- package/dist/modules/agent-guide/index.js +2 -1
- package/dist/modules/agent-guide/index.js.map +1 -1
- package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
- package/dist/modules/agent-instructions/compose.js +4 -0
- package/dist/modules/agent-instructions/compose.js.map +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.js +10 -8
- package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -1
- package/dist/modules/code-mode/code-mode.tool.js +2 -2
- package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
- package/dist/modules/connection-probe/connection-probe.service.d.ts +0 -12
- package/dist/modules/connection-probe/connection-probe.service.d.ts.map +1 -1
- package/dist/modules/connection-probe/connection-probe.service.js +1 -38
- package/dist/modules/connection-probe/connection-probe.service.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +6 -35
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts +11 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.js +159 -24
- package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
- package/dist/modules/secrets-vault/index.d.ts +1 -1
- package/dist/modules/secrets-vault/index.d.ts.map +1 -1
- package/dist/modules/secrets-vault/index.js +1 -1
- package/dist/modules/secrets-vault/index.js.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.contract.d.ts +53 -11
- package/dist/modules/secrets-vault/secrets-vault.contract.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.contract.js +27 -0
- package/dist/modules/secrets-vault/secrets-vault.contract.js.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js +85 -39
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/settings/oidc-check.d.ts.map +1 -1
- package/dist/modules/settings/oidc-check.js +5 -17
- package/dist/modules/settings/oidc-check.js.map +1 -1
- package/dist/modules/skills/skills.tools.js +1 -1
- package/dist/modules/skills/skills.tools.js.map +1 -1
- package/dist/modules/tool-helpers/tool-def.js +1 -1
- package/dist/modules/tool-helpers/tool-def.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +7 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.js +69 -38
- package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +27 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +12 -8
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +115 -44
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts +8 -2
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +31 -10
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +22 -18
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workspace/agent-access.gate.d.ts +1 -1
- package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -1
- package/dist/modules/workspace/agent-access.gate.js +1 -1
- package/dist/modules/workspace/agent-access.gate.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts +3 -2
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +71 -21
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/oauth-token-error.d.ts +23 -0
- package/dist/shared/oauth-token-error.d.ts.map +1 -0
- package/dist/shared/oauth-token-error.js +47 -0
- package/dist/shared/oauth-token-error.js.map +1 -0
- package/dist/shared/read-capped.d.ts +19 -0
- package/dist/shared/read-capped.d.ts.map +1 -0
- package/dist/shared/read-capped.js +45 -0
- package/dist/shared/read-capped.js.map +1 -0
- package/package.json +4 -3
- package/src/__tests__/hexis-catalog.ts +144 -0
- package/src/__tests__/real-disk.ts +21 -0
- package/src/__tests__/tokens.ts +19 -0
- package/src/__tests__/tool-manuals.ts +32 -0
- package/src/__tests__/tool-wiring.ts +25 -0
- package/src/core/__tests__/oauth-sign-in-real-file.test.ts +179 -0
- package/src/core/create-core-server.ts +1 -1
- package/src/core/create-core-services.ts +13 -3
- package/src/modules/access/__tests__/access.routes.prospective.test.ts +52 -12
- package/src/modules/access/__tests__/holder-changes.test.ts +127 -0
- package/src/modules/access/access-control.interface.ts +26 -0
- package/src/modules/access/access-control.service.ts +92 -0
- package/src/modules/access/access.routes.ts +23 -4
- package/src/modules/access/index.ts +1 -1
- package/src/modules/agent-guide/__tests__/agent-guide.test.ts +79 -25
- package/src/modules/agent-guide/__tests__/agent-guide.tools.test.ts +38 -11
- package/src/modules/agent-guide/__tests__/guide-budget.test.ts +95 -0
- package/src/modules/agent-guide/__tests__/guide-eval.test.ts +182 -0
- package/src/modules/agent-guide/__tests__/guide-matches-code.test.ts +263 -0
- package/src/modules/agent-guide/__tests__/tool-surface.json +514 -0
- package/src/modules/agent-guide/__tests__/tool-surface.test.ts +75 -0
- package/src/modules/agent-guide/agent-guide.tools.ts +49 -31
- package/src/modules/agent-guide/agent-guide.ts +120 -13
- package/src/modules/agent-guide/guide-budget.ts +73 -0
- package/src/modules/agent-guide/index.ts +12 -0
- package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +4 -4
- package/src/modules/agent-instructions/compose.ts +4 -0
- package/src/modules/agent-instructions/shared-file-rules.ts +10 -8
- package/src/modules/code-mode/code-mode.tool.ts +2 -2
- package/src/modules/connection-probe/__tests__/connection-probe.service.test.ts +5 -24
- package/src/modules/connection-probe/connection-probe.service.ts +1 -34
- package/src/modules/mcp/mcp.service.ts +5 -35
- package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +430 -3
- package/src/modules/secrets-vault/__tests__/oauth-live-declaration.route.test.ts +218 -0
- package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +39 -6
- package/src/modules/secrets-vault/db-secrets-vault.service.ts +170 -27
- package/src/modules/secrets-vault/index.ts +1 -1
- package/src/modules/secrets-vault/secrets-vault.contract.ts +61 -11
- package/src/modules/secrets-vault/secrets-vault.routes.ts +91 -39
- package/src/modules/settings/oidc-check.ts +5 -18
- package/src/modules/skills/skills.tools.ts +1 -1
- package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +19 -11
- package/src/modules/tool-helpers/__tests__/route-tool-schemas.catalog.test.ts +38 -0
- package/src/modules/tool-helpers/tool-def.ts +1 -1
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +197 -11
- package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +96 -5
- package/src/modules/tool-manuals/mcp-json-discovery.ts +73 -34
- package/src/modules/tool-manuals/tool-manuals.contract.ts +22 -0
- package/src/modules/tool-manuals/tool-manuals.service.ts +138 -46
- package/src/modules/tool-manuals/tool-manuals.tools.ts +53 -14
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +6 -113
- package/src/modules/workflow/agent-tools/workflow.tools.ts +22 -18
- package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +8 -10
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +156 -49
- package/src/modules/workspace/agent-access.gate.ts +1 -1
- package/src/modules/workspace/workspace.tools.ts +74 -21
- package/src/shared/__tests__/read-capped.test.ts +68 -0
- package/src/shared/oauth-token-error.ts +46 -0
- package/src/shared/read-capped.ts +40 -0
- package/agent-guide/finding-things.md +0 -7
- package/agent-guide/introduction.md +0 -27
|
@@ -2,15 +2,18 @@ import type { Server } from 'node:http';
|
|
|
2
2
|
import type { AddressInfo } from 'node:net';
|
|
3
3
|
import express from 'express';
|
|
4
4
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
-
import { ToolRegistry } from '../../tool-registry/
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
|
|
9
|
-
import type { ToolContext } from '../../tool-helpers/tool.contract.js';
|
|
10
|
-
import { toolDef } from '../../tool-helpers/tool-def.js';
|
|
5
|
+
import { GUIDE_FIRST_SENTENCE, TOOL_DESCRIPTION_CAP, ToolRegistry } from '../../tool-registry/index.js';
|
|
6
|
+
import { createToolHandlerFactory } from '../../../__tests__/tool-wiring.js';
|
|
7
|
+
import { toolDef, type ToolContext } from '../../tool-helpers/index.js';
|
|
11
8
|
import { GET_AGENT_GUIDE_TOOL, registerAgentGuideTool } from '../agent-guide.tools.js';
|
|
12
9
|
import { DEFAULT_KB_LAYOUT } from '@bevel-software/platform-shared';
|
|
13
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
START_HERE_SECTION_ID,
|
|
12
|
+
WHOLE_GUIDE_SECTION,
|
|
13
|
+
agentGuideSections,
|
|
14
|
+
joinGuideSections,
|
|
15
|
+
type RenderedGuideSection,
|
|
16
|
+
} from '../agent-guide.js';
|
|
14
17
|
|
|
15
18
|
let server: Server | null = null;
|
|
16
19
|
|
|
@@ -84,8 +87,11 @@ describe('get_agent_guide', () => {
|
|
|
84
87
|
for (const list of [registry.listExternal(), registry.listInternal()]) {
|
|
85
88
|
const def = (await list).find((t) => t.name === GET_AGENT_GUIDE_TOOL);
|
|
86
89
|
expect(def).toBeDefined();
|
|
87
|
-
expect(def!.description).toContain('ALWAYS call this first
|
|
88
|
-
expect(def!.description).toContain(
|
|
90
|
+
expect(def!.description).toContain('ALWAYS call this first, before anything else in the platform.');
|
|
91
|
+
expect(def!.description).toContain('With no `section` it returns the start section');
|
|
92
|
+
expect(def!.description).toContain('only `"all"` returns the whole guide.');
|
|
93
|
+
// The read of the guide's name is the start too (`guideStartText`), never the whole guide.
|
|
94
|
+
expect(def!.description).toContain('`read_file` on `AGENTS.md` at the KB root answers with the same start section');
|
|
89
95
|
// The sections, a distribution's own included, by id and title.
|
|
90
96
|
expect(def!.description).toContain(
|
|
91
97
|
'Sections: `introduction` (Knowledge base), `access-control` (Access control), `knowledge-graph` (Knowledge graph).',
|
|
@@ -176,7 +182,7 @@ describe('get_agent_guide', () => {
|
|
|
176
182
|
const shown = (counted.match(/`[^`]+`(?=, |\.$)/g) ?? []).filter((id) => id.startsWith('`a-section-id') || id === '`introduction`' || id === '`access-control`' || id === '`knowledge-graph`');
|
|
177
183
|
expect(shown.length).toBeGreaterThan(0);
|
|
178
184
|
expect(shown.length).toBeLessThan(203);
|
|
179
|
-
expect(counted).toMatch(new RegExp(`and ${203 - shown.length} more, all named under \`sections\` in the
|
|
185
|
+
expect(counted).toMatch(new RegExp(`and ${203 - shown.length} more, all named under \`sections\` in the answer\\.$`));
|
|
180
186
|
// No id is ever cut mid-way: every listed id is one of the guide's.
|
|
181
187
|
const all = new Set([...SECTIONS, ...flood].map((s) => `\`${s.id}\``));
|
|
182
188
|
for (const id of shown) expect(all.has(id), id).toBe(true);
|
|
@@ -188,6 +194,27 @@ describe('get_agent_guide', () => {
|
|
|
188
194
|
expect(await describedAs()).toContain('`introduction` (Knowledge base)');
|
|
189
195
|
});
|
|
190
196
|
|
|
197
|
+
it('answers a call without a section with the start section and every section, and `all` with the whole guide', async () => {
|
|
198
|
+
const { call, sectionsNow } = await serve();
|
|
199
|
+
const platform = await agentGuideSections(DEFAULT_KB_LAYOUT, undefined, { defaultBranch: 'production' });
|
|
200
|
+
sectionsNow([...platform]);
|
|
201
|
+
const index = platform.map((s) => ({ id: s.id, title: s.title }));
|
|
202
|
+
const start = platform.find((s) => s.id === START_HERE_SECTION_ID)!;
|
|
203
|
+
// The first call every description asks for costs the start section, not the guide.
|
|
204
|
+
expect(await call()).toEqual({
|
|
205
|
+
status: 200,
|
|
206
|
+
body: { guide: start.body, section: START_HERE_SECTION_ID, title: 'Start here', sections: index },
|
|
207
|
+
});
|
|
208
|
+
expect(start.body.replace(/\s+/g, ' ')).toContain('`production` is the default branch');
|
|
209
|
+
expect(await call({ section: WHOLE_GUIDE_SECTION })).toEqual({
|
|
210
|
+
status: 200,
|
|
211
|
+
body: { guide: joinGuideSections(platform), sections: index },
|
|
212
|
+
});
|
|
213
|
+
// A refusal says how to reach both.
|
|
214
|
+
const unknown = await call({ section: 'nope' });
|
|
215
|
+
expect(String(unknown.body.error)).toContain('Omit `section` for the start section, or pass `"all"` for the whole guide.');
|
|
216
|
+
});
|
|
217
|
+
|
|
191
218
|
it('returns the platform\'s HTML views section on its own, and inside the whole guide', async () => {
|
|
192
219
|
const { call, registry, sectionsNow } = await serve();
|
|
193
220
|
const platform = await agentGuideSections(DEFAULT_KB_LAYOUT);
|
|
@@ -197,7 +224,7 @@ describe('get_agent_guide', () => {
|
|
|
197
224
|
status: 200,
|
|
198
225
|
body: { guide: html.body, section: 'html-views', title: 'HTML views' },
|
|
199
226
|
});
|
|
200
|
-
const whole = await call();
|
|
227
|
+
const whole = await call({ section: 'all' });
|
|
201
228
|
expect(whole.body.guide).toBe(joinGuideSections(platform));
|
|
202
229
|
expect(String(whole.body.guide)).toContain('## HTML views');
|
|
203
230
|
// The description lists it, so an agent knows it can ask for it.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { DEFAULT_KB_LAYOUT } from '@bevel-software/platform-shared';
|
|
2
|
+
import { toListedTool } from '@bevel-software/platform-mcp-core';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { countTokens } from '../../../__tests__/tokens.js';
|
|
5
|
+
import { hexisCatalog } from '../../../__tests__/hexis-catalog.js';
|
|
6
|
+
import {
|
|
7
|
+
PREFIXED_TOOLS,
|
|
8
|
+
composeAgentInstructions,
|
|
9
|
+
platformInstructions,
|
|
10
|
+
prefixToolDescription,
|
|
11
|
+
} from '../../agent-instructions/index.js';
|
|
12
|
+
import { EXTERNAL_KB_MANUAL_NAME } from '../../tool-manuals/index.js';
|
|
13
|
+
import { CORE_SECTION_IDS, START_HERE_SECTION_ID, agentGuideSections, joinGuideSections } from '../agent-guide.js';
|
|
14
|
+
import {
|
|
15
|
+
INSTRUCTIONS_TOKEN_BUDGET,
|
|
16
|
+
SECTION_TOKEN_BUDGETS,
|
|
17
|
+
TOOL_LIST_TOKEN_BUDGET,
|
|
18
|
+
WHOLE_GUIDE_TOKEN_BUDGET,
|
|
19
|
+
} from '../guide-budget.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The budgets in `guide-budget.ts`, measured with a real tokenizer on the
|
|
23
|
+
* text an agent is actually handed: the sections as composed for a
|
|
24
|
+
* deployment (default layout, a default branch named `main`), the handshake's
|
|
25
|
+
* platform part, and the tool list as the hosted endpoint serves it. Each
|
|
26
|
+
* failure names what grew and by how much, so the next reader does not have
|
|
27
|
+
* to measure it by hand.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const composed = () => agentGuideSections(DEFAULT_KB_LAYOUT, undefined, { defaultBranch: 'main' });
|
|
31
|
+
|
|
32
|
+
describe('the guide fits its token budgets', () => {
|
|
33
|
+
it('gives every core section a budget, and no budget to a section that is gone', () => {
|
|
34
|
+
const budgeted = Object.keys(SECTION_TOKEN_BUDGETS);
|
|
35
|
+
expect(CORE_SECTION_IDS.filter((id) => !budgeted.includes(id)), 'sections without a budget').toEqual([]);
|
|
36
|
+
expect(budgeted.filter((id) => !CORE_SECTION_IDS.includes(id)), 'budgets for sections that do not exist').toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('keeps each section within its budget, the start section the tightest', async () => {
|
|
40
|
+
const over = (await composed())
|
|
41
|
+
.map((s) => ({ id: s.id, tokens: countTokens(s.body), budget: SECTION_TOKEN_BUDGETS[s.id]! }))
|
|
42
|
+
.filter((s) => s.tokens > s.budget);
|
|
43
|
+
expect(over, over.map((s) => `${s.id}: ${s.tokens} tokens (budget ${s.budget})`).join('\n')).toEqual([]);
|
|
44
|
+
expect(SECTION_TOKEN_BUDGETS[START_HERE_SECTION_ID]).toBe(Math.min(...Object.values(SECTION_TOKEN_BUDGETS)));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('keeps the whole guide within its budget', async () => {
|
|
48
|
+
const tokens = countTokens(joinGuideSections(await composed()));
|
|
49
|
+
expect(tokens, `the whole guide is ${tokens} tokens (budget ${WHOLE_GUIDE_TOKEN_BUDGET})`).toBeLessThanOrEqual(
|
|
50
|
+
WHOLE_GUIDE_TOKEN_BUDGET,
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('keeps the platform part of the handshake instructions within its budget', () => {
|
|
55
|
+
const tokens = countTokens(platformInstructions(DEFAULT_KB_LAYOUT));
|
|
56
|
+
expect(tokens, `the instructions are ${tokens} tokens (budget ${INSTRUCTIONS_TOKEN_BUDGET})`).toBeLessThanOrEqual(
|
|
57
|
+
INSTRUCTIONS_TOKEN_BUDGET,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('keeps the tool list a client is served within its budget', async () => {
|
|
62
|
+
// As `mcp.service.ts` lists it: each tool through `toListedTool` (its
|
|
63
|
+
// `Call:` line and normalised schema), the purpose prefix on the four
|
|
64
|
+
// knowledge-base tools, and the meta-tools as built for the endpoint.
|
|
65
|
+
const { external, metaTools } = await hexisCatalog();
|
|
66
|
+
const { toolPrefix } = composeAgentInstructions(null, DEFAULT_KB_LAYOUT);
|
|
67
|
+
const listed = external.map((t) => {
|
|
68
|
+
const entry = toListedTool({
|
|
69
|
+
mcpName: t.name,
|
|
70
|
+
utcpName: `${EXTERNAL_KB_MANUAL_NAME}.${t.name}`,
|
|
71
|
+
description: t.description ?? '',
|
|
72
|
+
inputSchema: (t.inputs ?? {}) as never,
|
|
73
|
+
manualName: EXTERNAL_KB_MANUAL_NAME,
|
|
74
|
+
})!;
|
|
75
|
+
return PREFIXED_TOOLS.has(entry.name)
|
|
76
|
+
? { ...entry, description: prefixToolDescription(toolPrefix, entry.description) }
|
|
77
|
+
: entry;
|
|
78
|
+
});
|
|
79
|
+
const served = [
|
|
80
|
+
...metaTools.map((t) => ({ name: t.name, description: t.description ?? '', inputSchema: (t as { inputSchema?: unknown }).inputSchema })),
|
|
81
|
+
...listed,
|
|
82
|
+
];
|
|
83
|
+
const perTool = served.map((t) => ({
|
|
84
|
+
name: t.name,
|
|
85
|
+
tokens: countTokens(t.name) + countTokens(t.description ?? '') + countTokens(JSON.stringify(t.inputSchema ?? {})),
|
|
86
|
+
}));
|
|
87
|
+
const total = perTool.reduce((sum, t) => sum + t.tokens, 0);
|
|
88
|
+
const largest = [...perTool].sort((a, b) => b.tokens - a.tokens).slice(0, 5);
|
|
89
|
+
expect(
|
|
90
|
+
total,
|
|
91
|
+
`the tool list is ${total} tokens over ${served.length} tools (budget ${TOOL_LIST_TOKEN_BUDGET}); largest: ` +
|
|
92
|
+
largest.map((t) => `${t.name} ${t.tokens}`).join(', '),
|
|
93
|
+
).toBeLessThanOrEqual(TOOL_LIST_TOKEN_BUDGET);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { DEFAULT_KB_LAYOUT } from '@bevel-software/platform-shared';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { countTokens } from '../../../__tests__/tokens.js';
|
|
4
|
+
import { START_HERE_SECTION_ID, agentGuideSections } from '../agent-guide.js';
|
|
5
|
+
import { TASK_TOKEN_BUDGET } from '../guide-budget.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The evaluation in openspec/changes/agent-guide-layers/design.md, as a test.
|
|
9
|
+
*
|
|
10
|
+
* Ten first-contact tasks from a customer's first day on the platform
|
|
11
|
+
* (2026-10-19), each with the facts the guide must let an agent get right.
|
|
12
|
+
* For each task: the start section routes its questions to every section
|
|
13
|
+
* named here (at most two), those sections and the start section state every fact,
|
|
14
|
+
* and reading them costs at most one task's budget. Before the change, 27 of
|
|
15
|
+
* these 53 facts were stated and every task cost the whole guide.
|
|
16
|
+
*
|
|
17
|
+
* A fact is pinned by the phrase that states it, so rewording a fact means
|
|
18
|
+
* rewording its phrase here: on purpose, as that is the moment to check the
|
|
19
|
+
* fact survived. Matched with whitespace collapsed and emphasis removed.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
interface Task {
|
|
23
|
+
task: string;
|
|
24
|
+
/** A phrase of each start-section question that routes this task: one per section it reads. */
|
|
25
|
+
asks?: string[];
|
|
26
|
+
sections: string[];
|
|
27
|
+
facts: Array<[fact: string, phrase: RegExp]>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const TASKS: Task[] = [
|
|
31
|
+
{
|
|
32
|
+
task: '1. What is this knowledge base, and how does my agent get its context?',
|
|
33
|
+
asks: ['What does my agent see when it connects'],
|
|
34
|
+
sections: ['how-agents-find-things'],
|
|
35
|
+
facts: [
|
|
36
|
+
['not explained as a Git repo', /do not explain it as "a Git repo"/],
|
|
37
|
+
['what the agent has on connect', /When an agent connects, it has three things and no more/],
|
|
38
|
+
['the rest is fetched, never all of it', /No agent reads the whole knowledge base/],
|
|
39
|
+
['descriptions and links bring context', /A skill's description decides when the skill is loaded.*Links between pages/],
|
|
40
|
+
['a snippet for the local AGENTS.md', /the one text an agent always reads in a repository is that repository's `AGENTS\.md` or `CLAUDE\.md`.*## Organisation knowledge/],
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
task: '2. Read a page, list a folder: the first calls',
|
|
45
|
+
sections: [],
|
|
46
|
+
facts: [
|
|
47
|
+
['every file tool takes branch, and which is live', /Every file tool takes `branch`\. `main` is the default branch: what is live/],
|
|
48
|
+
['paths sit under the checkout folder', /Paths sit under `knowledge-base\/`/],
|
|
49
|
+
['sessionId only where listed', /Pass `sessionId` only where a tool lists it/],
|
|
50
|
+
['copy the Call: line', /Copy each tool's `Call:` line/],
|
|
51
|
+
['grep takes RE2-style patterns', /`grep` takes RE2-style regular expressions \(ripgrep\): no look-around and no backreferences/],
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
task: '3. Make a skill on my branch and see it',
|
|
56
|
+
asks: ['How do I write a skill'],
|
|
57
|
+
sections: ['skills'],
|
|
58
|
+
facts: [
|
|
59
|
+
['where a skill lives', /A skill is a folder holding a `SKILL\.md`/],
|
|
60
|
+
['quote YAML values', /Quote a value that contains `: `/],
|
|
61
|
+
['a draft is seen through its change request', /its change request, once you open one, shows every changed file/],
|
|
62
|
+
['the catalog shows only merged skills', /A skill on a draft is not live.*pass the draft: `list_skills/],
|
|
63
|
+
['tools likewise', /Skills and tools go live only once they are on `main`/],
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
task: "4. Why can't I delete this?",
|
|
68
|
+
asks: ["Why can't I delete this?", 'moves, deletes'],
|
|
69
|
+
sections: ['for-git-users', 'working-with-files'],
|
|
70
|
+
facts: [
|
|
71
|
+
['on a draft, anything you can read, shared but not live until merged', /On your draft you can do anything to what you can read.*nothing is live until the draft is merged/],
|
|
72
|
+
['on a protected branch, write per file or propose', /On a protected branch, write access is checked per file.*how to propose it instead/],
|
|
73
|
+
['some platform files are never deleted', /are deleted by nobody/],
|
|
74
|
+
['file_stat says what may be deleted', /file_stat reports `managed`, `movable` and `deletable`/],
|
|
75
|
+
['what you cannot read you cannot change', /What you cannot read you cannot see or change, on any branch/],
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
task: '5. Is this like Git? Who merges?',
|
|
80
|
+
asks: ['How is this like Git'],
|
|
81
|
+
sections: ['for-git-users'],
|
|
82
|
+
facts: [
|
|
83
|
+
['drafts, and a protected default branch', /Work in progress goes on a draft branch/],
|
|
84
|
+
['the agent opens, a person merges', /a person reviews, approves and merges it in the app\. No agent tool approves or merges one/],
|
|
85
|
+
['access per folder, with an example', /Per folder and per file.*An example\. A team keeps its specs/],
|
|
86
|
+
['merging is where owners approve', /Merging is where the owners of the files approve/],
|
|
87
|
+
['saving shares, no git', /you have no git command.*Every save is committed and shared as the person/],
|
|
88
|
+
['not explained as a Git repo', /do not explain it as "a Git repo"/],
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
task: "6. Set up a team's first project",
|
|
93
|
+
asks: ["lay out a team's first project"],
|
|
94
|
+
sections: ['where-a-new-file-goes'],
|
|
95
|
+
facts: [
|
|
96
|
+
['where documents, skills and tools go', /A document goes under `KnowledgeBase\/`.*A shared skill goes under `Skills\/`.*Tool manuals, MCP servers and manifests go inside a plugin/],
|
|
97
|
+
['a starter layout, one question in one place', /### A team's first project.*One question, one place/],
|
|
98
|
+
['a folder can be restricted', /Give the team's folder an `access\.md`/],
|
|
99
|
+
['search before creating', /Search first: when another team already covers a subject, link to its page/],
|
|
100
|
+
['ask when the place is unclear', /When the place does not exist, or nothing fits, ask/],
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
task: '7. Plugin, MCP server, tool, skill, secret, OAuth, role, group, access.md: what is each?',
|
|
105
|
+
asks: ['What is a plugin, a skill, a tool'],
|
|
106
|
+
sections: ['glossary'],
|
|
107
|
+
facts: [
|
|
108
|
+
['plugin', /- Plugin: a package/],
|
|
109
|
+
['MCP server', /- MCP server: a connection to another service/],
|
|
110
|
+
['tool', /- Tool: one action an agent can call/],
|
|
111
|
+
['skill', /- Skill: a folder with a `SKILL\.md`/],
|
|
112
|
+
['secret, shared and personal', /- Secret: a credential.*A shared \(admin\) secret.*A personal \(user\) secret/],
|
|
113
|
+
['sign-in', /- Sign-in \(OAuth\): a personal secret filled by signing in/],
|
|
114
|
+
['role', /- Role: a capability the platform defines/],
|
|
115
|
+
['group', /- Group: a named set of people/],
|
|
116
|
+
['access.md', /- `access\.md`: a folder's rules/],
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
task: '8. I merged a tool: when can I, and Langdock, use it?',
|
|
121
|
+
asks: ['When can I use a tool'],
|
|
122
|
+
sections: ['tools'],
|
|
123
|
+
facts: [
|
|
124
|
+
['served from the default branch only', /Tools are served from the default branch only/],
|
|
125
|
+
['then each person signs in on the Connect page', /each person sets theirs on the Connect page/],
|
|
126
|
+
['then the client lists again, or reconnects', /The client lists the tools again.*must reconnect/],
|
|
127
|
+
['a connection-key client sees only set-up tools', /A client connected with a connection key.*only the tools whose personal secrets/],
|
|
128
|
+
['list_tool_setup shows what is missing', /When a tool is not working, call `list_tool_setup`/],
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
task: '9. Make my skill check its tools first',
|
|
133
|
+
asks: ['When can I use a tool', 'How do I write a skill'],
|
|
134
|
+
sections: ['skills', 'tools'],
|
|
135
|
+
facts: [
|
|
136
|
+
['name the tools in allowed-tools', /`allowed-tools` names the tools the skill uses/],
|
|
137
|
+
['check with list_tool_setup and stop with an instruction', /A skill that needs tools checks them first.*Call `list_tool_setup`.*stop and say/],
|
|
138
|
+
['never ask for a secret', /Never ask for an API key, token or client secret in the conversation/],
|
|
139
|
+
],
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
task: '10. A guided skill for a colleague who is not technical',
|
|
143
|
+
asks: ['or one for people who are not technical'],
|
|
144
|
+
sections: ['skills'],
|
|
145
|
+
facts: [
|
|
146
|
+
['one question per message', /One question per message/],
|
|
147
|
+
['proposals they confirm', /Propose, then ask them to confirm/],
|
|
148
|
+
['a checklist for "specified enough"', /A checklist for "specified enough"/],
|
|
149
|
+
['plain words', /Plain words\. No platform terms/],
|
|
150
|
+
['it reaches them only after merge', /A skill on a draft is not live/],
|
|
151
|
+
],
|
|
152
|
+
},
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
const flat = (markdown: string): string => markdown.replace(/\*+/g, '').replace(/\s+/g, ' ');
|
|
156
|
+
|
|
157
|
+
describe('the evaluation tasks are answered by the start section and at most two more', () => {
|
|
158
|
+
it('has the 53 facts design.md lists', () => {
|
|
159
|
+
expect(TASKS).toHaveLength(10);
|
|
160
|
+
expect(TASKS.reduce((n, t) => n + t.facts.length, 0)).toBe(53);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it.each(TASKS.map((t) => [t.task, t] as const))('%s', async (_name, task) => {
|
|
164
|
+
const sections = await agentGuideSections(DEFAULT_KB_LAYOUT, undefined, { defaultBranch: 'main' });
|
|
165
|
+
const byId = new Map(sections.map((s) => [s.id, s.body]));
|
|
166
|
+
const start = byId.get(START_HERE_SECTION_ID)!;
|
|
167
|
+
expect(task.sections.length).toBeLessThanOrEqual(2);
|
|
168
|
+
// Every section the task reads is one the start section routes it to.
|
|
169
|
+
const routedTo = (task.asks ?? []).map((asks) => {
|
|
170
|
+
const row = start.split('\n').find((line) => line.startsWith('|') && line.includes(asks));
|
|
171
|
+
expect(row, `the start section has no question with "${asks}"`).toBeDefined();
|
|
172
|
+
return /`([a-z0-9-]+)`\s*\|$/.exec(row!)?.[1];
|
|
173
|
+
});
|
|
174
|
+
for (const id of task.sections) expect(routedTo, `no start-section question routes to ${id}`).toContain(id);
|
|
175
|
+
const read = [start, ...task.sections.map((id) => byId.get(id)!)];
|
|
176
|
+
const text = flat(read.join('\n\n'));
|
|
177
|
+
const missing = task.facts.filter(([, phrase]) => !phrase.test(text)).map(([fact]) => fact);
|
|
178
|
+
expect(missing, 'facts the routed sections no longer state').toEqual([]);
|
|
179
|
+
const tokens = read.reduce((n, body) => n + countTokens(body), 0);
|
|
180
|
+
expect(tokens, `the task costs ${tokens} tokens (budget ${TASK_TOKEN_BUDGET})`).toBeLessThanOrEqual(TASK_TOKEN_BUDGET);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import {
|
|
3
|
+
AGENTS_DIR,
|
|
4
|
+
DATA_DIR,
|
|
5
|
+
DEFAULT_KB_LAYOUT,
|
|
6
|
+
FIXED_PLATFORM_FILE_NAMES,
|
|
7
|
+
HEXIS_EXTENSION_NS,
|
|
8
|
+
HEXIS_TOOLS_DIR,
|
|
9
|
+
PERSONAL_PLUGIN_PREFIX,
|
|
10
|
+
PIPELINES_DIR,
|
|
11
|
+
PLUGIN_MANIFEST_FILE,
|
|
12
|
+
PLUGIN_MCP_FILE,
|
|
13
|
+
PLUGIN_SKILLS_DIR,
|
|
14
|
+
PREAMBLE_FILE,
|
|
15
|
+
} from '@bevel-software/platform-shared';
|
|
16
|
+
import { describe, expect, it } from 'vitest';
|
|
17
|
+
import { hexisTools } from '../../../__tests__/hexis-catalog.js';
|
|
18
|
+
import { agentGuideDir } from '../../../assets.js';
|
|
19
|
+
import { GROUPS_YAML, SYNCED_GROUPS_YAML } from '../../access-model/index.js';
|
|
20
|
+
import { platformInstructions } from '../../agent-instructions/index.js';
|
|
21
|
+
import { BUNDLE_FILE } from '../../plugins/index.js';
|
|
22
|
+
import type { UtcpTool } from '../../tool-registry/index.js';
|
|
23
|
+
import {
|
|
24
|
+
CORE_SECTION_IDS,
|
|
25
|
+
START_HERE_SECTION_ID,
|
|
26
|
+
WORKING_WITH_FILES_SECTION_ID,
|
|
27
|
+
agentGuideSections,
|
|
28
|
+
composeAgentGuide,
|
|
29
|
+
coreAgentGuideSections,
|
|
30
|
+
} from '../agent-guide.js';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The guide and the handshake instructions name tools, arguments, result
|
|
34
|
+
* fields, folders and files. Each of those is a claim about the code, and the
|
|
35
|
+
* code moves: a renamed tool or argument leaves the guide teaching a call that
|
|
36
|
+
* is refused, which is exactly what an agent cannot tell from reading it. So
|
|
37
|
+
* every name the text uses is checked against the tool registry Hexis builds
|
|
38
|
+
* and against `kb-layout.ts`, and a mismatch fails here, naming the word and
|
|
39
|
+
* the section, rather than in an agent's first call.
|
|
40
|
+
*
|
|
41
|
+
* What counts as a name is found by shape, in prose and inline code (fenced
|
|
42
|
+
* blocks are examples of file formats and are left alone):
|
|
43
|
+
*
|
|
44
|
+
* - a `snake_case` word must be a tool, or an argument or result field of one;
|
|
45
|
+
* - a `camelCase` word in inline code must be an argument or result field;
|
|
46
|
+
* - a call example, `tool({ … })` in inline code, must name a tool and only
|
|
47
|
+
* arguments it takes, with every required one.
|
|
48
|
+
*
|
|
49
|
+
* A word of that shape that is NOT the registry's (a YAML field of a `.tool`
|
|
50
|
+
* file, an example id, a DOM API) is declared below with the reason. That list
|
|
51
|
+
* is the price of catching the rest; an entry the text no longer uses fails
|
|
52
|
+
* too, so it cannot outlive its reason.
|
|
53
|
+
*/
|
|
54
|
+
const NOT_THE_REGISTRYS: Readonly<Record<string, string>> = {
|
|
55
|
+
// Refusal kinds and result stubs the content rule names; not schema fields.
|
|
56
|
+
binary_not_writable: 'a refusal kind',
|
|
57
|
+
image_omitted: 'the stub a chained image read yields',
|
|
58
|
+
// Words about identifiers, and example identifiers in the manual format.
|
|
59
|
+
snake_case: 'a naming style',
|
|
60
|
+
serper_everyone: 'an example manual id',
|
|
61
|
+
serper_finance: 'an example manual id',
|
|
62
|
+
notion_search: 'an example of a connected server tool as a page calls it',
|
|
63
|
+
// `.tool` and `plugin.json` fields: the file formats, not tool schemas.
|
|
64
|
+
access_type: 'a Google sign-in parameter, in an `authParams` example',
|
|
65
|
+
tool_call_template: 'a `.tool` field',
|
|
66
|
+
call_template_type: 'a `.tool` field',
|
|
67
|
+
mcpServers: 'a `plugin.json` / `mcp.json` key',
|
|
68
|
+
clientId: 'an `oauth` block field',
|
|
69
|
+
authorizationUrl: 'an `oauth` block field',
|
|
70
|
+
tokenUrl: 'an `oauth` block field',
|
|
71
|
+
authParams: 'an `oauth` block field',
|
|
72
|
+
clientSecret: 'an `oauth` block field',
|
|
73
|
+
// The page bridge and the DOM (checked against the renderer by
|
|
74
|
+
// core-frontend's `htmlViewsGuide.test.ts`).
|
|
75
|
+
openNode: '`window.bevel` member',
|
|
76
|
+
requireTools: '`window.bevel` member',
|
|
77
|
+
searchTools: '`window.bevel.tools` member',
|
|
78
|
+
toolsInfo: '`window.bevel.tools` member',
|
|
79
|
+
callTool: '`window.bevel.tools` member',
|
|
80
|
+
callToolChain: '`window.bevel.tools` member',
|
|
81
|
+
uploadFile: '`window.bevel.tools` member',
|
|
82
|
+
getElementById: 'a DOM API',
|
|
83
|
+
preventDefault: 'a DOM API',
|
|
84
|
+
localStorage: 'a browser API',
|
|
85
|
+
sessionStorage: 'a browser API',
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** Every property name a schema declares, at any depth. */
|
|
89
|
+
function propertyNames(schema: unknown, out = new Set<string>()): Set<string> {
|
|
90
|
+
if (!schema || typeof schema !== 'object') return out;
|
|
91
|
+
if (Array.isArray(schema)) {
|
|
92
|
+
for (const s of schema) propertyNames(s, out);
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
const s = schema as Record<string, unknown>;
|
|
96
|
+
if (s.properties && typeof s.properties === 'object') {
|
|
97
|
+
for (const [name, sub] of Object.entries(s.properties as Record<string, unknown>)) {
|
|
98
|
+
out.add(name);
|
|
99
|
+
propertyNames(sub, out);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const key of ['items', 'anyOf', 'oneOf', 'allOf']) propertyNames(s[key], out);
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const inputsOf = (tool: UtcpTool): unknown => tool.inputs ?? (tool as { inputSchema?: unknown }).inputSchema;
|
|
107
|
+
|
|
108
|
+
/** Text without its fenced code blocks: those are examples of formats, not claims about tools. */
|
|
109
|
+
const prose = (markdown: string): string => markdown.replace(/^(```|~~~)[^\n]*\n[\s\S]*?^\1[ \t]*$/gm, '');
|
|
110
|
+
|
|
111
|
+
const inlineCode = (markdown: string): string[] => [...prose(markdown).matchAll(/`([^`\n]+)`/g)].map((m) => m[1]!);
|
|
112
|
+
|
|
113
|
+
/** A call example's arguments, read as the JSON the inline code nearly is. */
|
|
114
|
+
function callArguments(text: string): Record<string, unknown> {
|
|
115
|
+
return JSON.parse(text.replace(/([{,]\s*)([A-Za-z_][A-Za-z0-9_]*)\s*:/g, '$1"$2":')) as Record<string, unknown>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function texts(): Promise<Array<{ where: string; text: string }>> {
|
|
119
|
+
const sections = await agentGuideSections(DEFAULT_KB_LAYOUT, undefined, { defaultBranch: 'main' });
|
|
120
|
+
return [
|
|
121
|
+
...sections.map((s) => ({ where: `section "${s.id}"`, text: s.body })),
|
|
122
|
+
{ where: 'the handshake instructions', text: platformInstructions(DEFAULT_KB_LAYOUT) },
|
|
123
|
+
];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
describe('the guide names only what the code has', () => {
|
|
127
|
+
it('names tools, arguments and result fields that exist', async () => {
|
|
128
|
+
const tools = await hexisTools();
|
|
129
|
+
const known = new Set<string>(tools.map((t) => t.name));
|
|
130
|
+
for (const tool of tools) {
|
|
131
|
+
propertyNames(inputsOf(tool), known);
|
|
132
|
+
propertyNames((tool as { outputs?: unknown }).outputs, known);
|
|
133
|
+
}
|
|
134
|
+
const unknown: string[] = [];
|
|
135
|
+
const used = new Set<string>();
|
|
136
|
+
for (const { where, text } of await texts()) {
|
|
137
|
+
const words = [
|
|
138
|
+
...[...prose(text).matchAll(/\b[a-z][a-z0-9]*(?:_[a-z0-9]+)+\b/g)].map((m) => m[0]),
|
|
139
|
+
...inlineCode(text).flatMap((code) => [...code.matchAll(/\b[a-z]+(?:[A-Z][a-z0-9]*)+\b/g)].map((m) => m[0])),
|
|
140
|
+
];
|
|
141
|
+
for (const word of words) {
|
|
142
|
+
if (word in NOT_THE_REGISTRYS) used.add(word);
|
|
143
|
+
else if (!known.has(word)) unknown.push(`${where}: \`${word}\` is no tool, argument or result field`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
expect([...new Set(unknown)], 'rename it in the text, or declare it in NOT_THE_REGISTRYS with why').toEqual([]);
|
|
147
|
+
const stale = Object.keys(NOT_THE_REGISTRYS).filter((word) => !used.has(word));
|
|
148
|
+
expect(stale, 'declared words the guide no longer uses').toEqual([]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('shows calls a tool would accept, with every required argument', async () => {
|
|
152
|
+
const tools = new Map((await hexisTools()).map((t) => [t.name, t]));
|
|
153
|
+
const problems: string[] = [];
|
|
154
|
+
let examples = 0;
|
|
155
|
+
for (const { where, text } of await texts()) {
|
|
156
|
+
for (const code of inlineCode(text)) {
|
|
157
|
+
const call = /^([a-z][a-z0-9_]*)\((\{.*\})\)$/.exec(code);
|
|
158
|
+
if (!call) continue;
|
|
159
|
+
examples += 1;
|
|
160
|
+
const [, name, argsText] = call;
|
|
161
|
+
const tool = tools.get(name!);
|
|
162
|
+
if (!tool) {
|
|
163
|
+
problems.push(`${where}: \`${code}\` calls \`${name}\`, which is no tool`);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
let args: Record<string, unknown>;
|
|
167
|
+
try {
|
|
168
|
+
args = callArguments(argsText!);
|
|
169
|
+
} catch {
|
|
170
|
+
problems.push(`${where}: \`${code}\` is not an argument object`);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
// A platform tool takes its arguments under `body`; check what the
|
|
174
|
+
// example puts there against what the tool declares there.
|
|
175
|
+
let schema = inputsOf(tool) as { properties?: Record<string, unknown>; required?: string[] } | undefined;
|
|
176
|
+
let given = args;
|
|
177
|
+
if (schema?.properties?.body) {
|
|
178
|
+
schema = schema.properties.body as typeof schema;
|
|
179
|
+
given = (args.body ?? {}) as Record<string, unknown>;
|
|
180
|
+
if (!('body' in args)) problems.push(`${where}: \`${code}\` must put its arguments under \`body\``);
|
|
181
|
+
}
|
|
182
|
+
const declared = Object.keys(schema?.properties ?? {});
|
|
183
|
+
for (const key of Object.keys(given)) {
|
|
184
|
+
if (!declared.includes(key)) problems.push(`${where}: \`${code}\` passes \`${key}\`, which \`${name}\` does not take`);
|
|
185
|
+
}
|
|
186
|
+
for (const key of schema?.required ?? []) {
|
|
187
|
+
if (!(key in given)) problems.push(`${where}: \`${code}\` leaves out \`${key}\`, which \`${name}\` requires`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
expect(problems).toEqual([]);
|
|
192
|
+
expect(examples, 'the guide shows no call at all: the check would be vacuous').toBeGreaterThan(3);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('the guide names the folders and files kb-layout.ts defines', () => {
|
|
197
|
+
it('writes every root folder as its placeholder, never as the default name', async () => {
|
|
198
|
+
const renamed = { knowledgeBaseDir: 'Docs', skillsDir: 'Abilities', pluginsDir: 'Extensions' };
|
|
199
|
+
const guide = await composeAgentGuide(renamed, undefined, { kbDirName: 'repo', defaultBranch: 'production' });
|
|
200
|
+
for (const name of [DEFAULT_KB_LAYOUT.knowledgeBaseDir, DEFAULT_KB_LAYOUT.skillsDir, DEFAULT_KB_LAYOUT.pluginsDir, 'knowledge-base']) {
|
|
201
|
+
const hardCoded = [...guide.matchAll(new RegExp(`(^|[^\\w-])${name}/`, 'g'))].length;
|
|
202
|
+
expect(hardCoded, `"${name}/" is written out where its placeholder belongs`).toBe(0);
|
|
203
|
+
}
|
|
204
|
+
expect(guide).not.toMatch(/\{\{\w+\}\}/);
|
|
205
|
+
expect(guide).not.toContain('<the default branch>');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('lays out a plugin with the names kb-layout.ts gives each part, and names every part', async () => {
|
|
209
|
+
const raw = (await coreAgentGuideSections(DEFAULT_KB_LAYOUT)).map((s) => s.body).join('\n');
|
|
210
|
+
const parts = new Set([PLUGIN_MANIFEST_FILE, PLUGIN_MCP_FILE, PLUGIN_SKILLS_DIR, HEXIS_EXTENSION_NS, 'access.md']);
|
|
211
|
+
const named = [...raw.matchAll(/\{\{pluginsDir\}\}\/<Plugin>\/([^/\s`]+)/g)].map((m) => m[1]!);
|
|
212
|
+
expect(named.filter((part) => !parts.has(part)), 'a plugin part kb-layout.ts does not define').toEqual([]);
|
|
213
|
+
expect([...parts].filter((part) => !named.includes(part)), 'a plugin part the guide never lays out').toEqual([]);
|
|
214
|
+
expect(raw).toContain(`<Plugin>/${HEXIS_TOOLS_DIR}/`);
|
|
215
|
+
expect(raw).toContain(`{{pluginsDir}}/${PERSONAL_PLUGIN_PREFIX}<id>/`);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('names the platform files and reserved folders by their defined names', async () => {
|
|
219
|
+
const guide = (await agentGuideSections(DEFAULT_KB_LAYOUT)).map((s) => s.body).join('\n');
|
|
220
|
+
for (const name of [...FIXED_PLATFORM_FILE_NAMES, GROUPS_YAML, SYNCED_GROUPS_YAML, PREAMBLE_FILE, BUNDLE_FILE, DATA_DIR, AGENTS_DIR, PIPELINES_DIR]) {
|
|
221
|
+
expect(guide, name).toContain(name);
|
|
222
|
+
}
|
|
223
|
+
// A lowercase `.yaml` or `.json` file the guide names is one of the
|
|
224
|
+
// platform's: a misspelt one (`group.yaml`) is a file nothing reads.
|
|
225
|
+
const known = new Set([...FIXED_PLATFORM_FILE_NAMES, GROUPS_YAML, SYNCED_GROUPS_YAML, PLUGIN_MANIFEST_FILE, PLUGIN_MCP_FILE, BUNDLE_FILE]);
|
|
226
|
+
const files = inlineCode(guide).flatMap((code) => [...code.matchAll(/(?:^|\/)([a-z][a-z.-]*\.(?:yaml|json))$/g)].map((m) => m[1]!));
|
|
227
|
+
expect(files.filter((file) => !known.has(file))).toEqual([]);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe('the sections are the files, and the start section maps them', () => {
|
|
232
|
+
it('has a file for every section id and a section id for every file', () => {
|
|
233
|
+
const files = fs
|
|
234
|
+
.readdirSync(agentGuideDir())
|
|
235
|
+
.filter((f) => f.endsWith('.md'))
|
|
236
|
+
.map((f) => f.slice(0, -'.md'.length))
|
|
237
|
+
.sort();
|
|
238
|
+
expect(files).toEqual(CORE_SECTION_IDS.filter((id) => id !== WORKING_WITH_FILES_SECTION_ID).sort());
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('lists every section in the get_agent_guide description', async () => {
|
|
242
|
+
const guideTool = (await hexisTools()).find((t) => t.name === 'get_agent_guide')!;
|
|
243
|
+
for (const id of CORE_SECTION_IDS) expect(guideTool.description, id).toContain(`\`${id}\``);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('routes a question to every other section, and only to sections that exist', async () => {
|
|
247
|
+
const sections = await agentGuideSections(DEFAULT_KB_LAYOUT);
|
|
248
|
+
const start = sections.find((s) => s.id === START_HERE_SECTION_ID)!.body;
|
|
249
|
+
const routed = [...start.matchAll(/^\|[^|\n]+\|\s*`([a-z0-9-]+)`\s*\|$/gm)].map((m) => m[1]!);
|
|
250
|
+
const others = CORE_SECTION_IDS.filter((id) => id !== START_HERE_SECTION_ID);
|
|
251
|
+
expect(others.filter((id) => !routed.includes(id)), 'sections the start section never routes to').toEqual([]);
|
|
252
|
+
expect(routed.filter((id) => !CORE_SECTION_IDS.includes(id)), 'routes to sections that do not exist').toEqual([]);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('points "see `…`" at sections that exist', async () => {
|
|
256
|
+
const sections = await agentGuideSections(DEFAULT_KB_LAYOUT);
|
|
257
|
+
const pointers = sections.flatMap((s) =>
|
|
258
|
+
[...s.body.matchAll(/\bsee `([a-z0-9-]+)`/g)].map((m) => ({ from: s.id, to: m[1]! })),
|
|
259
|
+
);
|
|
260
|
+
expect(pointers.length).toBeGreaterThan(0);
|
|
261
|
+
expect(pointers.filter((p) => !CORE_SECTION_IDS.includes(p.to))).toEqual([]);
|
|
262
|
+
});
|
|
263
|
+
});
|