taskflow-hosts 0.2.8 → 0.2.10
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/README.md +8 -5
- package/dist/hermes-runner.d.ts +187 -0
- package/dist/hermes-runner.d.ts.map +1 -0
- package/dist/hermes-runner.js +1006 -0
- package/dist/hermes-runner.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/package.json +9 -3
|
@@ -0,0 +1,1006 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hermes Agent subagent runner — the Hermes host's `SubagentRunner`.
|
|
3
|
+
*
|
|
4
|
+
* Spawns an isolated one-shot:
|
|
5
|
+
* hermes chat -q <prompt> -Q --source tool [--in cwd] [-m model] [-t toolsets]
|
|
6
|
+
* [--reasoning level] [--max-turns N] [--yolo]
|
|
7
|
+
*
|
|
8
|
+
* Quiet mode (`-Q`) emits plain text on stdout (final answer).
|
|
9
|
+
* Session id is printed on stderr by Hermes so piped stdout stays clean.
|
|
10
|
+
* Mapping to the host-neutral contract:
|
|
11
|
+
* - output = stdout answer text (session id is stderr-only metadata)
|
|
12
|
+
* - lastActivity = last non-empty stdout line
|
|
13
|
+
* - usage = unavailable from quiet mode (emptyUsage); budgeted runs
|
|
14
|
+
* still fail-closed at the engine when costs are required
|
|
15
|
+
* - failure = non-zero exit, or empty output with non-zero semantics
|
|
16
|
+
*
|
|
17
|
+
* Permission mapping:
|
|
18
|
+
* - read-only + local-read tools → `-t taskflow_readonly_files`
|
|
19
|
+
* (ephemeral plugin: read_file + search_files only; write_file/patch blocked)
|
|
20
|
+
* - read-only without local tools → explicit empty model-only `-t`; network opt-in via
|
|
21
|
+
* PI_TASKFLOW_HERMES_READONLY_WEB=1 → web,search
|
|
22
|
+
* - mutating / default-capable → requires PI_TASKFLOW_HERMES_UNSAFE_YOLO=1 + `--yolo`
|
|
23
|
+
* - isolation: ephemeral HERMES_HOME (creds + minimal config + RO plugin) and
|
|
24
|
+
* `--ignore-rules` (not `--safe-mode`, so our config can disable reasoning UI)
|
|
25
|
+
*
|
|
26
|
+
* Quiet mode (`-Q`): answer on stdout; `session_id:` on stderr. Reasoning boxes
|
|
27
|
+
* are suppressed via config and stripped from output as defense-in-depth.
|
|
28
|
+
* Process handling (idle watchdog, abort, signal-kill, stderr cap, sanitize)
|
|
29
|
+
* is delegated to shared `runSubagentProcess` in taskflow-core.
|
|
30
|
+
*
|
|
31
|
+
* @see https://hermes-agent.nousresearch.com/docs/
|
|
32
|
+
*/
|
|
33
|
+
import { runSubagentProcess, sanitizeErrorMessage, unknownAgentResult, } from "taskflow-core";
|
|
34
|
+
import { emptyUsage } from "taskflow-core";
|
|
35
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
36
|
+
import { homedir, tmpdir } from "node:os";
|
|
37
|
+
import { join, resolve as resolvePath, sep } from "node:path";
|
|
38
|
+
import { filteredChildEnv } from "./child-env.js";
|
|
39
|
+
/** Mirrors taskflow-core TRANSPORT_ERROR_PLACEHOLDER (not always re-exported). */
|
|
40
|
+
const UPSTREAM_ERROR_PLACEHOLDER = "(upstream error: subagent failed; see error)";
|
|
41
|
+
/** Explicit operator acknowledgement required before Hermes may use `--yolo`
|
|
42
|
+
* (bypass dangerous-command approvals) for mutating/default-capable phases. */
|
|
43
|
+
export const HERMES_UNSAFE_YOLO_ENV = "PI_TASKFLOW_HERMES_UNSAFE_YOLO";
|
|
44
|
+
/** Optional max-turns override for child Hermes runs (default 64). */
|
|
45
|
+
export const HERMES_MAX_TURNS_ENV = "PI_TASKFLOW_HERMES_MAX_TURNS";
|
|
46
|
+
export function hermesUnsafeYoloEnabled(env = process.env) {
|
|
47
|
+
return env[HERMES_UNSAFE_YOLO_ENV] === "1";
|
|
48
|
+
}
|
|
49
|
+
/** Parent Hermes env keys that must never leak into taskflow children. */
|
|
50
|
+
const HERMES_CHILD_DENY = new Set([
|
|
51
|
+
"HERMES_YOLO_MODE",
|
|
52
|
+
"HERMES_ACCEPT_HOOKS",
|
|
53
|
+
// Avoid inheriting gateway/session routing that is irrelevant to one-shot children.
|
|
54
|
+
"HERMES_GATEWAY_TOKEN",
|
|
55
|
+
"HERMES_API_SERVER_KEY",
|
|
56
|
+
// Prompt/control-plane injection — never let parent gateway steer children.
|
|
57
|
+
"HERMES_PREFILL_MESSAGES_FILE",
|
|
58
|
+
"HERMES_EPHEMERAL_SYSTEM_PROMPT",
|
|
59
|
+
"HERMES_EXTRA_SYSTEM_PROMPT",
|
|
60
|
+
"HERMES_SYSTEM_PROMPT",
|
|
61
|
+
"HERMES_SAFE_MODE",
|
|
62
|
+
"HERMES_MAX_ITERATIONS",
|
|
63
|
+
"HERMES_REDACT_SECRETS",
|
|
64
|
+
"HERMES_ENVIRONMENT_HINT",
|
|
65
|
+
"HERMES_WRITE_SAFE_ROOT",
|
|
66
|
+
"HERMES_PLATFORM",
|
|
67
|
+
"PI_TASKFLOW_HERMES_UNSAFE_YOLO",
|
|
68
|
+
]);
|
|
69
|
+
/**
|
|
70
|
+
* Build a least-privilege env for a Hermes child.
|
|
71
|
+
* Keeps provider credentials + HERMES_HOME (for .env auth), but strips YOLO and
|
|
72
|
+
* other process-scoped bypass flags so parent gateway yolo cannot silently arm
|
|
73
|
+
* a read-only phase. Also strips prompt-injection HERMES_* control vars.
|
|
74
|
+
*/
|
|
75
|
+
export function hermesChildEnv(source = process.env, opts = {}) {
|
|
76
|
+
const filtered = filteredChildEnv(source, ["HERMES_HOME", "OPENROUTER_API_KEY", "NOUS_API_KEY"], [
|
|
77
|
+
"OPENAI_",
|
|
78
|
+
"ANTHROPIC_",
|
|
79
|
+
"GOOGLE_",
|
|
80
|
+
"GEMINI_",
|
|
81
|
+
"XAI_",
|
|
82
|
+
"GROQ_",
|
|
83
|
+
"MISTRAL_",
|
|
84
|
+
"COHERE_",
|
|
85
|
+
"DEEPSEEK_",
|
|
86
|
+
// Intentionally omit AWS_/AZURE_ — cloud control-plane keys are too
|
|
87
|
+
// broad for YOLO children; operators can PI_TASKFLOW_CHILD_ENV_ALLOW.
|
|
88
|
+
]);
|
|
89
|
+
for (const key of Object.keys(filtered)) {
|
|
90
|
+
const upper = key.toUpperCase();
|
|
91
|
+
// HERMES_HOME is replaced with the ephemeral child home below. Every
|
|
92
|
+
// other HERMES_* value is host control-plane state, not provider auth.
|
|
93
|
+
if (upper.startsWith("HERMES_") && upper !== "HERMES_HOME") {
|
|
94
|
+
delete filtered[key];
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (HERMES_CHILD_DENY.has(upper) || HERMES_CHILD_DENY.has(key)) {
|
|
98
|
+
delete filtered[key];
|
|
99
|
+
}
|
|
100
|
+
// Strip any HERMES_* prompt/prefill control surface by substring.
|
|
101
|
+
if (upper.startsWith("HERMES_") &&
|
|
102
|
+
/(PREFILL|EPHEMERAL|SYSTEM_PROMPT|YOLO|ACCEPT_HOOKS|GATEWAY|API_SERVER)/.test(upper)) {
|
|
103
|
+
delete filtered[key];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Explicit deny even if casing differs.
|
|
107
|
+
delete filtered.HERMES_YOLO_MODE;
|
|
108
|
+
delete filtered.HERMES_ACCEPT_HOOKS;
|
|
109
|
+
if (opts.allowUnsafeYolo) {
|
|
110
|
+
// Prefer argv --yolo; do not also freeze HERMES_YOLO_MODE unless needed.
|
|
111
|
+
}
|
|
112
|
+
return filtered;
|
|
113
|
+
}
|
|
114
|
+
export function newHermesAccumulator(model) {
|
|
115
|
+
return { usage: emptyUsage(), model, finalText: "", lastActivity: "" };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Fold one stdout line from `hermes chat -Q`. Stdout is answer text; Hermes
|
|
119
|
+
* session metadata is parsed from stderr after process exit. Empty lines are
|
|
120
|
+
* ignored for activity but preserved inside the body once content has started.
|
|
121
|
+
*/
|
|
122
|
+
export function foldHermesQuietLine(acc, line) {
|
|
123
|
+
// Keep trailing content fidelity: only strip the CR Hermes sometimes leaves.
|
|
124
|
+
const raw = line.replace(/\r$/, "");
|
|
125
|
+
if (!raw.trim()) {
|
|
126
|
+
// Preserve blank lines inside the answer body once started.
|
|
127
|
+
if (acc.finalText)
|
|
128
|
+
acc.finalText += "\n";
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
// Quiet mode can occasionally print a warning line to stdout; treat obvious
|
|
132
|
+
// fatal markers as errors rather than answer text.
|
|
133
|
+
if (/^error:\s+/i.test(raw) && !acc.finalText.trim()) {
|
|
134
|
+
acc.fatalError = raw.replace(/^error:\s+/i, "").trim() || "hermes run failed";
|
|
135
|
+
acc.lastActivity = `error: ${acc.fatalError}`;
|
|
136
|
+
return { text: acc.lastActivity, usage: { ...acc.usage }, model: acc.model };
|
|
137
|
+
}
|
|
138
|
+
if (acc.finalText)
|
|
139
|
+
acc.finalText += "\n";
|
|
140
|
+
acc.finalText += raw;
|
|
141
|
+
acc.terminalSeen = true;
|
|
142
|
+
acc.lastActivity = raw.trim();
|
|
143
|
+
return { text: acc.lastActivity, usage: { ...acc.usage }, model: acc.model };
|
|
144
|
+
}
|
|
145
|
+
/** Override the hermes binary (tests / unusual installs). */
|
|
146
|
+
export function hermesBin() {
|
|
147
|
+
return process.env.PI_TASKFLOW_HERMES_BIN || "hermes";
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Decide whether a phase is read-only from its tool whitelist. No whitelist →
|
|
151
|
+
* not read-only (default-capable, needs --yolo opt-in).
|
|
152
|
+
*
|
|
153
|
+
* Hermes-mutating tool aliases (taskflow DSL style + hermes native names).
|
|
154
|
+
*/
|
|
155
|
+
export function isHermesReadOnlyPhase(tools) {
|
|
156
|
+
if (!tools || tools.length === 0)
|
|
157
|
+
return false;
|
|
158
|
+
const mutating = new Set([
|
|
159
|
+
"write",
|
|
160
|
+
"edit",
|
|
161
|
+
"bash",
|
|
162
|
+
"terminal",
|
|
163
|
+
"process",
|
|
164
|
+
"apply_patch",
|
|
165
|
+
"write_file",
|
|
166
|
+
"patch",
|
|
167
|
+
"execute_code",
|
|
168
|
+
"code_execution",
|
|
169
|
+
"delegate_task",
|
|
170
|
+
"computer_use",
|
|
171
|
+
"skill_manage",
|
|
172
|
+
"memory",
|
|
173
|
+
"browser",
|
|
174
|
+
"browser_navigate",
|
|
175
|
+
"cronjob",
|
|
176
|
+
"send_message",
|
|
177
|
+
"text_to_speech",
|
|
178
|
+
]);
|
|
179
|
+
return !tools.some((t) => mutating.has(t));
|
|
180
|
+
}
|
|
181
|
+
/** Hermes toolset name registered by the ephemeral taskflow_readonly plugin. */
|
|
182
|
+
export const HERMES_READONLY_FILES_TOOLSET = "taskflow_readonly_files";
|
|
183
|
+
/** Empty toolset — always pass `-t` so Hermes does not fall back to hermes-cli defaults. */
|
|
184
|
+
export const HERMES_MODEL_ONLY_TOOLSET = "taskflow_model_only";
|
|
185
|
+
const LOCAL_READ_TOOLS = new Set([
|
|
186
|
+
"read",
|
|
187
|
+
"read_file",
|
|
188
|
+
"grep",
|
|
189
|
+
"glob",
|
|
190
|
+
"search_files",
|
|
191
|
+
"ls",
|
|
192
|
+
"list",
|
|
193
|
+
"list_dir",
|
|
194
|
+
]);
|
|
195
|
+
/**
|
|
196
|
+
* Map a phase tool whitelist to Hermes `-t` toolsets. Best-effort:
|
|
197
|
+
* - read-only + local-read aliases → `taskflow_readonly_files` (plugin)
|
|
198
|
+
* - read-only + READONLY_WEB → adds web,search
|
|
199
|
+
* - read-only otherwise → `taskflow_model_only` (empty toolset; NEVER omit -t —
|
|
200
|
+
* Hermes defaults to full hermes-cli tools when -t is absent)
|
|
201
|
+
* - mutating with explicit tools → union of matching toolsets (narrow)
|
|
202
|
+
* - default / empty tools → file,terminal (network/control-plane denied)
|
|
203
|
+
* - unmapped non-empty tools list → throw (never fail-open to wide default)
|
|
204
|
+
*/
|
|
205
|
+
export function resolveHermesToolsets(tools, readOnly, opts = {}) {
|
|
206
|
+
if (readOnly) {
|
|
207
|
+
const sets = new Set();
|
|
208
|
+
if (tools && tools.some((t) => LOCAL_READ_TOOLS.has(t))) {
|
|
209
|
+
sets.add(HERMES_READONLY_FILES_TOOLSET);
|
|
210
|
+
}
|
|
211
|
+
if (opts.readonlyWeb) {
|
|
212
|
+
sets.add("web");
|
|
213
|
+
sets.add("search");
|
|
214
|
+
}
|
|
215
|
+
// Critical: omitting -t loads full hermes-cli defaults (terminal/write/…).
|
|
216
|
+
if (sets.size === 0)
|
|
217
|
+
sets.add(HERMES_MODEL_ONLY_TOOLSET);
|
|
218
|
+
return [...sets].sort().join(",");
|
|
219
|
+
}
|
|
220
|
+
if (!tools || tools.length === 0)
|
|
221
|
+
return "file,terminal";
|
|
222
|
+
const sets = new Set();
|
|
223
|
+
const unmapped = [];
|
|
224
|
+
for (const t of tools) {
|
|
225
|
+
switch (t) {
|
|
226
|
+
case "read":
|
|
227
|
+
case "read_file":
|
|
228
|
+
case "grep":
|
|
229
|
+
case "glob":
|
|
230
|
+
case "search_files":
|
|
231
|
+
case "write":
|
|
232
|
+
case "edit":
|
|
233
|
+
case "write_file":
|
|
234
|
+
case "patch":
|
|
235
|
+
case "apply_patch":
|
|
236
|
+
sets.add("file");
|
|
237
|
+
break;
|
|
238
|
+
case "bash":
|
|
239
|
+
case "terminal":
|
|
240
|
+
case "process":
|
|
241
|
+
sets.add("terminal");
|
|
242
|
+
break;
|
|
243
|
+
case "web_search":
|
|
244
|
+
case "web_extract":
|
|
245
|
+
case "web":
|
|
246
|
+
sets.add("web");
|
|
247
|
+
break;
|
|
248
|
+
default:
|
|
249
|
+
unmapped.push(t);
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (unmapped.length > 0 || sets.size === 0) {
|
|
254
|
+
throw new Error(`Hermes tool whitelist [${tools.join(", ")}] did not map safely to supported Hermes toolsets. ` +
|
|
255
|
+
`0.2.10 permits only local file, terminal, and explicit web aliases; ` +
|
|
256
|
+
`delegation/skills/memory/browser/cron/control-plane tools are denied.`);
|
|
257
|
+
}
|
|
258
|
+
return [...sets].sort().join(",");
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Strip Hermes quiet-mode reasoning chrome and model think-tags from text.
|
|
262
|
+
* Primary suppression is `display.show_reasoning: false` in the ephemeral
|
|
263
|
+
* config; this is defense-in-depth when a model still leaks boxes/tags.
|
|
264
|
+
*/
|
|
265
|
+
export function stripHermesReasoningNoise(text) {
|
|
266
|
+
if (!text)
|
|
267
|
+
return text;
|
|
268
|
+
let t = text;
|
|
269
|
+
// Full reasoning box (open + body until blank line before final answer is hard;
|
|
270
|
+
// remove the box-drawing header line and matching footer if present).
|
|
271
|
+
t = t.replace(/^\s*┌─\s*Reasoning[^\n]*\n?/gim, "");
|
|
272
|
+
t = t.replace(/^\s*└[─\s]*┘\s*\n?/gim, "");
|
|
273
|
+
// XML-ish think blocks (also stripped by Hermes CLI when displayed; belt-and-suspenders).
|
|
274
|
+
t = t.replace(/<think(?:ing)?\b[^>]*>[\s\S]*?<\/think(?:ing)?>/gi, "");
|
|
275
|
+
t = t.replace(/<reasoning\b[^>]*>[\s\S]*?<\/reasoning>/gi, "");
|
|
276
|
+
t = t.replace(/<REASONING_SCRATCHPAD\b[^>]*>[\s\S]*?<\/REASONING_SCRATCHPAD>/gi, "");
|
|
277
|
+
// Hermes CLI transport chrome: fallback selection is diagnostic metadata,
|
|
278
|
+
// not part of the model's answer body.
|
|
279
|
+
t = t.replace(/^\s*⚠️\s+Primary auth failed\s+—\s+switching to fallback:[^\n]*\n?/gim, "");
|
|
280
|
+
// Collapse leading blank lines left by stripped headers.
|
|
281
|
+
t = t.replace(/^\s*\n+/, "");
|
|
282
|
+
return t.trimEnd();
|
|
283
|
+
}
|
|
284
|
+
/** Opt-in network for read-only Hermes phases (default off). */
|
|
285
|
+
export const HERMES_READONLY_WEB_ENV = "PI_TASKFLOW_HERMES_READONLY_WEB";
|
|
286
|
+
export function hermesReadonlyWebEnabled(env = process.env) {
|
|
287
|
+
return env[HERMES_READONLY_WEB_ENV] === "1";
|
|
288
|
+
}
|
|
289
|
+
/** Resolve a taskflow model id to something `hermes chat -m` accepts. */
|
|
290
|
+
export function resolveHermesModel(model) {
|
|
291
|
+
if (!model)
|
|
292
|
+
return undefined;
|
|
293
|
+
if (/^\{\{.*\}\}$/.test(model))
|
|
294
|
+
return undefined; // unresolved role placeholder
|
|
295
|
+
// Drop pi thinking suffixes: `provider/model:xhigh`
|
|
296
|
+
if (/:\s*(?:xhigh|high|medium|low|off|none|minimal|max|ultra)$/i.test(model)) {
|
|
297
|
+
const bare = model.replace(/:\s*(?:xhigh|high|medium|low|off|none|minimal|max|ultra)$/i, "");
|
|
298
|
+
return bare || undefined;
|
|
299
|
+
}
|
|
300
|
+
return model;
|
|
301
|
+
}
|
|
302
|
+
/** Normalize Taskflow thinking aliases to Hermes `--reasoning` levels. */
|
|
303
|
+
export function resolveHermesReasoning(thinking) {
|
|
304
|
+
if (!thinking)
|
|
305
|
+
return undefined;
|
|
306
|
+
const normalized = thinking.trim().toLowerCase();
|
|
307
|
+
if (normalized === "off")
|
|
308
|
+
return "none";
|
|
309
|
+
if (["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"].includes(normalized)) {
|
|
310
|
+
return normalized;
|
|
311
|
+
}
|
|
312
|
+
throw new Error(`Unsupported Hermes reasoning level '${thinking}'. Use off, none, minimal, low, medium, high, xhigh, max, or ultra.`);
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Build the full `hermes chat` argv — PURE (no process.env, no spawn).
|
|
316
|
+
*
|
|
317
|
+
* hermes chat -q <prompt> -Q --source tool
|
|
318
|
+
* --ignore-rules
|
|
319
|
+
* [--in cwd] [-m model] [-t toolsets] [--reasoning level]
|
|
320
|
+
* [--max-turns N] [--yolo]
|
|
321
|
+
*
|
|
322
|
+
* Isolation: ephemeral HERMES_HOME (credentials + show_reasoning:false + RO
|
|
323
|
+
* plugin). `--ignore-rules` skips AGENTS.md injection. Do not use --safe-mode
|
|
324
|
+
* (it would ignore our ephemeral config/plugin).
|
|
325
|
+
* Credentials still load from the ephemeral home's .env/auth.json. */
|
|
326
|
+
export function buildHermesArgs(ctx) {
|
|
327
|
+
const hermesModel = resolveHermesModel(ctx.model);
|
|
328
|
+
const readOnly = isHermesReadOnlyPhase(ctx.tools);
|
|
329
|
+
const toolsets = resolveHermesToolsets(ctx.tools, readOnly, { readonlyWeb: ctx.readonlyWeb });
|
|
330
|
+
const fullPrompt = ctx.systemPrompt.trim()
|
|
331
|
+
? `${ctx.systemPrompt.trim()}\n\n---\n\nTask: ${ctx.task}`
|
|
332
|
+
: `Task: ${ctx.task}`;
|
|
333
|
+
if (!readOnly && !ctx.allowUnsafeYolo) {
|
|
334
|
+
throw new Error(`Hermes mutating/default-capable phases require unsandboxed --yolo permissions. ` +
|
|
335
|
+
`Set ${HERMES_UNSAFE_YOLO_ENV}=1 to explicitly allow this execution.`);
|
|
336
|
+
}
|
|
337
|
+
const maxTurns = ctx.maxTurns && ctx.maxTurns > 0 ? Math.floor(ctx.maxTurns) : 64;
|
|
338
|
+
const args = [
|
|
339
|
+
"chat",
|
|
340
|
+
"-q",
|
|
341
|
+
fullPrompt,
|
|
342
|
+
"-Q", // quiet: final answer on stdout; session_id on stderr
|
|
343
|
+
"--source",
|
|
344
|
+
"tool", // third-party integrations — hide from user session lists
|
|
345
|
+
// Isolate from parent rules injection. Ephemeral HERMES_HOME supplies
|
|
346
|
+
// config (show_reasoning:false, no mcp) + RO plugin — do NOT use
|
|
347
|
+
// --safe-mode (it would ignore that config).
|
|
348
|
+
"--ignore-rules",
|
|
349
|
+
"--max-turns",
|
|
350
|
+
String(maxTurns),
|
|
351
|
+
];
|
|
352
|
+
// Empty toolsets must never happen for RO (Critical fail-open). Always pass -t.
|
|
353
|
+
if (toolsets) {
|
|
354
|
+
args.push("-t", toolsets);
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
args.push("-t", HERMES_MODEL_ONLY_TOOLSET);
|
|
358
|
+
}
|
|
359
|
+
if (ctx.cwd)
|
|
360
|
+
args.push("--in", ctx.cwd);
|
|
361
|
+
if (hermesModel)
|
|
362
|
+
args.push("-m", hermesModel);
|
|
363
|
+
const reasoning = resolveHermesReasoning(ctx.thinking);
|
|
364
|
+
if (reasoning)
|
|
365
|
+
args.push("--reasoning", reasoning);
|
|
366
|
+
if (!readOnly)
|
|
367
|
+
args.push("--yolo");
|
|
368
|
+
return { args, readOnly, toolsets };
|
|
369
|
+
}
|
|
370
|
+
/** Inference-provider ids whose auth-store entries may enter a child profile. */
|
|
371
|
+
const HERMES_INFERENCE_AUTH_PROVIDERS = new Set([
|
|
372
|
+
"alibaba-coding-plan", "anthropic", "arcee", "azure-foundry", "cohere",
|
|
373
|
+
"copilot", "dashscope", "deepinfra", "deepseek", "fireworks", "gemini",
|
|
374
|
+
"gmi", "google", "groq", "hf", "huggingface", "kimi", "kimi-coding",
|
|
375
|
+
"minimax", "minimax-oauth", "mistral", "nous", "novita", "nvidia",
|
|
376
|
+
"ollama", "openai", "openai-codex", "opencode", "opencode-go",
|
|
377
|
+
"opencode-zen", "openrouter", "stepfun", "together", "tokenhub", "upstage",
|
|
378
|
+
"xai", "xai-oauth", "xiaomi", "zai", "zhipu", "glm",
|
|
379
|
+
]);
|
|
380
|
+
/** Exact inference-provider dotenv keys allowed into an ephemeral child home. */
|
|
381
|
+
const HERMES_PROVIDER_DOTENV_KEYS = new Set([
|
|
382
|
+
"OPENROUTER_API_KEY",
|
|
383
|
+
"NOUS_API_KEY",
|
|
384
|
+
"OPENAI_API_KEY",
|
|
385
|
+
"OPENAI_BASE_URL",
|
|
386
|
+
"ANTHROPIC_API_KEY",
|
|
387
|
+
"ANTHROPIC_TOKEN",
|
|
388
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
389
|
+
"ANTHROPIC_BASE_URL",
|
|
390
|
+
"GOOGLE_API_KEY",
|
|
391
|
+
"GEMINI_API_KEY",
|
|
392
|
+
"GEMINI_BASE_URL",
|
|
393
|
+
"XAI_API_KEY",
|
|
394
|
+
"XAI_BASE_URL",
|
|
395
|
+
"GROQ_API_KEY",
|
|
396
|
+
"GROQ_BASE_URL",
|
|
397
|
+
"MISTRAL_API_KEY",
|
|
398
|
+
"MISTRAL_BASE_URL",
|
|
399
|
+
"COHERE_API_KEY",
|
|
400
|
+
"COHERE_BASE_URL",
|
|
401
|
+
"DEEPSEEK_API_KEY",
|
|
402
|
+
"DEEPSEEK_BASE_URL",
|
|
403
|
+
"TOGETHER_API_KEY",
|
|
404
|
+
"TOGETHER_BASE_URL",
|
|
405
|
+
"DASHSCOPE_API_KEY",
|
|
406
|
+
"FIREWORKS_API_KEY",
|
|
407
|
+
"HF_TOKEN",
|
|
408
|
+
"HUGGINGFACEHUB_API_TOKEN",
|
|
409
|
+
"MINIMAX_API_KEY",
|
|
410
|
+
"MINIMAX_BASE_URL",
|
|
411
|
+
"NVIDIA_API_KEY",
|
|
412
|
+
"OLLAMA_BASE_URL",
|
|
413
|
+
"OPENCODE_GO_API_KEY",
|
|
414
|
+
"ZAI_API_KEY",
|
|
415
|
+
"ZHIPU_API_KEY",
|
|
416
|
+
"GLM_API_KEY",
|
|
417
|
+
]);
|
|
418
|
+
/** Keep only explicitly supported inference-provider assignments from dotenv. */
|
|
419
|
+
export function filterHermesProviderDotenv(source) {
|
|
420
|
+
const kept = [];
|
|
421
|
+
for (const line of source.split(/\r?\n/)) {
|
|
422
|
+
const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/);
|
|
423
|
+
if (match && HERMES_PROVIDER_DOTENV_KEYS.has(match[1]))
|
|
424
|
+
kept.push(line);
|
|
425
|
+
}
|
|
426
|
+
return kept.length > 0 ? `${kept.join("\n")}\n` : "";
|
|
427
|
+
}
|
|
428
|
+
function isRecord(value) {
|
|
429
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
430
|
+
}
|
|
431
|
+
/** Filter Hermes auth.json to known inference-provider credential entries. */
|
|
432
|
+
export function filterHermesAuthJson(source, routedProviders = new Set()) {
|
|
433
|
+
let parsed;
|
|
434
|
+
try {
|
|
435
|
+
parsed = JSON.parse(source);
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
return "";
|
|
439
|
+
}
|
|
440
|
+
if (!isRecord(parsed))
|
|
441
|
+
return "";
|
|
442
|
+
const out = {};
|
|
443
|
+
if (typeof parsed.version === "number")
|
|
444
|
+
out.version = parsed.version;
|
|
445
|
+
if (typeof parsed.updated_at === "string")
|
|
446
|
+
out.updated_at = parsed.updated_at;
|
|
447
|
+
let activeProvider;
|
|
448
|
+
if (typeof parsed.active_provider === "string" &&
|
|
449
|
+
HERMES_INFERENCE_AUTH_PROVIDERS.has(parsed.active_provider)) {
|
|
450
|
+
activeProvider = parsed.active_provider;
|
|
451
|
+
}
|
|
452
|
+
const allowed = new Set([...routedProviders].filter((provider) => HERMES_INFERENCE_AUTH_PROVIDERS.has(provider)));
|
|
453
|
+
if (allowed.size === 0 && activeProvider)
|
|
454
|
+
allowed.add(activeProvider);
|
|
455
|
+
if (activeProvider && allowed.has(activeProvider))
|
|
456
|
+
out.active_provider = activeProvider;
|
|
457
|
+
for (const key of ["providers", "credential_pool"]) {
|
|
458
|
+
const sourceMap = parsed[key];
|
|
459
|
+
if (!isRecord(sourceMap))
|
|
460
|
+
continue;
|
|
461
|
+
const kept = {};
|
|
462
|
+
for (const [provider, value] of Object.entries(sourceMap)) {
|
|
463
|
+
if (allowed.has(provider))
|
|
464
|
+
kept[provider] = value;
|
|
465
|
+
}
|
|
466
|
+
if (Object.keys(kept).length > 0)
|
|
467
|
+
out[key] = kept;
|
|
468
|
+
}
|
|
469
|
+
return `${JSON.stringify(out, null, 2)}\n`;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Pull a top-level YAML mapping block (e.g. `model:`) from parent config text.
|
|
473
|
+
* Indentation-based; no full YAML parser dependency.
|
|
474
|
+
*/
|
|
475
|
+
export function extractYamlTopLevelBlock(source, key) {
|
|
476
|
+
const lines = source.split(/\r?\n/);
|
|
477
|
+
const start = lines.findIndex((l) => new RegExp(`^${key}:\\s*(?:#.*)?$`).test(l) || new RegExp(`^${key}:\\s+\\S`).test(l));
|
|
478
|
+
if (start < 0)
|
|
479
|
+
return undefined;
|
|
480
|
+
const first = lines[start];
|
|
481
|
+
// Inline scalar: `model: foo`
|
|
482
|
+
if (/^[\w-]+:\s+\S/.test(first) && !first.trimEnd().endsWith(":")) {
|
|
483
|
+
return first;
|
|
484
|
+
}
|
|
485
|
+
const out = [first];
|
|
486
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
487
|
+
const line = lines[i];
|
|
488
|
+
if (line.trim() === "" ||
|
|
489
|
+
line.startsWith(" ") ||
|
|
490
|
+
line.startsWith(" ") ||
|
|
491
|
+
line.trimStart().startsWith("#")) {
|
|
492
|
+
out.push(line);
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
// next top-level key
|
|
496
|
+
if (/^[\w-]+:/.test(line))
|
|
497
|
+
break;
|
|
498
|
+
out.push(line);
|
|
499
|
+
}
|
|
500
|
+
// trim trailing blank lines
|
|
501
|
+
while (out.length && out[out.length - 1].trim() === "")
|
|
502
|
+
out.pop();
|
|
503
|
+
return out.join("\n");
|
|
504
|
+
}
|
|
505
|
+
function safeHermesRoutingScalar(field, value) {
|
|
506
|
+
if (!/^[A-Za-z0-9_./:@+\-]+$/.test(value))
|
|
507
|
+
return undefined;
|
|
508
|
+
if (field === "api_mode")
|
|
509
|
+
return value;
|
|
510
|
+
if (field !== "base_url")
|
|
511
|
+
return value;
|
|
512
|
+
try {
|
|
513
|
+
const url = new URL(value);
|
|
514
|
+
if (!["http:", "https:"].includes(url.protocol))
|
|
515
|
+
return undefined;
|
|
516
|
+
if (url.username || url.password || url.search || url.hash)
|
|
517
|
+
return undefined;
|
|
518
|
+
return value;
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
return undefined;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Carry only non-secret scalar routing fields from the parent config.
|
|
526
|
+
* This deliberately omits `providers:` and all nested mappings (MCP/plugins/
|
|
527
|
+
* api_key/token) rather than treating indentation as a security boundary.
|
|
528
|
+
*/
|
|
529
|
+
export function sanitizeHermesRoutingConfig(source) {
|
|
530
|
+
const parts = [];
|
|
531
|
+
const model = extractYamlTopLevelBlock(source, "model");
|
|
532
|
+
if (model) {
|
|
533
|
+
const firstLine = model.split(/\r?\n/, 1)[0];
|
|
534
|
+
if (/^model:\s+[A-Za-z0-9_./:@+\-]+\s*$/.test(firstLine)) {
|
|
535
|
+
parts.push(firstLine.trimEnd());
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
const fields = model.split(/\r?\n/).flatMap((line) => {
|
|
539
|
+
const match = line.match(/^ (default|provider|base_url|api_mode):\s+([A-Za-z0-9_./:@+\-]+)\s*$/);
|
|
540
|
+
const value = match ? safeHermesRoutingScalar(match[1], match[2]) : undefined;
|
|
541
|
+
return match && value ? [` ${match[1]}: ${value}`] : [];
|
|
542
|
+
});
|
|
543
|
+
if (fields.length > 0)
|
|
544
|
+
parts.push(["model:", ...fields].join("\n"));
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
const fallback = extractYamlTopLevelBlock(source, "fallback_providers");
|
|
548
|
+
if (fallback) {
|
|
549
|
+
const rows = [];
|
|
550
|
+
for (const line of fallback.split(/\r?\n/).slice(1)) {
|
|
551
|
+
const first = line.match(/^ - (provider|model|base_url|api_mode):\s+([A-Za-z0-9_./:@+\-]+)\s*$/);
|
|
552
|
+
const firstValue = first ? safeHermesRoutingScalar(first[1], first[2]) : undefined;
|
|
553
|
+
if (first && firstValue) {
|
|
554
|
+
rows.push(` - ${first[1]}: ${firstValue}`);
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
const next = line.match(/^ (provider|model|base_url|api_mode):\s+([A-Za-z0-9_./:@+\-]+)\s*$/);
|
|
558
|
+
const nextValue = next ? safeHermesRoutingScalar(next[1], next[2]) : undefined;
|
|
559
|
+
if (next && nextValue && rows.length > 0)
|
|
560
|
+
rows.push(` ${next[1]}: ${nextValue}`);
|
|
561
|
+
}
|
|
562
|
+
if (rows.length > 0)
|
|
563
|
+
parts.push(["fallback_providers:", ...rows].join("\n"));
|
|
564
|
+
}
|
|
565
|
+
return parts.join("\n\n");
|
|
566
|
+
}
|
|
567
|
+
function hermesRoutingProviders(source) {
|
|
568
|
+
const routing = sanitizeHermesRoutingConfig(source);
|
|
569
|
+
const providers = new Set();
|
|
570
|
+
for (const line of routing.split(/\r?\n/)) {
|
|
571
|
+
const mapped = line.match(/^\s*(?:-\s+)?provider:\s+([A-Za-z0-9_.+\-]+)\s*$/);
|
|
572
|
+
if (mapped)
|
|
573
|
+
providers.add(mapped[1]);
|
|
574
|
+
}
|
|
575
|
+
const scalarModel = routing.match(/^model:\s+([A-Za-z0-9_.+\-]+)\//m);
|
|
576
|
+
if (scalarModel)
|
|
577
|
+
providers.add(scalarModel[1]);
|
|
578
|
+
return providers;
|
|
579
|
+
}
|
|
580
|
+
/** Build ephemeral config.yaml text: isolation defaults + parent model routing. */
|
|
581
|
+
export function buildEphemeralHermesConfigYaml(parentHome, opts = {}) {
|
|
582
|
+
const readOnly = opts.readOnly !== false;
|
|
583
|
+
const parts = [
|
|
584
|
+
"display:",
|
|
585
|
+
" show_reasoning: false",
|
|
586
|
+
"mcp_servers: {}",
|
|
587
|
+
];
|
|
588
|
+
if (readOnly) {
|
|
589
|
+
parts.push("plugins:", " enabled:", " - taskflow_readonly", " entries:", " taskflow_readonly:", " enabled: true");
|
|
590
|
+
}
|
|
591
|
+
const parentCfgPath = join(parentHome, "config.yaml");
|
|
592
|
+
if (existsSync(parentCfgPath)) {
|
|
593
|
+
try {
|
|
594
|
+
const raw = readFileSync(parentCfgPath, "utf8");
|
|
595
|
+
const routing = sanitizeHermesRoutingConfig(raw);
|
|
596
|
+
if (routing)
|
|
597
|
+
parts.push("", routing);
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
/* parent model optional — .env may still auth */
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return `${parts.join("\n")}\n`;
|
|
604
|
+
}
|
|
605
|
+
const EPHEMERAL_RO_PLUGIN_YAML = `name: taskflow_readonly
|
|
606
|
+
version: 0.1.0
|
|
607
|
+
description: Taskflow read-only file toolset (read_file + search_files only)
|
|
608
|
+
`;
|
|
609
|
+
const EPHEMERAL_RO_PLUGIN_INIT = `from __future__ import annotations
|
|
610
|
+
|
|
611
|
+
import os
|
|
612
|
+
from pathlib import Path
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def register(ctx) -> None:
|
|
616
|
+
"""Register RO + model-only toolsets; block writes and out-of-cwd reads."""
|
|
617
|
+
from toolsets import create_custom_toolset
|
|
618
|
+
|
|
619
|
+
create_custom_toolset(
|
|
620
|
+
name="taskflow_readonly_files",
|
|
621
|
+
description="Read-only local files for taskflow RO phases",
|
|
622
|
+
tools=["read_file", "search_files"],
|
|
623
|
+
)
|
|
624
|
+
create_custom_toolset(
|
|
625
|
+
name="taskflow_model_only",
|
|
626
|
+
description="No tools (taskflow RO model-only; prevents hermes-cli default toolset)",
|
|
627
|
+
tools=[],
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
_BLOCK = frozenset({
|
|
631
|
+
"write_file", "patch", "terminal", "process", "execute_code",
|
|
632
|
+
"delegate_task", "skill_manage", "computer_use", "cronjob",
|
|
633
|
+
"send_message", "text_to_speech", "browser_navigate", "browser",
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
def _deny(message: str):
|
|
637
|
+
return {
|
|
638
|
+
"action": "block",
|
|
639
|
+
"message": f"taskflow read-only phase: {message}",
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
def _pre_tool(tool_name: str = "", args=None, **kwargs):
|
|
643
|
+
name = tool_name or kwargs.get("name") or ""
|
|
644
|
+
if name in _BLOCK:
|
|
645
|
+
return _deny(f"{name} denied")
|
|
646
|
+
# Constrain every path-bearing local read to the child cwd.
|
|
647
|
+
if name in {"read_file", "search_files"}:
|
|
648
|
+
if not isinstance(args, dict):
|
|
649
|
+
return _deny(f"{name} requires path arguments")
|
|
650
|
+
if name == "search_files":
|
|
651
|
+
value = args.get("path", ".")
|
|
652
|
+
raw = "." if value is None or value == "" else value
|
|
653
|
+
else:
|
|
654
|
+
raw = args.get("path") or args.get("file")
|
|
655
|
+
if raw is None or raw == "":
|
|
656
|
+
return _deny("read_file requires a path")
|
|
657
|
+
if not isinstance(raw, str):
|
|
658
|
+
return _deny("path must be a string")
|
|
659
|
+
try:
|
|
660
|
+
cwd = Path(os.environ.get("PI_TASKFLOW_HERMES_PHASE_CWD", os.getcwd())).resolve(strict=True)
|
|
661
|
+
raw_paths = [raw]
|
|
662
|
+
if name == "search_files":
|
|
663
|
+
# Hermes search_files treats a missing "dir1,dir2" or
|
|
664
|
+
# "dir1 dir2" path as a multi-path request. Validate the
|
|
665
|
+
# same candidates; otherwise a relative prefix such as
|
|
666
|
+
# ".,/etc" resolves under cwd here but later escapes when
|
|
667
|
+
# the tool splits it. Preserve real in-cwd paths with spaces.
|
|
668
|
+
direct = Path(raw).expanduser()
|
|
669
|
+
if not direct.is_absolute():
|
|
670
|
+
direct = cwd / direct
|
|
671
|
+
direct = direct.resolve(strict=False)
|
|
672
|
+
if not direct.exists():
|
|
673
|
+
parts = [
|
|
674
|
+
part
|
|
675
|
+
for chunk in raw.split(",")
|
|
676
|
+
for part in chunk.split()
|
|
677
|
+
if part.strip()
|
|
678
|
+
]
|
|
679
|
+
if len(parts) >= 2:
|
|
680
|
+
raw_paths = parts
|
|
681
|
+
for raw_path in raw_paths:
|
|
682
|
+
target = Path(raw_path).expanduser()
|
|
683
|
+
if not target.is_absolute():
|
|
684
|
+
target = cwd / target
|
|
685
|
+
# Resolve existing symlink ancestors while allowing an
|
|
686
|
+
# in-cwd nonexistent final component to be searched safely.
|
|
687
|
+
target = target.resolve(strict=False)
|
|
688
|
+
target.relative_to(cwd)
|
|
689
|
+
except (OSError, RuntimeError, ValueError):
|
|
690
|
+
return _deny(
|
|
691
|
+
"path escapes phase cwd "
|
|
692
|
+
f"({raw!r}); only paths under the working directory are allowed"
|
|
693
|
+
)
|
|
694
|
+
return None
|
|
695
|
+
|
|
696
|
+
ctx.register_hook("pre_tool_call", _pre_tool)
|
|
697
|
+
`;
|
|
698
|
+
/**
|
|
699
|
+
* Build a throwaway HERMES_HOME with credentials + minimal config + RO plugin.
|
|
700
|
+
* Children authenticate via .env/auth.json, cannot see parent skills/MCP, and
|
|
701
|
+
* get display.show_reasoning=false so quiet stdout stays clean.
|
|
702
|
+
*/
|
|
703
|
+
export function prepareEphemeralHermesHome(parentHome, opts = {}) {
|
|
704
|
+
const root = opts.tmpRoot ?? tmpdir();
|
|
705
|
+
const readOnly = opts.readOnly !== false;
|
|
706
|
+
const home = mkdtempSync(join(root, "taskflow-hermes-"));
|
|
707
|
+
let routedProviders = new Set();
|
|
708
|
+
const parentConfig = join(parentHome, "config.yaml");
|
|
709
|
+
if (existsSync(parentConfig)) {
|
|
710
|
+
try {
|
|
711
|
+
routedProviders = hermesRoutingProviders(readFileSync(parentConfig, "utf8"));
|
|
712
|
+
}
|
|
713
|
+
catch {
|
|
714
|
+
// Missing routing falls back to the auth store's active provider below.
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
const parentAuth = join(parentHome, "auth.json");
|
|
718
|
+
if (existsSync(parentAuth)) {
|
|
719
|
+
try {
|
|
720
|
+
const filteredAuth = filterHermesAuthJson(readFileSync(parentAuth, "utf8"), routedProviders);
|
|
721
|
+
if (filteredAuth)
|
|
722
|
+
writeFileSync(join(home, "auth.json"), filteredAuth, "utf8");
|
|
723
|
+
}
|
|
724
|
+
catch {
|
|
725
|
+
// Missing/unreadable auth surfaces as an authentication failure; never
|
|
726
|
+
// fall back to copying the operator's unfiltered credential store.
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
const parentDotenv = join(parentHome, ".env");
|
|
730
|
+
if (existsSync(parentDotenv)) {
|
|
731
|
+
try {
|
|
732
|
+
const providerDotenv = filterHermesProviderDotenv(readFileSync(parentDotenv, "utf8"));
|
|
733
|
+
if (providerDotenv)
|
|
734
|
+
writeFileSync(join(home, ".env"), providerDotenv, "utf8");
|
|
735
|
+
}
|
|
736
|
+
catch {
|
|
737
|
+
// Process environment and auth.json remain available for provider auth.
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
try {
|
|
741
|
+
writeFileSync(join(home, "config.yaml"), buildEphemeralHermesConfigYaml(parentHome, { readOnly }), "utf8");
|
|
742
|
+
if (readOnly) {
|
|
743
|
+
const plugDir = join(home, "plugins", "taskflow_readonly");
|
|
744
|
+
mkdirSync(plugDir, { recursive: true });
|
|
745
|
+
writeFileSync(join(plugDir, "plugin.yaml"), EPHEMERAL_RO_PLUGIN_YAML, "utf8");
|
|
746
|
+
writeFileSync(join(plugDir, "__init__.py"), EPHEMERAL_RO_PLUGIN_INIT, "utf8");
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
catch (error) {
|
|
750
|
+
// Fail closed: without config/plugin, RO toolsets and show_reasoning are wrong.
|
|
751
|
+
try {
|
|
752
|
+
rmSync(home, { recursive: true, force: true });
|
|
753
|
+
}
|
|
754
|
+
catch {
|
|
755
|
+
/* ignore */
|
|
756
|
+
}
|
|
757
|
+
throw new Error(`Failed to materialize ephemeral Hermes home: ${error instanceof Error ? error.message : String(error)}`);
|
|
758
|
+
}
|
|
759
|
+
return {
|
|
760
|
+
home,
|
|
761
|
+
cleanup: () => {
|
|
762
|
+
try {
|
|
763
|
+
rmSync(home, { recursive: true, force: true });
|
|
764
|
+
}
|
|
765
|
+
catch {
|
|
766
|
+
/* ignore */
|
|
767
|
+
}
|
|
768
|
+
},
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Resolve the operator Hermes profile to clone credentials/model routing from.
|
|
773
|
+
* Prefer `PI_TASKFLOW_HERMES_PARENT_HOME`, then a usable `HERMES_HOME`, then `~/.hermes`.
|
|
774
|
+
* Skips ephemeral taskflow temps and empty tmp HERMES_HOME leftovers.
|
|
775
|
+
*/
|
|
776
|
+
export function resolveParentHermesHome(env = process.env) {
|
|
777
|
+
const override = env.PI_TASKFLOW_HERMES_PARENT_HOME?.trim();
|
|
778
|
+
if (override)
|
|
779
|
+
return override;
|
|
780
|
+
const fallback = join(env.HOME || env.USERPROFILE || homedir(), ".hermes");
|
|
781
|
+
const candidates = [env.HERMES_HOME?.trim(), fallback].filter(Boolean);
|
|
782
|
+
const tempRoot = resolvePath(tmpdir());
|
|
783
|
+
for (const candidate of candidates) {
|
|
784
|
+
if (candidate.includes("taskflow-hermes-"))
|
|
785
|
+
continue;
|
|
786
|
+
const hasAuth = existsSync(join(candidate, "auth.json")) || existsSync(join(candidate, ".env"));
|
|
787
|
+
const absolute = resolvePath(candidate);
|
|
788
|
+
const underTemp = absolute === tempRoot || absolute.startsWith(`${tempRoot}${sep}`);
|
|
789
|
+
// Test/probe profiles frequently leave HERMES_HOME pointing at a temp
|
|
790
|
+
// config-only directory. It cannot authenticate and must not shadow the
|
|
791
|
+
// operator profile. A non-temp config-only profile remains a valid custom
|
|
792
|
+
// routing home when credentials arrive through process env.
|
|
793
|
+
const hasCreds = hasAuth || (!underTemp && existsSync(join(candidate, "config.yaml")));
|
|
794
|
+
if (hasCreds)
|
|
795
|
+
return candidate;
|
|
796
|
+
}
|
|
797
|
+
return fallback;
|
|
798
|
+
}
|
|
799
|
+
function hermesMaxTurnsFromEnv(env = process.env) {
|
|
800
|
+
const raw = env[HERMES_MAX_TURNS_ENV];
|
|
801
|
+
if (!raw)
|
|
802
|
+
return undefined;
|
|
803
|
+
const n = Number(raw);
|
|
804
|
+
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : undefined;
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Run a single subagent task via `hermes chat -q -Q`. Resolves the agent from
|
|
808
|
+
* `agents` by name; returns the same structured `RunResult` the other host
|
|
809
|
+
* runners produce.
|
|
810
|
+
*/
|
|
811
|
+
export async function runHermesAgentTask(defaultCwd, agents, agentName, task, opts, globalThinking) {
|
|
812
|
+
const agent = agents.find((a) => a.name === agentName);
|
|
813
|
+
if (!agent)
|
|
814
|
+
return unknownAgentResult(agentName, task, agents);
|
|
815
|
+
const model = opts.model ?? agent.model;
|
|
816
|
+
const thinking = opts.thinking ?? agent.thinking ?? globalThinking;
|
|
817
|
+
const tools = opts.tools ?? agent.tools;
|
|
818
|
+
const cwd = opts.cwd ?? defaultCwd;
|
|
819
|
+
const allowUnsafeYolo = hermesUnsafeYoloEnabled();
|
|
820
|
+
const childEnv = hermesChildEnv(process.env, { allowUnsafeYolo });
|
|
821
|
+
let args;
|
|
822
|
+
let readOnly;
|
|
823
|
+
try {
|
|
824
|
+
({ args, readOnly } = buildHermesArgs({
|
|
825
|
+
systemPrompt: agent.systemPrompt,
|
|
826
|
+
task,
|
|
827
|
+
model,
|
|
828
|
+
thinking,
|
|
829
|
+
tools,
|
|
830
|
+
cwd,
|
|
831
|
+
allowUnsafeYolo,
|
|
832
|
+
maxTurns: hermesMaxTurnsFromEnv(),
|
|
833
|
+
readonlyWeb: hermesReadonlyWebEnabled(),
|
|
834
|
+
}));
|
|
835
|
+
}
|
|
836
|
+
catch (error) {
|
|
837
|
+
const message = sanitizeErrorMessage(error instanceof Error ? error.message : String(error));
|
|
838
|
+
return {
|
|
839
|
+
agent: agentName,
|
|
840
|
+
task,
|
|
841
|
+
exitCode: 1,
|
|
842
|
+
output: "",
|
|
843
|
+
stderr: message,
|
|
844
|
+
usage: emptyUsage(),
|
|
845
|
+
model,
|
|
846
|
+
errorMessage: message,
|
|
847
|
+
stopReason: "permission_denied",
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
let ephemeral;
|
|
851
|
+
try {
|
|
852
|
+
ephemeral = prepareEphemeralHermesHome(resolveParentHermesHome(process.env), { readOnly });
|
|
853
|
+
}
|
|
854
|
+
catch (error) {
|
|
855
|
+
const message = sanitizeErrorMessage(error instanceof Error ? error.message : String(error));
|
|
856
|
+
return {
|
|
857
|
+
agent: agentName,
|
|
858
|
+
task,
|
|
859
|
+
exitCode: 1,
|
|
860
|
+
output: "",
|
|
861
|
+
stderr: message,
|
|
862
|
+
usage: emptyUsage(),
|
|
863
|
+
model,
|
|
864
|
+
errorMessage: message,
|
|
865
|
+
stopReason: "error",
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
childEnv.HERMES_HOME = ephemeral.home;
|
|
869
|
+
childEnv.PI_TASKFLOW_HERMES_PHASE_CWD = cwd;
|
|
870
|
+
const acc = newHermesAccumulator(model);
|
|
871
|
+
// Hermes writes its canonical session footer at process exit. Keep only the
|
|
872
|
+
// final complete stderr line before runner-core's retained-diagnostic cap.
|
|
873
|
+
// A bounded raw tail is unsafe: slicing can start mid-line immediately before
|
|
874
|
+
// an embedded `session_id:` substring and make it look canonical.
|
|
875
|
+
let stderrFragment = "";
|
|
876
|
+
let stderrFragmentTruncated = false;
|
|
877
|
+
let lastCompleteStderrLine;
|
|
878
|
+
const observeStderr = (data) => {
|
|
879
|
+
const pieces = data.toString("utf8").split("\n");
|
|
880
|
+
if (pieces.length === 1) {
|
|
881
|
+
stderrFragment += pieces[0];
|
|
882
|
+
if (stderrFragment.length > 8192) {
|
|
883
|
+
stderrFragment = stderrFragment.slice(-8192);
|
|
884
|
+
stderrFragmentTruncated = true;
|
|
885
|
+
}
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
const firstComplete = `${stderrFragment}${pieces[0]}`.replace(/\r$/, "");
|
|
889
|
+
if (!stderrFragmentTruncated && firstComplete.trim())
|
|
890
|
+
lastCompleteStderrLine = firstComplete;
|
|
891
|
+
for (const complete of pieces.slice(1, -1)) {
|
|
892
|
+
const line = complete.replace(/\r$/, "");
|
|
893
|
+
if (line.trim())
|
|
894
|
+
lastCompleteStderrLine = line;
|
|
895
|
+
}
|
|
896
|
+
stderrFragment = pieces.at(-1) ?? "";
|
|
897
|
+
stderrFragmentTruncated = false;
|
|
898
|
+
if (stderrFragment.length > 8192) {
|
|
899
|
+
stderrFragment = stderrFragment.slice(-8192);
|
|
900
|
+
stderrFragmentTruncated = true;
|
|
901
|
+
}
|
|
902
|
+
};
|
|
903
|
+
let result;
|
|
904
|
+
try {
|
|
905
|
+
result = await runSubagentProcess({
|
|
906
|
+
agent: agentName,
|
|
907
|
+
task,
|
|
908
|
+
model,
|
|
909
|
+
bin: hermesBin(),
|
|
910
|
+
args,
|
|
911
|
+
env: childEnv,
|
|
912
|
+
cwd,
|
|
913
|
+
idleTimeoutMs: opts.idleTimeoutMs,
|
|
914
|
+
signal: opts.signal,
|
|
915
|
+
onLive: opts.onLive,
|
|
916
|
+
acc,
|
|
917
|
+
foldLine: foldHermesQuietLine,
|
|
918
|
+
// Quiet mode is plain text (not NDJSON); process exit is the terminal.
|
|
919
|
+
stdoutFormat: "text",
|
|
920
|
+
requireTerminalEvent: false,
|
|
921
|
+
observeStderr,
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
finally {
|
|
925
|
+
ephemeral.cleanup();
|
|
926
|
+
}
|
|
927
|
+
// Hermes quiet mode prints a complete canonical `session_id: …` stderr line.
|
|
928
|
+
// Embedded diagnostic prose is not metadata. The complete-line observer also
|
|
929
|
+
// recovers a legitimate footer emitted after runner-core's 64KB diagnostic cap.
|
|
930
|
+
const canonicalSessionId = (text) => {
|
|
931
|
+
const lines = text.split(/\r?\n/);
|
|
932
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
933
|
+
if (!lines[i].trim())
|
|
934
|
+
continue;
|
|
935
|
+
return lines[i].match(/^\s*session_id:\s*(\S+)\s*$/i)?.[1];
|
|
936
|
+
}
|
|
937
|
+
return undefined;
|
|
938
|
+
};
|
|
939
|
+
if (!acc.sessionId) {
|
|
940
|
+
acc.sessionId = canonicalSessionId(lastCompleteStderrLine ?? "") ?? canonicalSessionId(result.stderr ?? "");
|
|
941
|
+
}
|
|
942
|
+
if (result.stderr) {
|
|
943
|
+
result.stderr = result.stderr
|
|
944
|
+
.split(/\r?\n/)
|
|
945
|
+
.filter((line) => !/^\s*session_id:\s*\S+\s*$/i.test(line))
|
|
946
|
+
.join("\n")
|
|
947
|
+
.trimEnd();
|
|
948
|
+
}
|
|
949
|
+
// Suppress reasoning chrome (config + defense-in-depth strip).
|
|
950
|
+
acc.finalText = stripHermesReasoningNoise(acc.finalText);
|
|
951
|
+
if (typeof result.output === "string") {
|
|
952
|
+
result.output = stripHermesReasoningNoise(result.output);
|
|
953
|
+
}
|
|
954
|
+
// Keep output aligned with cleaned answer body.
|
|
955
|
+
if (acc.finalText && result.output !== acc.finalText) {
|
|
956
|
+
result.output = acc.finalText;
|
|
957
|
+
}
|
|
958
|
+
const preservedStop = result.stopReason === "aborted" ||
|
|
959
|
+
result.completionSource === "abort" ||
|
|
960
|
+
result.completionSource === "idle-timeout" ||
|
|
961
|
+
result.idleTimeout === true;
|
|
962
|
+
if (result.exitCode !== 0 && acc.finalText.trim() && !preservedStop && !result.errorMessage) {
|
|
963
|
+
const sessionNote = acc.sessionId ? ` (session_id=${acc.sessionId})` : "";
|
|
964
|
+
result.stopReason = result.stopReason === "end" ? "error" : (result.stopReason ?? "error");
|
|
965
|
+
result.errorMessage = sanitizeErrorMessage(`Hermes quiet run failed with exit code ${result.exitCode}${sessionNote} after producing partial output.`);
|
|
966
|
+
}
|
|
967
|
+
// Prefer provider Error: lines from stderr over core's generic empty-output
|
|
968
|
+
// message. Core may have already flipped exitCode 0→1 with a placeholder.
|
|
969
|
+
// Never rewrite abort / idle-timeout diagnostics.
|
|
970
|
+
if (!acc.finalText.trim()) {
|
|
971
|
+
const stderr = result.stderr ?? "";
|
|
972
|
+
const errLine = stderr.match(/^\s*Error:\s*(.+)$/im)?.[1]?.trim();
|
|
973
|
+
const sessionNote = acc.sessionId ? ` (session_id=${acc.sessionId})` : "";
|
|
974
|
+
const genericEmpty = !result.errorMessage ||
|
|
975
|
+
/without a final output/i.test(result.errorMessage) ||
|
|
976
|
+
result.errorMessage === UPSTREAM_ERROR_PLACEHOLDER;
|
|
977
|
+
const mayUpgradeProviderError = genericEmpty && !preservedStop;
|
|
978
|
+
if (preservedStop) {
|
|
979
|
+
// Keep core stopReason / errorMessage; optionally annotate session id.
|
|
980
|
+
if (acc.sessionId && result.errorMessage && !result.errorMessage.includes("session_id=")) {
|
|
981
|
+
result.errorMessage = sanitizeErrorMessage(`${result.errorMessage}${sessionNote}`);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
else if (errLine && mayUpgradeProviderError) {
|
|
985
|
+
result.exitCode = result.exitCode || 1;
|
|
986
|
+
result.stopReason = result.stopReason === "end" ? "error" : (result.stopReason ?? "error");
|
|
987
|
+
result.errorMessage = sanitizeErrorMessage(`${errLine}${sessionNote}`);
|
|
988
|
+
}
|
|
989
|
+
else if (result.exitCode === 0 || genericEmpty) {
|
|
990
|
+
result.exitCode = 1;
|
|
991
|
+
result.stopReason = "error";
|
|
992
|
+
result.errorMessage = sanitizeErrorMessage(acc.sessionId
|
|
993
|
+
? `Hermes quiet run produced no answer (session_id=${acc.sessionId})`
|
|
994
|
+
: "Hermes quiet run produced no answer");
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
return result;
|
|
998
|
+
}
|
|
999
|
+
/** The Hermes host's `SubagentRunner`. Drops into `RuntimeDeps.runTask`. */
|
|
1000
|
+
export const hermesSubagentRunner = {
|
|
1001
|
+
runTask: runHermesAgentTask,
|
|
1002
|
+
usageAccounting: "unavailable",
|
|
1003
|
+
};
|
|
1004
|
+
runHermesAgentTask.usageAccounting =
|
|
1005
|
+
"unavailable";
|
|
1006
|
+
//# sourceMappingURL=hermes-runner.js.map
|