Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 78 additions & 1 deletion pc/src/bridge/HermesChatClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ class FakeHermesApiClient {
}]
};
modelsPayload: unknown = { models: [] };
skillsPayload: unknown = { data: [] };
toolsetsPayload: unknown = { data: [] };
healthPayload: unknown = { ok: true };

async listSessions(): Promise<unknown> {
return this.sessionsPayload;
Expand All @@ -72,12 +75,20 @@ class FakeHermesApiClient {
return this.modelsPayload;
}

async listSkills(): Promise<unknown> {
return this.skillsPayload;
}

async listToolsets(): Promise<unknown> {
return this.toolsetsPayload;
}

async capabilities(): Promise<unknown> {
return {};
}

async health(): Promise<unknown> {
return { ok: true };
return this.healthPayload;
}

async stopRun(): Promise<void> {}
Expand Down Expand Up @@ -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<Record<string, unknown>> };

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<Record<string, unknown>> };
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<Record<string, unknown>> };

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<string, unknown>;

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 = {
Expand Down
110 changes: 99 additions & 11 deletions pc/src/bridge/HermesChatClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -34,6 +34,11 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" ? value as Record<string, unknown> : 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();
Expand Down Expand Up @@ -131,26 +136,30 @@ 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<string>();
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);
for (const key of modelKeys(apiModel)) {
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) {
Expand All @@ -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<string>();
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}`
Expand Down Expand Up @@ -430,27 +514,31 @@ export class HermesChatClient {
}

async listCommands(): Promise<unknown> {
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<unknown> {
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<unknown> {
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.
Expand Down Expand Up @@ -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;
}
Expand Down
14 changes: 14 additions & 0 deletions pc/src/dispatcher/HermesApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,20 @@ export class HermesApiClient {
});
}

async listSkills(): Promise<unknown> {
return await this.requestJson("/skills", {
method: "GET",
headers: this.headers()
});
}

async listToolsets(): Promise<unknown> {
return await this.requestJson("/toolsets", {
method: "GET",
headers: this.headers()
});
}

async listSessions(): Promise<unknown> {
return await this.requestJson("/api/sessions", {
method: "GET",
Expand Down
Loading