mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
Fix dashboard chat model profile scoping
This commit is contained in:
parent
75de0057bc
commit
543f069093
6 changed files with 162 additions and 101 deletions
|
|
@ -8675,14 +8675,12 @@ async def cancel_oauth_session(
|
|||
|
||||
|
||||
|
||||
def _session_latest_descendant(session_id: str):
|
||||
def _session_latest_descendant(session_id: str, db):
|
||||
"""Resolve a session id to the newest child leaf session.
|
||||
|
||||
/model may create child sessions. Dashboard refresh should continue the
|
||||
newest child instead of reopening the old parent.
|
||||
"""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
def row_get(row, key, index):
|
||||
if isinstance(row, dict):
|
||||
return row.get(key)
|
||||
|
|
@ -8694,62 +8692,58 @@ def _session_latest_descendant(session_id: str):
|
|||
except Exception:
|
||||
return None
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
sid = db.resolve_session_id(session_id)
|
||||
if not sid or not db.get_session(sid):
|
||||
return None, []
|
||||
sid = db.resolve_session_id(session_id)
|
||||
if not sid or not db.get_session(sid):
|
||||
return None, []
|
||||
|
||||
conn = (
|
||||
getattr(db, "conn", None)
|
||||
or getattr(db, "_conn", None)
|
||||
or getattr(db, "connection", None)
|
||||
or getattr(db, "_connection", None)
|
||||
)
|
||||
conn = (
|
||||
getattr(db, "conn", None)
|
||||
or getattr(db, "_conn", None)
|
||||
or getattr(db, "connection", None)
|
||||
or getattr(db, "_connection", None)
|
||||
)
|
||||
|
||||
rows = []
|
||||
if conn is not None:
|
||||
raw_rows = conn.execute(
|
||||
"SELECT id, parent_session_id, started_at FROM sessions"
|
||||
).fetchall()
|
||||
for row in raw_rows:
|
||||
rows.append({
|
||||
"id": row_get(row, "id", 0),
|
||||
"parent_session_id": row_get(row, "parent_session_id", 1),
|
||||
"started_at": row_get(row, "started_at", 2),
|
||||
})
|
||||
else:
|
||||
rows = db.list_sessions_rich(limit=10000, offset=0)
|
||||
rows = []
|
||||
if conn is not None:
|
||||
raw_rows = conn.execute(
|
||||
"SELECT id, parent_session_id, started_at FROM sessions"
|
||||
).fetchall()
|
||||
for row in raw_rows:
|
||||
rows.append({
|
||||
"id": row_get(row, "id", 0),
|
||||
"parent_session_id": row_get(row, "parent_session_id", 1),
|
||||
"started_at": row_get(row, "started_at", 2),
|
||||
})
|
||||
else:
|
||||
rows = db.list_sessions_rich(limit=10000, offset=0)
|
||||
|
||||
children = {}
|
||||
for row in rows:
|
||||
rid = row.get("id")
|
||||
parent = row.get("parent_session_id")
|
||||
if rid and parent:
|
||||
children.setdefault(parent, []).append(row)
|
||||
children = {}
|
||||
for row in rows:
|
||||
rid = row.get("id")
|
||||
parent = row.get("parent_session_id")
|
||||
if rid and parent:
|
||||
children.setdefault(parent, []).append(row)
|
||||
|
||||
def started(row):
|
||||
try:
|
||||
return float(row.get("started_at") or 0)
|
||||
except Exception:
|
||||
return 0.0
|
||||
def started(row):
|
||||
try:
|
||||
return float(row.get("started_at") or 0)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
current = sid
|
||||
path = [sid]
|
||||
seen = {sid}
|
||||
current = sid
|
||||
path = [sid]
|
||||
seen = {sid}
|
||||
|
||||
while children.get(current):
|
||||
candidates = [r for r in children[current] if r.get("id") not in seen]
|
||||
if not candidates:
|
||||
break
|
||||
candidates.sort(key=started, reverse=True)
|
||||
current = candidates[0]["id"]
|
||||
path.append(current)
|
||||
seen.add(current)
|
||||
while children.get(current):
|
||||
candidates = [r for r in children[current] if r.get("id") not in seen]
|
||||
if not candidates:
|
||||
break
|
||||
candidates.sort(key=started, reverse=True)
|
||||
current = candidates[0]["id"]
|
||||
path.append(current)
|
||||
seen.add(current)
|
||||
|
||||
return current, path
|
||||
finally:
|
||||
db.close()
|
||||
return current, path
|
||||
|
||||
|
||||
# CRITICAL — every literal-path route below MUST be declared BEFORE the
|
||||
|
|
@ -8923,16 +8917,23 @@ async def get_session_detail(session_id: str, profile: Optional[str] = None):
|
|||
|
||||
|
||||
@app.get("/api/sessions/{session_id}/latest-descendant")
|
||||
async def get_session_latest_descendant(session_id: str):
|
||||
latest, path = _session_latest_descendant(session_id)
|
||||
if not latest:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return {
|
||||
"requested_session_id": path[0] if path else session_id,
|
||||
"session_id": latest,
|
||||
"path": path,
|
||||
"changed": bool(path and latest != path[0]),
|
||||
}
|
||||
async def get_session_latest_descendant(
|
||||
session_id: str,
|
||||
profile: Optional[str] = None,
|
||||
):
|
||||
db = _open_session_db_for_profile(profile)
|
||||
try:
|
||||
latest, path = _session_latest_descendant(session_id, db)
|
||||
if not latest:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return {
|
||||
"requested_session_id": path[0] if path else session_id,
|
||||
"session_id": latest,
|
||||
"path": path,
|
||||
"changed": bool(path and latest != path[0]),
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@app.get("/api/sessions/{session_id}/messages")
|
||||
async def get_session_messages(session_id: str, profile: Optional[str] = None):
|
||||
|
|
|
|||
|
|
@ -1049,6 +1049,41 @@ class TestWebServerEndpoints:
|
|||
messages = self.client.get("/api/sessions/worker-only/messages?profile=worker").json()
|
||||
assert [m["content"] for m in messages["messages"]] == ["worker"]
|
||||
|
||||
def test_latest_descendant_reads_requested_profile(self):
|
||||
"""Chat resume must resolve compression tips in the chat profile DB."""
|
||||
from hermes_state import SessionDB
|
||||
from hermes_cli import profiles as profiles_mod
|
||||
|
||||
worker_home = profiles_mod.get_profile_dir("worker")
|
||||
worker_home.mkdir(parents=True)
|
||||
|
||||
default_db = SessionDB()
|
||||
try:
|
||||
default_db.create_session(session_id="shared-root", source="cli")
|
||||
finally:
|
||||
default_db.close()
|
||||
|
||||
worker_db = SessionDB(db_path=worker_home / "state.db")
|
||||
try:
|
||||
worker_db.create_session(session_id="shared-root", source="cli")
|
||||
worker_db.create_session(
|
||||
session_id="worker-tip",
|
||||
source="cli",
|
||||
parent_session_id="shared-root",
|
||||
)
|
||||
finally:
|
||||
worker_db.close()
|
||||
|
||||
default_resp = self.client.get("/api/sessions/shared-root/latest-descendant")
|
||||
assert default_resp.status_code == 200
|
||||
assert default_resp.json()["session_id"] == "shared-root"
|
||||
|
||||
worker_resp = self.client.get(
|
||||
"/api/sessions/shared-root/latest-descendant?profile=worker"
|
||||
)
|
||||
assert worker_resp.status_code == 200
|
||||
assert worker_resp.json()["session_id"] == "worker-tip"
|
||||
|
||||
def test_analytics_endpoints_read_requested_profile(self):
|
||||
from hermes_state import SessionDB
|
||||
from hermes_cli import profiles as profiles_mod
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ const STATE_TONE: Record<
|
|||
|
||||
interface ChatSidebarProps {
|
||||
channel: string;
|
||||
/** Management profile from the dashboard switcher — scopes session.create. */
|
||||
/** Chat profile from the dashboard switcher / URL scope. */
|
||||
profile?: string;
|
||||
className?: string;
|
||||
onDashboardNewSessionRequest?: () => void;
|
||||
|
|
@ -103,8 +103,9 @@ export function ChatSidebar({
|
|||
// session boots from. We deliberately don't use the sidecar's `session.info`
|
||||
// model: that's a one-time snapshot of the throwaway sidecar agent taken when
|
||||
// its session is created, and it never updates when the model is changed
|
||||
// elsewhere, so the badge would go stale. `/api/model/info` is profile-scoped
|
||||
// by `fetchJSON`, so it reads the same profile this sidebar is scoped to.
|
||||
// elsewhere, so the badge would go stale. Pass the chat profile explicitly so
|
||||
// this card stays scoped to the PTY even if the global dashboard switcher
|
||||
// changes while the chat is open.
|
||||
const [effectiveModel, setEffectiveModel] = useState("");
|
||||
// Whether the effective model supports reasoning effort — gates the
|
||||
// ReasoningPicker. Read from the same `/api/model/info` capabilities the
|
||||
|
|
@ -125,7 +126,7 @@ export function ChatSidebar({
|
|||
|
||||
const refreshEffectiveModel = useCallback(() => {
|
||||
void api
|
||||
.getModelInfo()
|
||||
.getModelInfo(profile)
|
||||
.then((r) => {
|
||||
if (r?.model) setEffectiveModel(String(r.model));
|
||||
setSupportsReasoning(!!r?.capabilities?.supports_reasoning);
|
||||
|
|
@ -135,7 +136,7 @@ export function ChatSidebar({
|
|||
.catch(() => {
|
||||
// Best-effort: keep the last known label rather than blanking it.
|
||||
});
|
||||
}, []);
|
||||
}, [profile]);
|
||||
|
||||
// Profile or PTY channel change tears down both WebSockets. Bump `version`
|
||||
// (same path as the manual Reconnect button) so the gateway client is
|
||||
|
|
@ -208,6 +209,7 @@ export function ChatSidebar({
|
|||
gw.close();
|
||||
};
|
||||
// `profile` is read from render; scope changes bump `version` → new `gw`.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gw]);
|
||||
|
||||
// Event subscriber WebSocket — receives the rebroadcast of every
|
||||
|
|
@ -344,6 +346,7 @@ export function ChatSidebar({
|
|||
<Card className="py-0">
|
||||
<ReasoningPicker
|
||||
currentModel={modelName}
|
||||
profile={profile}
|
||||
refreshKey={modelRefreshKey}
|
||||
onChanged={(effort) =>
|
||||
setModelNotice(
|
||||
|
|
@ -391,17 +394,20 @@ export function ChatSidebar({
|
|||
// Same path the Models page uses (REST /api/model/set), not the
|
||||
// sidecar config.set RPC, which didn't reliably land in the
|
||||
// config.yaml the agent boots from. Always persisted (alwaysGlobal).
|
||||
loader={api.getModelOptions}
|
||||
loader={() => api.getModelOptions(profile)}
|
||||
alwaysGlobal
|
||||
onApply={async ({ provider, model, confirmExpensiveModel }) => {
|
||||
setModelNotice(null);
|
||||
setPendingReloadModel(null);
|
||||
const result = await api.setModelAssignment({
|
||||
confirm_expensive_model: confirmExpensiveModel,
|
||||
scope: "main",
|
||||
provider,
|
||||
model,
|
||||
});
|
||||
const result = await api.setModelAssignment(
|
||||
{
|
||||
confirm_expensive_model: confirmExpensiveModel,
|
||||
scope: "main",
|
||||
provider,
|
||||
model,
|
||||
},
|
||||
profile,
|
||||
);
|
||||
// confirm_required => the dialog shows the expensive-model prompt
|
||||
// and calls back; don't announce until the user confirms.
|
||||
if (!result.confirm_required) {
|
||||
|
|
|
|||
|
|
@ -15,9 +15,8 @@
|
|||
* running chat session adopts the change on the next `/new` or page reload;
|
||||
* we surface that hint rather than forcing a reload here.
|
||||
*
|
||||
* Profile scoping: `/api/config` is profile-scoped by `fetchJSON` via the
|
||||
* global management profile — the same scope the sidebar's `/api/model/info`
|
||||
* badge reads from — so this writes the profile the sidebar is showing.
|
||||
* Profile scoping: the sidebar passes the chat profile explicitly, so this
|
||||
* reads/writes the same config the chat PTY was launched from.
|
||||
*/
|
||||
|
||||
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
|
||||
|
|
@ -35,6 +34,8 @@ interface ReasoningPickerProps {
|
|||
/** Current model string from config — re-reads the saved effort when it
|
||||
* changes (a different model may have been selected). */
|
||||
currentModel: string;
|
||||
/** Profile whose config should be read/written. */
|
||||
profile?: string;
|
||||
/** Bumped after the model picker saves, to re-read config in lockstep. */
|
||||
refreshKey?: number;
|
||||
/** Called after a successful change so the sidebar can show an "apply on
|
||||
|
|
@ -44,6 +45,7 @@ interface ReasoningPickerProps {
|
|||
|
||||
export function ReasoningPicker({
|
||||
currentModel,
|
||||
profile,
|
||||
refreshKey = 0,
|
||||
onChanged,
|
||||
}: ReasoningPickerProps) {
|
||||
|
|
@ -53,11 +55,11 @@ export function ReasoningPicker({
|
|||
const lastFetchKeyRef = useRef("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchKey = `${currentModel}:${refreshKey}`;
|
||||
const fetchKey = `${profile ?? ""}:${currentModel}:${refreshKey}`;
|
||||
if (fetchKey === lastFetchKeyRef.current) return;
|
||||
lastFetchKeyRef.current = fetchKey;
|
||||
void api
|
||||
.getConfig()
|
||||
.getConfig(profile)
|
||||
.then((cfg) => {
|
||||
const agent = (cfg?.agent as Record<string, unknown> | undefined) ?? {};
|
||||
setEffort(normalizeEffort(agent.reasoning_effort));
|
||||
|
|
@ -67,7 +69,7 @@ export function ReasoningPicker({
|
|||
// Best-effort: keep the last known value rather than blanking it.
|
||||
setLoaded(true);
|
||||
});
|
||||
}, [currentModel, refreshKey]);
|
||||
}, [currentModel, profile, refreshKey]);
|
||||
|
||||
const onSelect = useCallback(
|
||||
(next: string) => {
|
||||
|
|
@ -79,7 +81,7 @@ export function ReasoningPicker({
|
|||
// pattern — so we never clobber sibling keys. `saveConfig` PUTs the full
|
||||
// object the agent boots from.
|
||||
void api
|
||||
.getConfig()
|
||||
.getConfig(profile)
|
||||
.then((cfg) => {
|
||||
const base = (cfg ?? {}) as Record<string, unknown>;
|
||||
const agent =
|
||||
|
|
@ -87,7 +89,7 @@ export function ReasoningPicker({
|
|||
? { ...(base.agent as Record<string, unknown>) }
|
||||
: {};
|
||||
agent.reasoning_effort = next;
|
||||
return api.saveConfig({ ...base, agent });
|
||||
return api.saveConfig({ ...base, agent }, profile);
|
||||
})
|
||||
.then(() => {
|
||||
onChanged?.(next);
|
||||
|
|
@ -97,7 +99,7 @@ export function ReasoningPicker({
|
|||
})
|
||||
.finally(() => setSaving(false));
|
||||
},
|
||||
[effort, onChanged],
|
||||
[effort, onChanged, profile],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -369,9 +369,12 @@ export const api = {
|
|||
fetchJSON<SessionInfo>(
|
||||
appendProfileParam(`/api/sessions/${encodeURIComponent(id)}`, profile),
|
||||
),
|
||||
getSessionLatestDescendant: (id: string) =>
|
||||
getSessionLatestDescendant: (id: string, profile = getManagementProfile()) =>
|
||||
fetchJSON<SessionLatestDescendantResponse>(
|
||||
`/api/sessions/${encodeURIComponent(id)}/latest-descendant`,
|
||||
appendProfileParam(
|
||||
`/api/sessions/${encodeURIComponent(id)}/latest-descendant`,
|
||||
profile,
|
||||
),
|
||||
),
|
||||
deleteSession: (id: string, profile = getManagementProfile()) =>
|
||||
fetchJSON<{ ok: boolean }>(
|
||||
|
|
@ -471,10 +474,12 @@ export const api = {
|
|||
fetchJSON<ModelsAnalyticsResponse>(
|
||||
appendProfileParam(`/api/analytics/models?days=${days}`, profile),
|
||||
),
|
||||
getConfig: () => fetchJSON<Record<string, unknown>>("/api/config"),
|
||||
getConfig: (profile = getManagementProfile()) =>
|
||||
fetchJSON<Record<string, unknown>>(appendProfileParam("/api/config", profile)),
|
||||
getDefaults: () => fetchJSON<Record<string, unknown>>("/api/config/defaults"),
|
||||
getSchema: () => fetchJSON<{ fields: Record<string, unknown>; category_order: string[] }>("/api/config/schema"),
|
||||
getModelInfo: () => fetchJSON<ModelInfoResponse>("/api/model/info"),
|
||||
getModelInfo: (profile = getManagementProfile()) =>
|
||||
fetchJSON<ModelInfoResponse>(appendProfileParam("/api/model/info", profile)),
|
||||
getModelOptions: (
|
||||
profileOrOptions?: string | { profile?: string; refresh?: boolean },
|
||||
) => {
|
||||
|
|
@ -495,7 +500,10 @@ export const api = {
|
|||
const suffix = qs.toString() ? `?${qs.toString()}` : "";
|
||||
return fetchJSON<ModelOptionsResponse>(`/api/model/options${suffix}`);
|
||||
},
|
||||
getAuxiliaryModels: () => fetchJSON<AuxiliaryModelsResponse>("/api/model/auxiliary"),
|
||||
getAuxiliaryModels: (profile = getManagementProfile()) =>
|
||||
fetchJSON<AuxiliaryModelsResponse>(
|
||||
appendProfileParam("/api/model/auxiliary", profile),
|
||||
),
|
||||
getMoaModels: () => fetchJSON<MoaConfigResponse>("/api/model/moa"),
|
||||
saveMoaModels: (body: MoaConfigResponse) =>
|
||||
fetchJSON<MoaConfigResponse & { ok: boolean }>("/api/model/moa", {
|
||||
|
|
@ -503,21 +511,30 @@ export const api = {
|
|||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
setModelAssignment: (body: ModelAssignmentRequest) =>
|
||||
fetchJSON<ModelAssignmentResponse>("/api/model/set", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
saveConfig: (config: Record<string, unknown>) =>
|
||||
fetchJSON<{ ok: boolean }>("/api/config", {
|
||||
setModelAssignment: (
|
||||
body: ModelAssignmentRequest,
|
||||
profile = getManagementProfile(),
|
||||
) =>
|
||||
fetchJSON<ModelAssignmentResponse>(
|
||||
appendProfileParam("/api/model/set", profile),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
),
|
||||
saveConfig: (config: Record<string, unknown>, profile = getManagementProfile()) =>
|
||||
fetchJSON<{ ok: boolean }>(appendProfileParam("/api/config", profile), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ config }),
|
||||
}),
|
||||
getConfigRaw: () => fetchJSON<{ yaml: string; path?: string }>("/api/config/raw"),
|
||||
saveConfigRaw: (yaml_text: string) =>
|
||||
fetchJSON<{ ok: boolean }>("/api/config/raw", {
|
||||
getConfigRaw: (profile = getManagementProfile()) =>
|
||||
fetchJSON<{ yaml: string; path?: string }>(
|
||||
appendProfileParam("/api/config/raw", profile),
|
||||
),
|
||||
saveConfigRaw: (yaml_text: string, profile = getManagementProfile()) =>
|
||||
fetchJSON<{ ok: boolean }>(appendProfileParam("/api/config/raw", profile), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ yaml_text }),
|
||||
|
|
|
|||
|
|
@ -292,7 +292,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
|||
let cancelled = false;
|
||||
|
||||
api
|
||||
.getSessionLatestDescendant(resumeParam)
|
||||
.getSessionLatestDescendant(resumeParam, scopedProfile)
|
||||
.then((res) => {
|
||||
if (cancelled || !res.session_id || res.session_id === resumeParam) {
|
||||
return;
|
||||
|
|
@ -309,7 +309,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [resumeParam, searchParams, setSearchParams]);
|
||||
}, [resumeParam, scopedProfile, searchParams, setSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia("(max-width: 1023px)");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue