@bevel-software/platform-core-backend 0.23.0 → 0.25.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/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +17 -3
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +3 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +25 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +12 -0
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +10 -0
- package/dist/core/lifecycle.js.map +1 -1
- package/dist/core-config.d.ts +16 -0
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +17 -0
- package/dist/core-config.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -2
- package/dist/index.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +4 -1
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/directory-sync-bot.d.ts.map +1 -1
- package/dist/modules/access/directory-sync-bot.js +7 -3
- package/dist/modules/access/directory-sync-bot.js.map +1 -1
- package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +8 -1
- package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -1
- package/dist/modules/agent-instructions/agent-instructions.routes.js +8 -2
- package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -1
- package/dist/modules/agent-instructions/compose.d.ts +38 -6
- package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
- package/dist/modules/agent-instructions/compose.js +39 -6
- package/dist/modules/agent-instructions/compose.js.map +1 -1
- package/dist/modules/agent-instructions/index.d.ts +2 -1
- package/dist/modules/agent-instructions/index.d.ts.map +1 -1
- package/dist/modules/agent-instructions/index.js +2 -1
- package/dist/modules/agent-instructions/index.js.map +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.d.ts +115 -0
- package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -0
- package/dist/modules/agent-instructions/shared-file-rules.js +272 -0
- package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -0
- package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
- package/dist/modules/audit/agent-audit.service.js +4 -2
- package/dist/modules/audit/agent-audit.service.js.map +1 -1
- package/dist/modules/auth/account-erasure.service.d.ts.map +1 -1
- package/dist/modules/auth/account-erasure.service.js +57 -16
- package/dist/modules/auth/account-erasure.service.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +25 -15
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/code-mode/code-mode.tool.d.ts +20 -2
- package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
- package/dist/modules/code-mode/code-mode.tool.js +66 -35
- package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
- package/dist/modules/database/connection.d.ts +16 -0
- package/dist/modules/database/connection.d.ts.map +1 -1
- package/dist/modules/database/connection.js +117 -0
- package/dist/modules/database/connection.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +296 -96
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +81 -31
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/database/migrate.d.ts +95 -1
- package/dist/modules/database/migrate.d.ts.map +1 -1
- package/dist/modules/database/migrate.js +390 -2
- package/dist/modules/database/migrate.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts +17 -0
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +29 -0
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +8 -0
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +38 -8
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/plugins/join-request-records.store.d.ts.map +1 -1
- package/dist/modules/plugins/join-request-records.store.js +7 -4
- package/dist/modules/plugins/join-request-records.store.js.map +1 -1
- package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
- package/dist/modules/tool-auth/external-api-key.service.js +8 -2
- package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +30 -0
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +42 -31
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/tool-registry/description-length.d.ts +80 -0
- package/dist/modules/tool-registry/description-length.d.ts.map +1 -0
- package/dist/modules/tool-registry/description-length.js +108 -0
- package/dist/modules/tool-registry/description-length.js.map +1 -0
- package/dist/modules/workflow/git/git.service.d.ts +25 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +40 -1
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +5 -1
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/recovery-bot.d.ts.map +1 -1
- package/dist/modules/workflow/recovery-bot.js +7 -3
- package/dist/modules/workflow/recovery-bot.js.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
- package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +4 -1
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/agent-upload.routes.d.ts +77 -0
- package/dist/modules/workspace/agent-upload.routes.d.ts.map +1 -0
- package/dist/modules/workspace/agent-upload.routes.js +210 -0
- package/dist/modules/workspace/agent-upload.routes.js.map +1 -0
- package/dist/modules/workspace/agent-upload.store.d.ts +284 -0
- package/dist/modules/workspace/agent-upload.store.d.ts.map +1 -0
- package/dist/modules/workspace/agent-upload.store.js +553 -0
- package/dist/modules/workspace/agent-upload.store.js.map +1 -0
- package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
- package/dist/modules/workspace/startup/steps/seed-tree.js +3 -3
- package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
- package/dist/modules/workspace/startup/steps/template-source.d.ts +40 -0
- package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
- package/dist/modules/workspace/startup/steps/template-source.js +46 -4
- package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
- package/dist/modules/workspace/upload-limits.d.ts +13 -0
- package/dist/modules/workspace/upload-limits.d.ts.map +1 -0
- package/dist/modules/workspace/upload-limits.js +13 -0
- package/dist/modules/workspace/upload-limits.js.map +1 -0
- package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.routes.js +1 -1
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +13 -0
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +61 -33
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts +11 -9
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +570 -134
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/modules/workspace/write-denial.d.ts +0 -6
- package/dist/modules/workspace/write-denial.d.ts.map +1 -1
- package/dist/modules/workspace/write-denial.js +0 -6
- package/dist/modules/workspace/write-denial.js.map +1 -1
- package/dist/modules/workspace/zip-entry-rules.d.ts +114 -0
- package/dist/modules/workspace/zip-entry-rules.d.ts.map +1 -0
- package/dist/modules/workspace/zip-entry-rules.js +154 -0
- package/dist/modules/workspace/zip-entry-rules.js.map +1 -0
- package/dist/shared/column-crypto.d.ts +194 -0
- package/dist/shared/column-crypto.d.ts.map +1 -0
- package/dist/shared/column-crypto.js +144 -0
- package/dist/shared/column-crypto.js.map +1 -0
- package/dist/shared/token-crypto.d.ts.map +1 -1
- package/dist/shared/token-crypto.js +25 -1
- package/dist/shared/token-crypto.js.map +1 -1
- package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
- package/dist/tenancy/static-tenant-source.js +1 -0
- package/dist/tenancy/static-tenant-source.js.map +1 -1
- package/dist/tenancy/tenant-secrets.d.ts +5 -1
- package/dist/tenancy/tenant-secrets.d.ts.map +1 -1
- package/dist/tenancy/tenant-secrets.js +4 -0
- package/dist/tenancy/tenant-secrets.js.map +1 -1
- package/kb-template/AGENTS.md +42 -0
- package/migrations/0016_pii_encryption.sql +20 -0
- package/migrations/meta/0016_snapshot.json +2327 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +3 -3
- package/src/core/__tests__/lifecycle.test.ts +71 -4
- package/src/core/create-core-server.ts +21 -3
- package/src/core/create-core-services.ts +27 -1
- package/src/core/lifecycle.ts +19 -0
- package/src/core-config.ts +18 -0
- package/src/index.ts +25 -0
- package/src/modules/access/__tests__/users-db-double.ts +21 -12
- package/src/modules/access/access.routes.ts +4 -1
- package/src/modules/access/directory-sync-bot.ts +7 -3
- package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +8 -4
- package/src/modules/agent-instructions/__tests__/compose.test.ts +19 -10
- package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +238 -0
- package/src/modules/agent-instructions/agent-instructions.routes.ts +12 -2
- package/src/modules/agent-instructions/compose.ts +50 -7
- package/src/modules/agent-instructions/index.ts +12 -0
- package/src/modules/agent-instructions/shared-file-rules.ts +314 -0
- package/src/modules/audit/agent-audit.service.ts +4 -2
- package/src/modules/auth/__tests__/account-deactivation.test.ts +2 -1
- package/src/modules/auth/__tests__/account-erasure.approval-gate.test.ts +6 -2
- package/src/modules/auth/__tests__/account.routes.test.ts +6 -3
- package/src/modules/auth/__tests__/auth.service.test.ts +3 -1
- package/src/modules/auth/account-erasure.service.ts +69 -18
- package/src/modules/auth/auth.service.ts +33 -23
- package/src/modules/code-mode/__tests__/chain-runtime.e2e.test.ts +335 -0
- package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +46 -2
- package/src/modules/code-mode/code-mode.tool.ts +80 -34
- package/src/modules/database/__tests__/connection.test.ts +12 -0
- package/src/modules/database/__tests__/pii-backfill.pg.test.ts +780 -0
- package/src/modules/database/connection.ts +117 -0
- package/src/modules/database/core-schema.ts +81 -31
- package/src/modules/database/migrate.ts +540 -2
- package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +93 -0
- package/src/modules/kb-fs/locking-filesystem.ts +37 -0
- package/src/modules/mcp/__tests__/mcp.e2e.test.ts +4 -3
- package/src/modules/mcp/__tests__/mcp.service.test.ts +47 -7
- package/src/modules/mcp/mcp.service.ts +46 -7
- package/src/modules/plugins/join-request-records.store.ts +7 -4
- package/src/modules/tool-auth/external-api-key.service.ts +8 -2
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +151 -0
- package/src/modules/tool-manuals/tool-manuals.service.ts +37 -0
- package/src/modules/tool-manuals/tool-manuals.tools.ts +44 -31
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +378 -0
- package/src/modules/tool-registry/description-length.ts +111 -0
- package/src/modules/workflow/git/__tests__/git.service.prFetchFailure.test.ts +170 -0
- package/src/modules/workflow/git/git.service.ts +47 -1
- package/src/modules/workflow/pending-commits.service.ts +5 -1
- package/src/modules/workflow/recovery-bot.ts +7 -3
- package/src/modules/workflow/review-workflow/__tests__/carry-approvals-forward.test.ts +4 -0
- package/src/modules/workflow/review-workflow/__tests__/erase-approver.test.ts +25 -3
- package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
- package/src/modules/workflow/workflow.service.ts +4 -1
- package/src/modules/workspace/__tests__/agent-uploads.test.ts +1604 -0
- package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +24 -14
- package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +156 -0
- package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +77 -1
- package/src/modules/workspace/__tests__/workspace.service.test.ts +57 -0
- package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +39 -36
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +196 -46
- package/src/modules/workspace/agent-upload.routes.ts +214 -0
- package/src/modules/workspace/agent-upload.store.ts +668 -0
- package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +9 -9
- package/src/modules/workspace/startup/steps/seed-tree.ts +3 -6
- package/src/modules/workspace/startup/steps/template-source.ts +53 -5
- package/src/modules/workspace/upload-limits.ts +12 -0
- package/src/modules/workspace/workspace.routes.ts +1 -2
- package/src/modules/workspace/workspace.service.ts +63 -37
- package/src/modules/workspace/workspace.tools.ts +647 -148
- package/src/modules/workspace/write-denial.ts +0 -8
- package/src/modules/workspace/zip-entry-rules.ts +173 -0
- package/src/shared/__tests__/column-crypto.test.ts +217 -0
- package/src/shared/column-crypto.ts +218 -0
- package/src/shared/token-crypto.ts +28 -1
- package/src/tenancy/__tests__/static-tenant-source.test.ts +4 -0
- package/src/tenancy/static-tenant-source.ts +1 -0
- package/src/tenancy/tenant-secrets.ts +5 -1
|
@@ -65,32 +65,24 @@ export function registerToolManualsTools(
|
|
|
65
65
|
|
|
66
66
|
const listSetupDef = toolDef({
|
|
67
67
|
name: 'list_tool_setup',
|
|
68
|
+
// What each FIELD means is documented on the field, in `outputs` below:
|
|
69
|
+
// `setup.kind`, `variables` and `invalid` each carried a paragraph here,
|
|
70
|
+
// which made this description two thousand characters and so the first
|
|
71
|
+
// thing a client cut. The description says what the tool answers and the
|
|
72
|
+
// three things an agent cannot read off a field.
|
|
68
73
|
description:
|
|
69
|
-
'Configuration status of every `.tool` the current user can access: what each tool needs set up and ' +
|
|
70
|
-
'
|
|
71
|
-
'absent entirely, and
|
|
72
|
-
'
|
|
73
|
-
'
|
|
74
|
-
'
|
|
75
|
-
'
|
|
76
|
-
'
|
|
77
|
-
'
|
|
78
|
-
'
|
|
79
|
-
'
|
|
80
|
-
'
|
|
81
|
-
'from its frontmatter `write:`/`owner:` verbs and the access.md chain — NOT a platform role), which ' +
|
|
82
|
-
'is exactly what gates setting its shared secrets: the people who manage the file configure the tool. ' +
|
|
83
|
-
'Secret VALUES are never returned and can never be set through a tool — an admin enters them in the ' +
|
|
84
|
-
'tool editor; users sign in on /connect. ' +
|
|
85
|
-
'`invalid` names any `.tool` file the scan REFUSED, with the reason and its location: those files ' +
|
|
86
|
-
'are the only ones missing — every other tool is listed and callable, and a refused file is listed ' +
|
|
87
|
-
'again as a normal tool on the next call once it is fixed (or removed), with nothing to restart or ' +
|
|
88
|
-
'reconnect. ' +
|
|
89
|
-
'The listing is the RELEASED catalog, built from the default branch only: a server or `.tool` you ' +
|
|
90
|
-
'declared on a draft is not listed, not callable and not signed-in-able until that draft is merged. ' +
|
|
91
|
-
'Pass `branch` (the draft you wrote the declaration on) and `onBranchOnly` names every tool declared ' +
|
|
92
|
-
'there that the default branch does not serve yet — open a change request, then ask the user to ' +
|
|
93
|
-
'review and merge it in the app to activate it.',
|
|
74
|
+
'Configuration status of every `.tool` the current user can access: what each tool needs set up and what is ' +
|
|
75
|
+
'already configured, as `{ tools, invalid, onBranchOnly, note? }`. Scoped to the CALLER — a `.tool` it cannot ' +
|
|
76
|
+
'READ is absent entirely, and every flag is the caller\'s own state. ' +
|
|
77
|
+
'Secret VALUES are never returned and can never be set through a tool: an admin enters them in the tool editor, ' +
|
|
78
|
+
'and users sign in on /connect rather than typing a value. ' +
|
|
79
|
+
'What gates setting a tool\'s shared secrets is `canWrite` on the `.tool` FILE — per-file access from its ' +
|
|
80
|
+
'frontmatter `write:`/`owner:` verbs and the access.md chain, NOT a platform role: the people who manage the ' +
|
|
81
|
+
'file configure the tool. ' +
|
|
82
|
+
'The listing is the RELEASED catalog, built from the default branch only: a server or `.tool` you declared on a ' +
|
|
83
|
+
'draft is not listed, not callable and not signed-in-able until that draft is merged. Pass `branch` (the draft ' +
|
|
84
|
+
'you wrote the declaration on) and `onBranchOnly` names every tool declared there that the default branch does ' +
|
|
85
|
+
'not serve yet — open a change request, then ask the user to review and merge it in the app to activate it.',
|
|
94
86
|
path: '/api/agent/tools/list_tool_setup',
|
|
95
87
|
inputs: {
|
|
96
88
|
type: 'object',
|
|
@@ -119,12 +111,32 @@ export function registerToolManualsTools(
|
|
|
119
111
|
type: { type: 'string' },
|
|
120
112
|
setup: {
|
|
121
113
|
type: ['object', 'null'],
|
|
122
|
-
description:
|
|
123
|
-
properties: {
|
|
114
|
+
description: "An MCP server's sign-in requirement; null for non-mcp tools.",
|
|
115
|
+
properties: {
|
|
116
|
+
kind: {
|
|
117
|
+
type: 'string',
|
|
118
|
+
description:
|
|
119
|
+
'`open` = no sign-in; `oauth-auto` = sign-in was configured automatically; `oauth-manual` = the ' +
|
|
120
|
+
'sign-in needs an OAuth app the owner registers with the provider: a writer declares its client ' +
|
|
121
|
+
'id on a `user`-scoped variable with an `oauth` block — in the plugin.json extensions entry for ' +
|
|
122
|
+
'an mcp.json server (endpoints are discovered from the server; PKCE is on by default), or in the ' +
|
|
123
|
+
'`.tool` file with explicit URLs — and pastes the client secret on the tool\'s page.',
|
|
124
|
+
},
|
|
125
|
+
reason: {
|
|
126
|
+
type: 'string',
|
|
127
|
+
description: 'Present only while something still blocks the sign-in, and says what.',
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
canWrite: {
|
|
132
|
+
type: 'boolean',
|
|
133
|
+
description: 'You may write this `.tool` FILE, which is what gates setting its shared secrets.',
|
|
124
134
|
},
|
|
125
|
-
canWrite: { type: 'boolean' },
|
|
126
135
|
variables: {
|
|
127
136
|
type: 'array',
|
|
137
|
+
description:
|
|
138
|
+
'What this tool needs configured, and by whom: per variable, whether the shared (admin) value is ' +
|
|
139
|
+
'set and whether the CURRENT user has set or authorized their own.',
|
|
128
140
|
items: {
|
|
129
141
|
type: 'object',
|
|
130
142
|
properties: {
|
|
@@ -150,9 +162,10 @@ export function registerToolManualsTools(
|
|
|
150
162
|
invalid: {
|
|
151
163
|
type: 'array',
|
|
152
164
|
description:
|
|
153
|
-
'`.tool` files the scan refused — the ONLY tools missing from `tools
|
|
154
|
-
'why it was refused, with the line/column or field where the validation
|
|
155
|
-
'delete it) and the next call lists it as a normal tool
|
|
165
|
+
'`.tool` files the scan refused — the ONLY tools missing from `tools`; every other tool is listed and ' +
|
|
166
|
+
'callable. Each names the file and why it was refused, with the line/column or field where the validation ' +
|
|
167
|
+
'failed. Fix the file (or delete it) and the next call lists it as a normal tool, with nothing to restart ' +
|
|
168
|
+
'or reconnect. Never contains a secret value.',
|
|
156
169
|
items: {
|
|
157
170
|
type: 'object',
|
|
158
171
|
properties: {
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
CALL_TOOL_CHAIN_NAME,
|
|
5
|
+
CHAIN_FAILURES_RULE,
|
|
6
|
+
CHAIN_LARGE_RESULTS_RULE,
|
|
7
|
+
codeModeMetaTools,
|
|
8
|
+
} from '@bevel-software/platform-mcp-core';
|
|
9
|
+
import { testKbContext } from '../../../__tests__/kb-context.js';
|
|
10
|
+
import { EXTERNAL_KB_MANUAL_NAME } from '../../tool-manuals/tool-manuals.contract.js';
|
|
11
|
+
import { ToolRegistry } from '../tool-registry.js';
|
|
12
|
+
import type { UtcpTool } from '../tool.contract.js';
|
|
13
|
+
import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
|
|
14
|
+
import type { ToolAuth } from '../../tool-auth/tool-auth.middleware.js';
|
|
15
|
+
import { registerWorkspaceTools } from '../../workspace/workspace.tools.js';
|
|
16
|
+
import { registerWorkflowTools } from '../../workflow/agent-tools/workflow.tools.js';
|
|
17
|
+
import { registerPluginsTools } from '../../plugins/plugins.tools.js';
|
|
18
|
+
import { registerSkillsTools } from '../../skills/skills.tools.js';
|
|
19
|
+
import { registerToolManualsTools } from '../../tool-manuals/tool-manuals.tools.js';
|
|
20
|
+
import { ToolDescriptionNotes } from '../../workspace/agent-access.gate.js';
|
|
21
|
+
import { WorkflowHooks } from '../../workflow/workflow-hooks.js';
|
|
22
|
+
import { UuidSessionSink } from '../../workspace/session-sink.js';
|
|
23
|
+
import { RoutineWritePolicyService } from '../../workspace/routine-write-policy.js';
|
|
24
|
+
import { CLIENT_SHORT_CUT, TOOL_DESCRIPTION_CAP, clientVisibleLength, firstSentenceEnd } from '../description-length.js';
|
|
25
|
+
import {
|
|
26
|
+
POINTER_GUIDE_NAME_BUDGET,
|
|
27
|
+
SHARED_RULES_POINTER_MAX,
|
|
28
|
+
TOOL_PREFIX_CAP,
|
|
29
|
+
sharedFileRules,
|
|
30
|
+
sharedRulesPointer,
|
|
31
|
+
} from '../../agent-instructions/index.js';
|
|
32
|
+
import { isPlatformFile, platformFilesByDepth } from '@bevel-software/platform-shared';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The cap exists because clients cut a long tool description, and they cut it
|
|
36
|
+
* from the END — where the text specific to the tool sits. Agents reported
|
|
37
|
+
* `file_stat`, `read_file`, `write_file` and `write_files` arriving as
|
|
38
|
+
* "[truncated]". So this measures what a client is actually handed, and fails
|
|
39
|
+
* NAMING the tool: the next paragraph someone appends to a description has to
|
|
40
|
+
* answer to this test rather than to an agent's truncated catalog.
|
|
41
|
+
*
|
|
42
|
+
* Every registrar Hexis owns is mounted here, on stand-ins, because the
|
|
43
|
+
* descriptions are the only thing under test: a stand-in that is never called
|
|
44
|
+
* is honest about that. Tools PROXIED from connected MCP servers are not
|
|
45
|
+
* measured — their text is the other server's.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/** Dependencies the registrars take but never touch while they are only building defs. */
|
|
49
|
+
const unused = <T,>(): T => ({}) as T;
|
|
50
|
+
|
|
51
|
+
/** A `.tool` catalog with nothing in it: the shortest honest answer for a listing. */
|
|
52
|
+
const emptyManuals = {
|
|
53
|
+
listLocalOnly: async () => [],
|
|
54
|
+
listAll: async () => [],
|
|
55
|
+
list: async () => [],
|
|
56
|
+
listInvalid: async () => [],
|
|
57
|
+
} as unknown as Parameters<typeof registerToolManualsTools>[4];
|
|
58
|
+
|
|
59
|
+
/** A skill catalog with nothing in it — the per-user skill line is then one fixed sentence. */
|
|
60
|
+
const emptySkills = { listSkills: async () => [] } as unknown as Parameters<typeof registerSkillsTools>[4];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The meta-tools as the hosted endpoint builds them (`mcp.service.ts`):
|
|
64
|
+
* examples written against the namespace it registers the knowledge-base tools
|
|
65
|
+
* under and against the catalog it serves, and the chain ending in the pointer.
|
|
66
|
+
* So the chain description measured here carries the worked call a client is
|
|
67
|
+
* really sent, which is its longest form.
|
|
68
|
+
*/
|
|
69
|
+
function servedMetaTools(external: readonly UtcpTool[], pointer = sharedRulesPointer(testKbContext().layout)) {
|
|
70
|
+
return codeModeMetaTools(
|
|
71
|
+
EXTERNAL_KB_MANUAL_NAME,
|
|
72
|
+
external.map((t) => ({ utcpName: `${EXTERNAL_KB_MANUAL_NAME}.${t.name}`, inputSchema: t.inputs })),
|
|
73
|
+
{ sharedRulesPointer: pointer },
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Every tool Hexis itself registers, on both surfaces, deduplicated by name. */
|
|
78
|
+
async function hexisTools(): Promise<UtcpTool[]> {
|
|
79
|
+
const registry = new ToolRegistry();
|
|
80
|
+
const router = express.Router();
|
|
81
|
+
const toolAuth = ((_req, _res, next) => next()) as unknown as ToolAuth;
|
|
82
|
+
const toolHandler = createToolHandlerFactory(unused());
|
|
83
|
+
const kb = testKbContext();
|
|
84
|
+
const gate = { recoveryBotEmail: 'bot@x', hooks: new WorkflowHooks(), notes: new ToolDescriptionNotes() };
|
|
85
|
+
|
|
86
|
+
registerWorkspaceTools(
|
|
87
|
+
registry,
|
|
88
|
+
router,
|
|
89
|
+
toolAuth,
|
|
90
|
+
toolHandler,
|
|
91
|
+
unused(),
|
|
92
|
+
unused(),
|
|
93
|
+
unused(),
|
|
94
|
+
kb,
|
|
95
|
+
gate,
|
|
96
|
+
new RoutineWritePolicyService(),
|
|
97
|
+
new UuidSessionSink(),
|
|
98
|
+
undefined,
|
|
99
|
+
undefined,
|
|
100
|
+
// The two upload tools are mounted only when a store is supplied, and
|
|
101
|
+
// every real composition supplies one: without it they would be the two
|
|
102
|
+
// descriptions this suite never measured.
|
|
103
|
+
unused(),
|
|
104
|
+
);
|
|
105
|
+
registerWorkflowTools(registry, router, toolAuth, toolHandler, kb);
|
|
106
|
+
registerPluginsTools(registry);
|
|
107
|
+
registerSkillsTools(registry, router, toolAuth, toolHandler, emptySkills);
|
|
108
|
+
registerToolManualsTools(registry, router, toolAuth, toolHandler, emptyManuals, {
|
|
109
|
+
accessControl: unused(),
|
|
110
|
+
variableStatus: unused(),
|
|
111
|
+
kb,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const byName = new Map<string, UtcpTool>();
|
|
115
|
+
// The meta-tools as a client is served them: the chain carries its pointer
|
|
116
|
+
// (`mcp.service.ts` appends it at the mount), so the catalog measured here is
|
|
117
|
+
// the catalog that goes out rather than the unpointed constant.
|
|
118
|
+
const external = (await registry.listExternal()) as UtcpTool[];
|
|
119
|
+
const metaTools = servedMetaTools(external);
|
|
120
|
+
for (const tool of [...(await registry.listInternal()), ...external, ...metaTools]) {
|
|
121
|
+
byName.set(tool.name, tool as UtcpTool);
|
|
122
|
+
}
|
|
123
|
+
return [...byName.values()];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
describe('no Hexis tool description is long enough to be cut', () => {
|
|
127
|
+
it(`keeps every description a client is handed within ${TOOL_DESCRIPTION_CAP} characters`, async () => {
|
|
128
|
+
const over = (await hexisTools())
|
|
129
|
+
.map((t) => ({ tool: t.name, chars: clientVisibleLength(t) }))
|
|
130
|
+
.filter((m) => m.chars > TOOL_DESCRIPTION_CAP)
|
|
131
|
+
.sort((a, b) => b.chars - a.chars);
|
|
132
|
+
// The message names the tool and its length, because "a description is too
|
|
133
|
+
// long" sends the next reader back to measuring them by hand.
|
|
134
|
+
expect(
|
|
135
|
+
over,
|
|
136
|
+
over.map((m) => `${m.tool}: ${m.chars} characters (cap ${TOOL_DESCRIPTION_CAP})`).join('\n'),
|
|
137
|
+
).toEqual([]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it(`leaves the tool's own first sentence inside the shorter ${CLIENT_SHORT_CUT}-character cut`, async () => {
|
|
141
|
+
// The cap does not answer the ~500-character cut; the ORDER of the text
|
|
142
|
+
// does, and this is where that claim is checked rather than asserted in a
|
|
143
|
+
// comment. A client that stops at 500 must still have the sentence saying
|
|
144
|
+
// what the tool does — what it loses is the pointer tail, and the file the
|
|
145
|
+
// pointer names is stated in the handshake instructions and in the guide
|
|
146
|
+
// anyway. Lowering the cap to 500 would not buy this; only order does.
|
|
147
|
+
const late = (await hexisTools())
|
|
148
|
+
.map((t) => ({ tool: t.name, endsAt: firstSentenceEnd(t) }))
|
|
149
|
+
.filter((m) => m.endsAt > CLIENT_SHORT_CUT)
|
|
150
|
+
.sort((a, b) => b.endsAt - a.endsAt);
|
|
151
|
+
expect(
|
|
152
|
+
late,
|
|
153
|
+
late
|
|
154
|
+
.map((m) => `${m.tool}: first sentence ends at ${m.endsAt} (cut ${CLIENT_SHORT_CUT}) — lead with what it does`)
|
|
155
|
+
.join('\n'),
|
|
156
|
+
).toEqual([]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('holds the cap on a deployment that renamed its guide, not only under the default', async () => {
|
|
160
|
+
// The pointer ends every file tool's description and its length moves with
|
|
161
|
+
// a deployment setting, so a cap checked only against the nine characters
|
|
162
|
+
// of `AGENTS.md` guarantees nothing about the catalog a renamed deployment
|
|
163
|
+
// serves. Two things make it hold: the pointer is bounded by construction
|
|
164
|
+
// (past `POINTER_GUIDE_NAME_BUDGET` the guide is named by its role), and
|
|
165
|
+
// `clientVisibleLength` charges the worst case rather than this layout's.
|
|
166
|
+
const tools = await hexisTools();
|
|
167
|
+
const atWorst = tools
|
|
168
|
+
.map((t) => ({ tool: t.name, chars: clientVisibleLength(t) }))
|
|
169
|
+
.filter((m) => m.chars > TOOL_DESCRIPTION_CAP);
|
|
170
|
+
expect(atWorst, atWorst.map((m) => `${m.tool}: ${m.chars}`).join('\n')).toEqual([]);
|
|
171
|
+
|
|
172
|
+
// And measured literally, under the longest guide name a deployment can
|
|
173
|
+
// actually configure: every description still fits.
|
|
174
|
+
const longest = `${'x'.repeat(252)}.md`;
|
|
175
|
+
const pointerHere = sharedRulesPointer(testKbContext().layout);
|
|
176
|
+
const pointerThere = sharedRulesPointer({ ...testKbContext().layout, agentsFile: longest });
|
|
177
|
+
expect(pointerThere.length).toBeLessThanOrEqual(SHARED_RULES_POINTER_MAX);
|
|
178
|
+
for (const tool of tools) {
|
|
179
|
+
if (!tool.description?.endsWith(pointerHere)) continue;
|
|
180
|
+
const asRenamed = tool.description.slice(0, -pointerHere.length) + pointerThere;
|
|
181
|
+
const chars = clientVisibleLength({ name: tool.name, description: asRenamed });
|
|
182
|
+
expect(chars, `${tool.name} on a renamed deployment`).toBeLessThanOrEqual(TOOL_DESCRIPTION_CAP);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('measures a prefixed tool with the prefix a client sees, not without it', async () => {
|
|
187
|
+
// The four knowledge-base tools carry the deployment's tool prefix ahead of
|
|
188
|
+
// their description on the MCP surface — up to `TOOL_PREFIX_CAP` characters
|
|
189
|
+
// the admin writes. A cap applied to the bare description would pass while
|
|
190
|
+
// the catalog the agent reads was over it by 300.
|
|
191
|
+
const read = (await hexisTools()).find((t) => t.name === 'read_file');
|
|
192
|
+
expect(read).toBeDefined();
|
|
193
|
+
// Its own text, with the pointer charged at its worst case rather than at
|
|
194
|
+
// this layout's, plus the prefix at ITS cap and the blank line between.
|
|
195
|
+
const pointer = sharedRulesPointer();
|
|
196
|
+
const ownAtWorstPointer = read!.description!.length - pointer.length + SHARED_RULES_POINTER_MAX;
|
|
197
|
+
expect(clientVisibleLength(read!)).toBe(ownAtWorstPointer + TOOL_PREFIX_CAP + 2);
|
|
198
|
+
expect(ownAtWorstPointer + TOOL_PREFIX_CAP + 2).toBeLessThanOrEqual(TOOL_DESCRIPTION_CAP);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('fails, naming the tool, when a paragraph takes a description over the cap', () => {
|
|
202
|
+
const padded = { name: 'write_file', description: 'x'.repeat(TOOL_DESCRIPTION_CAP + 1) } as UtcpTool;
|
|
203
|
+
expect(clientVisibleLength(padded)).toBeGreaterThan(TOOL_DESCRIPTION_CAP);
|
|
204
|
+
// A tool with no description at all is not over the cap.
|
|
205
|
+
expect(clientVisibleLength({ name: 'nothing' } as UtcpTool)).toBe(0);
|
|
206
|
+
// Unless it is a PREFIXED one: the prefix is sent on its own then (no
|
|
207
|
+
// description, so no blank line either), and that text is what the client
|
|
208
|
+
// was handed. Measuring it as nothing would hide the only thing it got.
|
|
209
|
+
expect(clientVisibleLength({ name: 'read_file' } as UtcpTool)).toBe(TOOL_PREFIX_CAP);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('measures the catalog, which is what a client lists — and says what is outside it', async () => {
|
|
213
|
+
// The cap is about `tools/list`: a client cuts what it was sent. The ONE
|
|
214
|
+
// long description this repository still writes is the in-process Mastra
|
|
215
|
+
// `call_tool_chain`, which no client lists — the in-process agent is handed
|
|
216
|
+
// it directly — and which OPENS with `@utcp/code-mode`'s own 2,500-character
|
|
217
|
+
// usage guide, text this repository does not own. It is left as it is, on
|
|
218
|
+
// purpose, and this assertion is what keeps that a stated fact rather than
|
|
219
|
+
// an oversight: if it ever reaches the catalog, the cap test above measures
|
|
220
|
+
// it like everything else.
|
|
221
|
+
const { createCallToolChainTool } = await import('../../code-mode/code-mode.tool.js');
|
|
222
|
+
const mastraTool = createCallToolChainTool(unused(), unused(), EXTERNAL_KB_MANUAL_NAME) as unknown as {
|
|
223
|
+
description: string;
|
|
224
|
+
};
|
|
225
|
+
expect(mastraTool.description).toContain('UTCP CodeMode Tool Usage Guide');
|
|
226
|
+
expect(mastraTool.description.length).toBeGreaterThan(TOOL_DESCRIPTION_CAP);
|
|
227
|
+
expect((await hexisTools()).some((t) => t.description === mastraTool.description)).toBe(false);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe('every file tool ends with the pointer and carries no shared paragraph', () => {
|
|
232
|
+
/** The tools that used to carry the shared paragraphs — every `mount`ed one. */
|
|
233
|
+
const FILE_TOOLS = [
|
|
234
|
+
'read_file',
|
|
235
|
+
'list_files',
|
|
236
|
+
'file_stat',
|
|
237
|
+
'grep',
|
|
238
|
+
'write_file',
|
|
239
|
+
'write_files',
|
|
240
|
+
'edit_file',
|
|
241
|
+
'delete_file',
|
|
242
|
+
'delete_folder',
|
|
243
|
+
'mkdir',
|
|
244
|
+
'move_file',
|
|
245
|
+
'copy_file',
|
|
246
|
+
'unzip',
|
|
247
|
+
'execute_command',
|
|
248
|
+
'apply_file_upload',
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
it('ends each description with the one sentence naming the shared rules', async () => {
|
|
252
|
+
const tools = await hexisTools();
|
|
253
|
+
const pointer = sharedRulesPointer(testKbContext().layout);
|
|
254
|
+
for (const name of FILE_TOOLS) {
|
|
255
|
+
const def = tools.find((t) => t.name === name);
|
|
256
|
+
expect(def, name).toBeDefined();
|
|
257
|
+
expect(def!.description!.endsWith(pointer), `${name} must end with: ${pointer}`).toBe(true);
|
|
258
|
+
// Ends with a full sentence, so nothing reads as cut off mid-thought.
|
|
259
|
+
expect(def!.description!.trimEnd().endsWith('.'), name).toBe(true);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('no longer repeats a shared paragraph inside a description', async () => {
|
|
264
|
+
const tools = await hexisTools();
|
|
265
|
+
// One recognisable fragment per rule that moved out. Searched across EVERY
|
|
266
|
+
// Hexis description, not only the file tools: a rule that came back by
|
|
267
|
+
// being pasted into a neighbouring tool is the same regression.
|
|
268
|
+
const moved = [
|
|
269
|
+
'Content rule (the same on every file tool)',
|
|
270
|
+
'they refuse documents, images, archives and other binary files',
|
|
271
|
+
'`mode` decides what may happen at a path and DEFAULTS TO',
|
|
272
|
+
'Images: keep them in an `assets/` folder',
|
|
273
|
+
'Escape sequences: some clients decode them in arguments',
|
|
274
|
+
'If this is refused for permissions',
|
|
275
|
+
'Before your first read or change in a workspace',
|
|
276
|
+
'Do NOT set `confirm: true` on your first call',
|
|
277
|
+
];
|
|
278
|
+
for (const fragment of moved) {
|
|
279
|
+
const carriers = tools.filter((t) => (t.description ?? '').includes(fragment)).map((t) => t.name);
|
|
280
|
+
expect(carriers, `"${fragment}" is a shared rule and belongs in the shared places only`).toEqual([]);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe('the shared rules describe the tools they name', () => {
|
|
286
|
+
it('ends the chain description with the pointer too, under the cap', async () => {
|
|
287
|
+
// What a chain does with a failure, a large result or an image is true of
|
|
288
|
+
// every call, so it is stated in the shared rules — and the clients that
|
|
289
|
+
// drop the handshake `instructions` see only descriptions, so the chain
|
|
290
|
+
// gets the same pointer every file tool ends with. Composed at the mount,
|
|
291
|
+
// because `mcp-core` may not spell a guide name that is a deployment
|
|
292
|
+
// setting.
|
|
293
|
+
const pointer = sharedRulesPointer(testKbContext().layout);
|
|
294
|
+
const served = (await hexisTools()).find((t) => t.name === CALL_TOOL_CHAIN_NAME)!;
|
|
295
|
+
expect(served.description!.endsWith(pointer)).toBe(true);
|
|
296
|
+
expect(clientVisibleLength(served)).toBeLessThanOrEqual(TOOL_DESCRIPTION_CAP);
|
|
297
|
+
// The other two describe the registry, not what a call does: no pointer.
|
|
298
|
+
for (const tool of servedMetaTools([]).filter((t) => t.name !== CALL_TOOL_CHAIN_NAME)) {
|
|
299
|
+
expect(tool.description).not.toContain(pointer);
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('states what a chain does in the shared rules, and not a second time on the chain', async () => {
|
|
304
|
+
// The pointer is only honest if the rules it points at are THERE. These
|
|
305
|
+
// two were paragraphs of the chain's description; they moved, whole, and a
|
|
306
|
+
// description that kept them as well would be the long one a client cuts.
|
|
307
|
+
const rule = sharedFileRules(testKbContext().layout).find((r) => r.id === 'tool-chain')!;
|
|
308
|
+
expect(rule.body).toContain(CHAIN_FAILURES_RULE);
|
|
309
|
+
expect(rule.body).toContain(CHAIN_LARGE_RESULTS_RULE);
|
|
310
|
+
const served = (await hexisTools()).find((t) => t.name === CALL_TOOL_CHAIN_NAME)!;
|
|
311
|
+
expect(served.description).not.toContain(CHAIN_FAILURES_RULE);
|
|
312
|
+
expect(served.description).not.toContain(CHAIN_LARGE_RESULTS_RULE);
|
|
313
|
+
// What a chained read does to an image was already one of the shared rules.
|
|
314
|
+
const content = sharedFileRules(testKbContext().layout).find((r) => r.id === 'content-kinds')!;
|
|
315
|
+
expect(content.body).toContain('image_omitted');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('keeps the rules on the chain itself where no shared rules are served', () => {
|
|
319
|
+
// The standalone bridge proxies a deployment whose guide it cannot name, so
|
|
320
|
+
// it passes no pointer — and an agent there must still be told what a chain
|
|
321
|
+
// that timed out, or answered too much, or read an image, does.
|
|
322
|
+
const [chain] = codeModeMetaTools('hexis', []).filter((t) => t.name === CALL_TOOL_CHAIN_NAME);
|
|
323
|
+
expect(chain.description).toContain(CHAIN_FAILURES_RULE);
|
|
324
|
+
expect(chain.description).toContain(CHAIN_LARGE_RESULTS_RULE);
|
|
325
|
+
expect(chain.description).toContain('image_omitted');
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('names a dry run only on the tools that take one', async () => {
|
|
329
|
+
// A rule is worse than no rule when it promises an argument the tool
|
|
330
|
+
// rejects: `delete_file` has no `dryRun`, so an agent told to preflight a
|
|
331
|
+
// single-file delete gets a validation error on the safe call and learns to
|
|
332
|
+
// skip it. Read off the schemas rather than asserted by hand.
|
|
333
|
+
const rule = sharedFileRules(testKbContext().layout).find((r) => r.id === 'dry-run-confirm')!;
|
|
334
|
+
const tools = await hexisTools();
|
|
335
|
+
// `toolDef` wraps a tool's own inputs under `body`, which is the schema a
|
|
336
|
+
// client validates against — so that is where the argument either is or is not.
|
|
337
|
+
const takesDryRun = (name: string): boolean => {
|
|
338
|
+
const inputs = tools.find((t) => t.name === name)?.inputs as
|
|
339
|
+
| { properties?: { body?: { properties?: Record<string, unknown> } } }
|
|
340
|
+
| undefined;
|
|
341
|
+
return inputs?.properties?.body?.properties?.dryRun !== undefined;
|
|
342
|
+
};
|
|
343
|
+
expect(takesDryRun('move_file')).toBe(true);
|
|
344
|
+
expect(takesDryRun('delete_folder')).toBe(true);
|
|
345
|
+
expect(takesDryRun('delete_file')).toBe(false);
|
|
346
|
+
// Every tool that takes one, read off the catalog rather than listed here —
|
|
347
|
+
// a hand-written list is what let `copy_file` gain a `dryRun` on dev while
|
|
348
|
+
// the rule still named two tools, so an agent reading the guide was told
|
|
349
|
+
// the copy had no preflight it could run.
|
|
350
|
+
const withDryRun = tools.filter((t) => takesDryRun(t.name)).map((t) => t.name);
|
|
351
|
+
expect(withDryRun.length, 'no tool takes a dryRun — the rule would be vacuous').toBeGreaterThan(1);
|
|
352
|
+
for (const name of withDryRun) {
|
|
353
|
+
expect(rule.body, name).toContain(name);
|
|
354
|
+
}
|
|
355
|
+
// Named, but as the tool that has none — never as one that takes one.
|
|
356
|
+
expect(rule.body).toContain('delete_file takes neither');
|
|
357
|
+
expect(rule.body).not.toContain('move_file, delete_file and delete_folder take');
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('gives each platform file the depth it actually counts at', () => {
|
|
361
|
+
const rule = sharedFileRules(testKbContext().layout).find((r) => r.id === 'managed-items')!;
|
|
362
|
+
const { anyDepth, rootOnly } = platformFilesByDepth(testKbContext().layout);
|
|
363
|
+
// The split is the half of the rule a list of names leaves out, and
|
|
364
|
+
// `isPlatformFile` is the predicate the prose has to match.
|
|
365
|
+
expect(rule.body).toContain(`${anyDepth.map((n) => `\`${n}\``).join(' or ')} in any folder`);
|
|
366
|
+
expect(rule.body).toContain(`${rootOnly.map((n) => `\`${n}\``).join(' or ')} at the repository root`);
|
|
367
|
+
for (const name of anyDepth) expect(isPlatformFile(`Deep/Folder/${name}`, testKbContext().layout), name).toBe(true);
|
|
368
|
+
for (const name of rootOnly) expect(isPlatformFile(`Deep/Folder/${name}`, testKbContext().layout), name).toBe(false);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('says what unzip extracts, rather than that it takes any bytes', () => {
|
|
372
|
+
const rule = sharedFileRules(testKbContext().layout).find((r) => r.id === 'content-kinds')!;
|
|
373
|
+
// `unzip` refuses anything but a `.zip` (`workspace.service.ts`: "Only .zip
|
|
374
|
+
// files can be extracted"), so the byte-tool clause must not sweep it in.
|
|
375
|
+
expect(rule.body).toContain('unzip extracts the entries of a `.zip`');
|
|
376
|
+
expect(rule.body).not.toContain('and unzip act on bytes of any kind');
|
|
377
|
+
});
|
|
378
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long a tool description may be, and how to measure one.
|
|
3
|
+
*
|
|
4
|
+
* Clients cut a long description, and they cut it from the END — which is
|
|
5
|
+
* where the text specific to the tool sits, after whatever shared preamble it
|
|
6
|
+
* carried. Agents reported `file_stat`, `read_file`, `write_file` and
|
|
7
|
+
* `write_files` arriving ending in "[truncated]". The rules those descriptions
|
|
8
|
+
* shared now live in one place (see `agent-instructions/shared-file-rules.ts`)
|
|
9
|
+
* and each description ends with one sentence pointing there, which is what
|
|
10
|
+
* makes the cap below reachable rather than aspirational.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { TOOL_PREFIX_CAP } from '@bevel-software/platform-shared';
|
|
14
|
+
import { PREFIXED_TOOLS } from '../agent-instructions/compose.js';
|
|
15
|
+
import { SHARED_RULES_POINTER_MAX, sharedRulesPointer } from '../agent-instructions/shared-file-rules.js';
|
|
16
|
+
import type { UtcpTool } from './tool.contract.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The ceiling on what a client is handed for one tool, enforced by
|
|
20
|
+
* `__tests__/tool-description-length.test.ts`.
|
|
21
|
+
*
|
|
22
|
+
* DERIVED, not published: the clients that cut descriptions do not say where.
|
|
23
|
+
* Two cuts were observed, and they are different problems. claude.ai cuts
|
|
24
|
+
* around 500 characters — NOT what this cap answers, and not something a cap
|
|
25
|
+
* could answer: no useful description of `move_file` fits in 500. What answers
|
|
26
|
+
* that one is the ORDER of the text, which is why the deployment's purpose line
|
|
27
|
+
* is prepended rather than appended (see `prefixToolDescription`) and why every
|
|
28
|
+
* description now leads with what the tool does and ends with the pointer: a
|
|
29
|
+
* cut at 500 then takes the pointer and leaves the tool. The other cut is the
|
|
30
|
+
* four-figure one agents reported on `file_stat`, `read_file`, `write_file` and
|
|
31
|
+
* `write_files`, and 1,200 sits below it with room to spare.
|
|
32
|
+
*
|
|
33
|
+
* So the cap is a ceiling on growth rather than a guarantee of survival, and it
|
|
34
|
+
* is a number the reviewer may move: the point of pinning it is that moving it
|
|
35
|
+
* is a decision someone takes, rather than a paragraph someone appends.
|
|
36
|
+
*/
|
|
37
|
+
export const TOOL_DESCRIPTION_CAP = 1_200;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The OTHER cut — the ~500 characters claude.ai allows — as a number the tests
|
|
41
|
+
* can hold something to.
|
|
42
|
+
*
|
|
43
|
+
* It is deliberately not the value of {@link TOOL_DESCRIPTION_CAP}, and the
|
|
44
|
+
* difference is the whole point: lowering the cap to 500 would not make these
|
|
45
|
+
* descriptions survive that client, it would only move the loss from the client
|
|
46
|
+
* to the source, because no useful description of `move_file` or `file_stat`
|
|
47
|
+
* fits in 500 characters and shortening them to fit means dropping facts an
|
|
48
|
+
* agent needs.
|
|
49
|
+
*
|
|
50
|
+
* What survives a cut here is decided by ORDER instead, and order is testable:
|
|
51
|
+
* the purpose prefix goes first, the tool's own opening sentence next, the
|
|
52
|
+
* pointer to the shared rules last. So a cut at 500 takes the pointer — which
|
|
53
|
+
* costs the agent the name of a file the handshake instructions and the guide
|
|
54
|
+
* both state anyway — and leaves the sentence saying what the tool does.
|
|
55
|
+
* {@link firstSentenceEnd} measures where that sentence ends, and the suite
|
|
56
|
+
* pins it under this number for every tool, so the claim above is a check
|
|
57
|
+
* rather than a comment.
|
|
58
|
+
*/
|
|
59
|
+
export const CLIENT_SHORT_CUT = 500;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Where the tool's OWN opening sentence ends in the text a client is handed:
|
|
63
|
+
* the purpose prefix counted at its cap, as in {@link clientVisibleLength}, and
|
|
64
|
+
* the pointer not counted at all, since it is the part a short cut is meant to
|
|
65
|
+
* take.
|
|
66
|
+
*
|
|
67
|
+
* A description with no sentence-ending punctuation counts whole — the honest
|
|
68
|
+
* answer for text that never finishes a sentence.
|
|
69
|
+
*/
|
|
70
|
+
export function firstSentenceEnd(tool: Pick<UtcpTool, 'name' | 'description'>): number {
|
|
71
|
+
const prefix = PREFIXED_TOOLS.has(tool.name) ? TOOL_PREFIX_CAP + 2 : 0;
|
|
72
|
+
const description = tool.description ?? '';
|
|
73
|
+
if (description === '') return prefix;
|
|
74
|
+
const pointer = sharedRulesPointer();
|
|
75
|
+
const own = description.endsWith(pointer) ? description.slice(0, -pointer.length) : description;
|
|
76
|
+
const firstSentence = own.match(/^[\s\S]*?[.!?](?=\s|$)/)?.[0] ?? own;
|
|
77
|
+
return prefix + firstSentence.length;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The length of the description as a CLIENT receives it — which for the four
|
|
82
|
+
* knowledge-base tools includes the deployment's purpose prefix, since the MCP
|
|
83
|
+
* surface prepends it (`prefixToolDescription`) and the client cuts the result.
|
|
84
|
+
* Measured at the prefix's CAP rather than at whatever the current admin wrote:
|
|
85
|
+
* the cap is what an admin may grow their text to without being told, so a
|
|
86
|
+
* description that only fits beside a short prefix does not really fit.
|
|
87
|
+
*
|
|
88
|
+
* The pointer sentence is measured the same way, for the same reason. It ends
|
|
89
|
+
* every file tool's description and its length moves with a DEPLOYMENT SETTING
|
|
90
|
+
* — the guide's file name — so a description measured beside the nine
|
|
91
|
+
* characters of `AGENTS.md` would pass here and arrive cut on a deployment
|
|
92
|
+
* that renamed its guide. Whatever pointer a description actually carries is
|
|
93
|
+
* discounted and charged at {@link SHARED_RULES_POINTER_MAX} instead.
|
|
94
|
+
*/
|
|
95
|
+
export function clientVisibleLength(tool: Pick<UtcpTool, 'name' | 'description'>): number {
|
|
96
|
+
const own = tool.description?.length ?? 0;
|
|
97
|
+
// A prefixed tool with no description of its own is still handed the prefix,
|
|
98
|
+
// and nothing else — `prefixToolDescription` sends the prefix alone, with no
|
|
99
|
+
// blank line after it. Measuring that as zero would under-report the only
|
|
100
|
+
// text the client got.
|
|
101
|
+
if (own === 0) return PREFIXED_TOOLS.has(tool.name) ? TOOL_PREFIX_CAP : 0;
|
|
102
|
+
// The pointer at its worst case rather than at this layout's: swap the one
|
|
103
|
+
// it carries for the longest it could be. A description that does not end
|
|
104
|
+
// with it (`start_session`, the proxied tools) is charged nothing.
|
|
105
|
+
const pointer = sharedRulesPointer();
|
|
106
|
+
const atWorstPointer = tool.description!.endsWith(pointer)
|
|
107
|
+
? own - pointer.length + SHARED_RULES_POINTER_MAX
|
|
108
|
+
: own;
|
|
109
|
+
// `+ 2` for the blank line `prefixToolDescription` puts between the two.
|
|
110
|
+
return PREFIXED_TOOLS.has(tool.name) ? atWorstPointer + TOOL_PREFIX_CAP + 2 : atWorstPointer;
|
|
111
|
+
}
|