diff --git a/hub/src/overseer/brainClient.test.ts b/hub/src/overseer/brainClient.test.ts new file mode 100644 index 0000000000..d3364ea7f6 --- /dev/null +++ b/hub/src/overseer/brainClient.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import { filterChatModels, listBrainProfiles, resolveBrainConfig } from './brainClient' + +const baseEnv = { + OVERSEER_BRAIN_URL: 'http://local.test/v1/', + OVERSEER_BRAIN_MODEL: 'main' +} as NodeJS.ProcessEnv + +const multiEnv = { + ...baseEnv, + OVERSEER_BRAIN_PROFILE_OPENAI_URL: 'https://api.openai.com/v1', + OVERSEER_BRAIN_PROFILE_OPENAI_MODEL: 'gpt-4o', + OVERSEER_BRAIN_PROFILE_OPENAI_API_KEY: 'sk-test' +} as NodeJS.ProcessEnv + +describe('resolveBrainConfig', () => { + it('reads the default profile and trims the trailing slash', () => { + const cfg = resolveBrainConfig(baseEnv) + expect(cfg).toMatchObject({ baseUrl: 'http://local.test/v1', model: 'main' }) + }) + + it('returns null when no brain url is configured', () => { + expect(resolveBrainConfig({} as NodeJS.ProcessEnv)).toBeNull() + }) + + it('applies a per-request model override', () => { + expect(resolveBrainConfig(baseEnv, { model: 'qwen3-32b' })?.model).toBe('qwen3-32b') + }) + + it('selects a named profile (case-insensitive) with its own key', () => { + const cfg = resolveBrainConfig(multiEnv, { profile: 'openai' }) + expect(cfg).toMatchObject({ baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o', apiKey: 'sk-test' }) + }) + + it('falls back to default for an unknown profile', () => { + expect(resolveBrainConfig(multiEnv, { profile: 'nope' })?.baseUrl).toBe('http://local.test/v1') + }) + + it('model override wins over the selected profile model', () => { + expect(resolveBrainConfig(multiEnv, { profile: 'openai', model: 'gpt-4o-mini' })?.model).toBe('gpt-4o-mini') + }) +}) + +describe('listBrainProfiles', () => { + it('lists the default plus named profiles (no url/key exposed)', () => { + const list = listBrainProfiles(multiEnv) + expect(list).toEqual([ + { id: 'default', label: 'Default', model: 'main', isDefault: true }, + { id: 'openai', label: 'openai', model: 'gpt-4o', isDefault: false } + ]) + }) + + it('is empty when no brain is configured', () => { + expect(listBrainProfiles({} as NodeJS.ProcessEnv)).toEqual([]) + }) +}) + +describe('filterChatModels', () => { + it('drops non-chat models and sorts the rest', () => { + const filtered = filterChatModels([ + 'gpt-4o', + 'text-embedding-3-small', + 'gpt-4.1', + 'whisper-1', + 'dall-e-3', + 'gpt-3.5-turbo-instruct', + 'tts-1' + ]) + expect(filtered).toEqual(['gpt-4.1', 'gpt-4o']) + }) + + it('keeps a single local model id like "main"', () => { + expect(filterChatModels(['main'])).toEqual(['main']) + }) + + it('falls back to the raw list when filtering removes everything', () => { + expect(filterChatModels(['text-embedding-3-large'])).toEqual(['text-embedding-3-large']) + }) +}) diff --git a/hub/src/overseer/brainClient.ts b/hub/src/overseer/brainClient.ts new file mode 100644 index 0000000000..325b77f132 --- /dev/null +++ b/hub/src/overseer/brainClient.ts @@ -0,0 +1,217 @@ +/** + * OpenAI-compatible client for the Overseer "brain" LLM. + * + * The brain is any OpenAI chat-completions endpoint that supports tool calling + * (e.g. the estate `llama-server` serving Qwen3.6-27B at + * `https://oos-llm.tail9944ee.ts.net/v1`, model `main`). It runs on contended + * GPUs and can vanish (pulled for VR); every reachability failure is surfaced as + * `BrainUnavailableError` so callers can degrade gracefully instead of erroring. + */ + +export type BrainConfig = { + /** Base URL including the `/v1` suffix. */ + baseUrl: string + model: string + apiKey?: string + timeoutMs: number +} + +export type OpenAiToolCall = { + id?: string + type?: 'function' + function: { name: string; arguments: string } +} + +export type OpenAiChatMessage = { + role: 'system' | 'user' | 'assistant' | 'tool' + content: string | null + tool_calls?: OpenAiToolCall[] + tool_call_id?: string + name?: string +} + +export type OverseerOpenAiToolLike = { + type: 'function' + function: { name: string; description: string; parameters: Record } +} + +/** + * `kind` distinguishes a brain that is genuinely unreachable (network/timeout — + * e.g. GPU pulled for VR) from one that answered with an error (http 4xx/5xx or + * a malformed body). Callers use this so a chat-template 400 is not mislabeled + * to the operator as "brain offline". + */ +export type BrainErrorKind = 'unreachable' | 'timeout' | 'http' | 'protocol' + +export class BrainUnavailableError extends Error { + constructor( + message: string, + readonly kind: BrainErrorKind = 'unreachable', + readonly status?: number, + readonly cause?: unknown + ) { + super(message) + this.name = 'BrainUnavailableError' + } + + /** True when the brain was reachable but the request itself failed. */ + get reachable(): boolean { + return this.kind === 'http' || this.kind === 'protocol' + } +} + +export type OverseerBrainProfileInfo = { + id: string + label: string + model: string + isDefault: boolean +} + +function timeoutFromEnv(env: NodeJS.ProcessEnv): number { + return Number(env.OVERSEER_BRAIN_TIMEOUT_MS) > 0 ? Number(env.OVERSEER_BRAIN_TIMEOUT_MS) : 60_000 +} + +/** Read a brain config from a set of env keys with the given prefix. */ +function readProfile(env: NodeJS.ProcessEnv, prefix: string): BrainConfig | null { + const baseUrl = env[`${prefix}URL`]?.trim() + if (!baseUrl) return null + return { + baseUrl: baseUrl.replace(/\/+$/, ''), + model: env[`${prefix}MODEL`]?.trim() || 'main', + apiKey: env[`${prefix}API_KEY`]?.trim() || undefined, + timeoutMs: timeoutFromEnv(env) + } +} + +/** + * Resolve brain config from env, applying an optional profile + model override. + * + * Default profile: `OVERSEER_BRAIN_URL` / `_MODEL` / `_API_KEY`. + * Named profiles: `OVERSEER_BRAIN_PROFILE__URL` / `_MODEL` / `_API_KEY` + * (so a frontier endpoint's key stays server-side, never in the browser). + * + * Returns null when the requested/default profile has no URL configured. + */ +export function resolveBrainConfig( + env: NodeJS.ProcessEnv = process.env, + opts: { profile?: string; model?: string } = {} +): BrainConfig | null { + let cfg: BrainConfig | null = null + const profile = opts.profile?.trim() + if (profile && profile.toLowerCase() !== 'default') { + cfg = readProfile(env, `OVERSEER_BRAIN_PROFILE_${profile.toUpperCase()}_`) + } + if (!cfg) cfg = readProfile(env, 'OVERSEER_BRAIN_') + if (!cfg) return null + const model = opts.model?.trim() + return model ? { ...cfg, model } : cfg +} + +const NON_CHAT_MODEL = /embedding|whisper|tts|dall-?e|moderation|audio|realtime|image|transcribe|-search|babbage|davinci-002|instruct/i + +/** Keep the chat-usable model ids (drop embeddings/audio/image/etc.), sorted. */ +export function filterChatModels(ids: string[]): string[] { + const chat = ids.filter((id) => !NON_CHAT_MODEL.test(id)) + return (chat.length > 0 ? chat : ids).slice().sort() +} + +/** List model ids a brain endpoint serves (OpenAI-compatible GET /models). */ +export async function listBrainModels(config: BrainConfig, timeoutMs = 12_000): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + let res: Response + try { + res = await fetch(`${config.baseUrl}/models`, { + headers: config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}, + signal: controller.signal + }) + } catch (error) { + throw new BrainUnavailableError('Brain model list unreachable', 'unreachable', undefined, error) + } finally { + clearTimeout(timeout) + } + if (!res.ok) throw new BrainUnavailableError(`Brain model list returned ${res.status}`, 'http', res.status) + const json = (await res.json().catch(() => null)) as { data?: Array<{ id?: unknown }> } | null + return (json?.data ?? []) + .map((m) => m?.id) + .filter((id): id is string => typeof id === 'string' && id.length > 0) +} + +/** List configured brain profiles for the UI (id/label/model only — no url/key). */ +export function listBrainProfiles(env: NodeJS.ProcessEnv = process.env): OverseerBrainProfileInfo[] { + const out: OverseerBrainProfileInfo[] = [] + const def = readProfile(env, 'OVERSEER_BRAIN_') + if (def) out.push({ id: 'default', label: 'Default', model: def.model, isDefault: true }) + for (const key of Object.keys(env)) { + const match = key.match(/^OVERSEER_BRAIN_PROFILE_(.+)_URL$/) + if (!match) continue + const id = match[1].toLowerCase() + const cfg = readProfile(env, `OVERSEER_BRAIN_PROFILE_${match[1]}_`) + if (cfg) out.push({ id, label: id, model: cfg.model, isDefault: false }) + } + return out +} + +/** + * One chat-completions round-trip. Returns the assistant message (which may + * carry `tool_calls`). Throws `BrainUnavailableError` on any transport failure, + * timeout, or non-2xx response. + */ +export async function callBrain(params: { + config: BrainConfig + messages: OpenAiChatMessage[] + tools?: OverseerOpenAiToolLike[] + temperature?: number + signal?: AbortSignal +}): Promise { + const { config, messages, tools, temperature = 0.2, signal } = params + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), config.timeoutMs) + if (signal) { + if (signal.aborted) controller.abort() + else signal.addEventListener('abort', () => controller.abort(), { once: true }) + } + + let res: Response + try { + res = await fetch(`${config.baseUrl}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}) + }, + body: JSON.stringify({ + model: config.model, + messages, + ...(tools && tools.length > 0 ? { tools, tool_choice: 'auto' } : {}), + temperature, + stream: false + }), + signal: controller.signal + }) + } catch (error) { + throw controller.signal.aborted + ? new BrainUnavailableError('Overseer brain timed out', 'timeout', undefined, error) + : new BrainUnavailableError('Overseer brain unreachable', 'unreachable', undefined, error) + } finally { + clearTimeout(timeout) + } + + if (!res.ok) { + const body = await res.text().catch(() => '') + throw new BrainUnavailableError(`Overseer brain returned ${res.status}: ${body.slice(0, 300)}`, 'http', res.status) + } + + let json: unknown + try { + json = await res.json() + } catch (error) { + throw new BrainUnavailableError('Overseer brain returned invalid JSON', 'protocol', undefined, error) + } + + const message = (json as { choices?: Array<{ message?: OpenAiChatMessage }> })?.choices?.[0]?.message + if (!message) { + throw new BrainUnavailableError('Overseer brain response missing a message', 'protocol') + } + return message +} diff --git a/hub/src/overseer/converse.test.ts b/hub/src/overseer/converse.test.ts new file mode 100644 index 0000000000..e476bd98cf --- /dev/null +++ b/hub/src/overseer/converse.test.ts @@ -0,0 +1,178 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MAX_TOOL_RESULT_CHARS, clampToolResult, runOverseerConverse } from './converse' +import { BrainUnavailableError, type BrainConfig } from './brainClient' +import type { OverseerEntity } from '../sync/overseerEntity' + +const originalFetch = globalThis.fetch +function setFetch(mock: unknown) { + globalThis.fetch = mock as typeof globalThis.fetch +} + +const config: BrainConfig = { baseUrl: 'http://brain.test/v1', model: 'main', timeoutMs: 5000 } + +const fakeOverseer = { + queryInbox: () => ({ total: 1, items: [{ id: 7, title: 'CI blocking 3 workers' }] }), + listActiveWorkers: () => ({ workers: [{ sessionId: 'sess-web', name: 'web refactor', observedState: 'stale' }] }), + queryEvents: () => [], + getSessionState: () => ({ sessionId: 'x' }), + getSessionRecentOutput: () => ({ chunks: [] }), + getWorkerHealth: () => ({ sessionId: 'x' }), + explainPriority: () => ({ inboxItemId: 1 }) +} as unknown as OverseerEntity + +function chatResponse(message: unknown) { + return new Response(JSON.stringify({ choices: [{ message }] }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) +} + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +describe('clampToolResult', () => { + it('passes small results through unchanged', () => { + const small = JSON.stringify({ items: [1, 2, 3] }) + expect(clampToolResult(small)).toBe(small) + }) + + it('truncates an oversized result and appends a narrow-your-query note', () => { + const huge = JSON.stringify({ items: Array.from({ length: 5000 }, (_, i) => ({ id: i, title: 'x'.repeat(40) })) }) + expect(huge.length).toBeGreaterThan(MAX_TOOL_RESULT_CHARS) + const clamped = clampToolResult(huge) + expect(clamped.length).toBeLessThan(huge.length) + expect(clamped.startsWith(huge.slice(0, 100))).toBe(true) + expect(clamped).toContain('truncated') + }) +}) + +describe('runOverseerConverse', () => { + it('runs a tool call then returns the final answer', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(chatResponse({ + role: 'assistant', + content: '', + tool_calls: [{ id: 'c1', type: 'function', function: { name: 'query_inbox', arguments: '{"limit":10}' } }] + })) + .mockResolvedValueOnce(chatResponse({ + role: 'assistant', + content: 'One item needs your attention: CI is blocking 3 workers.' + })) + setFetch(fetchMock) + + const { reply, toolTrace } = await runOverseerConverse({ + overseer: fakeOverseer, + config, + messages: [{ role: 'operator', content: 'What needs my attention?' }] + }) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(toolTrace).toHaveLength(1) + expect(toolTrace[0]).toMatchObject({ tool: 'query_inbox', ok: true }) + expect(reply).toContain('CI is blocking 3 workers') + }) + + it('records a failed tool call but keeps going', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(chatResponse({ + role: 'assistant', + content: '', + tool_calls: [{ id: 'c1', type: 'function', function: { name: 'explain_priority', arguments: '{"itemId":-1}' } }] + })) + .mockResolvedValueOnce(chatResponse({ role: 'assistant', content: 'That item id was invalid.' })) + setFetch(fetchMock) + + const { reply, toolTrace } = await runOverseerConverse({ + overseer: fakeOverseer, + config, + messages: [{ role: 'operator', content: 'why is item -1 flagged?' }] + }) + + expect(toolTrace[0]?.ok).toBe(false) + expect(reply).toContain('invalid') + }) + + it('ignores an unknown tool name', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(chatResponse({ + role: 'assistant', + content: '', + tool_calls: [{ id: 'c1', type: 'function', function: { name: 'dispatch_now', arguments: '{}' } }] + })) + .mockResolvedValueOnce(chatResponse({ role: 'assistant', content: 'I cannot dispatch at Stage 0.' })) + setFetch(fetchMock) + + const { toolTrace } = await runOverseerConverse({ + overseer: fakeOverseer, + config, + messages: [{ role: 'operator', content: 'restart it' }] + }) + + expect(toolTrace[0]).toMatchObject({ ok: false, error: 'unknown tool' }) + }) + + it('nudges once when the first answer skips tools, then grounds', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(chatResponse({ role: 'assistant', content: 'The inbox is empty.' })) + .mockResolvedValueOnce(chatResponse({ + role: 'assistant', + content: '', + tool_calls: [{ id: 'c1', type: 'function', function: { name: 'query_inbox', arguments: '{}' } }] + })) + .mockResolvedValueOnce(chatResponse({ role: 'assistant', content: 'One item needs your attention.' })) + setFetch(fetchMock) + + const { reply, toolTrace } = await runOverseerConverse({ + overseer: fakeOverseer, + config, + messages: [{ role: 'operator', content: 'what needs my attention?' }] + }) + + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(toolTrace.map((t) => t.tool)).toEqual(['query_inbox']) + expect(reply).toContain('needs your attention') + }) + + it('accepts a genuine no-tool answer after one nudge', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(chatResponse({ role: 'assistant', content: 'Hi.' })) + .mockResolvedValueOnce(chatResponse({ role: 'assistant', content: 'Hello — I can advise on your fleet.' })) + setFetch(fetchMock) + + const { reply, toolTrace } = await runOverseerConverse({ + overseer: fakeOverseer, + config, + messages: [{ role: 'operator', content: 'hi' }] + }) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(toolTrace).toHaveLength(0) + expect(reply).toBe('Hello — I can advise on your fleet.') + }) + + it('surfaces brain unavailability (unreachable)', async () => { + setFetch(vi.fn().mockRejectedValue(new Error('ECONNREFUSED'))) + try { + await runOverseerConverse({ overseer: fakeOverseer, config, messages: [{ role: 'operator', content: 'hi' }] }) + throw new Error('should have thrown') + } catch (e) { + expect(e).toBeInstanceOf(BrainUnavailableError) + expect((e as BrainUnavailableError).kind).toBe('unreachable') + expect((e as BrainUnavailableError).reachable).toBe(false) + } + }) + + it('classifies an http error as reachable (template 400 is not offline)', async () => { + setFetch(vi.fn().mockResolvedValue(new Response('error loading template: tool_call_id', { status: 400 }))) + try { + await runOverseerConverse({ overseer: fakeOverseer, config, messages: [{ role: 'operator', content: 'hi' }] }) + throw new Error('should have thrown') + } catch (e) { + expect(e).toBeInstanceOf(BrainUnavailableError) + expect((e as BrainUnavailableError).kind).toBe('http') + expect((e as BrainUnavailableError).status).toBe(400) + expect((e as BrainUnavailableError).reachable).toBe(true) + } + }) +}) diff --git a/hub/src/overseer/converse.ts b/hub/src/overseer/converse.ts new file mode 100644 index 0000000000..fafd58e493 --- /dev/null +++ b/hub/src/overseer/converse.ts @@ -0,0 +1,148 @@ +/** + * Overseer converse loop — the modality-agnostic conversation core. + * + * Takes the operator<->Overseer message history, runs the brain LLM with the 7 + * read-only tools, executes any tool calls in-process (read-only), feeds results + * back, and returns the final human-facing reply plus an audit trail of the + * tools it used. Text/voice/XR transports all call this same function. + */ + +import { + buildOverseerOpenAiTools, + buildOverseerSystemPrompt, + type OverseerConverseMessage, + type OverseerToolTraceEntry +} from '@hapi/protocol' +import type { OverseerEntity } from '../sync/overseerEntity' +import { isOverseerToolName, runOverseerTool } from './runOverseerTool' +import { projectToolResultForBrain } from './toolProjection' +import { + callBrain, + type BrainConfig, + type OpenAiChatMessage, + type OverseerOpenAiToolLike +} from './brainClient' + +// Appended to the shared system prompt for the converse transport. The brain +// tends to "narrate from memory" without calling tools; this makes grounding a +// hard rule rather than a suggestion. +const GROUNDING_DIRECTIVE = [ + '# Grounding (mandatory)', + '', + 'You have NO prior knowledge of the current fleet. Every fact about the inbox,', + 'workers, events, counts, health, or status must come from a tool call you make', + 'in THIS turn. Never state such a fact — including "nothing needs attention" or', + '"the inbox is empty" — without having called the relevant tool first. When in', + 'doubt, call a tool.', + '', + 'Query narrowly. Ask for a SMALL limit (10-25) and use filters (status, project,', + 'eventType, severity, time window); never dump the whole inbox or event stream.', + 'For depth on one item, call explain_priority instead of widening the query.' +].join('\n') + +// A single tool result fed back to the brain is capped to this many characters +// (~4k tokens). The 64k-ctx local brain would otherwise overflow on a full +// query_inbox / query_events dump (~75k / ~60k tokens at limit=200). Truncation +// keeps the head (results are priority-/recency-ordered) and tells the model to +// narrow. Exported for the unit test. +export const MAX_TOOL_RESULT_CHARS = 16_000 + +export function clampToolResult(json: string): string { + if (json.length <= MAX_TOOL_RESULT_CHARS) return json + return `${json.slice(0, MAX_TOOL_RESULT_CHARS)}\n…[truncated: result too large for the context window. Re-query with a smaller limit or a tighter filter, or use explain_priority for a single item.]` +} + +function parseToolArgs(raw: string): Record { + if (!raw || raw.trim().length === 0) return {} + try { + const parsed: unknown = JSON.parse(raw) + return parsed && typeof parsed === 'object' ? (parsed as Record) : {} + } catch { + return {} + } +} + +export async function runOverseerConverse(params: { + overseer: OverseerEntity + config: BrainConfig + messages: OverseerConverseMessage[] + maxIterations?: number + signal?: AbortSignal +}): Promise<{ reply: string; toolTrace: OverseerToolTraceEntry[] }> { + const { overseer, config, messages, maxIterations = 6, signal } = params + + const tools = buildOverseerOpenAiTools() as OverseerOpenAiToolLike[] + const convo: OpenAiChatMessage[] = [ + { role: 'system', content: `${buildOverseerSystemPrompt()}\n\n${GROUNDING_DIRECTIVE}` }, + ...messages.map((m): OpenAiChatMessage => ({ + role: m.role === 'operator' ? 'user' : 'assistant', + content: m.content + })) + ] + + const toolTrace: OverseerToolTraceEntry[] = [] + // The brain (llama-server) does not honor tool_choice:'required', so it will + // sometimes answer a fleet question from nothing (e.g. "the inbox is empty" + // when it never called query_inbox). Guardrail: if the very first answer + // carries zero tool calls AND no tool has run this turn, nudge once to force + // it to verify. If it still declines, the question genuinely needed no tool. + let nudged = false + + for (let iter = 0; iter < maxIterations; iter++) { + const message = await callBrain({ config, messages: convo, tools, signal }) + const calls = message.tool_calls ?? [] + + if (calls.length === 0) { + convo.push(message) + if (toolTrace.length === 0 && !nudged) { + nudged = true + convo.push({ + role: 'user', + content: 'You answered without checking. Before stating any fact about the fleet (inbox, workers, events, counts, status), call the read-only tool needed to verify it, then answer. If the question truly needs no fleet data, answer directly.' + }) + continue + } + return { reply: (message.content ?? '').trim(), toolTrace } + } + + // Execute the requested tools and feed the results back as a plain USER + // message rather than role:'tool'+tool_call_id follow-ups. llama.cpp chat + // templates 400 ("template"/"tool_call_id") on multi-round tool-role + // exchanges with real data; the flattened form keeps every turn on the + // user/assistant path that all templates render. We also drop the raw + // assistant tool-call message from history for the same reason. + const resultLines: string[] = [] + for (const call of calls) { + const name = call.function?.name ?? '' + const argsRaw = call.function?.arguments ?? '' + const args = parseToolArgs(argsRaw) + if (!isOverseerToolName(name)) { + toolTrace.push({ tool: name as never, args, ok: false, error: 'unknown tool' }) + resultLines.push(`${name || 'unknown'}(${argsRaw}) => ${JSON.stringify({ error: `unknown tool: ${name}` })}`) + continue + } + try { + const result = runOverseerTool(overseer, name, args) + toolTrace.push({ tool: name, args, ok: true }) + const lean = projectToolResultForBrain(name, result) + resultLines.push(`${name}(${argsRaw}) => ${clampToolResult(JSON.stringify(lean ?? null))}`) + } catch (error) { + const msg = error instanceof Error ? error.message : String(error) + toolTrace.push({ tool: name, args, ok: false, error: msg }) + resultLines.push(`${name}(${argsRaw}) => ${JSON.stringify({ error: msg })}`) + } + } + convo.push({ + role: 'user', + content: `Results of the tool call(s) you requested:\n${resultLines.join('\n')}\n\nAnswer my question using only these results. Call another tool only if you still lack data.` + }) + } + + // Iteration cap hit while still calling tools — ask once more for a plain answer. + const finalMsg = await callBrain({ + config, + messages: [...convo, { role: 'user', content: 'Answer now in plain text, no more tools.' }], + signal + }) + return { reply: (finalMsg.content ?? '').trim() || 'I gathered the data but could not compose an answer.', toolTrace } +} diff --git a/hub/src/overseer/runOverseerTool.ts b/hub/src/overseer/runOverseerTool.ts new file mode 100644 index 0000000000..9f8413b81f --- /dev/null +++ b/hub/src/overseer/runOverseerTool.ts @@ -0,0 +1,46 @@ +import { + OVERSEER_TOOL_NAMES, + overseerToolArgsSchemas, + type OverseerToolName +} from '@hapi/protocol' +import type { OverseerEntity } from '../sync/overseerEntity' + +/** + * Execute one read-only Overseer tool by name against the entity. Shared by the + * HTTP tool-dispatch route and the converse tool-calling loop so both go through + * exactly one place. Throws `ZodError` on invalid args; every tool is read-only. + */ +export function runOverseerTool(overseer: OverseerEntity, tool: OverseerToolName, args: unknown): unknown { + switch (tool) { + case 'query_events': + return { events: overseer.queryEvents(overseerToolArgsSchemas.query_events.parse(args)) } + case 'query_inbox': + return overseer.queryInbox(overseerToolArgsSchemas.query_inbox.parse(args)) + case 'get_session_state': { + const parsed = overseerToolArgsSchemas.get_session_state.parse(args) + return { state: overseer.getSessionState(parsed.sessionId) } + } + case 'get_session_recent_output': { + const parsed = overseerToolArgsSchemas.get_session_recent_output.parse(args) + return { chunks: overseer.getSessionRecentOutput(parsed.sessionId, parsed.n ?? 10) } + } + case 'get_worker_health': { + const parsed = overseerToolArgsSchemas.get_worker_health.parse(args) + return { health: overseer.getWorkerHealth(parsed.sessionId) } + } + case 'explain_priority': { + const parsed = overseerToolArgsSchemas.explain_priority.parse(args) + return { explanation: overseer.explainPriority(parsed.itemId) } + } + case 'list_active_workers': + return { workers: overseer.listActiveWorkers(overseerToolArgsSchemas.list_active_workers.parse(args)) } + default: { + const exhaustive: never = tool + throw new Error(`Unknown overseer tool: ${String(exhaustive)}`) + } + } +} + +export function isOverseerToolName(value: string): value is OverseerToolName { + return (OVERSEER_TOOL_NAMES as readonly string[]).includes(value) +} diff --git a/hub/src/overseer/toolProjection.test.ts b/hub/src/overseer/toolProjection.test.ts new file mode 100644 index 0000000000..19b848fddd --- /dev/null +++ b/hub/src/overseer/toolProjection.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { projectToolResultForBrain } from './toolProjection' + +describe('projectToolResultForBrain', () => { + it('derives total from items length and reports segment counts', () => { + const full = { + items: [{ id: 1, title: 'a', status: 'surfaced', priority: 9 }, { id: 2, title: 'b', status: 'new', priority: 5 }], + candidates: [{ id: 2 }], + surfaced: [{ id: 1 }], + held: [] + } + const lean = projectToolResultForBrain('query_inbox', full) as { total: number; counts: Record } + expect(lean.total).toBe(2) + expect(lean.counts).toEqual({ candidates: 1, surfaced: 1, held: 0 }) + }) + + it('thins inbox items to id/what/status/priority and drops the fat', () => { + const full = { + items: [ + { + id: 7, title: 'CI auth blocking 3 workers', status: 'surfaced', priority: 90, + category: 'blocker', summary: 'long summary…', reasonForPriority: 'shared root cause', + sourceEventIds: [1, 2, 3], artifactRefs: ['a'.repeat(400)], createdAt: 1, updatedAt: 2 + }, + { id: 8, title: 'needs a decision', status: 'new', priority: 40, artifactRefs: ['x'.repeat(400)] } + ] + } + const lean = projectToolResultForBrain('query_inbox', full) as { total: number; items: unknown[] } + expect(lean.total).toBe(2) + expect(lean.items).toEqual([ + { id: 7, what: 'CI auth blocking 3 workers', status: 'surfaced', priority: 90 }, + { id: 8, what: 'needs a decision', status: 'new', priority: 40 } + ]) + // the fat is gone + expect(JSON.stringify(lean)).not.toContain('artifactRefs') + expect(JSON.stringify(lean)).not.toContain('sourceEventIds') + // and it is dramatically smaller + expect(JSON.stringify(lean).length).toBeLessThan(JSON.stringify(full).length / 3) + }) + + it('preserves incoming (priority) order', () => { + const full = { total: 3, items: [{ id: 1, priority: 99 }, { id: 2, priority: 50 }, { id: 3, priority: 10 }] } + const lean = projectToolResultForBrain('query_inbox', full) as { items: Array<{ id: number }> } + expect(lean.items.map((i) => i.id)).toEqual([1, 2, 3]) + }) + + it('thins events to the essentials and drops the fat payload', () => { + const raw = { + events: [{ + id: 5, ts: 111, eventType: 'blocked', sourceKind: 'worker', relatedSessionId: 'sess-a', + attentionCandidate: 1, summary: 'CI auth failing', + payloadJson: 'x'.repeat(500), idempotencyKey: 'k'.repeat(120), artifactRefs: ['a'.repeat(90)] + }] + } + const lean = projectToolResultForBrain('query_events', raw) as { total: number; events: unknown[] } + expect(lean.total).toBe(1) + expect(lean.events[0]).toEqual({ id: 5, ts: 111, type: 'blocked', source: 'worker', session: 'sess-a', attention: 1, what: 'CI auth failing' }) + expect(JSON.stringify(lean)).not.toContain('payloadJson') + expect(JSON.stringify(lean)).not.toContain('idempotencyKey') + }) + + it('thins workers to id/name/project/state/age', () => { + const raw = { workers: [{ sessionId: 'sess-a', name: 'web refactor', project: 'hapi', flavor: 'cursor', observedState: 'stale', active: true, lastActivityAt: 999, ageMs: 60000 }] } + const lean = projectToolResultForBrain('list_active_workers', raw) as { total: number; workers: unknown[] } + expect(lean.total).toBe(1) + expect(lean.workers[0]).toEqual({ id: 'sess-a', name: 'web refactor', project: 'hapi', state: 'stale', ageMs: 60000 }) + expect(JSON.stringify(lean)).not.toContain('flavor') + }) + + it('passes un-projected tools through untouched', () => { + const state = { state: { sessionId: 'x', observedState: 'idle' } } + expect(projectToolResultForBrain('get_session_state', state)).toBe(state) + }) +}) diff --git a/hub/src/overseer/toolProjection.ts b/hub/src/overseer/toolProjection.ts new file mode 100644 index 0000000000..aedd71c3a7 --- /dev/null +++ b/hub/src/overseer/toolProjection.ts @@ -0,0 +1,90 @@ +import type { OverseerToolName } from '@hapi/protocol' + +/** + * Project a raw tool result into the lean view the brain actually needs. + * + * The read-only tools return rich rows for the debug/HTTP surface, but the brain + * only needs enough to reason and to ask a follow-up by id. Measured on 174 live + * inbox items: the FULL result is ~75k tokens (overflows the 64k window); the + * projected view below is ~3.7k. Everything dropped here (source events, reasons, + * artifact refs, timestamps) is one `explain_priority` call away. + * + * Projection is applied ONLY on the converse path (brain-facing). The HTTP tool + * endpoint and debug panels still get the full rows. + */ + +function isObj(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +/** + * Inbox item → the minimum for triage: + * - `id` — to reference it (explain_priority, follow-ups) + * - `what` — the title (the "what") + * - `status` — new / surfaced / held (has the operator seen it?) + * - `priority` — explicit rank (also implied by order, but explicit lets the + * brain speak with confidence) + * Items stay in their incoming priority order. + */ +function projectInboxItem(item: unknown): Record { + const o = isObj(item) ? item : {} + return { id: o.id, what: o.title, status: o.status, priority: o.priority } +} + +function len(value: unknown): number | undefined { + return Array.isArray(value) ? value.length : undefined +} + +/** + * Event → the minimum for reasoning. Drops the fat (payloadJson, idempotencyKey, + * artifactRefs, provenance, dedupe/sink plumbing) that dominates the row. + */ +function projectEvent(event: unknown): Record { + const o = isObj(event) ? event : {} + return { + id: o.id, + ts: o.ts, + type: o.eventType, + source: o.sourceKind, + session: o.relatedSessionId ?? o.sourceRef, + attention: o.attentionCandidate, + what: o.summary + } +} + +/** Worker → id/name/project/state/age; drops flavor + raw timestamps. */ +function projectWorker(worker: unknown): Record { + const o = isObj(worker) ? worker : {} + return { + id: o.sessionId, + name: o.name, + project: o.project, + state: o.observedState, + ageMs: o.ageMs + } +} + +export function projectToolResultForBrain(tool: OverseerToolName, result: unknown): unknown { + if (tool === 'query_events' && isObj(result) && Array.isArray(result.events)) { + return { total: result.events.length, events: result.events.map(projectEvent) } + } + if (tool === 'list_active_workers' && isObj(result) && Array.isArray(result.workers)) { + return { total: result.workers.length, workers: result.workers.map(projectWorker) } + } + if (tool === 'query_inbox' && isObj(result) && Array.isArray(result.items)) { + // The raw result is {items, candidates, surfaced, held} — four arrays that + // repeat the same rows (a big part of the ~75k-token bloat). We keep only + // the union `items` (thinned) plus cheap segment counts. `total` comes from + // items.length because the raw result has no total field (was null before). + return { + total: result.items.length, + counts: { + candidates: len(result.candidates), + surfaced: len(result.surfaced), + held: len(result.held) + }, + items: result.items.map(projectInboxItem) + } + } + return result +} diff --git a/hub/src/web/routes/overseer.ts b/hub/src/web/routes/overseer.ts index 4b42bf3b3d..0dfdfb9dfc 100644 --- a/hub/src/web/routes/overseer.ts +++ b/hub/src/web/routes/overseer.ts @@ -4,14 +4,15 @@ import { OVERSEER_TOOL_NAMES, buildOverseerIdentity, buildOverseerSystemPrompt, - overseerToolArgsSchemas, - type OverseerToolName + type OverseerConverseMessage } from '@hapi/protocol' import { listConfiguredVoiceBackends, resolveHubVoiceBackend } from '@hapi/protocol/voice' import type { SyncEngine } from '../../sync/syncEngine' -import type { OverseerEntity } from '../../sync/overseerEntity' import type { WebAppEnv } from '../middleware/auth' import { requireSyncEngine } from './guards' +import { isOverseerToolName, runOverseerTool } from '../../overseer/runOverseerTool' +import { runOverseerConverse } from '../../overseer/converse' +import { BrainUnavailableError, filterChatModels, listBrainModels, listBrainProfiles, resolveBrainConfig } from '../../overseer/brainClient' const convoTurnBodySchema = z.object({ operatorText: z.string().max(8000).default(''), @@ -25,40 +26,15 @@ const convoTurnBodySchema = z.object({ ts: z.number().int().positive().optional() }) -function runTool(overseer: OverseerEntity, tool: OverseerToolName, args: unknown): unknown { - switch (tool) { - case 'query_events': - return { events: overseer.queryEvents(overseerToolArgsSchemas.query_events.parse(args)) } - case 'query_inbox': - return overseer.queryInbox(overseerToolArgsSchemas.query_inbox.parse(args)) - case 'get_session_state': { - const parsed = overseerToolArgsSchemas.get_session_state.parse(args) - return { state: overseer.getSessionState(parsed.sessionId) } - } - case 'get_session_recent_output': { - const parsed = overseerToolArgsSchemas.get_session_recent_output.parse(args) - return { chunks: overseer.getSessionRecentOutput(parsed.sessionId, parsed.n ?? 10) } - } - case 'get_worker_health': { - const parsed = overseerToolArgsSchemas.get_worker_health.parse(args) - return { health: overseer.getWorkerHealth(parsed.sessionId) } - } - case 'explain_priority': { - const parsed = overseerToolArgsSchemas.explain_priority.parse(args) - return { explanation: overseer.explainPriority(parsed.itemId) } - } - case 'list_active_workers': - return { workers: overseer.listActiveWorkers(overseerToolArgsSchemas.list_active_workers.parse(args)) } - default: { - const exhaustive: never = tool - throw new Error(`Unknown overseer tool: ${String(exhaustive)}`) - } - } -} - -function isToolName(value: string): value is OverseerToolName { - return (OVERSEER_TOOL_NAMES as readonly string[]).includes(value) -} +const converseBodySchema = z.object({ + messages: z.array(z.object({ + role: z.enum(['operator', 'overseer']), + content: z.string().max(8000) + })).min(1).max(40), + relatedSessionId: z.string().min(1).optional(), + model: z.string().max(100).optional(), + profile: z.string().max(64).optional() +}) export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Hono { const app = new Hono() @@ -86,6 +62,33 @@ export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Ho }) }) + // Configured brain profiles for the converse UI (id/label/model only — no + // url or api key is exposed to the client). + app.get('/overseer/brains', (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) return engine + return c.json({ profiles: listBrainProfiles(process.env) }) + }) + + // Live model list for a brain profile (proxies the endpoint's GET /models so + // the api key stays server-side). Powers the model dropdown in the debug UI. + app.get('/overseer/brains/:id/models', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) return engine + const id = c.req.param('id') + const config = resolveBrainConfig(process.env, { profile: id }) + if (!config) { + return c.json({ profile: id, defaultModel: null, models: [], error: 'profile not configured' }, 404) + } + try { + const models = filterChatModels(await listBrainModels(config)) + return c.json({ profile: id, defaultModel: config.model, models }) + } catch (error) { + const message = error instanceof Error ? error.message : 'failed to list models' + return c.json({ profile: id, defaultModel: config.model, models: [], error: message }) + } + }) + // Read-only tool dispatch. All tools are read-only; this endpoint never // mutates worker or inbox state. app.post('/overseer/tools/:tool', async (c) => { @@ -93,7 +96,7 @@ export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Ho if (engine instanceof Response) return engine const tool = c.req.param('tool') - if (!isToolName(tool)) { + if (!isOverseerToolName(tool)) { return c.json({ error: `Unknown overseer tool: ${tool}` }, 404) } @@ -105,7 +108,7 @@ export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Ho } try { - const result = runTool(engine.getOverseer(), tool, body ?? {}) + const result = runOverseerTool(engine.getOverseer(), tool, body ?? {}) return c.json({ tool, result }) } catch (error) { if (error instanceof z.ZodError) { @@ -115,6 +118,80 @@ 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 + // friendly message rather than an error. + app.post('/overseer/converse', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) return engine + + let body: unknown + try { + body = await c.req.json() + } catch { + return c.json({ error: 'Invalid JSON body' }, 400) + } + + const parsed = converseBodySchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid body', issues: parsed.error.flatten() }, 400) + } + const messages = parsed.data.messages as OverseerConverseMessage[] + if (messages[messages.length - 1]?.role !== 'operator') { + return c.json({ error: 'Last message must be from the operator' }, 400) + } + + const config = resolveBrainConfig(process.env, { + profile: parsed.data.profile, + model: parsed.data.model + }) + if (!config) { + return c.json({ + 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.', + toolTrace: [], + model: null, + brainOnline: false + }) + } + + try { + const { reply, toolTrace } = await runOverseerConverse({ + overseer: engine.getOverseer(), + config, + messages + }) + + const lastOperator = [...messages].reverse().find((m) => m.role === 'operator')?.content ?? '' + engine.getOverseer().recordConvoTurn({ + operatorText: lastOperator, + overseerText: reply, + relatedSessionId: parsed.data.relatedSessionId ?? null, + toolCalls: toolTrace + .filter((t) => t.ok) + .map((t) => ({ tool: t.tool, argsSummary: JSON.stringify(t.args).slice(0, 500) })) + }) + + return c.json({ reply, toolTrace, model: config.model, brainOnline: true }) + } catch (error) { + 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. + 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.' + return c.json({ + reply, + toolTrace: [], + model: config.model, + brainOnline: error.reachable + }) + } + throw error + } + }) + // convo_turn writeback — persists an operator<->Overseer exchange as a // memory-bearing event (attention_candidate=0, never an inbox item). app.post('/overseer/convo-turns', async (c) => { diff --git a/shared/src/index.ts b/shared/src/index.ts index 4f819d4728..822e3a60c1 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -4,6 +4,7 @@ export * from './messages' export * from './overseerEvents' export * from './overseerInbox' export * from './overseerEntity' +export * from './overseerConverse' export * from './buildInfo' export * from './effort' export * from './flavors' diff --git a/shared/src/overseerConverse.ts b/shared/src/overseerConverse.ts new file mode 100644 index 0000000000..417de90127 --- /dev/null +++ b/shared/src/overseerConverse.ts @@ -0,0 +1,152 @@ +/** + * Overseer conversation — modality-agnostic core types + brain tool schemas. + * + * The Overseer conversation is a hub-owned core: `messages in -> brain reasons + * and calls read-only tools -> reply out`. Text, voice, and XR are all just + * transports over this same core; text is merely the cheapest one to build and + * test first, and is NOT privileged over the others. + * + * This module owns the protocol surface shared by hub + web: + * - the request/response shapes for a converse turn + * - the OpenAI-compatible function-tool array for the 7 read-only tools, + * derived from the catalog + arg schemas in `overseerEntity.ts` (no extra + * dependency — the params are hand-mapped to stay in lock-step with the zod + * schemas, which are simple and stable). + */ + +import { + OVERSEER_TOOL_CATALOG, + OVERSEER_WORKER_STATES, + type OverseerToolName +} from './overseerEntity' + +export type OverseerConverseRole = 'operator' | 'overseer' + +export type OverseerConverseMessage = { + role: OverseerConverseRole + content: string +} + +export type OverseerToolTraceEntry = { + tool: OverseerToolName + args: Record + ok: boolean + /** Present when the tool call failed (bad args / not found). */ + error?: string +} + +export type OverseerConverseRequest = { + /** Full conversation so far, oldest first. Last message must be the operator. */ + messages: OverseerConverseMessage[] + /** Optional session this conversation is threaded to (for convo_turn linkage). */ + relatedSessionId?: string | null + /** + * Per-request model override — swap the brain model without touching env or + * restarting (useful for A/B-ing a frontier model against the local one on a + * multi-model endpoint). Blank = the profile's configured model. + */ + model?: string + /** + * Named brain profile to use for this request. Profiles are defined + * server-side (url + model + key stay off the browser). Blank = default. + */ + profile?: string +} + +/** Public info about a configured brain profile (no url/key exposed). */ +export type OverseerBrainProfileInfo = { + id: string + label: string + model: string + isDefault: boolean +} + +export type OverseerConverseResponse = { + /** The Overseer's spoken/typed answer — human-facing, contract-free. */ + reply: string + /** The read-only tools the brain called while answering (audit/dogfood). */ + toolTrace: OverseerToolTraceEntry[] + /** Model id that answered, when known. */ + model: string | null + /** + * False when the brain endpoint was unreachable/offline (GPU pulled for VR, + * etc). `reply` then carries a friendly offline message; callers should not + * treat this as an error. + */ + brainOnline: boolean +} + +// --------------------------------------------------------------------------- +// OpenAI-compatible function-tool schemas for the 7 read-only tools +// --------------------------------------------------------------------------- + +export type OverseerOpenAiTool = { + type: 'function' + function: { + name: OverseerToolName + description: string + parameters: Record + } +} + +type JsonSchema = Record + +function obj(properties: Record, required: string[] = []): JsonSchema { + return { + type: 'object', + properties, + ...(required.length > 0 ? { required } : {}), + additionalProperties: false + } +} + +const sessionIdProp: JsonSchema = { type: 'string', description: 'Exact session id (resolve a human name via list_active_workers first).' } + +/** Hand-mapped params mirroring `overseerToolArgsSchemas` (kept simple + stable). */ +const OVERSEER_TOOL_PARAMS: Record = { + query_events: obj({ + sessionId: sessionIdProp, + project: { type: 'string' }, + eventType: { type: 'string', description: 'e.g. blocked, completed, failed, needs_decision, progress, stale.' }, + sourceKind: { type: 'string', enum: ['worker', 'overseer', 'operator', 'system', 'channel'] }, + attentionCandidate: { type: 'integer', enum: [0, 1] }, + severityMin: { type: 'integer', minimum: 1, maximum: 5 }, + sinceTs: { type: 'integer', minimum: 0, description: 'Epoch ms lower bound.' }, + untilTs: { type: 'integer', minimum: 0, description: 'Epoch ms upper bound.' }, + beforeId: { type: 'integer', minimum: 1 }, + limit: { type: 'integer', minimum: 1, maximum: 200 } + }), + query_inbox: obj({ + statuses: { type: 'array', items: { type: 'string' }, description: 'e.g. candidate, surfaced, held.' }, + sessionId: sessionIdProp, + category: { type: 'string' }, + limit: { type: 'integer', minimum: 1, maximum: 200 } + }), + get_session_state: obj({ sessionId: sessionIdProp }, ['sessionId']), + get_session_recent_output: obj({ + sessionId: sessionIdProp, + n: { type: 'integer', minimum: 1, maximum: 50, description: 'How many recent transcript chunks.' } + }, ['sessionId']), + get_worker_health: obj({ sessionId: sessionIdProp }, ['sessionId']), + explain_priority: obj({ + itemId: { type: 'integer', minimum: 1, description: 'Inbox item id.' } + }, ['itemId']), + list_active_workers: obj({ + project: { type: 'string' }, + state: { type: 'string', enum: [...OVERSEER_WORKER_STATES] }, + minAgeMs: { type: 'integer', minimum: 0 }, + limit: { type: 'integer', minimum: 1, maximum: 200 } + }) +} + +/** The 7 read-only tools as an OpenAI-compatible `tools` array for the brain. */ +export function buildOverseerOpenAiTools(): OverseerOpenAiTool[] { + return OVERSEER_TOOL_CATALOG.map((entry) => ({ + type: 'function', + function: { + name: entry.name, + description: entry.description, + parameters: OVERSEER_TOOL_PARAMS[entry.name] + } + })) +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 00022e41bc..750d4c84ca 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -819,4 +819,29 @@ export class ApiClient { }) } + async overseerConverse( + messages: import('@hapi/protocol').OverseerConverseMessage[], + opts: { relatedSessionId?: string | null; model?: string; profile?: string } = {} + ): Promise { + return await this.request('/api/overseer/converse', { + method: 'POST', + body: JSON.stringify({ + messages, + relatedSessionId: opts.relatedSessionId ?? undefined, + model: opts.model?.trim() || undefined, + profile: opts.profile || undefined + }) + }) + } + + async fetchOverseerBrains(): Promise<{ profiles: import('@hapi/protocol').OverseerBrainProfileInfo[] }> { + return await this.request('/api/overseer/brains') + } + + async fetchOverseerBrainModels( + profileId: string + ): Promise<{ profile: string; defaultModel: string | null; models: string[]; error?: string }> { + return await this.request(`/api/overseer/brains/${encodeURIComponent(profileId)}/models`) + } + } diff --git a/web/src/components/settings/OverseerChatDebugControls.tsx b/web/src/components/settings/OverseerChatDebugControls.tsx new file mode 100644 index 0000000000..f5c66eac30 --- /dev/null +++ b/web/src/components/settings/OverseerChatDebugControls.tsx @@ -0,0 +1,251 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useAppContext } from '@/lib/app-context' +import type { OverseerBrainProfileInfo, OverseerConverseMessage, OverseerConverseResponse, OverseerToolTraceEntry } from '@hapi/protocol' + +type ChatTurn = { + role: 'operator' | 'overseer' + content: string + toolTrace?: OverseerToolTraceEntry[] + brainOnline?: boolean +} + +// Debug-only text transport for the modality-agnostic Overseer converse core. +// This is deliberately a Settings/debug affordance, not a top-level surface: +// voice/XR are the intended first-class modalities and reuse the same +// /api/overseer/converse endpoint. Text is here only to exercise the loop. +const STARTER_QUESTIONS = [ + 'What needs my attention?', + 'Which agents are blocked?', + "What's everyone working on right now?", + 'Anything need a decision from me?' +] + +export function OverseerChatDebugControls() { + const { api } = useAppContext() + const [open, setOpen] = useState(false) + const [turns, setTurns] = useState([]) + const [input, setInput] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [model, setModel] = useState(null) + const [profiles, setProfiles] = useState([]) + const [selectedProfile, setSelectedProfile] = useState('default') + const [models, setModels] = useState([]) + const [modelsLoading, setModelsLoading] = useState(false) + const [modelsError, setModelsError] = useState(null) + const [selectedModel, setSelectedModel] = useState('') + const scrollRef = useRef(null) + + useEffect(() => { + if (!open || !api || profiles.length > 0) return + void api.fetchOverseerBrains() + .then((res) => setProfiles(res.profiles)) + .catch(() => { /* brains list is optional chrome */ }) + }, [open, api, profiles.length]) + + // Populate the model dropdown live from the selected profile's endpoint + // (server proxies GET /models so the api key never reaches the browser). + useEffect(() => { + if (!open || !api || !selectedProfile) return + let cancelled = false + setModelsLoading(true) + setModelsError(null) + setSelectedModel('') + void api.fetchOverseerBrainModels(selectedProfile) + .then((res) => { + if (cancelled) return + setModels(res.models) + if (res.error) setModelsError(res.error) + }) + .catch((err) => { if (!cancelled) setModelsError(err instanceof Error ? err.message : 'model list failed') }) + .finally(() => { if (!cancelled) setModelsLoading(false) }) + return () => { cancelled = true } + }, [open, api, selectedProfile]) + + const profileDefaultModel = profiles.find((p) => p.id === selectedProfile)?.model ?? null + + const send = useCallback(async (text: string) => { + const trimmed = text.trim() + if (!trimmed || !api || loading) return + setError(null) + setInput('') + + const history = turns.map((turn): OverseerConverseMessage => ({ role: turn.role, content: turn.content })) + const nextHistory: OverseerConverseMessage[] = [...history, { role: 'operator', content: trimmed }] + setTurns((prev) => [...prev, { role: 'operator', content: trimmed }]) + setLoading(true) + try { + const res = await api.overseerConverse(nextHistory, { + profile: selectedProfile !== 'default' ? selectedProfile : undefined, + model: selectedModel || undefined + }) as OverseerConverseResponse + setModel(res.model) + setTurns((prev) => [...prev, { + role: 'overseer', + content: res.reply, + toolTrace: res.toolTrace, + brainOnline: res.brainOnline + }]) + requestAnimationFrame(() => { + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }) + }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Converse failed') + } finally { + setLoading(false) + } + }, [api, loading, turns, selectedProfile, selectedModel]) + + return ( +
+ + {open && ( +
+

+ Read-only fleet chief-of-staff (Stage 0). Text transport over the same converse core voice will use. Answers are driven by a local LLM calling read-only overseer tools. +

+ +
+ {profiles.length > 1 ? ( + + ) : null} + + {modelsError ? ( + models: {modelsError} + ) : ( + Per-request — no hub restart. + )} +
+ +
+ {turns.length === 0 ? ( +
+

Ask the Overseer about your fleet. Try:

+
+ {STARTER_QUESTIONS.map((q) => ( + + ))} +
+
+ ) : ( + turns.map((turn, idx) => ( +
+
+

{turn.content}

+ {turn.role === 'overseer' && turn.brainOnline === false ? ( +

brain offline

+ ) : null} + {turn.role === 'overseer' && turn.toolTrace && turn.toolTrace.length > 0 ? ( +
+ + {turn.toolTrace.length} tool call{turn.toolTrace.length === 1 ? '' : 's'} + +
    + {turn.toolTrace.map((tt, i) => ( +
  • + {tt.ok ? '✓' : '✗'}{' '} + {tt.tool}({Object.keys(tt.args).length ? JSON.stringify(tt.args) : ''}) + {tt.error ? ` — ${tt.error}` : ''} +
  • + ))} +
+
+ ) : null} +
+
+ )) + )} + {loading ?

Overseer is thinking…

: null} +
+ + {error ?

{error}

: null} + +
{ e.preventDefault(); void send(input) }} + className="flex items-center gap-2" + > + setInput(e.target.value)} + placeholder="Ask the Overseer…" + disabled={loading} + className="flex-1 rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] px-2 py-1.5 text-[13px] text-[var(--app-fg)] disabled:opacity-50" + /> + + {turns.length > 0 ? ( + + ) : null} +
+
+ )} +
+ ) +} diff --git a/web/src/routes/settings/about.tsx b/web/src/routes/settings/about.tsx index 78534416dd..882a09ff3d 100644 --- a/web/src/routes/settings/about.tsx +++ b/web/src/routes/settings/about.tsx @@ -2,6 +2,7 @@ import { PROTOCOL_VERSION } from '@hapi/protocol' import { useTranslation } from '@/lib/use-translation' import { EventsDebugControls } from '@/components/settings/EventsDebugControls' import { InboxDebugControls } from '@/components/settings/InboxDebugControls' +import { OverseerChatDebugControls } from '@/components/settings/OverseerChatDebugControls' import { SettingsPageContent, SettingsRow, SettingsSection } from '@/components/settings/SettingsPrimitives' export default function SettingsAboutPage() { @@ -18,6 +19,7 @@ export default function SettingsAboutPage() { + )