feat: Reginald is real — model router + agent loop + query_project (P4)
The fixture chat panel is now a working agent. Ask Reginald a question and it consults the real project through a tool loop, then answers in grounded prose. Read-only v0 — writes still go through the propose-approve controls. core (@commitea/core/agent): - chat-client: OpenAI-wire chat completions over an injected fetch (same seam as gitea). Points at any OpenAI-compatible endpoint (LM Studio/Ollama/OpenAI). - model-router: small model for prose + the read tool; big model reserved for later decomposition (pickModel). - agent-loop: runAgentTurn drives call→tool→result→call until prose (or a step budget), recording each tool step. Injected complete + execute → fully testable. - query-project: the single read tool's engine — compact focus/board/calibration/ issue/search views built from scheduler + lifecycle + calibration; unbuilt views return a notImplemented marker (never fabricated). The model reports, never computes. - agent-tools: query_project declaration + Reginald's system prompt. app: - main model bridge (model:status, model:chat) runs the loop; query_project reconciles the repo and builds the view. Model traffic stays in main (token/CSP). gitea.ts refactored to share getGiteaClient + reconcileSnapshot. - preload + global.d.ts expose the model bridge; useChat drives the panel — real agent turn when a model is configured, scripted fixture reply otherwise (so fixture e2e is unchanged). A subtle "consulted the project" activity line. Model config (env, defaults to LM Studio on :1234): COMMITEA_MODEL_URL / _SMALL (google/gemma-4-e4b) / _BIG (qwen/qwen3.6-35b-a3b). COMMITEA_E2E=1 keeps it unconfigured so the panel stays scripted. Verified: 88 core tests green (14 agent: client parse, loop tool/error/budget, all views) + a gated live integration test. Desktop typecheck clean, 14 fixture e2e green. Gated live e2e drives the real app against gitea + gemma-4-e4b: asked "what now?", Reginald called query_project and answered "focus is on issue #2" (the real scheduler pick). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
69
apps/desktop/src/main/model.ts
Normal file
69
apps/desktop/src/main/model.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Main-process model bridge — Reginald's brain runs here. Model traffic (like
|
||||
* gitea's) stays in main: the renderer is CSP-locked and never talks to the
|
||||
* LLM directly. On `model:chat` it drives the agent loop against the configured
|
||||
* OpenAI-compatible endpoint, executing `query_project` by reconciling the repo
|
||||
* and building the requested view. v0 is read-only — writes still go through the
|
||||
* propose-approve controls.
|
||||
*/
|
||||
|
||||
import {
|
||||
buildProjectView,
|
||||
type ChatMessage,
|
||||
createChatClient,
|
||||
type ModelRouter,
|
||||
type ProjectView,
|
||||
type QueryFilters,
|
||||
REGINALD_SYSTEM,
|
||||
REGINALD_TOOLS,
|
||||
runAgentTurn,
|
||||
} from '@commitea/core'
|
||||
import { ipcMain } from 'electron'
|
||||
|
||||
import { getGiteaClient, reconcileSnapshot } from './gitea.js'
|
||||
|
||||
/** Small local model for prose + the read tool; big model reserved for later decomposition. */
|
||||
function resolveModelRouter(): ModelRouter | null {
|
||||
if (process.env.COMMITEA_E2E === '1') return null // e2e uses the scripted fixture Reginald
|
||||
const baseUrl = process.env.COMMITEA_MODEL_URL ?? 'http://localhost:1234/v1'
|
||||
if (!baseUrl) return null
|
||||
return {
|
||||
small: { baseUrl, model: process.env.COMMITEA_MODEL_SMALL ?? 'google/gemma-4-e4b' },
|
||||
big: { baseUrl, model: process.env.COMMITEA_MODEL_BIG ?? 'qwen/qwen3.6-35b-a3b' },
|
||||
}
|
||||
}
|
||||
|
||||
export function registerModelIpc(): void {
|
||||
const router = resolveModelRouter()
|
||||
|
||||
ipcMain.handle('model:status', () => ({
|
||||
configured: !!router,
|
||||
model: router?.small.model ?? null,
|
||||
}))
|
||||
|
||||
ipcMain.handle('model:chat', async (_event, messages: ChatMessage[]) => {
|
||||
if (!router) return { ok: false as const, reason: 'unconfigured' as const }
|
||||
const client = getGiteaClient()
|
||||
const chat = createChatClient(router.small, fetch)
|
||||
|
||||
const execute = async (name: string, args: unknown) => {
|
||||
if (name !== 'query_project') return { error: `unknown tool: ${name}` }
|
||||
if (!client) return { error: 'gitea is not configured' }
|
||||
const snap = await reconcileSnapshot(client)
|
||||
const a = (args ?? {}) as { view: ProjectView; filters?: QueryFilters }
|
||||
return buildProjectView(a.view, a.filters, snap, new Date())
|
||||
}
|
||||
|
||||
try {
|
||||
const turn = await runAgentTurn({
|
||||
complete: (m, t) => chat.complete(m, t),
|
||||
messages: [{ role: 'system', content: REGINALD_SYSTEM }, ...messages],
|
||||
tools: REGINALD_TOOLS,
|
||||
execute,
|
||||
})
|
||||
return { ok: true as const, content: turn.content, steps: turn.steps }
|
||||
} catch (e) {
|
||||
return { ok: false as const, reason: 'error' as const, message: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user