Skip to content

Commit 2f743df

Browse files
committed
Merge branch 'fix/model-discovery-and-agent-picker'
2 parents 4a686ac + e5336bc commit 2f743df

7 files changed

Lines changed: 630 additions & 12 deletions

File tree

extension/lib/agent-discovery.mjs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// agent-discovery.mjs
2+
// Discover local Hermes API gateways by probing /health across a configurable
3+
// localhost port range. This backs the "Connected agent" picker in the side
4+
// panel settings and is extracted from sidepanel.js for testability.
5+
6+
import { normalizeGatewayUrl } from './common.mjs';
7+
8+
export const DEFAULT_AGENT_PORTS = Object.freeze([8642, 8643, 8644, 8645, 8646]);
9+
const PROBE_TIMEOUT_MS = 1500;
10+
11+
export async function probeGatewayHealth(baseUrl, { apiKey = '', timeoutMs = PROBE_TIMEOUT_MS } = {}) {
12+
if (!baseUrl) return { ok: false, error: 'no-url' };
13+
const url = `${normalizeGatewayUrl(baseUrl)}/health`;
14+
const controller = new AbortController();
15+
const timer = setTimeout(() => controller.abort(), timeoutMs);
16+
try {
17+
const headers = apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
18+
const response = await fetch(url, { headers, signal: controller.signal });
19+
const body = await response.json().catch(() => ({}));
20+
return {
21+
ok: response.ok,
22+
status: response.status,
23+
version: body.version || '',
24+
platform: body.platform || '',
25+
model: body.model || '',
26+
};
27+
} catch (error) {
28+
return { ok: false, error: error?.name === 'AbortError' ? 'timeout' : (error?.message || 'error') };
29+
} finally {
30+
clearTimeout(timer);
31+
}
32+
}
33+
34+
export function deriveAgentName(port, probe) {
35+
if (!probe || !probe.ok) return null;
36+
if (probe.platform !== 'hermes-agent') return null;
37+
// Future: gateway could expose its profile name in /health/detailed.
38+
// For now, port-derived name is the only reliable label.
39+
return `agent-${port}`;
40+
}
41+
42+
export async function discoverLocalAgents({
43+
ports = DEFAULT_AGENT_PORTS,
44+
apiKey = '',
45+
host = '127.0.0.1',
46+
scheme = 'http',
47+
} = {}) {
48+
if (!Array.isArray(ports) || !ports.length) return [];
49+
const candidates = ports.map((port) => ({
50+
url: `${scheme}://${host}:${port}`,
51+
port,
52+
}));
53+
const results = await Promise.all(
54+
candidates.map(async (candidate) => {
55+
const probe = await probeGatewayHealth(candidate.url, { apiKey });
56+
return {
57+
...candidate,
58+
ok: probe.ok,
59+
status: probe.status,
60+
version: probe.version,
61+
model: probe.model,
62+
error: probe.error || '',
63+
name: deriveAgentName(candidate.port, probe),
64+
};
65+
}),
66+
);
67+
return results;
68+
}
69+
70+
export function activeAgents(results = []) {
71+
return results.filter((agent) => agent.ok && agent.name);
72+
}
73+
74+
export function parseAgentPortsInput(value = '') {
75+
if (typeof value !== 'string') return [];
76+
const parsed = value
77+
.split(/[\s,]+/)
78+
.map((token) => Number.parseInt(token, 10))
79+
.filter((n) => Number.isFinite(n) && n > 0 && n <= 65535);
80+
// de-dupe, preserve order
81+
const seen = new Set();
82+
const out = [];
83+
for (const port of parsed) {
84+
if (seen.has(port)) continue;
85+
seen.add(port);
86+
out.push(port);
87+
}
88+
return out;
89+
}
90+
91+
export const AGENT_DISCOVERY_DEFAULTS = Object.freeze({
92+
ports: [...DEFAULT_AGENT_PORTS],
93+
host: '127.0.0.1',
94+
scheme: 'http',
95+
});

extension/lib/model-discovery.mjs

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// model-discovery.mjs
2+
// Discover real model names from /api/sessions when /v1/models only returns
3+
// the synthetic `hermes-agent` alias. Used as a fallback for multi-provider
4+
// setups where the gateway's OpenAI-compat /v1/models doesn't reflect the
5+
// real model registry.
6+
//
7+
// The actual model used per turn is stored in /api/sessions[*].model, so we
8+
// can reconstruct a useful model picker from session history.
9+
10+
const SESSION_HISTORY_LIMIT = 100;
11+
12+
const KNOWN_PROVIDER_PREFIXES = [
13+
// Order matters: most specific first.
14+
// Provider names that appear as substrings (e.g. `openai-codex/gpt-5.4`).
15+
['openai-codex', 'openai-codex'],
16+
['openai', 'openai'],
17+
['minimax', 'minimax'],
18+
['kimi', 'moonshot'],
19+
['moonshot', 'moonshot'],
20+
['qwen', 'alibaba'],
21+
['glm', 'zhipu'],
22+
['deepseek', 'deepseek'],
23+
['grok', 'xai'],
24+
['gemini', 'google'],
25+
['claude', 'anthropic'],
26+
];
27+
28+
// Brand prefixes that don't contain the provider name as a substring.
29+
const KNOWN_PROVIDER_BRANDS = [
30+
['gpt-', 'openai'],
31+
['gpt5', 'openai'],
32+
['gpt4', 'openai'],
33+
['o1', 'openai'],
34+
['o3', 'openai'],
35+
['o4', 'openai'],
36+
];
37+
38+
export function deriveProviderFromModelId(modelId = '') {
39+
const lower = String(modelId || '').toLowerCase();
40+
if (!lower) return '';
41+
for (const [prefix, provider] of KNOWN_PROVIDER_PREFIXES) {
42+
if (lower.startsWith(prefix) || lower.includes(`/${prefix}`) || lower.includes(`-${prefix}`)) {
43+
return provider;
44+
}
45+
}
46+
for (const [brand, provider] of KNOWN_PROVIDER_BRANDS) {
47+
if (lower.startsWith(brand) || lower.includes(`-${brand}`) || lower.includes(`/${brand}`)) {
48+
return provider;
49+
}
50+
}
51+
return '';
52+
}
53+
54+
export async function discoverModelsFromSessions({
55+
apiFetch,
56+
readJsonResponse,
57+
limit = SESSION_HISTORY_LIMIT,
58+
} = {}) {
59+
if (typeof apiFetch !== 'function' || typeof readJsonResponse !== 'function') {
60+
return { ok: false, error: 'no-fetch', models: [] };
61+
}
62+
try {
63+
const response = await apiFetch(`/api/sessions?limit=${encodeURIComponent(limit)}`, { method: 'GET' });
64+
const payload = await readJsonResponse(response);
65+
if (!response.ok) {
66+
return {
67+
ok: false,
68+
error: payload?.error?.message || `status-${response.status}`,
69+
models: [],
70+
};
71+
}
72+
const rows = Array.isArray(payload?.data) ? payload.data : [];
73+
const buckets = new Map();
74+
for (const row of rows) {
75+
const modelId = String(row?.model || '').trim();
76+
if (!modelId || modelId === 'hermes-agent') continue;
77+
const lastSeen = Number(row?.last_active || row?.started_at || 0);
78+
const bucket = buckets.get(modelId) || {
79+
id: modelId,
80+
label: modelId,
81+
provider: deriveProviderFromModelId(modelId),
82+
contextTokens: 0,
83+
lastSeen: 0,
84+
sessionCount: 0,
85+
inputTokens: 0,
86+
outputTokens: 0,
87+
};
88+
bucket.sessionCount += 1;
89+
bucket.lastSeen = Math.max(bucket.lastSeen, lastSeen);
90+
bucket.inputTokens += Number(row?.input_tokens || 0);
91+
bucket.outputTokens += Number(row?.output_tokens || 0);
92+
buckets.set(modelId, bucket);
93+
}
94+
const models = Array.from(buckets.values())
95+
.map((bucket) => ({
96+
id: bucket.id,
97+
label: bucket.label,
98+
provider: bucket.provider,
99+
providerLabel: bucket.provider,
100+
description: `${bucket.sessionCount} session${bucket.sessionCount === 1 ? '' : 's'} · ${(bucket.inputTokens + bucket.outputTokens).toLocaleString()} tokens`,
101+
contextTokens: 0,
102+
source: 'sessions',
103+
lastSeen: bucket.lastSeen,
104+
sessionCount: bucket.sessionCount,
105+
}))
106+
.sort((a, b) => b.lastSeen - a.lastSeen);
107+
return { ok: true, models, error: '' };
108+
} catch (error) {
109+
return { ok: false, error: error?.message || 'error', models: [] };
110+
}
111+
}
112+
113+
export function mergeModelsWithRegistry({ registryModels = [], sessionModels = [] } = {}) {
114+
const out = [];
115+
const seen = new Set();
116+
// Registry first (these are the gateway-blessed models)
117+
for (const model of registryModels) {
118+
if (!model || !model.id || seen.has(model.id)) continue;
119+
seen.add(model.id);
120+
out.push({ ...model, source: model.source || 'registry' });
121+
}
122+
// Then session-discovered models, marking them as such
123+
for (const model of sessionModels) {
124+
if (!model || !model.id || seen.has(model.id)) continue;
125+
seen.add(model.id);
126+
out.push({ ...model, source: 'sessions' });
127+
}
128+
return out;
129+
}
130+
131+
export const MODEL_DISCOVERY_DEFAULTS = Object.freeze({
132+
limit: SESSION_HISTORY_LIMIT,
133+
});

extension/sidepanel.css

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1683,3 +1683,64 @@ textarea:focus, input:focus, select:focus { border-color: var(--hermes-accent);
16831683
@media (prefers-reduced-motion: reduce) {
16841684
*, *::before, *::after { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; }
16851685
}
1686+
1687+
/* --- Agent picker (v0.1.3 tweak) ----------------------------------------- */
1688+
.agent-list {
1689+
display: flex;
1690+
flex-direction: column;
1691+
gap: 8px;
1692+
margin: 8px 0;
1693+
}
1694+
.agent-card {
1695+
display: grid;
1696+
grid-template-columns: 1fr auto;
1697+
grid-template-areas:
1698+
"name status"
1699+
"meta status"
1700+
"action action";
1701+
gap: 4px 12px;
1702+
padding: 10px 12px;
1703+
border: 1px solid var(--hermes-rule, rgba(255, 255, 255, 0.12));
1704+
border-radius: 8px;
1705+
background: var(--hermes-surface-2, rgba(255, 255, 255, 0.03));
1706+
}
1707+
.agent-card-active {
1708+
border-color: var(--hermes-accent, #4f8cff);
1709+
background: var(--hermes-accent-soft, rgba(79, 140, 255, 0.08));
1710+
}
1711+
.agent-card-name {
1712+
grid-area: name;
1713+
font-family: var(--hermes-font-display, sans-serif);
1714+
font-size: 14px;
1715+
font-weight: 600;
1716+
letter-spacing: 0.02em;
1717+
}
1718+
.agent-card-meta {
1719+
grid-area: meta;
1720+
font-size: 11px;
1721+
color: var(--hermes-muted, rgba(255, 255, 255, 0.6));
1722+
text-transform: uppercase;
1723+
letter-spacing: 0.04em;
1724+
}
1725+
.agent-card-status {
1726+
grid-area: status;
1727+
align-self: center;
1728+
font-size: 10px;
1729+
text-transform: uppercase;
1730+
letter-spacing: 0.08em;
1731+
padding: 3px 8px;
1732+
border-radius: 999px;
1733+
}
1734+
.agent-card-status-ok {
1735+
background: rgba(64, 192, 128, 0.18);
1736+
color: rgb(80, 220, 140);
1737+
}
1738+
.agent-card-status-off {
1739+
background: rgba(160, 160, 160, 0.15);
1740+
color: rgba(200, 200, 200, 0.7);
1741+
}
1742+
.agent-card .secondary {
1743+
grid-area: action;
1744+
justify-self: stretch;
1745+
margin-top: 6px;
1746+
}

extension/sidepanel.html

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -183,25 +183,43 @@ <h3 id="releaseTitle">Extension</h3>
183183
</div>
184184
<p id="updateStatus" class="hint">Updates are checked against the public GitHub repo.</p>
185185
</section>
186-
187186
<section class="settings-section profile-settings" aria-labelledby="profileTitle">
188187
<div class="settings-section-head">
189188
<div>
190189
<h3 id="profileTitle">Agent Profile</h3>
191190
<p>Choose which Hermes agent profile the Browser should use when the local gateway exposes profiles.</p>
192191
</div>
193192
</div>
194-
195-
<div class="profile-row">
193+
<section class="settings-section">
194+
<h3>Active profile</h3>
195+
<div class="settings-row">
196+
<label>
197+
Active profile
198+
<select id="profileSelect" name="profileSelect">
199+
<option value="">Detect from Hermes gateway</option>
200+
</select>
201+
</label>
202+
<button id="refreshProfilesButton" class="secondary" type="button">↻ Profiles</button>
203+
</div>
204+
<p id="profileStatus" class="hint">Profiles sync after connecting to Hermes.</p>
205+
</section>
206+
<section class="settings-section">
207+
<h3>Connected agent</h3>
208+
<p class="hint">
209+
Scans localhost for running Hermes API gateways. Each agent can run
210+
as a separate gateway process on its own port. Use this to switch
211+
between trusted local agents that share the same browser token.
212+
</p>
213+
<div id="agentList" class="agent-list" role="list" aria-label="Discovered Hermes agents"></div>
214+
<div class="settings-row">
215+
<button id="refreshAgentsButton" class="secondary" type="button">↻ Scan local agents</button>
216+
</div>
196217
<label>
197-
Active profile
198-
<select id="profileSelect" name="profileSelect">
199-
<option value="">Detect from Hermes gateway</option>
200-
</select>
218+
Agent ports (comma-separated, default 8642-8646)
219+
<input id="agentPortsInput" type="text" inputmode="numeric" placeholder="8642,8643,8644,8645,8646" />
201220
</label>
202-
<button id="refreshProfilesButton" class="secondary" type="button">↻ Profiles</button>
203-
</div>
204-
<p id="profileStatus" class="hint">Profiles sync after connecting to Hermes.</p>
221+
<p id="agentPickerStatus" class="hint">Agent discovery has not run yet.</p>
222+
</section>
205223
</section>
206224

207225
<section class="settings-section appearance-settings" aria-labelledby="appearanceTitle">

0 commit comments

Comments
 (0)