Skip to content
Open
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
8 changes: 7 additions & 1 deletion hub/src/web/routes/overseer.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'bun:test'
import { describe, expect, it, spyOn } from 'bun:test'
import { Hono } from 'hono'
import { Store } from '../../store'
import { SyncEngine } from '../../sync/syncEngine'
Expand Down Expand Up @@ -125,6 +125,7 @@ describe('overseer routes', () => {
it('POST /overseer/converse persists offline reply when brain is unconfigured', async () => {
const prev = process.env.OVERSEER_BRAIN_URL
delete process.env.OVERSEER_BRAIN_URL
const warnSpy = spyOn(console, 'warn').mockImplementation(() => {})
try {
const store = new Store(':memory:')
const app = buildApp(store)
Expand All @@ -137,13 +138,18 @@ describe('overseer routes', () => {
const body = await res.json() as { brainOnline: boolean; reply: string }
expect(body.brainOnline).toBe(false)
expect(body.reply).toContain('not configured')
expect(warnSpy).toHaveBeenCalledWith(
'[Overseer][Converse] brain unavailable',
expect.objectContaining({ reason: 'not_configured', kind: 'not_configured', reachable: false })
)

const recent = await app.request('/api/overseer/converse/recent?limit=5')
const recentBody = await recent.json() as { turns: Array<{ operatorText: string; overseerText: string }> }
expect(recentBody.turns).toHaveLength(1)
expect(recentBody.turns[0]?.operatorText).toBe('hello fleet')
expect(recentBody.turns[0]?.overseerText).toContain('not configured')
} finally {
warnSpy.mockRestore()
if (prev === undefined) delete process.env.OVERSEER_BRAIN_URL
else process.env.OVERSEER_BRAIN_URL = prev
}
Expand Down
22 changes: 20 additions & 2 deletions hub/src/web/routes/overseer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Ho
// Converse — the modality-agnostic conversation core. Runs the brain LLM
// with the read-only tools and returns a human-facing reply + tool trace.
// Text is the first transport (debug settings); voice/XR reuse this. When
// the brain is offline (GPU pulled for VR), returns brainOnline:false with a
// the brain is offline, returns brainOnline:false with a
// friendly message rather than an error.
//
// Continuity: hub assembles prior `convo_turn`s (budgeted) + latest operator
Expand Down Expand Up @@ -244,6 +244,14 @@ export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Ho
model: parsed.data.model
}))
if (!config) {
// Soft 200 for clients — journal still needs a greppable line (access log is 200).
console.warn('[Overseer][Converse] brain unavailable', {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep structured warning fields on the greppable line

Under Bun, passing this object as a second console.warn argument renders the warning across multiple lines: the prefixed line contains only [Overseer][Converse] brain unavailable {, while reason, kind, reachable, and the other fields appear on separate unprefixed lines. Consequently, grepping the journal for the advertised prefix returns none of the diagnostic fields, defeating the purpose of this structured warning; serialize the payload onto the prefixed line or use a logger that emits a single structured record.

Useful? React with 👍 / 👎.

reason: 'not_configured',
kind: 'not_configured',
reachable: false,
model: null,
profile: parsed.data.profile ?? null
})
const reply = 'The Overseer brain is not configured on this hub (set OVERSEER_BRAIN_URL). I can still show raw events and inbox items, but I cannot answer in conversation yet.'
persistOverseerConvoExchange(overseer, assembled, {
operatorText: lastOperator,
Expand Down Expand Up @@ -289,9 +297,19 @@ export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Ho
if (error instanceof BrainUnavailableError) {
// Reachable-but-failed (http 4xx/5xx, malformed body) is a converse
// bug, not an offline brain — do not mislabel it as GPU/VR downtime.
// Soft 200 for clients — structured warn so journalctl can find it.
console.warn('[Overseer][Converse] brain unavailable', {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Warn when the brain fails after a successful write

When a follow-up brain call fails after ping_session or another write succeeds, runOverseerConverse deliberately catches the BrainUnavailableError in hub/src/overseer/converse.ts and returns a fallback success so the operator does not repeat the write. The route therefore resolves normally and never reaches this warning, leaving that soft brain outage absent from the journal; carry failure metadata back in the result or emit the warning where the error is swallowed while retaining the successful-write fallback.

Useful? React with 👍 / 👎.

reason: error.reachable ? 'request_error' : 'unreachable',
kind: error.kind,
reachable: error.reachable,
status: error.status ?? null,
model: config.model,
profile: parsed.data.profile ?? null,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Log the effective profile instead of only the request override

When a valid caller omits profile to use the persisted active brain and that brain request fails, this new warning records profile: null even though config was resolved from active.profile. That makes the diagnostic line ambiguous precisely for the default API flow and can send operators toward the wrong endpoint or credentials; retain the resolved selection and log its effective profile here.

Useful? React with 👍 / 👎.

message: error.message.slice(0, 200)
})
const reply = error.reachable
? 'I reached the Overseer brain but could not complete the tool conversation (request error). This is a converse-loop issue, not the brain being offline — please retry, and flag it if it persists.'
: 'The Overseer brain is offline right now (the GPU may be in use for VR). Try again shortly — your events and inbox are still being captured.'
: 'The Overseer brain is offline right now. Try again shortly — your events and inbox are still being captured.'
persistOverseerConvoExchange(overseer, assembled, {
operatorText: lastOperator,
overseerText: reply,
Expand Down
10 changes: 9 additions & 1 deletion web/src/components/settings/OverseerChatDebugControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,15 @@ export function OverseerChatDebugControls() {
useEffect(() => {
if (!open || !api || profiles.length > 0) return
void api.fetchOverseerBrains()
.then((res) => setProfiles(res.profiles))
.then((res) => {
setProfiles(res.profiles)
// Prefer hub active brain — do not stick on hard-coded "default" when
// the operator saved a working profile (e.g. local loopback).
if (res.active?.profile) {
setSelectedProfile(res.active.profile)
setSelectedModel(res.active.model ?? '')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the active model when applying the active profile

When the persisted active profile differs from the initial default profile and has a model override, changing selectedProfile here reruns the model-list effect, which unconditionally resets selectedModel to ''. The next converse request still supplies the profile but omits the saved model; resolveBrainSelection treats an explicit profile as a wholesale override, so the conversation uses the profile's default model instead of the operator's active model.

Useful? React with 👍 / 👎.

}
})
.catch(() => { /* brains list is optional chrome */ })
}, [open, api, profiles.length])

Expand Down
Loading