@fideliosai/adapter-hermes-local 0.0.42 → 0.0.44

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.
@@ -0,0 +1,264 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import {
3
+ buildTriageSystemPrompt,
4
+ filterToolsetNames,
5
+ parseTriageJson,
6
+ triageToolsets,
7
+ } from "./triage.js";
8
+ import {
9
+ HERMES_TOOLSET_REGISTRY,
10
+ SAFE_DEFAULT_TOOLSETS,
11
+ } from "./toolset-registry.js";
12
+
13
+ // ---------- pure helpers ----------
14
+
15
+ describe("buildTriageSystemPrompt", () => {
16
+ it("includes every registry name and description", () => {
17
+ const prompt = buildTriageSystemPrompt(HERMES_TOOLSET_REGISTRY);
18
+ for (const entry of HERMES_TOOLSET_REGISTRY) {
19
+ expect(prompt).toContain(entry.name);
20
+ expect(prompt).toContain(entry.description);
21
+ }
22
+ });
23
+
24
+ it("instructs the model to emit JSON in the documented shape", () => {
25
+ const prompt = buildTriageSystemPrompt(HERMES_TOOLSET_REGISTRY);
26
+ expect(prompt).toMatch(/"toolsets"/);
27
+ expect(prompt).toMatch(/JSON only/i);
28
+ });
29
+ });
30
+
31
+ describe("parseTriageJson", () => {
32
+ it("parses a clean JSON object with toolsets array", () => {
33
+ const result = parseTriageJson('{"toolsets":["file","web"]}');
34
+ expect(result).toEqual(["file", "web"]);
35
+ });
36
+
37
+ it("accepts the alias field `tools`", () => {
38
+ const result = parseTriageJson('{"tools":["terminal"]}');
39
+ expect(result).toEqual(["terminal"]);
40
+ });
41
+
42
+ it("accepts the alias field `selected`", () => {
43
+ const result = parseTriageJson('{"selected":["memory"]}');
44
+ expect(result).toEqual(["memory"]);
45
+ });
46
+
47
+ it("extracts the first JSON object when wrapped in surrounding prose", () => {
48
+ const result = parseTriageJson('Sure! Here you go:\n{"toolsets":["file"]}\n— end.');
49
+ expect(result).toEqual(["file"]);
50
+ });
51
+
52
+ it("returns null for unparseable content", () => {
53
+ expect(parseTriageJson("nope, not json at all")).toBeNull();
54
+ });
55
+
56
+ it("returns null when the toolsets value is not an array", () => {
57
+ expect(parseTriageJson('{"toolsets":"file"}')).toBeNull();
58
+ });
59
+
60
+ it("returns null for empty / non-string input", () => {
61
+ expect(parseTriageJson("")).toBeNull();
62
+ expect(parseTriageJson(" ")).toBeNull();
63
+ expect(parseTriageJson(null)).toBeNull();
64
+ expect(parseTriageJson(undefined)).toBeNull();
65
+ });
66
+
67
+ it("drops non-string entries", () => {
68
+ const result = parseTriageJson('{"toolsets":["file",123,null,"web"]}');
69
+ expect(result).toEqual(["file", "web"]);
70
+ });
71
+ });
72
+
73
+ describe("filterToolsetNames", () => {
74
+ it("keeps only registry-known names", () => {
75
+ expect(filterToolsetNames(["file", "fake_tool", "web"])).toEqual(["file", "web"]);
76
+ });
77
+
78
+ it("dedupes while preserving order", () => {
79
+ expect(filterToolsetNames(["file", "web", "file", "web"])).toEqual(["file", "web"]);
80
+ });
81
+
82
+ it("trims whitespace before matching", () => {
83
+ expect(filterToolsetNames([" file ", "\tweb\n"])).toEqual(["file", "web"]);
84
+ });
85
+
86
+ it("rejects empty / non-string entries", () => {
87
+ expect(filterToolsetNames(["", " ", null, undefined, 1, "file"])).toEqual(["file"]);
88
+ });
89
+
90
+ it("returns [] when nothing is recognized", () => {
91
+ expect(filterToolsetNames(["foo", "bar"])).toEqual([]);
92
+ });
93
+ });
94
+
95
+ // ---------- triageToolsets (mocked Ollama) ----------
96
+
97
+ function makeFakeClient(impl) {
98
+ return { chat: vi.fn(impl) };
99
+ }
100
+
101
+ describe("triageToolsets — happy path", () => {
102
+ it("returns the LLM-selected subset filtered against the registry", async () => {
103
+ const client = makeFakeClient(async () => ({
104
+ message: { content: '{"toolsets":["file","web","made_up"]}' },
105
+ }));
106
+
107
+ const result = await triageToolsets({
108
+ prompt: "Read README.md and summarize",
109
+ model: "qwen3:4b",
110
+ client,
111
+ });
112
+
113
+ expect(client.chat).toHaveBeenCalledTimes(1);
114
+ expect(result.toolsets).toEqual(["file", "web"]);
115
+ expect(result.usedFallback).toBe(false);
116
+ expect(result.error).toBeUndefined();
117
+ expect(typeof result.durationMs).toBe("number");
118
+ });
119
+
120
+ it("forwards the configured model and JSON format to Ollama.chat", async () => {
121
+ const client = makeFakeClient(async () => ({
122
+ message: { content: '{"toolsets":["file"]}' },
123
+ }));
124
+
125
+ await triageToolsets({
126
+ prompt: "p",
127
+ model: "qwen3:4b",
128
+ client,
129
+ });
130
+
131
+ const call = client.chat.mock.calls[0][0];
132
+ expect(call.model).toBe("qwen3:4b");
133
+ expect(call.format).toBe("json");
134
+ expect(call.stream).toBe(false);
135
+ expect(Array.isArray(call.messages)).toBe(true);
136
+ expect(call.messages[0].role).toBe("system");
137
+ expect(call.messages[1].role).toBe("user");
138
+ });
139
+
140
+ it("truncates very long prompts to keep the router input bounded", async () => {
141
+ const client = makeFakeClient(async () => ({
142
+ message: { content: '{"toolsets":["file"]}' },
143
+ }));
144
+ const huge = "x".repeat(10_000);
145
+
146
+ await triageToolsets({ prompt: huge, model: "qwen3:4b", client });
147
+
148
+ const userMsg = client.chat.mock.calls[0][0].messages[1].content;
149
+ // 4000-char cap + small "Task:\n" prefix
150
+ expect(userMsg.length).toBeLessThanOrEqual(4100);
151
+ });
152
+ });
153
+
154
+ describe("triageToolsets — fallback paths", () => {
155
+ it("falls back to safe defaults when no model is provided", async () => {
156
+ const result = await triageToolsets({ prompt: "p", model: "" });
157
+ expect(result.usedFallback).toBe(true);
158
+ expect(result.toolsets).toEqual([...SAFE_DEFAULT_TOOLSETS]);
159
+ expect(result.error).toMatch(/no model/i);
160
+ });
161
+
162
+ it("falls back when the LLM call throws", async () => {
163
+ const client = makeFakeClient(async () => {
164
+ throw new Error("connection refused");
165
+ });
166
+
167
+ const result = await triageToolsets({
168
+ prompt: "p",
169
+ model: "qwen3:4b",
170
+ client,
171
+ });
172
+
173
+ expect(result.usedFallback).toBe(true);
174
+ expect(result.toolsets).toEqual([...SAFE_DEFAULT_TOOLSETS]);
175
+ expect(result.error).toMatch(/connection refused/);
176
+ });
177
+
178
+ it("falls back when the LLM returns invalid JSON", async () => {
179
+ const client = makeFakeClient(async () => ({
180
+ message: { content: "I am sorry I cannot comply." },
181
+ }));
182
+
183
+ const result = await triageToolsets({
184
+ prompt: "p",
185
+ model: "qwen3:4b",
186
+ client,
187
+ });
188
+
189
+ expect(result.usedFallback).toBe(true);
190
+ expect(result.error).toMatch(/not valid JSON/i);
191
+ });
192
+
193
+ it("falls back when LLM picks zero known toolsets", async () => {
194
+ const client = makeFakeClient(async () => ({
195
+ message: { content: '{"toolsets":["bogus","also_bogus"]}' },
196
+ }));
197
+
198
+ const result = await triageToolsets({
199
+ prompt: "p",
200
+ model: "qwen3:4b",
201
+ client,
202
+ });
203
+
204
+ expect(result.usedFallback).toBe(true);
205
+ expect(result.error).toMatch(/no known toolsets/i);
206
+ });
207
+
208
+ it("falls back when the LLM call exceeds the timeout", async () => {
209
+ const client = makeFakeClient(
210
+ () => new Promise((resolve) => setTimeout(resolve, 100))
211
+ );
212
+
213
+ const result = await triageToolsets({
214
+ prompt: "p",
215
+ model: "qwen3:4b",
216
+ client,
217
+ timeoutMs: 10,
218
+ });
219
+
220
+ expect(result.usedFallback).toBe(true);
221
+ expect(result.error).toMatch(/timed out/i);
222
+ }, 1000);
223
+ });
224
+
225
+ describe("triageToolsets — registry override", () => {
226
+ it("respects a custom registry/fallback pair end-to-end", async () => {
227
+ const customRegistry = [
228
+ { name: "alpha", description: "alpha tool" },
229
+ { name: "beta", description: "beta tool" },
230
+ ];
231
+ const client = makeFakeClient(async () => ({
232
+ message: { content: '{"toolsets":["beta","gamma"]}' },
233
+ }));
234
+
235
+ const result = await triageToolsets({
236
+ prompt: "p",
237
+ model: "qwen3:4b",
238
+ client,
239
+ registry: customRegistry,
240
+ fallback: ["alpha"],
241
+ });
242
+
243
+ expect(result.usedFallback).toBe(false);
244
+ expect(result.toolsets).toEqual(["beta"]);
245
+ });
246
+
247
+ it("filters fallback list against the supplied registry too", async () => {
248
+ const customRegistry = [{ name: "alpha", description: "a" }];
249
+ const client = makeFakeClient(async () => {
250
+ throw new Error("offline");
251
+ });
252
+
253
+ const result = await triageToolsets({
254
+ prompt: "p",
255
+ model: "qwen3:4b",
256
+ client,
257
+ registry: customRegistry,
258
+ fallback: ["alpha", "not_in_custom_registry"],
259
+ });
260
+
261
+ expect(result.usedFallback).toBe(true);
262
+ expect(result.toolsets).toEqual(["alpha"]);
263
+ });
264
+ });
@@ -41,6 +41,14 @@ export function buildHermesConfig(v) {
41
41
  if (v.promptTemplate) {
42
42
  ac.promptTemplate = v.promptTemplate;
43
43
  }
44
+ // Toolset whitelist (empty → LLM-driven triage chooses per prompt)
45
+ if (typeof v.toolsets === "string" && v.toolsets.trim()) {
46
+ ac.toolsets = v.toolsets.trim();
47
+ }
48
+ // Optional triage router model override
49
+ if (typeof v.triageModel === "string" && v.triageModel.trim()) {
50
+ ac.triageModel = v.triageModel.trim();
51
+ }
44
52
  // Heartbeat config is handled by FideliOS itself
45
53
  return ac;
46
54
  }