diff --git a/pc/src/bridge/HermesChatClient.test.ts b/pc/src/bridge/HermesChatClient.test.ts index 81ef588..ad6239d 100644 --- a/pc/src/bridge/HermesChatClient.test.ts +++ b/pc/src/bridge/HermesChatClient.test.ts @@ -59,6 +59,9 @@ class FakeHermesApiClient { }] }; modelsPayload: unknown = { models: [] }; + skillsPayload: unknown = { data: [] }; + toolsetsPayload: unknown = { data: [] }; + healthPayload: unknown = { ok: true }; async listSessions(): Promise { return this.sessionsPayload; @@ -72,12 +75,20 @@ class FakeHermesApiClient { return this.modelsPayload; } + async listSkills(): Promise { + return this.skillsPayload; + } + + async listToolsets(): Promise { + return this.toolsetsPayload; + } + async capabilities(): Promise { return {}; } async health(): Promise { - return { ok: true }; + return this.healthPayload; } async stopRun(): Promise {} @@ -204,6 +215,72 @@ test("Hermes model listing enriches API models with local metadata", async () => }); }); +test("Hermes model listing suppresses generic API proxy when provider models are discovered", async () => { + await withHermesHome({ + "config.yaml": [ + "model:", + " default: gpt-5.5", + " provider: openai-codex" + ].join("\n"), + "auth.json": JSON.stringify({ + credential_pool: { + "openai-codex": [{}] + } + }) + }, async () => { + const api = new FakeHermesApiClient(); + api.modelsPayload = { data: [{ id: "gpt-5.5", owned_by: "hermes" }] }; + const client = new HermesChatClient({ ...config, hermesModel: "talos" }, api as unknown as HermesApiClient, null); + + const payload = await client.listModels() as { models: Array> }; + + assert.equal(payload.models.some((model) => model.id === "gpt-5.5" && model.provider === "hermes"), false); + assert.equal(payload.models[0]?.id, "openai-codex:gpt-5.5"); + }); +}); + +test("Hermes lists native API skills as skill commands", async () => { + const api = new FakeHermesApiClient(); + api.skillsPayload = { + data: [{ name: "android-control", description: "Control Android", category: "phone" }] + }; + const client = new HermesChatClient(config, api as unknown as HermesApiClient, null); + + const payload = await client.listCommands() as { commands: Array> }; + const skill = payload.commands.find((command) => command.name === "android-control"); + + assert.equal(skill?.source, "skill"); + assert.deepEqual(skill?.textAliases, ["/skill android-control"]); + assert.equal(skill?.description, "Control Android"); + assert.ok(payload.commands.some((command) => command.name === "skill")); +}); + +test("Hermes lists native API toolsets as effective tools", async () => { + const api = new FakeHermesApiClient(); + api.toolsetsPayload = { + data: [{ name: "terminal", label: "Terminal", enabled: true, tools: ["terminal", "process"] }, { name: "browser", label: "Browser", enabled: false, tools: ["browser_click"] }] + }; + const client = new HermesChatClient(config, api as unknown as HermesApiClient, null); + + const payload = await client.effectiveTools() as { tools: Array> }; + + assert.deepEqual(payload.tools.map((tool) => tool.id), ["terminal", "process"]); + assert.equal(payload.tools[0]?.group, "Terminal"); + assert.equal(payload.tools[0]?.source, "terminal"); +}); + +test("Hermes health normalizes native API status responses", async () => { + const api = new FakeHermesApiClient(); + api.healthPayload = { status: "ok" }; + const client = new HermesChatClient(config, api as unknown as HermesApiClient, null); + + const payload = await client.health() as Record; + + assert.equal(payload.ok, true); + assert.equal(payload.mode, "api"); + assert.equal(payload.status, "ok"); +}); + test("Hermes lists sessions with flat token count fields", async () => { const api = new FakeHermesApiClient(); api.sessionsPayload = { diff --git a/pc/src/bridge/HermesChatClient.ts b/pc/src/bridge/HermesChatClient.ts index 1f2b6be..17c312a 100644 --- a/pc/src/bridge/HermesChatClient.ts +++ b/pc/src/bridge/HermesChatClient.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { promisify } from "node:util"; import { HermesApiClient } from "../dispatcher/HermesApiClient.js"; import { HermesRunDriver, type HermesActiveRun, type HermesRunDriverEvent } from "../dispatcher/HermesRunDriver.js"; -import type { ChatAttachment, ChatHistoryMessage, ChatModelOption, ChatSessionSummary } from "../protocol/messages.js"; +import type { ChatAttachment, ChatCommandOption, ChatHistoryMessage, ChatModelOption, ChatSessionSummary, ChatToolSummary } from "../protocol/messages.js"; import { resolveCommand, type CommandResolution } from "../host/CommandDiscovery.js"; import type { BridgeConfig } from "./config.js"; import { discoverHermesModels } from "./HermesModelDiscovery.js"; @@ -34,6 +34,11 @@ function asRecord(value: unknown): Record | undefined { return value && typeof value === "object" ? value as Record : undefined; } +function isHealthyHermesResponse(value: unknown): boolean { + const record = asRecord(value); + return record?.ok === true || record?.status === "ok"; +} + function firstStringField(value: unknown, keys: string[]): string | undefined { if (typeof value === "string" && value.trim()) { return value.trim(); @@ -131,7 +136,11 @@ function mergeHermesModels(apiModels: ChatModelOption[], discoveredModels: ChatM const discoveredByKey = new Map(discoveredModels.flatMap((model) => modelKeys(model).map((key) => [key, model] as const))); const seen = new Set(); - const merged = apiModels.map((apiModel) => { + const suppressGenericHermesProxy = discoveredModels.length > 0; + const merged = apiModels.flatMap((apiModel) => { + if (suppressGenericHermesProxy && isGenericHermesProxyModel(apiModel, discoveredModels, defaultModel)) { + return []; + } const discovered = modelKeys(apiModel) .map((key) => discoveredByKey.get(key)) .find(Boolean); @@ -139,18 +148,18 @@ function mergeHermesModels(apiModels: ChatModelOption[], discoveredModels: ChatM seen.add(key); } if (!discovered) { - return apiModel; + return [apiModel]; } for (const key of modelKeys(discovered)) { seen.add(key); } - return { + return [{ ...discovered, ...apiModel, contextWindow: apiModel.contextWindow ?? discovered.contextWindow ?? null, reasoningOptions: apiModel.reasoningOptions ?? discovered.reasoningOptions ?? null, defaultReasoningEffort: apiModel.defaultReasoningEffort ?? discovered.defaultReasoningEffort ?? null - }; + }]; }); for (const discovered of discoveredModels) { @@ -169,6 +178,81 @@ function modelKeys(model: ChatModelOption): string[] { return [...new Set([model.id, model.modelId ?? undefined].filter((value): value is string => Boolean(value)))]; } +function isGenericHermesProxyModel(apiModel: ChatModelOption, discoveredModels: ChatModelOption[], defaultModel: string): boolean { + if (apiModel.provider !== "hermes") { + return false; + } + const apiBareId = bareSelectionModel(apiModel.id); + if (!apiBareId) { + return false; + } + return apiModel.id === defaultModel || discoveredModels.some((model) => model.provider !== "hermes" && bareSelectionModel(model.modelId ?? model.id) === apiBareId); +} + +function bareSelectionModel(value: string): string { + const separator = value.indexOf(":"); + return separator > 0 ? value.slice(separator + 1) : value; +} + +function normalizeHermesSkills(payload: unknown): ChatCommandOption[] { + const rawSkills = Array.isArray(asRecord(payload)?.data) + ? asRecord(payload)?.data as unknown[] + : Array.isArray(asRecord(payload)?.skills) + ? asRecord(payload)?.skills as unknown[] + : []; + return rawSkills.flatMap((item) => { + const record = asRecord(item); + const name = directStringField(record, ["name", "id"]); + if (!name) { + return []; + } + return [{ + name, + description: directStringField(record, ["description", "summary"]), + category: directStringField(record, ["category"]), + textAliases: [`/skill ${name}`], + source: "skill", + acceptsArgs: true, + args: [{ name: "input", description: "Optional prompt or instructions for this skill", type: "string", required: false }] + }]; + }); +} + +function normalizeHermesToolsets(payload: unknown): ChatToolSummary[] { + const rawToolsets = Array.isArray(asRecord(payload)?.data) + ? asRecord(payload)?.data as unknown[] + : Array.isArray(asRecord(payload)?.toolsets) + ? asRecord(payload)?.toolsets as unknown[] + : []; + const tools: ChatToolSummary[] = []; + const seen = new Set(); + for (const item of rawToolsets) { + const record = asRecord(item); + if (record?.enabled === false) { + continue; + } + const group = directStringField(record, ["label", "name"]); + const source = directStringField(record, ["name"]); + const rawTools = Array.isArray(record?.tools) ? record.tools as unknown[] : []; + for (const rawTool of rawTools) { + const rawToolRecord = asRecord(rawTool); + const id = typeof rawTool === "string" ? rawTool : directStringField(rawToolRecord, ["id", "name"]); + if (!id || seen.has(id)) { + continue; + } + seen.add(id); + tools.push({ + id, + label: id, + description: typeof rawTool === "string" ? null : directStringField(rawToolRecord, ["description"]), + source, + group + }); + } + } + return tools; +} + function cliPrompt(message: string, instructions: string | undefined): string { return instructions?.trim() ? `${instructions.trim()}\n\nCurrent user message:\n${message}` @@ -430,27 +514,31 @@ export class HermesChatClient { } async listCommands(): Promise { + const skills = this.api ? normalizeHermesSkills(await this.api.listSkills().catch(() => undefined)) : []; return { commands: [ { name: "status", description: "Show Hermes status", textAliases: ["/status"], acceptsArgs: false }, { name: "new", description: "Start a new Hermes chat", textAliases: ["/new"], acceptsArgs: false }, - { name: "help", description: "Show available Hermes commands", textAliases: ["/help"], acceptsArgs: false } + { name: "help", description: "Show available Hermes commands", textAliases: ["/help"], acceptsArgs: false }, + { name: "skills", description: "List available Hermes skills", textAliases: ["/skills"], acceptsArgs: false }, + { name: "skill", description: "Load or use a Hermes skill", textAliases: ["/skill"], acceptsArgs: true }, + ...skills ] }; } async effectiveTools(): Promise { const capabilities = await this.api?.capabilities().catch(() => undefined); - return { tools: [], capabilities }; + const toolsets = await this.api?.listToolsets().catch(() => undefined); + return { tools: normalizeHermesToolsets(toolsets), capabilities, toolsets: asRecord(toolsets)?.data ?? [] }; } async health(): Promise { if (this.api) { try { const health = await this.api.health(); - const record = asRecord(health); - if (record?.ok === true) { - return health; + if (isHealthyHermesResponse(health)) { + return { ...asRecord(health), ok: true, mode: "api" }; } } catch { // Fall through to CLI mode if Hermes itself is installed but no Lynk runs API is serving. @@ -674,7 +762,7 @@ export class HermesChatClient { } try { const health = await this.api.health(); - return asRecord(health)?.ok === true; + return isHealthyHermesResponse(health); } catch { return false; } diff --git a/pc/src/dispatcher/HermesApiClient.ts b/pc/src/dispatcher/HermesApiClient.ts index 6c8b494..d9c4e72 100644 --- a/pc/src/dispatcher/HermesApiClient.ts +++ b/pc/src/dispatcher/HermesApiClient.ts @@ -152,6 +152,20 @@ export class HermesApiClient { }); } + async listSkills(): Promise { + return await this.requestJson("/skills", { + method: "GET", + headers: this.headers() + }); + } + + async listToolsets(): Promise { + return await this.requestJson("/toolsets", { + method: "GET", + headers: this.headers() + }); + } + async listSessions(): Promise { return await this.requestJson("/api/sessions", { method: "GET",