@fideliosai/adapter-hermes-local 0.0.41 → 0.0.43
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/package.json +9 -2
- package/src/server/execute.js +74 -1
- package/src/server/execute.test.js +324 -0
- package/src/server/headless.js +185 -0
- package/src/server/headless.test.js +253 -0
- package/src/server/toolset-registry.js +95 -0
- package/src/server/toolset-registry.test.js +58 -0
- package/src/server/triage.js +234 -0
- package/src/server/triage.test.js +264 -0
- package/src/ui/build-config.js +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fideliosai/adapter-hermes-local",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.43",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Hermes AI agent adapter for FideliOS",
|
|
6
6
|
"homepage": "https://github.com/fideliosai/fidelios",
|
|
@@ -23,7 +23,14 @@
|
|
|
23
23
|
"src"
|
|
24
24
|
],
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@fideliosai/adapter-utils": "0.0.
|
|
26
|
+
"@fideliosai/adapter-utils": "0.0.43",
|
|
27
|
+
"ollama": "^0.6.3",
|
|
27
28
|
"picocolors": "^1.1.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"vitest": "^3.2.4"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "vitest run"
|
|
28
35
|
}
|
|
29
36
|
}
|
package/src/server/execute.js
CHANGED
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { runChildProcess, buildFideliOSEnv, renderTemplate, ensureAbsoluteDirectory, } from "@fideliosai/adapter-utils/server-utils";
|
|
19
19
|
import { HERMES_CLI, DEFAULT_TIMEOUT_SEC, DEFAULT_GRACE_SEC, VALID_PROVIDERS, } from "../shared/constants.js";
|
|
20
|
+
import { triageToolsets } from "./triage.js";
|
|
21
|
+
import { HERMES_TOOLSET_REGISTRY } from "./toolset-registry.js";
|
|
22
|
+
import { isHeadlessEnv, filterHeadlessCsv } from "./headless.js";
|
|
20
23
|
// ---------------------------------------------------------------------------
|
|
21
24
|
// Config helpers
|
|
22
25
|
// ---------------------------------------------------------------------------
|
|
@@ -74,6 +77,18 @@ Someone commented. Read it:
|
|
|
74
77
|
Address the comment, POST a reply if needed, then continue working.
|
|
75
78
|
{{/commentId}}
|
|
76
79
|
|
|
80
|
+
{{#taskId}}
|
|
81
|
+
## If you need clarification
|
|
82
|
+
|
|
83
|
+
You are running unattended — there is no human at the terminal, so do NOT use \`clarify\` (it has been stripped from your toolset for this reason). If the task is genuinely ambiguous and you cannot proceed safely, escalate via the FideliOS API:
|
|
84
|
+
|
|
85
|
+
1. Post the question as a comment:
|
|
86
|
+
\`curl -s -X POST "{{fideliosApiUrl}}/issues/{{taskId}}/comments" -H "Content-Type: application/json" -d '{"body":"❓ Question: <your question>"}'\`
|
|
87
|
+
2. Mark the issue blocked:
|
|
88
|
+
\`curl -s -X PATCH "{{fideliosApiUrl}}/issues/{{taskId}}" -H "Content-Type: application/json" -d '{"status":"blocked"}'\`
|
|
89
|
+
3. Stop. The Board will answer in a comment; FideliOS will wake you on the next heartbeat with the reply attached. Prefer this over making large irreversible decisions on shaky assumptions.
|
|
90
|
+
{{/taskId}}
|
|
91
|
+
|
|
77
92
|
{{#noTask}}
|
|
78
93
|
## Heartbeat Wake — Check for Work
|
|
79
94
|
|
|
@@ -248,13 +263,56 @@ export async function execute(ctx) {
|
|
|
248
263
|
const provider = cfgString(config.provider);
|
|
249
264
|
const timeoutSec = cfgNumber(config.timeoutSec) || DEFAULT_TIMEOUT_SEC;
|
|
250
265
|
const graceSec = cfgNumber(config.graceSec) || DEFAULT_GRACE_SEC;
|
|
251
|
-
const
|
|
266
|
+
const explicitToolsets = cfgString(config.toolsets) || cfgStringArray(config.enabledToolsets)?.join(",");
|
|
267
|
+
let toolsets = explicitToolsets;
|
|
268
|
+
let triageMeta = null;
|
|
252
269
|
const extraArgs = cfgStringArray(config.extraArgs);
|
|
253
270
|
const persistSession = cfgBoolean(config.persistSession) !== false;
|
|
254
271
|
const worktreeMode = cfgBoolean(config.worktreeMode) === true;
|
|
255
272
|
const checkpoints = cfgBoolean(config.checkpoints) === true;
|
|
256
273
|
// ── Build prompt ───────────────────────────────────────────────────────
|
|
257
274
|
const prompt = buildPrompt(ctx, config);
|
|
275
|
+
// ── Triage toolsets (FID-48) ───────────────────────────────────────────
|
|
276
|
+
// If the operator pinned toolsets explicitly, respect that whitelist
|
|
277
|
+
// (backwards compat). Otherwise ask the configured local model to pick a
|
|
278
|
+
// relevant subset for this prompt out of all available Hermes toolsets.
|
|
279
|
+
const triageEnabled = cfgBoolean(config.triageEnabled) !== false; // default on
|
|
280
|
+
if (!explicitToolsets && triageEnabled) {
|
|
281
|
+
const triageModel = cfgString(config.triageModel) || model;
|
|
282
|
+
const triageTimeoutMs = cfgNumber(config.triageTimeoutMs);
|
|
283
|
+
const triageHost = cfgString(config.triageHost) || cfgString(config.ollamaHost);
|
|
284
|
+
triageMeta = await triageToolsets({
|
|
285
|
+
prompt,
|
|
286
|
+
model: triageModel,
|
|
287
|
+
host: triageHost,
|
|
288
|
+
timeoutMs: triageTimeoutMs,
|
|
289
|
+
});
|
|
290
|
+
if (triageMeta.toolsets.length > 0) {
|
|
291
|
+
toolsets = triageMeta.toolsets.join(",");
|
|
292
|
+
}
|
|
293
|
+
const total = HERMES_TOOLSET_REGISTRY.length;
|
|
294
|
+
await ctx.onLog("stdout", `[hermes-triage] selected: ${triageMeta.toolsets.join(",") || "(none)"} (${triageMeta.toolsets.length} of ${total}, ${triageMeta.durationMs}ms${triageMeta.usedFallback ? `, fallback: ${triageMeta.error}` : ""})\n`);
|
|
295
|
+
}
|
|
296
|
+
// ── Headless I/O contract (FID-52) ─────────────────────────────────────
|
|
297
|
+
// Strip toolsets that block on stdin (e.g. `clarify`) when running
|
|
298
|
+
// unattended. Without this, hermes hangs ~120 s per clarify call up to
|
|
299
|
+
// the adapter's hard timeout (FID-47). The model is instead expected to
|
|
300
|
+
// escalate via the FideliOS API — the prompt template carries that
|
|
301
|
+
// guidance.
|
|
302
|
+
let headlessMeta = null;
|
|
303
|
+
if (isHeadlessEnv(process.env, { stdinIsTTY: process.stdin?.isTTY })) {
|
|
304
|
+
const { csv, stripped } = filterHeadlessCsv(toolsets);
|
|
305
|
+
headlessMeta = {
|
|
306
|
+
headless: true,
|
|
307
|
+
stripped,
|
|
308
|
+
explicitOverride: !!explicitToolsets && stripped.length > 0,
|
|
309
|
+
};
|
|
310
|
+
if (stripped.length > 0) {
|
|
311
|
+
const noun = stripped.length === 1 ? "toolset" : "toolsets";
|
|
312
|
+
await ctx.onLog("stdout", `[hermes-headless] stripped interactive ${noun}: ${stripped.join(",")} (escalate via FideliOS API instead)\n`);
|
|
313
|
+
toolsets = csv || undefined;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
258
316
|
// ── Build command args ─────────────────────────────────────────────────
|
|
259
317
|
// Use -Q (quiet) to get clean output: just response + session_id line
|
|
260
318
|
const useQuiet = cfgBoolean(config.quiet) !== false; // default true
|
|
@@ -400,6 +458,21 @@ export async function execute(ctx) {
|
|
|
400
458
|
session_id: parsed.sessionId || null,
|
|
401
459
|
usage: parsed.usage || null,
|
|
402
460
|
cost_usd: parsed.costUsd ?? null,
|
|
461
|
+
triage: triageMeta
|
|
462
|
+
? {
|
|
463
|
+
toolsets: triageMeta.toolsets,
|
|
464
|
+
used_fallback: triageMeta.usedFallback,
|
|
465
|
+
error: triageMeta.error ?? null,
|
|
466
|
+
duration_ms: triageMeta.durationMs,
|
|
467
|
+
}
|
|
468
|
+
: null,
|
|
469
|
+
headless: headlessMeta
|
|
470
|
+
? {
|
|
471
|
+
headless: headlessMeta.headless,
|
|
472
|
+
stripped: headlessMeta.stripped,
|
|
473
|
+
explicit_override: headlessMeta.explicitOverride,
|
|
474
|
+
}
|
|
475
|
+
: null,
|
|
403
476
|
};
|
|
404
477
|
// Store session ID for next run
|
|
405
478
|
if (persistSession && parsed.sessionId) {
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Module mocks must be declared before importing the SUT.
|
|
4
|
+
const runChildProcessMock = vi.fn();
|
|
5
|
+
const triageToolsetsMock = vi.fn();
|
|
6
|
+
|
|
7
|
+
vi.mock("@fideliosai/adapter-utils/server-utils", async () => {
|
|
8
|
+
const actual = await vi.importActual(
|
|
9
|
+
"@fideliosai/adapter-utils/server-utils"
|
|
10
|
+
);
|
|
11
|
+
return {
|
|
12
|
+
...actual,
|
|
13
|
+
runChildProcess: (...args) => runChildProcessMock(...args),
|
|
14
|
+
ensureAbsoluteDirectory: vi.fn(async () => undefined),
|
|
15
|
+
};
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
vi.mock("./triage.js", () => ({
|
|
19
|
+
triageToolsets: (...args) => triageToolsetsMock(...args),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
const { execute } = await import("./execute.js");
|
|
23
|
+
|
|
24
|
+
function makeCtx(overrides = {}) {
|
|
25
|
+
const logs = [];
|
|
26
|
+
const ctx = {
|
|
27
|
+
runId: "run-1",
|
|
28
|
+
authToken: "tok",
|
|
29
|
+
agent: {
|
|
30
|
+
id: "agent-1",
|
|
31
|
+
companyId: "co-1",
|
|
32
|
+
name: "TestAgent",
|
|
33
|
+
adapterConfig: {
|
|
34
|
+
hermesCommand: "echo", // anything; we mock runChildProcess
|
|
35
|
+
model: "qwen3:4b",
|
|
36
|
+
...overrides.adapterConfig,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
config: {
|
|
40
|
+
...overrides.config,
|
|
41
|
+
},
|
|
42
|
+
runtime: {},
|
|
43
|
+
onLog: async (stream, chunk) => {
|
|
44
|
+
logs.push({ stream, chunk });
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
return { ctx, logs };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
runChildProcessMock.mockReset();
|
|
52
|
+
triageToolsetsMock.mockReset();
|
|
53
|
+
runChildProcessMock.mockResolvedValue({
|
|
54
|
+
stdout: "ok\n\nsession_id: sess-1\n",
|
|
55
|
+
stderr: "",
|
|
56
|
+
exitCode: 0,
|
|
57
|
+
signal: null,
|
|
58
|
+
timedOut: false,
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function getArgs() {
|
|
63
|
+
expect(runChildProcessMock).toHaveBeenCalledTimes(1);
|
|
64
|
+
// (runId, cmd, args, opts)
|
|
65
|
+
return runChildProcessMock.mock.calls[0][2];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function findToolsetsArg(args) {
|
|
69
|
+
const idx = args.indexOf("-t");
|
|
70
|
+
return idx === -1 ? null : args[idx + 1];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe("execute: explicit toolsets bypass triage (backwards-compat)", () => {
|
|
74
|
+
it("respects adapterConfig.toolsets and never calls triage", async () => {
|
|
75
|
+
const { ctx } = makeCtx({
|
|
76
|
+
adapterConfig: { toolsets: "file,terminal" },
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await execute(ctx);
|
|
80
|
+
|
|
81
|
+
expect(triageToolsetsMock).not.toHaveBeenCalled();
|
|
82
|
+
expect(findToolsetsArg(getArgs())).toBe("file,terminal");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("respects adapterConfig.enabledToolsets array form", async () => {
|
|
86
|
+
const { ctx } = makeCtx({
|
|
87
|
+
adapterConfig: { enabledToolsets: ["file", "web"] },
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await execute(ctx);
|
|
91
|
+
|
|
92
|
+
expect(triageToolsetsMock).not.toHaveBeenCalled();
|
|
93
|
+
expect(findToolsetsArg(getArgs())).toBe("file,web");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("does not invoke triage when triageEnabled === false", async () => {
|
|
97
|
+
const { ctx } = makeCtx({
|
|
98
|
+
adapterConfig: { triageEnabled: false },
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await execute(ctx);
|
|
102
|
+
|
|
103
|
+
expect(triageToolsetsMock).not.toHaveBeenCalled();
|
|
104
|
+
// No -t flag — Hermes will use its default enabled set.
|
|
105
|
+
expect(findToolsetsArg(getArgs())).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("execute: auto-triage path", () => {
|
|
110
|
+
it("calls triage with the configured model when toolsets is unset", async () => {
|
|
111
|
+
triageToolsetsMock.mockResolvedValue({
|
|
112
|
+
toolsets: ["file", "terminal"],
|
|
113
|
+
usedFallback: false,
|
|
114
|
+
durationMs: 42,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const { ctx } = makeCtx();
|
|
118
|
+
|
|
119
|
+
await execute(ctx);
|
|
120
|
+
|
|
121
|
+
expect(triageToolsetsMock).toHaveBeenCalledTimes(1);
|
|
122
|
+
const triageArgs = triageToolsetsMock.mock.calls[0][0];
|
|
123
|
+
expect(triageArgs.model).toBe("qwen3:4b");
|
|
124
|
+
expect(typeof triageArgs.prompt).toBe("string");
|
|
125
|
+
expect(triageArgs.prompt.length).toBeGreaterThan(0);
|
|
126
|
+
|
|
127
|
+
expect(findToolsetsArg(getArgs())).toBe("file,terminal");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("uses adapterConfig.triageModel override when set", async () => {
|
|
131
|
+
triageToolsetsMock.mockResolvedValue({
|
|
132
|
+
toolsets: ["file"],
|
|
133
|
+
usedFallback: false,
|
|
134
|
+
durationMs: 10,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const { ctx } = makeCtx({
|
|
138
|
+
adapterConfig: { triageModel: "qwen3:0.6b" },
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await execute(ctx);
|
|
142
|
+
|
|
143
|
+
expect(triageToolsetsMock.mock.calls[0][0].model).toBe("qwen3:0.6b");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("forwards triageHost / ollamaHost / triageTimeoutMs into triage opts", async () => {
|
|
147
|
+
triageToolsetsMock.mockResolvedValue({
|
|
148
|
+
toolsets: ["file"],
|
|
149
|
+
usedFallback: false,
|
|
150
|
+
durationMs: 5,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const { ctx } = makeCtx({
|
|
154
|
+
adapterConfig: {
|
|
155
|
+
ollamaHost: "http://localhost:11434",
|
|
156
|
+
triageTimeoutMs: 5000,
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await execute(ctx);
|
|
161
|
+
|
|
162
|
+
const opts = triageToolsetsMock.mock.calls[0][0];
|
|
163
|
+
expect(opts.host).toBe("http://localhost:11434");
|
|
164
|
+
expect(opts.timeoutMs).toBe(5000);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("emits a banner log including the selected subset and total registry size", async () => {
|
|
168
|
+
triageToolsetsMock.mockResolvedValue({
|
|
169
|
+
toolsets: ["file", "web"],
|
|
170
|
+
usedFallback: false,
|
|
171
|
+
durationMs: 17,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const { ctx, logs } = makeCtx();
|
|
175
|
+
await execute(ctx);
|
|
176
|
+
|
|
177
|
+
const banner = logs.find((l) =>
|
|
178
|
+
l.chunk.startsWith("[hermes-triage] selected: file,web")
|
|
179
|
+
);
|
|
180
|
+
expect(banner).toBeTruthy();
|
|
181
|
+
// total registry size of 22 (current Hermes v0.12)
|
|
182
|
+
expect(banner.chunk).toMatch(/\(2 of \d+,/);
|
|
183
|
+
expect(banner.chunk).toMatch(/17ms/);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("surfaces triage metadata in resultJson.triage", async () => {
|
|
187
|
+
triageToolsetsMock.mockResolvedValue({
|
|
188
|
+
toolsets: ["file"],
|
|
189
|
+
usedFallback: false,
|
|
190
|
+
durationMs: 9,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const { ctx } = makeCtx();
|
|
194
|
+
const result = await execute(ctx);
|
|
195
|
+
|
|
196
|
+
expect(result.resultJson?.triage).toEqual({
|
|
197
|
+
toolsets: ["file"],
|
|
198
|
+
used_fallback: false,
|
|
199
|
+
error: null,
|
|
200
|
+
duration_ms: 9,
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("when triage returns an empty list, omits -t and surfaces fallback metadata", async () => {
|
|
205
|
+
triageToolsetsMock.mockResolvedValue({
|
|
206
|
+
toolsets: [],
|
|
207
|
+
usedFallback: true,
|
|
208
|
+
error: "triage call failed",
|
|
209
|
+
durationMs: 4,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const { ctx } = makeCtx();
|
|
213
|
+
const result = await execute(ctx);
|
|
214
|
+
|
|
215
|
+
expect(findToolsetsArg(getArgs())).toBeNull();
|
|
216
|
+
expect(result.resultJson?.triage?.used_fallback).toBe(true);
|
|
217
|
+
expect(result.resultJson?.triage?.error).toBe("triage call failed");
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe("execute: triage off when no toolsets and triageEnabled disabled", () => {
|
|
222
|
+
it("resultJson.triage is null when triage is skipped", async () => {
|
|
223
|
+
const { ctx } = makeCtx({
|
|
224
|
+
adapterConfig: { toolsets: "file" },
|
|
225
|
+
});
|
|
226
|
+
const result = await execute(ctx);
|
|
227
|
+
expect(result.resultJson?.triage).toBeNull();
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe("execute: headless I/O contract (FID-52)", () => {
|
|
232
|
+
// FideliOS spawns always set FIDELIOS_RUN_ID — vitest's process.env may or
|
|
233
|
+
// may not have it depending on parent invocation. We force the desired
|
|
234
|
+
// state per test via FIDELIOS_HEADLESS to avoid leaking between cases.
|
|
235
|
+
const ORIG_HEADLESS = process.env.FIDELIOS_HEADLESS;
|
|
236
|
+
const ORIG_RUN_ID = process.env.FIDELIOS_RUN_ID;
|
|
237
|
+
|
|
238
|
+
beforeEach(() => {
|
|
239
|
+
delete process.env.FIDELIOS_HEADLESS;
|
|
240
|
+
delete process.env.FIDELIOS_RUN_ID;
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("strips clarify from a triage selection when headless", async () => {
|
|
244
|
+
process.env.FIDELIOS_HEADLESS = "1";
|
|
245
|
+
triageToolsetsMock.mockResolvedValue({
|
|
246
|
+
toolsets: ["file", "clarify", "web"],
|
|
247
|
+
usedFallback: false,
|
|
248
|
+
durationMs: 7,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const { ctx, logs } = makeCtx();
|
|
252
|
+
const result = await execute(ctx);
|
|
253
|
+
|
|
254
|
+
expect(findToolsetsArg(getArgs())).toBe("file,web");
|
|
255
|
+
expect(result.resultJson?.headless).toEqual({
|
|
256
|
+
headless: true,
|
|
257
|
+
stripped: ["clarify"],
|
|
258
|
+
explicit_override: false,
|
|
259
|
+
});
|
|
260
|
+
const banner = logs.find((l) => l.chunk.includes("[hermes-headless] stripped"));
|
|
261
|
+
expect(banner).toBeTruthy();
|
|
262
|
+
expect(banner.chunk).toMatch(/clarify/);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("strips clarify from an explicitly-pinned toolset whitelist when headless", async () => {
|
|
266
|
+
process.env.FIDELIOS_HEADLESS = "1";
|
|
267
|
+
|
|
268
|
+
const { ctx } = makeCtx({
|
|
269
|
+
adapterConfig: { toolsets: "clarify,file" },
|
|
270
|
+
});
|
|
271
|
+
const result = await execute(ctx);
|
|
272
|
+
|
|
273
|
+
expect(triageToolsetsMock).not.toHaveBeenCalled();
|
|
274
|
+
expect(findToolsetsArg(getArgs())).toBe("file");
|
|
275
|
+
expect(result.resultJson?.headless?.stripped).toEqual(["clarify"]);
|
|
276
|
+
expect(result.resultJson?.headless?.explicit_override).toBe(true);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("omits -t entirely when stripping leaves nothing", async () => {
|
|
280
|
+
process.env.FIDELIOS_HEADLESS = "1";
|
|
281
|
+
|
|
282
|
+
const { ctx } = makeCtx({
|
|
283
|
+
adapterConfig: { toolsets: "clarify" },
|
|
284
|
+
});
|
|
285
|
+
await execute(ctx);
|
|
286
|
+
|
|
287
|
+
expect(findToolsetsArg(getArgs())).toBeNull();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("does not strip clarify in interactive mode (no FideliOS markers, default TTY)", async () => {
|
|
291
|
+
// No FIDELIOS_HEADLESS, no FIDELIOS_RUN_ID. The detector falls back to
|
|
292
|
+
// the stdin TTY hint; in vitest stdin is typically not a TTY but we
|
|
293
|
+
// bypass that here by explicitly setting FIDELIOS_HEADLESS=0.
|
|
294
|
+
process.env.FIDELIOS_HEADLESS = "0";
|
|
295
|
+
|
|
296
|
+
const { ctx } = makeCtx({
|
|
297
|
+
adapterConfig: { toolsets: "clarify,file" },
|
|
298
|
+
});
|
|
299
|
+
const result = await execute(ctx);
|
|
300
|
+
|
|
301
|
+
expect(findToolsetsArg(getArgs())).toBe("clarify,file");
|
|
302
|
+
expect(result.resultJson?.headless).toBeNull();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("resultJson.headless is null in interactive mode even with no toolsets", async () => {
|
|
306
|
+
process.env.FIDELIOS_HEADLESS = "0";
|
|
307
|
+
|
|
308
|
+
triageToolsetsMock.mockResolvedValue({
|
|
309
|
+
toolsets: ["file"],
|
|
310
|
+
usedFallback: false,
|
|
311
|
+
durationMs: 2,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const { ctx } = makeCtx();
|
|
315
|
+
const result = await execute(ctx);
|
|
316
|
+
|
|
317
|
+
expect(result.resultJson?.headless).toBeNull();
|
|
318
|
+
|
|
319
|
+
// Restore for subsequent suites
|
|
320
|
+
if (ORIG_HEADLESS !== undefined) process.env.FIDELIOS_HEADLESS = ORIG_HEADLESS;
|
|
321
|
+
else delete process.env.FIDELIOS_HEADLESS;
|
|
322
|
+
if (ORIG_RUN_ID !== undefined) process.env.FIDELIOS_RUN_ID = ORIG_RUN_ID;
|
|
323
|
+
});
|
|
324
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless I/O contract for hermes-local (FID-52).
|
|
3
|
+
*
|
|
4
|
+
* Hermes ships interactive toolsets — most notably `clarify` — that block on
|
|
5
|
+
* `stdin.read()` waiting for a human answer. When FideliOS spawns Hermes
|
|
6
|
+
* unattended, no human is at the terminal and the agent stalls until Hermes'
|
|
7
|
+
* own per-clarify timeout (120 s) elapses, multiplied by the number of
|
|
8
|
+
* clarify calls in a session. FID-47 surfaced this as the 600 s adapter
|
|
9
|
+
* timeout being hit.
|
|
10
|
+
*
|
|
11
|
+
* This module enforces a contract for headless runs:
|
|
12
|
+
* 1. Detect headless mode (FideliOS spawn vs. interactive `hermes chat`).
|
|
13
|
+
* 2. Strip toolsets flagged `headlessSafe: false` in the registry from the
|
|
14
|
+
* final `-t` whitelist, regardless of whether they were chosen by triage
|
|
15
|
+
* or pinned by the operator. Telemetry records what was stripped.
|
|
16
|
+
* 3. Provide a small escalation helper used by both the prompt template
|
|
17
|
+
* (which teaches the model to escalate via curl) and an optional
|
|
18
|
+
* adapter-side safety-net.
|
|
19
|
+
*
|
|
20
|
+
* Resume flow is handled by FideliOS: when the Board comments on the issue,
|
|
21
|
+
* the next heartbeat fires with `FIDELIOS_WAKE_COMMENT_ID` set, which the
|
|
22
|
+
* existing prompt template surfaces to the model.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
HERMES_TOOLSET_REGISTRY,
|
|
27
|
+
isHeadlessSafeToolset,
|
|
28
|
+
} from "./toolset-registry.js";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {Object} HeadlessFilterResult
|
|
32
|
+
* @property {string[]} kept Toolset names that survived the filter.
|
|
33
|
+
* @property {string[]} stripped Toolset names that were removed (unknown to
|
|
34
|
+
* caller as either unsafe or unrecognised).
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @typedef {Object} HeadlessTelemetry
|
|
39
|
+
* @property {boolean} headless True when running unattended.
|
|
40
|
+
* @property {string[]} stripped Toolsets removed because flagged
|
|
41
|
+
* `headlessSafe: false` in the registry.
|
|
42
|
+
* @property {boolean} explicitOverride True when the operator pinned toolsets
|
|
43
|
+
* and the strip removed at least one.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Heuristic: are we running unattended?
|
|
48
|
+
*
|
|
49
|
+
* FideliOS spawns always inject `FIDELIOS_RUN_ID`. An explicit override
|
|
50
|
+
* (`FIDELIOS_HEADLESS=1`) is also honoured for tests / SaaS deployments where
|
|
51
|
+
* the spawn is wrapped further. Otherwise fall back to TTY detection.
|
|
52
|
+
*
|
|
53
|
+
* @param {Record<string, string|undefined>} env
|
|
54
|
+
* @param {{stdinIsTTY?: boolean}} [opts]
|
|
55
|
+
* @returns {boolean}
|
|
56
|
+
*/
|
|
57
|
+
export function isHeadlessEnv(env, opts = {}) {
|
|
58
|
+
if (env && env.FIDELIOS_HEADLESS === "0") return false;
|
|
59
|
+
if (env && (env.FIDELIOS_HEADLESS === "1" || env.FIDELIOS_HEADLESS === "true")) return true;
|
|
60
|
+
if (env && typeof env.FIDELIOS_RUN_ID === "string" && env.FIDELIOS_RUN_ID.length > 0) return true;
|
|
61
|
+
// Fall back to stdin TTY hint if provided. Default: assume interactive
|
|
62
|
+
// when no FideliOS markers are present so local `hermes chat` keeps working.
|
|
63
|
+
if (typeof opts.stdinIsTTY === "boolean") return !opts.stdinIsTTY;
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Filter a toolset list, dropping anything flagged `headlessSafe: false` in
|
|
69
|
+
* the canonical registry. Unknown names pass through untouched (the triage
|
|
70
|
+
* filter and Hermes itself will flag them).
|
|
71
|
+
*
|
|
72
|
+
* @param {string[]} toolsets
|
|
73
|
+
* @param {Array<{name: string, headlessSafe?: boolean}>} [registry]
|
|
74
|
+
* @returns {HeadlessFilterResult}
|
|
75
|
+
*/
|
|
76
|
+
export function filterHeadlessUnsafe(toolsets, registry) {
|
|
77
|
+
const kept = [];
|
|
78
|
+
const stripped = [];
|
|
79
|
+
const reg = registry ?? HERMES_TOOLSET_REGISTRY;
|
|
80
|
+
const byName = new Map(reg.map((e) => [e.name, e]));
|
|
81
|
+
for (const name of toolsets) {
|
|
82
|
+
const entry = byName.get(name);
|
|
83
|
+
if (entry && entry.headlessSafe === false) {
|
|
84
|
+
stripped.push(name);
|
|
85
|
+
} else {
|
|
86
|
+
kept.push(name);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { kept, stripped };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Convenience: parse a comma-separated `-t` value, run the filter, and return
|
|
94
|
+
* both the new comma-string and the stripped list. Empty input → empty output.
|
|
95
|
+
*
|
|
96
|
+
* @param {string|undefined} csv
|
|
97
|
+
* @param {Array<{name: string, headlessSafe?: boolean}>} [registry]
|
|
98
|
+
* @returns {{csv: string|undefined, stripped: string[]}}
|
|
99
|
+
*/
|
|
100
|
+
export function filterHeadlessCsv(csv, registry) {
|
|
101
|
+
if (!csv || typeof csv !== "string") return { csv, stripped: [] };
|
|
102
|
+
const names = csv.split(",").map((s) => s.trim()).filter(Boolean);
|
|
103
|
+
if (names.length === 0) return { csv, stripped: [] };
|
|
104
|
+
const result = filterHeadlessUnsafe(names, registry);
|
|
105
|
+
const newCsv = result.kept.length > 0 ? result.kept.join(",") : "";
|
|
106
|
+
return { csv: newCsv, stripped: result.stripped };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Escalation helper: post a comment on the FideliOS issue and PATCH it to
|
|
111
|
+
* `blocked` so the Board sees the question and can answer.
|
|
112
|
+
*
|
|
113
|
+
* Designed to be safe to call from the prompt-template guidance path
|
|
114
|
+
* (model-driven escalation) AND from a future adapter-level intercept.
|
|
115
|
+
*
|
|
116
|
+
* @param {Object} args
|
|
117
|
+
* @param {string} args.question The clarification question to escalate.
|
|
118
|
+
* @param {string} args.taskId The FideliOS issue ID.
|
|
119
|
+
* @param {string} args.apiUrl FideliOS API base, e.g. http://127.0.0.1:3100/api.
|
|
120
|
+
* @param {string} [args.apiKey] Optional bearer token.
|
|
121
|
+
* @param {typeof fetch} [args.fetchImpl]
|
|
122
|
+
* @returns {Promise<{ok: boolean, commentId?: string, error?: string}>}
|
|
123
|
+
*/
|
|
124
|
+
export async function escalateClarify(args) {
|
|
125
|
+
const fetchImpl = args.fetchImpl ?? globalThis.fetch;
|
|
126
|
+
if (typeof fetchImpl !== "function") {
|
|
127
|
+
return { ok: false, error: "fetch not available" };
|
|
128
|
+
}
|
|
129
|
+
if (!args.taskId) return { ok: false, error: "taskId required" };
|
|
130
|
+
if (!args.apiUrl) return { ok: false, error: "apiUrl required" };
|
|
131
|
+
|
|
132
|
+
const headers = { "Content-Type": "application/json" };
|
|
133
|
+
if (args.apiKey) headers["Authorization"] = `Bearer ${args.apiKey}`;
|
|
134
|
+
const body = `❓ Agent question (escalated, headless): ${args.question}`;
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const commentRes = await fetchImpl(
|
|
138
|
+
`${args.apiUrl.replace(/\/+$/, "")}/issues/${args.taskId}/comments`,
|
|
139
|
+
{ method: "POST", headers, body: JSON.stringify({ body }) },
|
|
140
|
+
);
|
|
141
|
+
if (!commentRes.ok) {
|
|
142
|
+
return { ok: false, error: `comment POST ${commentRes.status}` };
|
|
143
|
+
}
|
|
144
|
+
const commentJson = await commentRes.json().catch(() => ({}));
|
|
145
|
+
const commentId = commentJson?.id;
|
|
146
|
+
|
|
147
|
+
const patchRes = await fetchImpl(
|
|
148
|
+
`${args.apiUrl.replace(/\/+$/, "")}/issues/${args.taskId}`,
|
|
149
|
+
{ method: "PATCH", headers, body: JSON.stringify({ status: "blocked" }) },
|
|
150
|
+
);
|
|
151
|
+
if (!patchRes.ok) {
|
|
152
|
+
return { ok: false, error: `status PATCH ${patchRes.status}`, commentId };
|
|
153
|
+
}
|
|
154
|
+
return { ok: true, commentId };
|
|
155
|
+
} catch (err) {
|
|
156
|
+
return { ok: false, error: err && err.message ? err.message : String(err) };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Hermes prints clarify questions to its TUI; in `-q` quiet mode it is not
|
|
162
|
+
* trivial to extract them. This helper detects the marker line emitted by
|
|
163
|
+
* Hermes' verbose log when entering clarify, so a future safety-net watcher
|
|
164
|
+
* can pull the question text. Exposed for test ergonomics.
|
|
165
|
+
*
|
|
166
|
+
* Marker format (Hermes v0.12.0): `[tool] clarify { "question": "...", ... }`
|
|
167
|
+
* or the `❓ Clarifying Questions` banner.
|
|
168
|
+
*
|
|
169
|
+
* @param {string} line
|
|
170
|
+
* @returns {string|null} The question text if the line marks clarify entry.
|
|
171
|
+
*/
|
|
172
|
+
export function parseClarifyMarker(line) {
|
|
173
|
+
if (!line || typeof line !== "string") return null;
|
|
174
|
+
// Verbose tool-call line: [tool] clarify {"question":"..."}
|
|
175
|
+
const toolMatch = line.match(/\[tool\]\s+clarify\s+(\{.*\})/);
|
|
176
|
+
if (toolMatch) {
|
|
177
|
+
try {
|
|
178
|
+
const obj = JSON.parse(toolMatch[1]);
|
|
179
|
+
if (obj && typeof obj.question === "string") return obj.question;
|
|
180
|
+
} catch {
|
|
181
|
+
// Fall through to other markers.
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|