@hydraharness/harness-tool-skill 0.0.0-stage → 0.1.1-rc.6
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/LICENSE +21 -0
- package/README.md +115 -2
- package/lib/index.js +439 -0
- package/lib/invariant.js +23 -0
- package/lib/types/index.d.ts +25 -0
- package/lib/types/invariant.d.ts +16 -0
- package/package.json +58 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeepSeek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,116 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @hydraharness/harness-tool-skill
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Bounded automatic skill routing, model-facing discovery, exact instruction loading, and deterministic user invocation.
|
|
4
|
+
|
|
5
|
+
Requires `ctx.agents`, `ctx.tools`, and `ctx.skills` (`inject: ['agents', 'tools', 'skills']`). The plugin does not inject a session-wide skill catalog. It may inject one strongly matched skill body for a direct user task.
|
|
6
|
+
|
|
7
|
+
## Tool: `skill_search`
|
|
8
|
+
|
|
9
|
+
| Arg | Type | Notes |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `query` | string (required) | Concise task keywords. Greetings, thanks, acknowledgements, casual chat, meta questions, and vague requests should not be searched. |
|
|
12
|
+
|
|
13
|
+
The tool snapshots the calling agent's cwd-sensitive registry view, keeps only model-invocable summaries, and ranks lexical matches across `name`, `description`, and `whenToUse`. Exact whole-name phrases rank first, then matched terms and metadata fields. Equal scores prefer an alias, then lexical name order. Each canonical definition contributes only its best-ranked name before count and byte limits apply. It does not call `ctx.skills.get()` and therefore never loads instruction bodies while searching.
|
|
14
|
+
|
|
15
|
+
One result returns at most `searchMaxResults` candidates (default `5`), caps each description or routing hint at `searchDescriptionMaxLength` characters (default `500`), and caps the complete rendered UTF-8 result at `searchMaxResultBytes` bytes (default `8192`). All limits are positive integers; the description limit has minimum `3`, and the byte limit must fit the fixed empty-result framing. `truncated: true` says matching candidates were omitted by a count or byte bound. `complete: false` says provider discovery was unstable or partially unavailable, so an empty result is not authoritative.
|
|
16
|
+
|
|
17
|
+
The rendered result is:
|
|
18
|
+
|
|
19
|
+
```markdown
|
|
20
|
+
<skill_candidates complete="true" truncated="false">
|
|
21
|
+
- `<name>`: <normalized-and-capped-description>
|
|
22
|
+
Use when: <normalized-and-capped-routing-hint>
|
|
23
|
+
</skill_candidates>
|
|
24
|
+
Choose zero or one candidate. Call `skill` only for the best match; load another only when the task clearly requires an independent skill.
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
An empty complete search renders `(none)` and means no skill should be loaded. This two-step contract keeps model-visible discovery cost bounded even when the registry contains many skills.
|
|
28
|
+
|
|
29
|
+
## Automatic invocation
|
|
30
|
+
|
|
31
|
+
Before an accepted step reaches the model, direct-user text is ranked with the same lexical metadata scores as `skill_search`. The host loads exactly one skill only when the registry snapshot is complete and the best model-invocable candidate is unambiguous before lexical name tie-breaking. An exact whole name is strong only when the skill name has multiple terms. Other matches require at least two distinct name terms including the leading name term. Greetings, generic test requests, description-only matches, ties, and non-user text load nothing; `skill_search` remains the fallback.
|
|
32
|
+
|
|
33
|
+
An explicit `/name` gesture suppresses automatic routing for that step. Automatic discovery and loading fail open on stale, invalid, incomplete, or failing providers, while cancellation still stops the step. A successful injection uses durable `skill-invocation` source metadata with `trigger: 'automatic'`; direct gestures use `trigger: 'user'`.
|
|
34
|
+
|
|
35
|
+
English and Vietnamese negation or avoidance cues, including `not`, `don't`, `without`, `avoid`, `không`, and `đừng`, suppress automatic loading for the whole request. This conservative check leaves selection to the model-facing tools even if the negation concerns another part of the task. Explicit slash gestures retain their direct invocation behavior.
|
|
36
|
+
|
|
37
|
+
## Tool: `skill`
|
|
38
|
+
|
|
39
|
+
| Arg | Type | Notes |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| `name` | string (required) | Exact kebab-case name returned by `skill_search` or explicitly named by the user. |
|
|
42
|
+
|
|
43
|
+
Execution uses the calling agent's `session.header.cwd`, validates the name, rejects non-model-invocable skills before loading, then rechecks policy on the loaded definition. A successful call returns canonical `{ name, provider, resourceBase?, content }`; its Native renderer produces one text result containing `<skill_content name="...">`, `<skill_resources>`, and `<skill_instructions>`.
|
|
44
|
+
|
|
45
|
+
Resource guidance resolves only paths or URLs explicitly referenced by the instructions against `resourceBase`. Scripts, references, and assets load on demand; the result does not enumerate a skill directory. An unresolved name reports that the skill is unknown or no longer available. Invalid names and model-disabled skills have distinct errors.
|
|
46
|
+
|
|
47
|
+
## User-explicit invocation
|
|
48
|
+
|
|
49
|
+
A whitespace-bounded `/name` token in a claimed direct-user message deterministically loads a user-invocable skill and appends its full `<skill_content>` rendering after the other injections for that step. Tokens from non-user sources cannot forge the gesture; unknown or user-disabled names stay ordinary prose. This is the model-independent path for `disable-model-invocation` skills, takes precedence over automatic routing, and does not call `skill_search` or `skill`.
|
|
50
|
+
|
|
51
|
+
## Model Experience
|
|
52
|
+
|
|
53
|
+
### Tool schemas
|
|
54
|
+
|
|
55
|
+
#### What the model sees
|
|
56
|
+
|
|
57
|
+
The model sees the generated [`skill_search` and `skill` schemas](../../../docs/tool-catalog.md#hydraharness-tool-skill). No data-dependent skill roster is added to the request prefix.
|
|
58
|
+
|
|
59
|
+
#### Token effect
|
|
60
|
+
|
|
61
|
+
Fixed schema cost on each request where the tools are visible.
|
|
62
|
+
|
|
63
|
+
#### KV Cache effect
|
|
64
|
+
|
|
65
|
+
Prefix-stable while definitions and visibility remain unchanged.
|
|
66
|
+
|
|
67
|
+
### Search result
|
|
68
|
+
|
|
69
|
+
#### What the model sees
|
|
70
|
+
|
|
71
|
+
A search adds only the bounded candidate result above.
|
|
72
|
+
|
|
73
|
+
#### Token effect
|
|
74
|
+
|
|
75
|
+
At most the configured rendered UTF-8 byte limit, retained in later requests until compaction.
|
|
76
|
+
|
|
77
|
+
#### KV Cache effect
|
|
78
|
+
|
|
79
|
+
Append-only after the reusable request prefix.
|
|
80
|
+
|
|
81
|
+
### Loaded result
|
|
82
|
+
|
|
83
|
+
#### What the model sees
|
|
84
|
+
|
|
85
|
+
The selected provider's `<skill_content>`, resource guidance, and complete instructions. The tool does not add a duplicate injected copy.
|
|
86
|
+
|
|
87
|
+
#### Token effect
|
|
88
|
+
|
|
89
|
+
Data-dependent body tokens are resent on later steps until compaction.
|
|
90
|
+
|
|
91
|
+
#### KV Cache effect
|
|
92
|
+
|
|
93
|
+
Append-only after the reusable request prefix.
|
|
94
|
+
|
|
95
|
+
### Tool errors
|
|
96
|
+
|
|
97
|
+
#### What the model sees
|
|
98
|
+
|
|
99
|
+
Invalid or stale selections return `Error: invalid skill name "<name>"`, `Error: skill "<name>" is unknown or no longer available`, or `Error: skill "<name>" is not available for model invocation`. Provider lookup failures use the same `Error: <message>` wrapper.
|
|
100
|
+
|
|
101
|
+
#### Token effect
|
|
102
|
+
|
|
103
|
+
Only a failing tool call adds the short retained error text.
|
|
104
|
+
|
|
105
|
+
#### KV Cache effect
|
|
106
|
+
|
|
107
|
+
Append-only after the reusable request prefix.
|
|
108
|
+
|
|
109
|
+
## Known Limitations and Deferred Work
|
|
110
|
+
|
|
111
|
+
- Automatic routing and search use lexical metadata matching, not semantic retrieval. Add a semantic index only after measured routing misses justify its dependency and operational cost.
|
|
112
|
+
- The automatic-routing veto recognizes common English/Vietnamese cues; it is not a general intent classifier and does not cover every language or phrasing.
|
|
113
|
+
- Loaded instruction bodies have no size cap; a provider can return a body that consumes substantial next-step context.
|
|
114
|
+
- Resources are guidance, not attachments; the tools neither enumerate nor fetch referenced files.
|
|
115
|
+
- Loading is one-shot text; there is no partial, streaming, or cached-content handle.
|
|
116
|
+
- Body-only edits do not notify the model. A later exact load reads current content while earlier tool results remain historical facts.
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import z from "@hydraharness/schemastery";
|
|
3
|
+
import { defineTool } from "@hydraharness/harness-tools";
|
|
4
|
+
import { createUserMessage } from "@hydraharness/harness-llm";
|
|
5
|
+
import { escapeText, isModelInvocable, isSkillName, isUserInvocable, renderSkillContent } from "@hydraharness/harness-skill";
|
|
6
|
+
//#region lib/types/index.js
|
|
7
|
+
/**
|
|
8
|
+
* Bounded skill routing, model-facing search, and exact loading.
|
|
9
|
+
*
|
|
10
|
+
* @module @hydraharness/harness-tool-skill
|
|
11
|
+
*/
|
|
12
|
+
const name = "tool-skill";
|
|
13
|
+
const inject = [
|
|
14
|
+
"agents",
|
|
15
|
+
"tools",
|
|
16
|
+
"skills"
|
|
17
|
+
];
|
|
18
|
+
const DEFAULT_SEARCH_MAX_RESULTS = 5;
|
|
19
|
+
const DEFAULT_SEARCH_DESCRIPTION_MAX_LENGTH = 500;
|
|
20
|
+
const DEFAULT_SEARCH_MAX_RESULT_BYTES = 8192;
|
|
21
|
+
/** Validate and default the model-facing skill search configuration. */
|
|
22
|
+
const Config = z.object({
|
|
23
|
+
searchMaxResults: z.number().default(DEFAULT_SEARCH_MAX_RESULTS),
|
|
24
|
+
searchDescriptionMaxLength: z.number().default(DEFAULT_SEARCH_DESCRIPTION_MAX_LENGTH),
|
|
25
|
+
searchMaxResultBytes: z.number().default(DEFAULT_SEARCH_MAX_RESULT_BYTES)
|
|
26
|
+
});
|
|
27
|
+
/**
|
|
28
|
+
* Register bounded automatic routing, model-facing search, exact loading, and direct user invocation.
|
|
29
|
+
*/
|
|
30
|
+
function apply(ctx, config = {}) {
|
|
31
|
+
const searchMaxResults = config.searchMaxResults ?? DEFAULT_SEARCH_MAX_RESULTS;
|
|
32
|
+
const searchDescriptionMaxLength = config.searchDescriptionMaxLength ?? DEFAULT_SEARCH_DESCRIPTION_MAX_LENGTH;
|
|
33
|
+
const searchMaxResultBytes = config.searchMaxResultBytes ?? DEFAULT_SEARCH_MAX_RESULT_BYTES;
|
|
34
|
+
assertPositiveInteger("searchMaxResults", searchMaxResults);
|
|
35
|
+
assertPositiveInteger("searchDescriptionMaxLength", searchDescriptionMaxLength, 3);
|
|
36
|
+
assertPositiveInteger("searchMaxResultBytes", searchMaxResultBytes, minimumSearchResultBytes());
|
|
37
|
+
const skillTool = defineTool({
|
|
38
|
+
name: "skill",
|
|
39
|
+
description: "Load the full instructions for exactly one skill. Use only an exact name returned by `skill_search` for the current task or explicitly named by the user; do not guess names or reload an inline <skill_content> block.",
|
|
40
|
+
parameters: { name: {
|
|
41
|
+
type: "string",
|
|
42
|
+
required: true,
|
|
43
|
+
description: "The exact skill name returned by `skill_search` or explicitly named by the user."
|
|
44
|
+
} },
|
|
45
|
+
output: {
|
|
46
|
+
schema: {
|
|
47
|
+
type: "object",
|
|
48
|
+
additionalProperties: false,
|
|
49
|
+
properties: {
|
|
50
|
+
name: {
|
|
51
|
+
type: "string",
|
|
52
|
+
required: true
|
|
53
|
+
},
|
|
54
|
+
provider: {
|
|
55
|
+
type: "string",
|
|
56
|
+
required: true
|
|
57
|
+
},
|
|
58
|
+
resourceBase: { oneOf: [
|
|
59
|
+
{
|
|
60
|
+
type: "object",
|
|
61
|
+
additionalProperties: false,
|
|
62
|
+
properties: {
|
|
63
|
+
kind: {
|
|
64
|
+
type: "string",
|
|
65
|
+
required: true,
|
|
66
|
+
const: "directory"
|
|
67
|
+
},
|
|
68
|
+
path: {
|
|
69
|
+
type: "string",
|
|
70
|
+
required: true
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
type: "object",
|
|
76
|
+
additionalProperties: false,
|
|
77
|
+
properties: {
|
|
78
|
+
kind: {
|
|
79
|
+
type: "string",
|
|
80
|
+
required: true,
|
|
81
|
+
const: "url"
|
|
82
|
+
},
|
|
83
|
+
url: {
|
|
84
|
+
type: "string",
|
|
85
|
+
required: true
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
type: "object",
|
|
91
|
+
additionalProperties: false,
|
|
92
|
+
properties: {
|
|
93
|
+
kind: {
|
|
94
|
+
type: "string",
|
|
95
|
+
required: true,
|
|
96
|
+
const: "opaque"
|
|
97
|
+
},
|
|
98
|
+
description: {
|
|
99
|
+
type: "string",
|
|
100
|
+
required: true
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
] },
|
|
105
|
+
content: {
|
|
106
|
+
type: "string",
|
|
107
|
+
required: true
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
render: (_args, value) => [{
|
|
112
|
+
type: "text",
|
|
113
|
+
text: renderSkillContent(value)
|
|
114
|
+
}]
|
|
115
|
+
},
|
|
116
|
+
async execute(args, exec) {
|
|
117
|
+
if (!isSkillName(args.name)) throw new Error(`invalid skill name "${args.name}"`);
|
|
118
|
+
const lookup = {
|
|
119
|
+
cwd: exec.agent?.session.header.cwd,
|
|
120
|
+
signal: exec.signal,
|
|
121
|
+
scope: exec.agent
|
|
122
|
+
};
|
|
123
|
+
const summary = (await ctx.skills.list(lookup)).find((skill) => skill.name === args.name);
|
|
124
|
+
if (!summary) throw new Error(`skill "${args.name}" is unknown or no longer available`);
|
|
125
|
+
if (!isModelInvocable(summary)) throw new Error(`skill "${args.name}" is not available for model invocation`);
|
|
126
|
+
const skill = await ctx.skills.get(args.name, lookup);
|
|
127
|
+
if (!skill) throw new Error(`skill "${args.name}" is unknown or no longer available`);
|
|
128
|
+
if (!isModelInvocable(skill)) throw new Error(`skill "${args.name}" is not available for model invocation`);
|
|
129
|
+
return {
|
|
130
|
+
name: skill.name,
|
|
131
|
+
provider: skill.provider,
|
|
132
|
+
...skill.resourceBase !== void 0 ? { resourceBase: { ...skill.resourceBase } } : {},
|
|
133
|
+
content: skill.content
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
presentCall(args) {
|
|
137
|
+
return {
|
|
138
|
+
card: "generic",
|
|
139
|
+
title: `Load skill ${args.name}`,
|
|
140
|
+
kind: "read",
|
|
141
|
+
rawInput: args.name
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
ctx.tools.register(skillTool);
|
|
146
|
+
const skillSearchTool = defineTool({
|
|
147
|
+
name: "skill_search",
|
|
148
|
+
description: "Find a bounded shortlist of skills for a substantive user task before loading one. Search with concise task keywords; do not call this for greetings, thanks, acknowledgements, casual chat, meta questions, or vague requests. An empty result means load no skill.",
|
|
149
|
+
parameters: { query: {
|
|
150
|
+
type: "string",
|
|
151
|
+
required: true,
|
|
152
|
+
description: "Concise keywords describing the user task, not a greeting or conversational filler."
|
|
153
|
+
} },
|
|
154
|
+
output: {
|
|
155
|
+
schema: {
|
|
156
|
+
type: "object",
|
|
157
|
+
additionalProperties: false,
|
|
158
|
+
properties: {
|
|
159
|
+
complete: {
|
|
160
|
+
type: "boolean",
|
|
161
|
+
required: true
|
|
162
|
+
},
|
|
163
|
+
truncated: {
|
|
164
|
+
type: "boolean",
|
|
165
|
+
required: true
|
|
166
|
+
},
|
|
167
|
+
matches: {
|
|
168
|
+
type: "array",
|
|
169
|
+
required: true,
|
|
170
|
+
items: {
|
|
171
|
+
type: "object",
|
|
172
|
+
additionalProperties: false,
|
|
173
|
+
properties: {
|
|
174
|
+
name: {
|
|
175
|
+
type: "string",
|
|
176
|
+
required: true
|
|
177
|
+
},
|
|
178
|
+
description: {
|
|
179
|
+
type: "string",
|
|
180
|
+
required: true
|
|
181
|
+
},
|
|
182
|
+
whenToUse: { type: "string" }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
render: (_args, value) => [{
|
|
189
|
+
type: "text",
|
|
190
|
+
text: renderSkillSearchResult(value)
|
|
191
|
+
}]
|
|
192
|
+
},
|
|
193
|
+
async execute(args, exec) {
|
|
194
|
+
const snapshot = await ctx.skills.snapshot({
|
|
195
|
+
cwd: exec.agent?.session.header.cwd,
|
|
196
|
+
signal: exec.signal,
|
|
197
|
+
scope: exec.agent
|
|
198
|
+
});
|
|
199
|
+
exec.signal.throwIfAborted();
|
|
200
|
+
return boundedSearchResult(rankSkills(snapshot.skills.filter(isModelInvocable), args.query).map((entry) => entry.skill), snapshot.complete, searchMaxResults, searchDescriptionMaxLength, searchMaxResultBytes);
|
|
201
|
+
},
|
|
202
|
+
presentCall(args) {
|
|
203
|
+
return {
|
|
204
|
+
card: "generic",
|
|
205
|
+
title: "Search skills",
|
|
206
|
+
kind: "read",
|
|
207
|
+
rawInput: args.query
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
ctx.tools.register(skillSearchTool);
|
|
212
|
+
ctx.on("agent/pre-step", async ({ agent, messages, signal }, next) => {
|
|
213
|
+
const decision = await next();
|
|
214
|
+
if (decision.kind === "reject") return decision;
|
|
215
|
+
const names = invokedSkillNames(messages);
|
|
216
|
+
const task = directUserText(messages);
|
|
217
|
+
if (names.length === 0 && task === "") return decision;
|
|
218
|
+
signal.throwIfAborted();
|
|
219
|
+
const lookup = {
|
|
220
|
+
cwd: agent.session.header.cwd,
|
|
221
|
+
signal,
|
|
222
|
+
scope: agent
|
|
223
|
+
};
|
|
224
|
+
const injections = [];
|
|
225
|
+
if (names.length === 0) {
|
|
226
|
+
if (vetoesAutomaticSkill(task)) return decision;
|
|
227
|
+
try {
|
|
228
|
+
const snapshot = await ctx.skills.snapshot(lookup);
|
|
229
|
+
signal.throwIfAborted();
|
|
230
|
+
if (!snapshot.complete) return decision;
|
|
231
|
+
const selected = automaticallySelectedSkill(rankSkills(snapshot.skills.filter(isModelInvocable), task));
|
|
232
|
+
if (selected === void 0) return decision;
|
|
233
|
+
const skill = await ctx.skills.get(selected.name, lookup);
|
|
234
|
+
signal.throwIfAborted();
|
|
235
|
+
if (skill === void 0 || !isModelInvocable(skill)) return decision;
|
|
236
|
+
const source = {
|
|
237
|
+
kind: "skill-invocation",
|
|
238
|
+
name: skill.name,
|
|
239
|
+
trigger: "automatic",
|
|
240
|
+
form: "instructions"
|
|
241
|
+
};
|
|
242
|
+
injections.push(createUserMessage({
|
|
243
|
+
content: [{
|
|
244
|
+
type: "text",
|
|
245
|
+
text: renderSkillContent(skill)
|
|
246
|
+
}],
|
|
247
|
+
source
|
|
248
|
+
}));
|
|
249
|
+
} catch (error) {
|
|
250
|
+
signal.throwIfAborted();
|
|
251
|
+
ctx.logger.warn(`tool-skill: automatic routing failed: ${String(error)}`);
|
|
252
|
+
return decision;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
for (const name of names) {
|
|
256
|
+
const skill = await ctx.skills.get(name, lookup);
|
|
257
|
+
signal.throwIfAborted();
|
|
258
|
+
if (skill === void 0 || !isUserInvocable(skill)) continue;
|
|
259
|
+
const source = {
|
|
260
|
+
kind: "skill-invocation",
|
|
261
|
+
name,
|
|
262
|
+
trigger: "user",
|
|
263
|
+
form: "instructions"
|
|
264
|
+
};
|
|
265
|
+
injections.push(createUserMessage({
|
|
266
|
+
content: [{
|
|
267
|
+
type: "text",
|
|
268
|
+
text: renderSkillContent(skill)
|
|
269
|
+
}],
|
|
270
|
+
source
|
|
271
|
+
}));
|
|
272
|
+
}
|
|
273
|
+
if (injections.length === 0) return decision;
|
|
274
|
+
return {
|
|
275
|
+
kind: "enter",
|
|
276
|
+
messages: [...decision.messages, ...injections]
|
|
277
|
+
};
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
const ROUTING_TERM = /[\p{L}\p{N}]+/gu;
|
|
281
|
+
/**
|
|
282
|
+
* Rank model-invocable skill summaries against task keywords without loading any body.
|
|
283
|
+
* @param skills - candidate summaries visible to the calling agent.
|
|
284
|
+
* @param query - task text or concise model-authored keywords.
|
|
285
|
+
* @returns matching summaries and scores in deterministic relevance order.
|
|
286
|
+
*/
|
|
287
|
+
function rankSkills(skills, query) {
|
|
288
|
+
const queryPhrase = routingPhrase(query);
|
|
289
|
+
const queryTerms = new Set(queryPhrase.split(" ").filter(Boolean));
|
|
290
|
+
if (queryTerms.size === 0) return [];
|
|
291
|
+
const ranked = [];
|
|
292
|
+
for (const skill of skills) {
|
|
293
|
+
const namePhrase = routingPhrase(skill.name);
|
|
294
|
+
const nameTermList = namePhrase.split(" ");
|
|
295
|
+
const nameTerms = new Set(nameTermList);
|
|
296
|
+
const descriptionTerms = routingTerms(skill.description);
|
|
297
|
+
const whenToUseTerms = routingTerms(skill.whenToUse ?? "");
|
|
298
|
+
const matchedTerms = countMatches(queryTerms, new Set([
|
|
299
|
+
...nameTerms,
|
|
300
|
+
...descriptionTerms,
|
|
301
|
+
...whenToUseTerms
|
|
302
|
+
]));
|
|
303
|
+
const exactName = ` ${queryPhrase} `.includes(` ${namePhrase} `);
|
|
304
|
+
if (!exactName && matchedTerms === 0) continue;
|
|
305
|
+
ranked.push({
|
|
306
|
+
skill,
|
|
307
|
+
exactName,
|
|
308
|
+
matchedTerms,
|
|
309
|
+
nameMatches: countMatches(queryTerms, nameTerms),
|
|
310
|
+
nameTermCount: nameTerms.size,
|
|
311
|
+
leadingNameMatch: queryTerms.has(nameTermList[0]),
|
|
312
|
+
whenToUseMatches: countMatches(queryTerms, whenToUseTerms),
|
|
313
|
+
descriptionMatches: countMatches(queryTerms, descriptionTerms)
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
ranked.sort((left, right) => Number(right.exactName) - Number(left.exactName) || right.matchedTerms - left.matchedTerms || right.nameMatches - left.nameMatches || right.whenToUseMatches - left.whenToUseMatches || right.descriptionMatches - left.descriptionMatches || Number(right.skill.aliasFor !== void 0) - Number(left.skill.aliasFor !== void 0) || compareText(left.skill.name, right.skill.name));
|
|
317
|
+
const seen = /* @__PURE__ */ new Set();
|
|
318
|
+
return ranked.filter(({ skill }) => {
|
|
319
|
+
const canonical = skill.aliasFor ?? skill.name;
|
|
320
|
+
if (seen.has(canonical)) return false;
|
|
321
|
+
seen.add(canonical);
|
|
322
|
+
return true;
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
function vetoesAutomaticSkill(task) {
|
|
326
|
+
const text = task.normalize("NFKC");
|
|
327
|
+
return /(?:^|[^\p{L}\p{N}])(?:no|not|never|without|avoid|skip|stop|cannot|\p{L}+n['’]t)(?=$|[^\p{L}\p{N}])/iu.test(text) || /(?:^|[^\p{L}\p{N}])(?:không|đừng|chớ|ngừng|khong|dung)(?=$|[^\p{L}\p{N}])/iu.test(text);
|
|
328
|
+
}
|
|
329
|
+
function automaticallySelectedSkill(ranked) {
|
|
330
|
+
const best = ranked.find(isStrongAutomaticMatch);
|
|
331
|
+
if (best === void 0) return void 0;
|
|
332
|
+
const next = ranked.slice(ranked.indexOf(best) + 1).find(isStrongAutomaticMatch);
|
|
333
|
+
if (next !== void 0 && next.exactName === best.exactName && next.matchedTerms === best.matchedTerms && next.nameMatches === best.nameMatches && next.whenToUseMatches === best.whenToUseMatches && next.descriptionMatches === best.descriptionMatches) return void 0;
|
|
334
|
+
return best.skill;
|
|
335
|
+
}
|
|
336
|
+
function isStrongAutomaticMatch(candidate) {
|
|
337
|
+
return candidate.exactName ? candidate.nameTermCount >= 2 : candidate.matchedTerms >= 2 && candidate.nameMatches >= 2 && candidate.leadingNameMatch;
|
|
338
|
+
}
|
|
339
|
+
function boundedSearchResult(ranked, complete, maxResults, descriptionMaxLength, maxResultBytes) {
|
|
340
|
+
const candidates = ranked.slice(0, maxResults).map((skill) => {
|
|
341
|
+
const whenToUse = skill.whenToUse === void 0 ? void 0 : boundSearchText(skill.whenToUse, descriptionMaxLength);
|
|
342
|
+
return {
|
|
343
|
+
name: skill.name,
|
|
344
|
+
description: boundSearchText(skill.description, descriptionMaxLength),
|
|
345
|
+
...whenToUse === void 0 || whenToUse === "" ? {} : { whenToUse }
|
|
346
|
+
};
|
|
347
|
+
});
|
|
348
|
+
const matches = [];
|
|
349
|
+
for (const candidate of candidates) {
|
|
350
|
+
const nextMatches = [...matches, candidate];
|
|
351
|
+
if (resultBytes({
|
|
352
|
+
complete,
|
|
353
|
+
truncated: nextMatches.length < ranked.length,
|
|
354
|
+
matches: nextMatches
|
|
355
|
+
}) > maxResultBytes) break;
|
|
356
|
+
matches.push(candidate);
|
|
357
|
+
}
|
|
358
|
+
return {
|
|
359
|
+
complete,
|
|
360
|
+
truncated: matches.length < ranked.length,
|
|
361
|
+
matches
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
function renderSkillSearchResult(result) {
|
|
365
|
+
const candidates = result.matches.length === 0 ? ["(none)"] : result.matches.flatMap((match) => [`- \`${match.name}\`: ${escapeText(match.description)}`, ...match.whenToUse === void 0 ? [] : [` Use when: ${escapeText(match.whenToUse)}`]]);
|
|
366
|
+
return [
|
|
367
|
+
`<skill_candidates complete="${result.complete}" truncated="${result.truncated}">`,
|
|
368
|
+
...candidates,
|
|
369
|
+
"</skill_candidates>",
|
|
370
|
+
...result.complete ? [] : ["Discovery was incomplete; an empty result does not prove that no matching skill exists."],
|
|
371
|
+
"Choose zero or one candidate. Call `skill` only for the best match; load another only when the task clearly requires an independent skill."
|
|
372
|
+
].join("\n");
|
|
373
|
+
}
|
|
374
|
+
function minimumSearchResultBytes() {
|
|
375
|
+
return Math.max(...[true, false].flatMap((complete) => [true, false].map((truncated) => resultBytes({
|
|
376
|
+
complete,
|
|
377
|
+
truncated,
|
|
378
|
+
matches: []
|
|
379
|
+
}))));
|
|
380
|
+
}
|
|
381
|
+
function resultBytes(result) {
|
|
382
|
+
return Buffer.byteLength(renderSkillSearchResult(result), "utf8");
|
|
383
|
+
}
|
|
384
|
+
function routingPhrase(value) {
|
|
385
|
+
return (value.normalize("NFKD").replaceAll(/\p{M}/gu, "").toLowerCase().match(ROUTING_TERM) ?? []).join(" ");
|
|
386
|
+
}
|
|
387
|
+
function routingTerms(value) {
|
|
388
|
+
return new Set(routingPhrase(value).split(" ").filter(Boolean));
|
|
389
|
+
}
|
|
390
|
+
function countMatches(left, right) {
|
|
391
|
+
let count = 0;
|
|
392
|
+
for (const value of left) if (right.has(value)) count += 1;
|
|
393
|
+
return count;
|
|
394
|
+
}
|
|
395
|
+
function directUserText(messages) {
|
|
396
|
+
return messages.flatMap((message) => message.source.kind === "user" ? message.content.flatMap((block) => block.type === "text" ? [block.text] : []) : []).join("\n").trim();
|
|
397
|
+
}
|
|
398
|
+
function compareText(left, right) {
|
|
399
|
+
return Number(left > right) - Number(left < right);
|
|
400
|
+
}
|
|
401
|
+
/** Normalize and length-bound one model-visible summary field. */
|
|
402
|
+
function boundSearchText(value, maxLength) {
|
|
403
|
+
const normalized = value.replaceAll(/\s+/g, " ").trim();
|
|
404
|
+
return normalized.length <= maxLength ? normalized : `${normalized.slice(0, maxLength - 3)}...`;
|
|
405
|
+
}
|
|
406
|
+
function assertPositiveInteger(name, value, minimum = 1) {
|
|
407
|
+
if (!Number.isInteger(value) || value < minimum) throw new Error(`tool-skill: ${name} must be an integer greater than or equal to ${minimum}`);
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* A whitespace-bounded `/name` token (the public skill-name grammar) anywhere
|
|
411
|
+
* in the text — the same word-boundary shape the transcript chip decoration
|
|
412
|
+
* uses, so a gesture reads as one wherever it sits in the sentence. A second
|
|
413
|
+
* `/` or any non-boundary character breaks the match, which keeps file paths
|
|
414
|
+
* (`/usr/bin`) and fractions (`5/8`) out.
|
|
415
|
+
*/
|
|
416
|
+
const SKILL_GESTURE = /(^|\s)\/([a-z0-9]+(?:-[a-z0-9]+)*)(?=\s|$)/g;
|
|
417
|
+
/**
|
|
418
|
+
* `/name` gesture tokens from the claimed user messages, deduplicated in
|
|
419
|
+
* first-seen order. Every text block of direct user input is scanned; no
|
|
420
|
+
* other source can forge a gesture.
|
|
421
|
+
* @param messages - the step's claimed batch.
|
|
422
|
+
* @returns candidate skill names, unvalidated against the registry.
|
|
423
|
+
*/
|
|
424
|
+
function invokedSkillNames(messages) {
|
|
425
|
+
const names = [];
|
|
426
|
+
for (const message of messages) {
|
|
427
|
+
if (message.source.kind !== "user") continue;
|
|
428
|
+
for (const block of message.content) {
|
|
429
|
+
if (block.type !== "text") continue;
|
|
430
|
+
for (const match of block.text.matchAll(SKILL_GESTURE)) {
|
|
431
|
+
const name = match[2];
|
|
432
|
+
if (name !== void 0 && !names.includes(name)) names.push(name);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return names;
|
|
437
|
+
}
|
|
438
|
+
//#endregion
|
|
439
|
+
export { Config, apply, inject, name };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region lib/types/invariant.js
|
|
2
|
+
/**
|
|
3
|
+
* Package-owned invariant companion for `@hydraharness/harness-tool-skill`.
|
|
4
|
+
* @module @hydraharness/harness-tool-skill/invariant
|
|
5
|
+
*/
|
|
6
|
+
const PACKAGE_NAME = "@hydraharness/harness-tool-skill";
|
|
7
|
+
/** Cordis companion plugin name. */
|
|
8
|
+
const name = "tool-skill-invariant";
|
|
9
|
+
/** Service required before the companion can reserve package ownership. */
|
|
10
|
+
const inject = ["invariants"];
|
|
11
|
+
/**
|
|
12
|
+
* No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution
|
|
13
|
+
* relations are owned by the capability seam it calls.
|
|
14
|
+
*/
|
|
15
|
+
const install = () => {};
|
|
16
|
+
/**
|
|
17
|
+
* Register this package's invariant companion.
|
|
18
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
19
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
20
|
+
*/
|
|
21
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
22
|
+
//#endregion
|
|
23
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded skill routing, model-facing search, and exact loading.
|
|
3
|
+
*
|
|
4
|
+
* @module @hydraharness/harness-tool-skill
|
|
5
|
+
*/
|
|
6
|
+
import type { Context } from '@hydraharness/cordis';
|
|
7
|
+
import z from '@hydraharness/schemastery';
|
|
8
|
+
export declare const name = "tool-skill";
|
|
9
|
+
export declare const inject: string[];
|
|
10
|
+
/** Model-facing skill search configuration. */
|
|
11
|
+
export interface Config {
|
|
12
|
+
/** Maximum candidates returned by one search; minimum 1. */
|
|
13
|
+
searchMaxResults?: number;
|
|
14
|
+
/** Maximum normalized description or routing-hint length per candidate; minimum 3. */
|
|
15
|
+
searchDescriptionMaxLength?: number;
|
|
16
|
+
/** Maximum UTF-8 bytes in one rendered search result. */
|
|
17
|
+
searchMaxResultBytes?: number;
|
|
18
|
+
}
|
|
19
|
+
/** Validate and default the model-facing skill search configuration. */
|
|
20
|
+
export declare const Config: z<Config>;
|
|
21
|
+
/**
|
|
22
|
+
* Register bounded automatic routing, model-facing search, exact loading, and direct user invocation.
|
|
23
|
+
*/
|
|
24
|
+
export declare function apply(ctx: Context, config?: Config): void;
|
|
25
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@hydraharness/harness-tool-skill`.
|
|
3
|
+
* @module @hydraharness/harness-tool-skill/invariant
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@hydraharness/cordis';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export declare const name = "tool-skill-invariant";
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export declare const inject: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Register this package's invariant companion.
|
|
12
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
13
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
14
|
+
*/
|
|
15
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
16
|
+
//# sourceMappingURL=invariant.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hydraharness/harness-tool-skill",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
|
|
3
|
+
"description": "Bounded automatic skill routing, model-facing search, and exact loading for Hydra harness",
|
|
4
|
+
"hydra": {
|
|
5
|
+
"plugin": {
|
|
6
|
+
"application": "Let the agent search for relevant skills and load their full instructions."
|
|
7
|
+
}
|
|
8
|
+
},
|
|
9
|
+
"version": "0.1.1-rc.6",
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/MaiHongPhong1902/Hydra-Harness.git",
|
|
16
|
+
"directory": "packages/skill/tool-skill"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "lib/index.js",
|
|
20
|
+
"types": "lib/types/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./lib/types/index.d.ts",
|
|
24
|
+
"default": "./lib/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./invariant": {
|
|
27
|
+
"types": "./lib/types/invariant.d.ts",
|
|
28
|
+
"default": "./lib/invariant.js"
|
|
29
|
+
},
|
|
30
|
+
"./src/*": "./src/*",
|
|
31
|
+
"./package.json": "./package.json"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"lib/index.js",
|
|
35
|
+
"lib/invariant.js",
|
|
36
|
+
"lib/types/**/*.d.ts"
|
|
37
|
+
],
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@hydraharness/harness-invariants": "^0.1.1-rc.6",
|
|
41
|
+
"@hydraharness/harness-llm": "^0.1.1-rc.6",
|
|
42
|
+
"@hydraharness/harness-skill": "^0.1.1-rc.6",
|
|
43
|
+
"@hydraharness/harness-tools": "^0.1.1-rc.6",
|
|
44
|
+
"@hydraharness/harness-agent": "^0.1.1-rc.6",
|
|
45
|
+
"@hydraharness/cordis": "^4.0.2"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@hydraharness/schemastery": "^3.18.2"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@hydraharness/harness-invariants": "^0.1.1-rc.6",
|
|
52
|
+
"@hydraharness/harness-agent": "^0.1.1-rc.6",
|
|
53
|
+
"@hydraharness/harness-llm": "^0.1.1-rc.6",
|
|
54
|
+
"@hydraharness/harness-session": "^0.1.1-rc.6",
|
|
55
|
+
"@hydraharness/harness-scope": "^0.1.1-rc.6",
|
|
56
|
+
"@hydraharness/harness-skill": "^0.1.1-rc.6",
|
|
57
|
+
"@hydraharness/harness-skill-filesystem": "^0.1.1-rc.6",
|
|
58
|
+
"@hydraharness/harness-tools": "^0.1.1-rc.6",
|
|
59
|
+
"@hydraharness/cordis": "^4.0.2"
|
|
60
|
+
}
|
|
6
61
|
}
|