@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.
Files changed (179) hide show
  1. package/agent-guide/access-control.md +146 -300
  2. package/agent-guide/conventions.md +23 -25
  3. package/agent-guide/directory-structure.md +72 -120
  4. package/agent-guide/drafts-and-change-requests.md +92 -0
  5. package/agent-guide/for-git-users.md +47 -0
  6. package/agent-guide/glossary.md +36 -0
  7. package/agent-guide/how-agents-find-things.md +49 -0
  8. package/agent-guide/html-views.md +16 -11
  9. package/agent-guide/new-knowledge-base.md +9 -8
  10. package/agent-guide/skills.md +67 -42
  11. package/agent-guide/start-here.md +65 -0
  12. package/agent-guide/tool-manuals.md +132 -154
  13. package/agent-guide/tools.md +67 -0
  14. package/agent-guide/where-a-new-file-goes.md +45 -28
  15. package/dist/core/create-core-server.js +1 -1
  16. package/dist/core/create-core-server.js.map +1 -1
  17. package/dist/core/create-core-services.d.ts +6 -0
  18. package/dist/core/create-core-services.d.ts.map +1 -1
  19. package/dist/core/create-core-services.js +7 -4
  20. package/dist/core/create-core-services.js.map +1 -1
  21. package/dist/modules/access/access-control.interface.d.ts +22 -0
  22. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  23. package/dist/modules/access/access-control.service.d.ts +3 -1
  24. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  25. package/dist/modules/access/access-control.service.js +75 -0
  26. package/dist/modules/access/access-control.service.js.map +1 -1
  27. package/dist/modules/access/access.routes.d.ts.map +1 -1
  28. package/dist/modules/access/access.routes.js +23 -4
  29. package/dist/modules/access/access.routes.js.map +1 -1
  30. package/dist/modules/access/index.d.ts +1 -1
  31. package/dist/modules/access/index.d.ts.map +1 -1
  32. package/dist/modules/agent-guide/agent-guide.d.ts +47 -7
  33. package/dist/modules/agent-guide/agent-guide.d.ts.map +1 -1
  34. package/dist/modules/agent-guide/agent-guide.js +103 -13
  35. package/dist/modules/agent-guide/agent-guide.js.map +1 -1
  36. package/dist/modules/agent-guide/agent-guide.tools.d.ts +6 -2
  37. package/dist/modules/agent-guide/agent-guide.tools.d.ts.map +1 -1
  38. package/dist/modules/agent-guide/agent-guide.tools.js +45 -31
  39. package/dist/modules/agent-guide/agent-guide.tools.js.map +1 -1
  40. package/dist/modules/agent-guide/guide-budget.d.ts +48 -0
  41. package/dist/modules/agent-guide/guide-budget.d.ts.map +1 -0
  42. package/dist/modules/agent-guide/guide-budget.js +67 -0
  43. package/dist/modules/agent-guide/guide-budget.js.map +1 -0
  44. package/dist/modules/agent-guide/index.d.ts +2 -1
  45. package/dist/modules/agent-guide/index.d.ts.map +1 -1
  46. package/dist/modules/agent-guide/index.js +2 -1
  47. package/dist/modules/agent-guide/index.js.map +1 -1
  48. package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
  49. package/dist/modules/agent-instructions/compose.js +4 -0
  50. package/dist/modules/agent-instructions/compose.js.map +1 -1
  51. package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -1
  52. package/dist/modules/agent-instructions/shared-file-rules.js +10 -8
  53. package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -1
  54. package/dist/modules/code-mode/code-mode.tool.js +2 -2
  55. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  56. package/dist/modules/connection-probe/connection-probe.service.d.ts +0 -12
  57. package/dist/modules/connection-probe/connection-probe.service.d.ts.map +1 -1
  58. package/dist/modules/connection-probe/connection-probe.service.js +1 -38
  59. package/dist/modules/connection-probe/connection-probe.service.js.map +1 -1
  60. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  61. package/dist/modules/mcp/mcp.service.js +6 -35
  62. package/dist/modules/mcp/mcp.service.js.map +1 -1
  63. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts +11 -1
  64. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
  65. package/dist/modules/secrets-vault/db-secrets-vault.service.js +159 -24
  66. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
  67. package/dist/modules/secrets-vault/index.d.ts +1 -1
  68. package/dist/modules/secrets-vault/index.d.ts.map +1 -1
  69. package/dist/modules/secrets-vault/index.js +1 -1
  70. package/dist/modules/secrets-vault/index.js.map +1 -1
  71. package/dist/modules/secrets-vault/secrets-vault.contract.d.ts +53 -11
  72. package/dist/modules/secrets-vault/secrets-vault.contract.d.ts.map +1 -1
  73. package/dist/modules/secrets-vault/secrets-vault.contract.js +27 -0
  74. package/dist/modules/secrets-vault/secrets-vault.contract.js.map +1 -1
  75. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  76. package/dist/modules/secrets-vault/secrets-vault.routes.js +85 -39
  77. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  78. package/dist/modules/settings/oidc-check.d.ts.map +1 -1
  79. package/dist/modules/settings/oidc-check.js +5 -17
  80. package/dist/modules/settings/oidc-check.js.map +1 -1
  81. package/dist/modules/skills/skills.tools.js +1 -1
  82. package/dist/modules/skills/skills.tools.js.map +1 -1
  83. package/dist/modules/tool-helpers/tool-def.js +1 -1
  84. package/dist/modules/tool-helpers/tool-def.js.map +1 -1
  85. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +7 -1
  86. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
  87. package/dist/modules/tool-manuals/mcp-json-discovery.js +69 -38
  88. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
  89. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +27 -0
  90. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  91. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +12 -8
  92. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  93. package/dist/modules/tool-manuals/tool-manuals.service.js +115 -44
  94. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  95. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts +8 -2
  96. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  97. package/dist/modules/tool-manuals/tool-manuals.tools.js +31 -10
  98. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  99. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  100. package/dist/modules/workflow/agent-tools/workflow.tools.js +22 -18
  101. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  102. package/dist/modules/workspace/agent-access.gate.d.ts +1 -1
  103. package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -1
  104. package/dist/modules/workspace/agent-access.gate.js +1 -1
  105. package/dist/modules/workspace/agent-access.gate.js.map +1 -1
  106. package/dist/modules/workspace/workspace.tools.d.ts +3 -2
  107. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  108. package/dist/modules/workspace/workspace.tools.js +71 -21
  109. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  110. package/dist/shared/oauth-token-error.d.ts +23 -0
  111. package/dist/shared/oauth-token-error.d.ts.map +1 -0
  112. package/dist/shared/oauth-token-error.js +47 -0
  113. package/dist/shared/oauth-token-error.js.map +1 -0
  114. package/dist/shared/read-capped.d.ts +19 -0
  115. package/dist/shared/read-capped.d.ts.map +1 -0
  116. package/dist/shared/read-capped.js +45 -0
  117. package/dist/shared/read-capped.js.map +1 -0
  118. package/package.json +4 -3
  119. package/src/__tests__/hexis-catalog.ts +144 -0
  120. package/src/__tests__/real-disk.ts +21 -0
  121. package/src/__tests__/tokens.ts +19 -0
  122. package/src/__tests__/tool-manuals.ts +32 -0
  123. package/src/__tests__/tool-wiring.ts +25 -0
  124. package/src/core/__tests__/oauth-sign-in-real-file.test.ts +179 -0
  125. package/src/core/create-core-server.ts +1 -1
  126. package/src/core/create-core-services.ts +13 -3
  127. package/src/modules/access/__tests__/access.routes.prospective.test.ts +52 -12
  128. package/src/modules/access/__tests__/holder-changes.test.ts +127 -0
  129. package/src/modules/access/access-control.interface.ts +26 -0
  130. package/src/modules/access/access-control.service.ts +92 -0
  131. package/src/modules/access/access.routes.ts +23 -4
  132. package/src/modules/access/index.ts +1 -1
  133. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +79 -25
  134. package/src/modules/agent-guide/__tests__/agent-guide.tools.test.ts +38 -11
  135. package/src/modules/agent-guide/__tests__/guide-budget.test.ts +95 -0
  136. package/src/modules/agent-guide/__tests__/guide-eval.test.ts +182 -0
  137. package/src/modules/agent-guide/__tests__/guide-matches-code.test.ts +263 -0
  138. package/src/modules/agent-guide/__tests__/tool-surface.json +514 -0
  139. package/src/modules/agent-guide/__tests__/tool-surface.test.ts +75 -0
  140. package/src/modules/agent-guide/agent-guide.tools.ts +49 -31
  141. package/src/modules/agent-guide/agent-guide.ts +120 -13
  142. package/src/modules/agent-guide/guide-budget.ts +73 -0
  143. package/src/modules/agent-guide/index.ts +12 -0
  144. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +4 -4
  145. package/src/modules/agent-instructions/compose.ts +4 -0
  146. package/src/modules/agent-instructions/shared-file-rules.ts +10 -8
  147. package/src/modules/code-mode/code-mode.tool.ts +2 -2
  148. package/src/modules/connection-probe/__tests__/connection-probe.service.test.ts +5 -24
  149. package/src/modules/connection-probe/connection-probe.service.ts +1 -34
  150. package/src/modules/mcp/mcp.service.ts +5 -35
  151. package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +430 -3
  152. package/src/modules/secrets-vault/__tests__/oauth-live-declaration.route.test.ts +218 -0
  153. package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +39 -6
  154. package/src/modules/secrets-vault/db-secrets-vault.service.ts +170 -27
  155. package/src/modules/secrets-vault/index.ts +1 -1
  156. package/src/modules/secrets-vault/secrets-vault.contract.ts +61 -11
  157. package/src/modules/secrets-vault/secrets-vault.routes.ts +91 -39
  158. package/src/modules/settings/oidc-check.ts +5 -18
  159. package/src/modules/skills/skills.tools.ts +1 -1
  160. package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +19 -11
  161. package/src/modules/tool-helpers/__tests__/route-tool-schemas.catalog.test.ts +38 -0
  162. package/src/modules/tool-helpers/tool-def.ts +1 -1
  163. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +197 -11
  164. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +96 -5
  165. package/src/modules/tool-manuals/mcp-json-discovery.ts +73 -34
  166. package/src/modules/tool-manuals/tool-manuals.contract.ts +22 -0
  167. package/src/modules/tool-manuals/tool-manuals.service.ts +138 -46
  168. package/src/modules/tool-manuals/tool-manuals.tools.ts +53 -14
  169. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +6 -113
  170. package/src/modules/workflow/agent-tools/workflow.tools.ts +22 -18
  171. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +8 -10
  172. package/src/modules/workspace/__tests__/workspace.tools.test.ts +156 -49
  173. package/src/modules/workspace/agent-access.gate.ts +1 -1
  174. package/src/modules/workspace/workspace.tools.ts +74 -21
  175. package/src/shared/__tests__/read-capped.test.ts +68 -0
  176. package/src/shared/oauth-token-error.ts +46 -0
  177. package/src/shared/read-capped.ts +40 -0
  178. package/agent-guide/finding-things.md +0 -7
  179. package/agent-guide/introduction.md +0 -27
@@ -3,13 +3,23 @@ import type { IToolRegistry, UtcpTool } from '../tool-registry/index.js';
3
3
  import { GET_AGENT_GUIDE_TOOL, TOOL_DESCRIPTION_CAP } from '../tool-registry/index.js';
4
4
  import { ToolError, toolDef } from '../tool-helpers/index.js';
5
5
  import type { ToolHandlerFactory } from '../tool-helpers/index.js';
6
- import { AGENT_GUIDE_FILE, joinGuideSections, type AgentGuideSectionsReader } from './agent-guide.js';
6
+ import {
7
+ AGENT_GUIDE_FILE,
8
+ START_HERE_SECTION_ID,
9
+ WHOLE_GUIDE_SECTION,
10
+ joinGuideSections,
11
+ type AgentGuideSectionsReader,
12
+ } from './agent-guide.js';
7
13
 
8
14
  export { GET_AGENT_GUIDE_TOOL };
9
15
 
10
16
  /**
11
- * The one tool that returns the guide on its own — whole, or one section by
12
- * id. It takes nothing else: the guide is the platform's text for this
17
+ * The one tool that returns the guide on its own: the start section when no
18
+ * section is named, one section by id, or the whole guide (`all`). Start
19
+ * section by default because this is the call every tool description tells
20
+ * an agent, and each sub-agent it starts, to make first: answering it with
21
+ * the whole guide made the most-read text the platform has also its longest.
22
+ * It takes nothing else: the guide is the platform's text for this
13
23
  * deployment, the same for every caller and every branch, and reading it
14
24
  * touches no file — so no `branch`, no `sessionId`, and no access gate. The
15
25
  * other way to the same text is a `read_file` of `AGENTS.md` at the
@@ -33,27 +43,30 @@ export function registerAgentGuideTool(
33
43
  const build = async (): Promise<UtcpTool> => {
34
44
  const list = await sections();
35
45
  const describe = (sectionList: string) =>
36
- "The platform's guide to this knowledge base: its layout, where a new file goes, the rules every file tool " +
37
- 'shares, access control, skills and tool manuals. ALWAYS call this first and read the guide before you do ' +
38
- 'anything else in the platform. Returns `{ guide }` as markdown: the whole guide, or one section when ' +
39
- '`section` names one. The same text comes back from `read_file` on `' +
46
+ "The platform's guide to this knowledge base. ALWAYS call this first, before anything else in the " +
47
+ 'platform. With no `section` it returns the start section: the default branch every file tool takes, the ' +
48
+ 'first-call rules, and which section answers which question. Then fetch only the section a task needs, by ' +
49
+ '`section`; only `"' +
50
+ WHOLE_GUIDE_SECTION +
51
+ '"` returns the whole guide. `read_file` on `' +
40
52
  AGENT_GUIDE_FILE +
41
- "` at the KB root, after the knowledge base's own " +
53
+ "` at the KB root answers with the same start section (after the knowledge base's own " +
42
54
  AGENT_GUIDE_FILE +
43
- ` when it has one. Sections: ${sectionList}.`;
55
+ ` when it has one). Returns \`{ guide }\` as markdown. Sections: ${sectionList}.`;
44
56
  // The description stays under the cap WHATEVER a distribution adds, in
45
57
  // three steps: every id with its title; every id alone; as many ids as
46
58
  // fit and a count of the rest. An id a client cut off the end would be a
47
59
  // section an agent cannot ask for — so nothing is ever cut: the one
48
- // complete list of sections is `sections` on the whole-guide response,
49
- // which the description names when it cannot carry them all itself.
60
+ // complete list of sections is `sections` on the answer to a call
61
+ // without one, which the description names when it cannot carry them
62
+ // all itself.
50
63
  const titled = describe(list.map((s) => `\`${s.id}\` (${s.title})`).join(', '));
51
64
  const ids = list.map((s) => `\`${s.id}\``);
52
65
  const counted = (shown: number) =>
53
66
  describe(
54
67
  shown === 0
55
- ? `${ids.length}, named under \`sections\` in the whole-guide response`
56
- : [...ids.slice(0, shown), `and ${ids.length - shown} more, all named under \`sections\` in the whole-guide response`].join(', '),
68
+ ? `${ids.length}, named under \`sections\` in the answer`
69
+ : [...ids.slice(0, shown), `and ${ids.length - shown} more, all named under \`sections\` in the answer`].join(', '),
57
70
  );
58
71
  let description = titled.length <= TOOL_DESCRIPTION_CAP ? titled : describe(ids.join(', '));
59
72
  for (let shown = ids.length; description.length > TOOL_DESCRIPTION_CAP && shown > 0; shown--) {
@@ -69,8 +82,10 @@ export function registerAgentGuideTool(
69
82
  section: {
70
83
  type: 'string',
71
84
  description:
72
- 'Optional: the id of one section to read instead of the whole guide (the ids are listed in this ' +
73
- 'description, and every one under `sections` in the whole-guide response). Omit it for the whole guide.',
85
+ 'Optional: the id of one section to read (the ids are listed in this description, and every one under ' +
86
+ '`sections` in the answer), or `"' +
87
+ WHOLE_GUIDE_SECTION +
88
+ '"` for the whole guide. Omit it for the start section.',
74
89
  },
75
90
  },
76
91
  additionalProperties: false,
@@ -78,18 +93,18 @@ export function registerAgentGuideTool(
78
93
  outputs: {
79
94
  type: 'object',
80
95
  properties: {
81
- guide: { type: 'string', description: 'The guide, or the one section asked for, as markdown.' },
96
+ guide: { type: 'string', description: 'The section asked for (the start section by default), or the whole guide, as markdown.' },
82
97
  sections: {
83
98
  type: 'array',
84
- description: 'Without `section`: every section of the guide, in order, by id and heading — the complete list.',
99
+ description: `Without \`section\`, or with \`"${WHOLE_GUIDE_SECTION}"\`: every section of the guide, in order, by id and heading — the complete list.`,
85
100
  items: {
86
101
  type: 'object',
87
102
  properties: { id: { type: 'string' }, title: { type: 'string' } },
88
103
  required: ['id', 'title'],
89
104
  },
90
105
  },
91
- section: { type: 'string', description: 'With `section`: the id of the section returned.' },
92
- title: { type: 'string', description: 'With `section`: its heading.' },
106
+ section: { type: 'string', description: 'The id of the one section returned.' },
107
+ title: { type: 'string', description: 'Its heading.' },
93
108
  },
94
109
  required: ['guide'],
95
110
  },
@@ -104,24 +119,27 @@ export function registerAgentGuideTool(
104
119
  toolAuth,
105
120
  toolHandler(async (args, ctx) => {
106
121
  const list = await sections();
122
+ const index = list.map((s) => ({ id: s.id, title: s.title }));
107
123
  const wanted = typeof args.section === 'string' ? args.section.trim() : '';
108
- // A connection made as an agent configuration is told so at the top of the
109
- // whole guide: the catalog it describes is narrowed to that agent.
110
- // Everything is told nothing new, so its guide is the same text.
124
+ // A distribution that dropped the start section gets the whole guide by
125
+ // default, as before there was one: never an answer with nothing in it.
126
+ const start = list.find((s) => s.id === START_HERE_SECTION_ID);
127
+ // A connection made as an agent configuration is told so at the top of
128
+ // the guide's first answer and of the whole guide: the catalog it
129
+ // describes is narrowed to that agent. Everything is told nothing new,
130
+ // so its guide is the same text.
111
131
  const connection = ctx.connectionScope?.describe() ?? null;
112
- if (!wanted) {
113
- const guide = joinGuideSections(list);
114
- return {
115
- guide: connection ? `> ${connection}\n\n${guide}` : guide,
116
- sections: list.map((s) => ({ id: s.id, title: s.title })),
117
- ...(connection ? { connection } : {}),
118
- };
119
- }
132
+ const told = (guide: string) => ({
133
+ guide: connection ? `> ${connection}\n\n${guide}` : guide,
134
+ ...(connection ? { connection } : {}),
135
+ });
136
+ if (wanted === WHOLE_GUIDE_SECTION || (!wanted && !start)) return { ...told(joinGuideSections(list)), sections: index };
137
+ if (!wanted) return { ...told(start!.body), section: start!.id, title: start!.title, sections: index };
120
138
  const one = list.find((s) => s.id === wanted);
121
139
  if (!one) {
122
140
  throw new ToolError(
123
141
  `The guide has no section "${wanted}". Sections: ${list.map((s) => `\`${s.id}\``).join(', ')}. ` +
124
- 'Omit `section` for the whole guide.',
142
+ `Omit \`section\` for the start section, or pass \`"${WHOLE_GUIDE_SECTION}"\` for the whole guide.`,
125
143
  400,
126
144
  );
127
145
  }
@@ -20,10 +20,17 @@
20
20
  * The sections ship as markdown files in the package's `agent-guide/` folder
21
21
  * (see `assets.ts`), one per section, with the layout placeholders the old
22
22
  * template carried (`{{knowledgeBaseDir}}`, `{{skillsDir}}`, `{{pluginsDir}}`,
23
- * `{{agentsFile}}`). ONE section is computed rather than read: the rules every
24
- * file tool shares, which the MCP handshake also sends — the same string in
25
- * both places, from `shared-file-rules.ts`, so a rule cannot be changed in one
26
- * and left stale in the other.
23
+ * `{{agentsFile}}`), the checkout folder's `{{kbDirName}}` and the default
24
+ * branch's `{{defaultBranch}}`. ONE section is computed rather than read: the
25
+ * rules every file tool shares, which the MCP handshake also sends — the same
26
+ * string in both places, from `shared-file-rules.ts`, so a rule cannot be
27
+ * changed in one and left stale in the other.
28
+ *
29
+ * The guide is LAYERED (see openspec/changes/agent-guide-layers/design.md):
30
+ * `get_agent_guide` with no section answers with the short start section
31
+ * ({@link START_HERE_SECTION_ID}), which says which section answers which
32
+ * question, and an agent fetches the others by id when a task needs them.
33
+ * Each section has a token budget (`guide-budget.ts`), held by a test.
27
34
  */
28
35
 
29
36
  import fs from 'node:fs/promises';
@@ -59,6 +66,13 @@ export interface AgentGuideSection {
59
66
  * could only misread a folder name it STATES as a placeholder.
60
67
  */
61
68
  literal?: boolean;
69
+ /**
70
+ * The question this section answers, as a row of the start section's
71
+ * "which section answers what" table. Core's sections are in that table
72
+ * already; a distribution sets it on a section it adds, so an agent that
73
+ * reads only the start section is routed to it.
74
+ */
75
+ asks?: string;
62
76
  }
63
77
 
64
78
  /**
@@ -86,21 +100,35 @@ export const WORKING_WITH_FILES_SECTION_ID = 'working-with-files';
86
100
  */
87
101
  export const NEW_KNOWLEDGE_BASE_SECTION_ID = 'new-knowledge-base';
88
102
 
103
+ /**
104
+ * The first-contact section: what `get_agent_guide` answers when no section is
105
+ * named. Short on purpose, and the one place that maps each question to the
106
+ * section answering it, so an agent fetches one section rather than the guide.
107
+ */
108
+ export const START_HERE_SECTION_ID = 'start-here';
109
+
110
+ /** The `section` that asks `get_agent_guide` for every section at once. */
111
+ export const WHOLE_GUIDE_SECTION = 'all';
112
+
89
113
  /**
90
114
  * Core's sections, in reading order. Every id but the shared-rules one names
91
115
  * a file in the package's `agent-guide/` folder.
92
116
  */
93
117
  export const CORE_SECTION_IDS: readonly string[] = Object.freeze([
94
- 'introduction',
118
+ START_HERE_SECTION_ID,
119
+ 'how-agents-find-things',
120
+ 'for-git-users',
121
+ 'glossary',
95
122
  'directory-structure',
96
123
  'where-a-new-file-goes',
97
124
  WORKING_WITH_FILES_SECTION_ID,
98
125
  'access-control',
126
+ 'drafts-and-change-requests',
99
127
  'skills',
128
+ 'tools',
100
129
  'tool-manuals',
101
130
  'html-views',
102
131
  'conventions',
103
- 'finding-things',
104
132
  NEW_KNOWLEDGE_BASE_SECTION_ID,
105
133
  ]);
106
134
 
@@ -174,11 +202,21 @@ export interface AgentGuideContext {
174
202
  * deployment's tools report back.
175
203
  */
176
204
  kbDirName?: string;
205
+ /**
206
+ * The knowledge base's default branch, rendered into `{{defaultBranch}}` so
207
+ * the start section names the branch every file tool's `branch` takes for
208
+ * what is live. Read when the guide is composed: a fresh deployment gets its
209
+ * branch model from the setup screen after the services are built.
210
+ */
211
+ defaultBranch?: string;
177
212
  }
178
213
 
179
214
  /** The checkout folder's name when none is given — core's own default. */
180
215
  const DEFAULT_KB_DIR_NAME = 'knowledge-base';
181
216
 
217
+ /** What `{{defaultBranch}}` reads as when no branch is configured yet: a name no branch can have. */
218
+ const UNCONFIGURED_DEFAULT_BRANCH = '<the default branch>';
219
+
182
220
  /** One section of the guide as an agent reads it: rendered, with the heading's text as its title. */
183
221
  export interface RenderedGuideSection {
184
222
  id: string;
@@ -197,7 +235,11 @@ export type AgentGuideSectionsReader = () => Promise<RenderedGuideSection[]>;
197
235
  * with its title. The whole guide is these joined (see
198
236
  * {@link composeAgentGuide}); `get_agent_guide` also serves one at a time.
199
237
  *
200
- * A hook may append, replace and drop sections, with ONE exception: the
238
+ * A hook may append, replace and drop sections. It replaces one by returning
239
+ * its own under the same id, beside core's or in its place: of two sections
240
+ * under one id the LAST is served, where the FIRST stood, so a distribution
241
+ * overrides core's `start-here` or `html-views` by adding its own and the
242
+ * guide keeps its order (see {@link oneSectionPerId}). ONE exception: the
201
243
  * shared file rules ({@link WORKING_WITH_FILES_SECTION_ID}) must come back
202
244
  * WITH TEXT IN THEM, as core's or as the hook's own replacement. A client
203
245
  * that drops the handshake instructions has the guide as the only place to
@@ -215,18 +257,25 @@ export async function agentGuideSections(
215
257
  ): Promise<RenderedGuideSection[]> {
216
258
  const resolved = resolveKbLayout(layout);
217
259
  const core = await coreAgentGuideSections(resolved);
218
- const sections = hook ? await hook(core, resolved) : core;
260
+ const sections = withRoutes(oneSectionPerId(hook ? await hook(core, resolved) : core));
219
261
  const kbDirName = context.kbDirName?.trim() || DEFAULT_KB_DIR_NAME;
262
+ const defaultBranch = context.defaultBranch?.trim() || UNCONFIGURED_DEFAULT_BRANCH;
220
263
  const rendered = sections.map((section) => {
221
264
  const body = (
222
265
  section.literal
223
266
  ? section.body
224
- : renderKbLayoutPlaceholders(section.body.replaceAll('{{kbDirName}}', () => kbDirName), resolved)
267
+ : renderKbLayoutPlaceholders(
268
+ section.body
269
+ .replaceAll('{{kbDirName}}', () => kbDirName)
270
+ .replaceAll('{{defaultBranch}}', () => defaultBranch),
271
+ resolved,
272
+ )
225
273
  ).trim();
226
274
  return { id: section.id, title: titleOf(body, section.id), body };
227
275
  });
228
- // Judged over EVERY section under the id — a hook may return the id twice
229
- // — on what survives the empty-body filter below: one with text is enough.
276
+ // One section per id by now (`oneSectionPerId`), so a hook's own version
277
+ // of the rules is what is judged, on what survives the empty-body filter
278
+ // below.
230
279
  const rules = rendered.filter((section) => section.id === WORKING_WITH_FILES_SECTION_ID);
231
280
  if (!rules.some((section) => section.body.length > 0)) {
232
281
  throw new Error(
@@ -237,6 +286,51 @@ export async function agentGuideSections(
237
286
  return rendered.filter((section) => section.body.length > 0);
238
287
  }
239
288
 
289
+ /**
290
+ * One section per id. A hook that returns an id twice (its own version of a
291
+ * core section, added beside core's) means the later one: it is served in
292
+ * the earlier one's place, so a distribution overrides a section by adding
293
+ * its own under the same id, and the guide keeps core's order. Without this
294
+ * the whole guide carried both and `get_agent_guide` served the first, which
295
+ * was always core's.
296
+ */
297
+ function oneSectionPerId(sections: readonly AgentGuideSection[]): readonly AgentGuideSection[] {
298
+ const at = new Map<string, number>();
299
+ const out: AgentGuideSection[] = [];
300
+ for (const section of sections) {
301
+ const index = at.get(section.id);
302
+ if (index === undefined) {
303
+ at.set(section.id, out.length);
304
+ out.push(section);
305
+ } else {
306
+ out[index] = section;
307
+ }
308
+ }
309
+ return out;
310
+ }
311
+
312
+ /**
313
+ * Adds a row to the start section's routing table for every section that
314
+ * says what it {@link AgentGuideSection.asks} and is not routed there yet.
315
+ * The rows go after the table's last row. A start section without the table
316
+ * (a distribution replaced it) is left as it is.
317
+ */
318
+ function withRoutes(sections: readonly AgentGuideSection[]): readonly AgentGuideSection[] {
319
+ const start = sections.find((section) => section.id === START_HERE_SECTION_ID);
320
+ if (!start) return sections;
321
+ const routed = sections.filter((section) => section.asks?.trim() && !start.body.includes(`| \`${section.id}\` |`));
322
+ if (routed.length === 0) return sections;
323
+ const lines = start.body.split('\n');
324
+ const header = lines.findIndex((line) => /^\|\s*Question\s*\|\s*Section\s*\|/.test(line));
325
+ if (header < 0) return sections;
326
+ let last = header;
327
+ while (last + 1 < lines.length && lines[last + 1]!.startsWith('|')) last += 1;
328
+ const rows = routed.map((section) => `| ${section.asks!.trim().replaceAll('|', '\\|')} | \`${section.id}\` |`);
329
+ lines.splice(last + 1, 0, ...rows);
330
+ const body = lines.join('\n');
331
+ return sections.map((section) => (section === start ? { ...start, body } : section));
332
+ }
333
+
240
334
  /**
241
335
  * The heading's text, or the id when the section opens with no heading. An
242
336
  * ATX heading may close with a run of `#` of its own (`## Custom ##`); that
@@ -260,6 +354,19 @@ export async function composeAgentGuide(
260
354
  return joinGuideSections(await agentGuideSections(layout, hook, context));
261
355
  }
262
356
 
357
+ /**
358
+ * What a `read_file` of the guide's name answers: the start section and the
359
+ * list of every section, the same as `get_agent_guide` with no `section`, so
360
+ * no read hands an agent the whole guide unless it asks for it. A guide
361
+ * without a start section (a distribution dropped it) is answered whole.
362
+ */
363
+ export function guideStartText(sections: readonly RenderedGuideSection[]): string {
364
+ const start = sections.find((section) => section.id === START_HERE_SECTION_ID);
365
+ if (!start) return joinGuideSections(sections);
366
+ const list = sections.map((section) => `- \`${section.id}\`: ${section.title}`).join('\n');
367
+ return `${start.body}\n\n### Every section\n\n${list}\n`;
368
+ }
369
+
263
370
  /** The sections as one document, the way `composeAgentGuide` joins them. */
264
371
  export function joinGuideSections(sections: readonly RenderedGuideSection[]): string {
265
372
  return `${sections.map((section) => section.body).join('\n\n')}\n`;
@@ -268,12 +375,12 @@ export function joinGuideSections(sections: readonly RenderedGuideSection[]): st
268
375
  /**
269
376
  * What a `read_file` of the guide's name answers when the knowledge base has an
270
377
  * `AGENTS.md` of its own: their text first, whole, then a rule and one line
271
- * saying what follows, then the platform's guide. Theirs first because it is
378
+ * saying what follows, then the start of the platform's guide. Theirs first because it is
272
379
  * the more specific of the two, and the line between so neither is read as
273
380
  * part of the other.
274
381
  */
275
382
  export const PLATFORM_GUIDE_SEPARATOR =
276
- "---\n\n_The text above is this knowledge base's own conventions file. The platform's guide follows; `get_agent_guide` returns it on its own._";
383
+ "---\n\n_The text above is this knowledge base's own conventions file. The start of the platform's guide follows; `get_agent_guide` with a `section` returns the rest._";
277
384
 
278
385
  export function withPlatformGuideAppended(ownText: string, guide: string): string {
279
386
  // Their text as they wrote it, line endings normalised and the trailing
@@ -0,0 +1,73 @@
1
+ /**
2
+ * How much each layer of what an agent is told may cost, in tokens.
3
+ *
4
+ * Three layers (openspec/changes/agent-guide-layers/design.md): what every
5
+ * connection carries (the handshake instructions and the tool list), what an
6
+ * agent reads on first contact (the start section `get_agent_guide` answers
7
+ * with), and what it fetches only when a task needs it (every other section).
8
+ * A line in the first layer costs every conversation and every sub-agent; a
9
+ * line in the last costs only the tasks that fetch it. The budgets are what
10
+ * keeps the guide from growing into a document every agent loads whole and
11
+ * reads little of, which is what a customer's agent did fifteen times in one
12
+ * day.
13
+ *
14
+ * Counted with the `o200k_base` encoding by `__tests__/guide-budget.test.ts`.
15
+ * It is a real tokenizer that runs offline; it is not Claude's own, which
16
+ * usually counts somewhat more for the same text, so these are ceilings to
17
+ * compare against each other and over time rather than a model's bill.
18
+ *
19
+ * Every number is one a reviewer may move. The point of pinning them is that
20
+ * growing the guide is a decision someone takes, not a paragraph someone
21
+ * appends: a section that needs more room says why in the pull request that
22
+ * raises its line here.
23
+ */
24
+
25
+ /** The encoding the budgets are counted in. */
26
+ export const GUIDE_TOKEN_ENCODING = 'o200k_base';
27
+
28
+ /** The platform's part of the handshake instructions: the header and the shared file rules. */
29
+ export const INSTRUCTIONS_TOKEN_BUDGET = 2_000;
30
+
31
+ /**
32
+ * The platform's own tools as the hosted endpoint lists them to an MCP client:
33
+ * each name, description (with its `Call:` line and, on the four
34
+ * knowledge-base tools, the purpose prefix) and input schema. Measured over
35
+ * every registrar the root mounts, the change-request readers and `open_page`
36
+ * included: 46 tools, 17,278 tokens at 0.35.0 (an earlier figure of 15,710
37
+ * left those five out).
38
+ */
39
+ export const TOOL_LIST_TOKEN_BUDGET = 17_500;
40
+
41
+ /** The ceiling for a section a distribution adds, which core's checks cannot see: the same as core's own. */
42
+ export const SECTION_TOKEN_BUDGET = 2_500;
43
+
44
+ /**
45
+ * Each core section's budget, by id. A section without a line here fails the
46
+ * budget test: a new section starts by saying what it may cost.
47
+ */
48
+ export const SECTION_TOKEN_BUDGETS: Readonly<Record<string, number>> = Object.freeze({
49
+ 'start-here': 1_200,
50
+ 'how-agents-find-things': SECTION_TOKEN_BUDGET,
51
+ 'for-git-users': SECTION_TOKEN_BUDGET,
52
+ glossary: SECTION_TOKEN_BUDGET,
53
+ 'directory-structure': SECTION_TOKEN_BUDGET,
54
+ 'where-a-new-file-goes': SECTION_TOKEN_BUDGET,
55
+ 'working-with-files': SECTION_TOKEN_BUDGET,
56
+ 'access-control': SECTION_TOKEN_BUDGET,
57
+ 'drafts-and-change-requests': SECTION_TOKEN_BUDGET,
58
+ skills: SECTION_TOKEN_BUDGET,
59
+ tools: SECTION_TOKEN_BUDGET,
60
+ 'tool-manuals': SECTION_TOKEN_BUDGET,
61
+ // The page contract is long by nature (the frame, the bridge, the link
62
+ // rules, pages that load live data), and an agent fetches it only to build
63
+ // a page.
64
+ 'html-views': 5_500,
65
+ conventions: SECTION_TOKEN_BUDGET,
66
+ 'new-knowledge-base': SECTION_TOKEN_BUDGET,
67
+ });
68
+
69
+ /** Every core section at once: what `section: "all"` returns. */
70
+ export const WHOLE_GUIDE_TOKEN_BUDGET = 21_500;
71
+
72
+ /** One task: the start section plus the sections it routes the task to. */
73
+ export const TASK_TOKEN_BUDGET = 6_000;
@@ -3,10 +3,13 @@ export {
3
3
  CORE_SECTION_IDS,
4
4
  NEW_KNOWLEDGE_BASE_SECTION_ID,
5
5
  PLATFORM_GUIDE_SEPARATOR,
6
+ START_HERE_SECTION_ID,
7
+ WHOLE_GUIDE_SECTION,
6
8
  WORKING_WITH_FILES_SECTION_ID,
7
9
  agentGuideSections,
8
10
  composeAgentGuide,
9
11
  coreAgentGuideSections,
12
+ guideStartText,
10
13
  isAgentGuidePath,
11
14
  isManagedGuide,
12
15
  joinGuideSections,
@@ -19,4 +22,13 @@ export {
19
22
  type RenderedGuideSection,
20
23
  } from './agent-guide.js';
21
24
  export { GET_AGENT_GUIDE_TOOL, registerAgentGuideTool } from './agent-guide.tools.js';
25
+ export {
26
+ GUIDE_TOKEN_ENCODING,
27
+ INSTRUCTIONS_TOKEN_BUDGET,
28
+ SECTION_TOKEN_BUDGET,
29
+ SECTION_TOKEN_BUDGETS,
30
+ TASK_TOKEN_BUDGET,
31
+ TOOL_LIST_TOKEN_BUDGET,
32
+ WHOLE_GUIDE_TOKEN_BUDGET,
33
+ } from './guide-budget.js';
22
34
  export { GUIDE_FIRST_SENTENCE, withGuideFirst } from '../tool-registry/index.js';
@@ -1,6 +1,6 @@
1
1
  import { DEFAULT_KB_LAYOUT, PREAMBLE_CAP } from '@bevel-software/platform-shared';
2
2
  import { describe, expect, it } from 'vitest';
3
- import { composeAgentGuide } from '../../agent-guide/agent-guide.js';
3
+ import { composeAgentGuide } from '../../agent-guide/index.js';
4
4
  import {
5
5
  INSTRUCTIONS_CAP,
6
6
  PLATFORM_HEADER,
@@ -93,10 +93,10 @@ describe('the shared file rules are one text, in two places', () => {
93
93
  * organisation's own conventions file beside it — which is what a
94
94
  * `read_file` of that name serves first.
95
95
  */
96
- it('tells an agent to call get_agent_guide first, and that AGENTS.md answers with the same guide after the organisation\'s own', () => {
96
+ it('tells an agent to call get_agent_guide first, and that AGENTS.md answers with that start after the organisation\'s own', () => {
97
97
  const rule = sharedFileRules(DEFAULT_KB_LAYOUT).find((r) => r.id === 'agent-guide')!.body;
98
- expect(rule).toContain("call `get_agent_guide` and read the platform's guide");
99
- expect(rule).toContain('read_file on `AGENTS.md` at the KB root answers with the same guide');
98
+ expect(rule).toContain("call `get_agent_guide`: it answers the guide's start, which names the default branch");
99
+ expect(rule).toContain('read_file on `AGENTS.md` at the KB root answers with that start');
100
100
  expect(rule).toContain("after the organisation's own conventions file of that name");
101
101
  // The pre-rename name is still offered, for a knowledge base that kept one.
102
102
  expect(rule).toContain('`CLAUDE.md`');
@@ -50,6 +50,10 @@ export const PLATFORM_HEADER =
50
50
  'search the knowledge base: call `start_session` once, then `grep` for the key terms, `list_files` to orient, and `read_file` what matches. ' +
51
51
  'Prefer what you find there over memory or the web, and say so when the knowledge base is silent on something the organisation should have documented. ' +
52
52
  'Skills are available as prompts and through `list_skills` and `get_skill`. ' +
53
+ // Said here as well as on the write tools: agents wrote to the default
54
+ // branch without asking (the agent-guide benchmark, 2026-10-26), and this
55
+ // text reaches an agent that never fetches the guide.
56
+ 'A write to the default branch is live for everyone: unless the person said where, ask first whether to write there or on a draft. ' +
53
57
  // There is no one calling shape to state: the knowledge-base tools take
54
58
  // their arguments under `body`, a tool that calls another service takes them
55
59
  // flat, and an agent told one rule for all of them sends a GET with a body.
@@ -177,10 +177,10 @@ export function sharedFileRules(layout: KbLayout): readonly SharedFileRule[] {
177
177
  body:
178
178
  'move_file, copy_file and delete_folder take `dryRun: true`: it changes nothing and answers the impact — ' +
179
179
  'what the call would touch, `allowed`, and `reason` when it may not run. A non-empty folder is deleted, and a ' +
180
- 'move that changes your access runs, only with `confirm: true`; without it the call changes nothing and ' +
180
+ 'move that changes anyone\'s access runs, only with `confirm: true`; without it the call changes nothing and ' +
181
181
  'returns the same impact with `confirmationRequired: true`. Do NOT set `confirm: true` on your first call — ' +
182
182
  'dry-run, check the impact, then confirm. delete_file takes neither: check it first with file_stat ' +
183
- '(`deletable`) if you are unsure. delete_branch has its own `dryRun`.',
183
+ '(`deletable`). delete_branch has its own `dryRun`.',
184
184
  },
185
185
  {
186
186
  id: 'managed-items',
@@ -200,7 +200,7 @@ export function sharedFileRules(layout: KbLayout): readonly SharedFileRule[] {
200
200
  id: 'refused-for-permissions',
201
201
  heading: 'When a write is refused for permissions',
202
202
  body:
203
- 'A refusal is not necessarily the end of the road: the `write-denied` error says whether you may propose the ' +
203
+ 'The `write-denied` error says whether you may propose the ' +
204
204
  'change instead (create a branch from this one, repeat the call on it, then `open_change_request` into this ' +
205
205
  'branch) and lists those steps.',
206
206
  },
@@ -238,17 +238,19 @@ function platformFileList(layout: KbLayout): string {
238
238
  * The conventions reminder — where the platform's guide is, that the
239
239
  * organisation's own conventions file comes with it, and to read both first.
240
240
  *
241
- * The guide is read by name at the repository root, where coding agents look
242
- * for an `AGENTS.md` by convention, and `get_agent_guide` returns it alone.
241
+ * The guide's start is read by name at the repository root, where coding
242
+ * agents look for an `AGENTS.md` by convention, and `get_agent_guide` returns
243
+ * it or any one section.
243
244
  * One name on every deployment, so the rule takes no layout. `CLAUDE.md` is
244
245
  * named as a fallback because a knowledge base seeded before the rename may
245
246
  * still carry one its people edited.
246
247
  */
247
248
  function conventionsRule(): string {
248
249
  return (
249
- "Before your first read or change in a workspace, call `get_agent_guide` and read the platform's guide " +
250
- `(whole, or one section). read_file on \`${LEGACY_AGENTS_FILE}\` at the KB root answers with the same ` +
251
- "guide, after the organisation's own conventions file of that name when it has one: follow both. A " +
250
+ "Before your first read or change in a workspace, call `get_agent_guide`: it answers the guide's start, " +
251
+ "which names the default branch and the section for each question. " +
252
+ `read_file on \`${LEGACY_AGENTS_FILE}\` at the KB root answers with ` +
253
+ "that start, after the organisation's own conventions file of that name when it has one: follow both. A " +
252
254
  `\`${PRE_RENAME_AGENTS_FILE}\` at the KB root is the organisation's own too; read it if it exists.`
253
255
  );
254
256
  }
@@ -51,7 +51,7 @@ export function createCallToolChainTool(
51
51
  `There is NO single calling shape: some tools take their arguments under a \`body\` object, others take them flat. Call each tool as the \`Call:\` line at the top of its own description shows — it is generated from that tool's input schema, and every tool in \`${ns}\` has one. Every argument a tool declares REQUIRED must be present; for the knowledge-base tools that includes \`branch\`. Arguments that do not match the schema are refused before anything is sent or run, and the refusal carries the tool's interface.`,
52
52
  'Error handling inside the chain: a failing tool call THROWS, and the thrown error\'s `.message` holds the server\'s actual reason (e.g. a 403 with the explanation, not just a status code). If you catch it, surface `err.message` (and `err.status` / `err.data` when present) — NEVER `return { error: err }` or otherwise return the raw Error object, because an Error serializes to `{}` (its `message` is non-enumerable) and the reason is lost. If you don\'t need to handle it, just let it throw — the runtime already reports `err.message` back to you.',
53
53
  `Failures are answered, never dropped: a chain that throws comes back with \`success: false\` and the reason, and one that outlives \`timeout\` (default ${CHAIN_TIMEOUT_DEFAULT_MS} ms, maximum ${CHAIN_TIMEOUT_MAX_MS} ms) comes back saying it timed out — raise \`timeout\` or split the work and run it again. Either way your next tool call works as usual.`,
54
- 'Large return values: if the returned value exceeds `max_output_size`, the full JSON is auto-spilled to a shared spill store (outside any workspace, never committed) and the response contains only a `__tool_chain_spill__/…` ref + a truncated marker. You can read the spill back with the regular `read_file` tool — pass that ref as `path` (its `branch` is ignored) plus `offset` / `limit` to slice it, never read a multi-MB file in full. Order of preference: (1) re-run `call_tool_chain` with a follow-up code chain that filters/maps the data inline and returns just what you need; (2) narrow the API call — shorter `fields`, tighter date window, lower `limit`; (3) last resort — `read_file` against the spill ref with `offset` / `limit`. The spill is read-only context only; do NOT use it as a way to persist KB content — for KB writes use the regular `write_file` / `edit_file` tools, which go through the lock/commit pipeline.',
54
+ 'Large return values: if the returned value exceeds `max_output_size`, the full JSON is auto-spilled to a shared spill store (outside any workspace, never committed) and the response contains only a `__tool_chain_spill__/…` ref + a truncated marker. You can read the spill back with the regular `read_file` tool — pass that ref as `path` (any existing branch as `branch`; it is not used) plus `offset` / `limit` to slice it, never read a multi-MB file in full. Order of preference: (1) re-run `call_tool_chain` with a follow-up code chain that filters/maps the data inline and returns just what you need; (2) narrow the API call — shorter `fields`, tighter date window, lower `limit`; (3) last resort — `read_file` against the spill ref with `offset` / `limit`. The spill is read-only context only; do NOT use it as a way to persist KB content — for KB writes use the regular `write_file` / `edit_file` tools, which go through the lock/commit pipeline.',
55
55
  ].join('\n\n'),
56
56
  inputSchema: z.object({
57
57
  code: z
@@ -121,7 +121,7 @@ export function createCallToolChainTool(
121
121
  truncated: true,
122
122
  result_ref: ref,
123
123
  result_bytes: bytes,
124
- message: `Combined result+logs payload was ${fullJson.length} characters (exceeded max_output_size of ${maxOutputSize}). Full JSON (both \`result\` and \`logs\`) saved to the shared spill store as \`${ref}\` (outside any workspace, uncommitted). Read it back with \`read_file\` — pass that ref as \`path\` (\`branch\` is ignored) plus \`offset\` / \`limit\` for a slice — or, usually better, narrow the next \`call_tool_chain\` call (smaller fields list, tighter date window, lower limit) so the result fits inline.`,
124
+ message: `Combined result+logs payload was ${fullJson.length} characters (exceeded max_output_size of ${maxOutputSize}). Full JSON (both \`result\` and \`logs\`) saved to the shared spill store as \`${ref}\` (outside any workspace, uncommitted). Read it back with \`read_file\` — pass that ref as \`path\` (any existing branch as \`branch\`; it is not used) plus \`offset\` / \`limit\` for a slice — or, usually better, narrow the next \`call_tool_chain\` call (smaller fields list, tighter date window, lower limit) so the result fits inline.`,
125
125
  };
126
126
  } catch (err) {
127
127
  // The chain itself SUCCEEDED and only its delivery failed, so the logs
@@ -4,8 +4,8 @@ import type {
4
4
  IToolManualService,
5
5
  ToolHealthCheck,
6
6
  ToolProbeTarget,
7
- } from '../../tool-manuals/tool-manuals.contract.js';
8
- import type { ISecretsVaultService } from '../../secrets-vault/secrets-vault.contract.js';
7
+ } from '../../tool-manuals/index.js';
8
+ import type { ISecretsVaultService } from '../../secrets-vault/index.js';
9
9
 
10
10
  /**
11
11
  * The three-way verdict is the whole point of this module, and the line that
@@ -33,7 +33,7 @@ vi.mock('@utcp/code-mode', () => ({
33
33
  CodeModeUtcpClient: { create: () => createClientMock() },
34
34
  }));
35
35
 
36
- const { ConnectionProbeService, readCapped } = await import('../connection-probe.service.js');
36
+ const { ConnectionProbeService } = await import('../connection-probe.service.js');
37
37
 
38
38
  const DECLARED_PROBE: ToolHealthCheck = {
39
39
  url: 'https://api.acme.test/me',
@@ -739,25 +739,6 @@ describe('ConnectionProbeService: what the probe concludes', () => {
739
739
  expect(r?.detail!.length).toBeLessThan(400);
740
740
  expect(cancelled).toBe(true);
741
741
  });
742
-
743
- /**
744
- * The cap has to bound what is DECODED, not merely how many times we loop.
745
- * Counting a chunk against the budget after decoding it lets a provider that
746
- * answers in one huge chunk put the whole thing through the decoder — the
747
- * bound bypassed by exactly the party it exists to bound. Asserted on
748
- * `readCapped` directly because the caller's 200-character snippet is
749
- * identical either way, which is how the first version of this passed.
750
- */
751
- it('holds one oversized chunk to the byte budget, not just the loop', async () => {
752
- const oneHugeChunk = new ReadableStream<Uint8Array>({
753
- start(controller) {
754
- controller.enqueue(new TextEncoder().encode('x'.repeat(5_000_000)));
755
- controller.close();
756
- },
757
- });
758
-
759
- const out = await readCapped(new Response(oneHugeChunk), 8 * 1024);
760
-
761
- expect(out.length).toBe(8 * 1024);
762
- });
742
+ // The byte budget's own property (one oversized chunk held to the cap) is
743
+ // asserted on the shared reader in `shared/__tests__/read-capped.test.ts`.
763
744
  });