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:
@@ -11,11 +11,13 @@ import { dirname, join } from 'node:path'
|
||||
|
||||
import {
|
||||
createGiteaClient,
|
||||
type GiteaClient,
|
||||
type GiteaConfig,
|
||||
type GiteaLabel,
|
||||
type IssueChange,
|
||||
type LifecycleEvent,
|
||||
planIssueChange,
|
||||
type ProjectSnapshot,
|
||||
} from '@commitea/core'
|
||||
import { ipcMain } from 'electron'
|
||||
|
||||
@@ -50,28 +52,45 @@ function resolveConfig(): GiteaConfig | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function registerGiteaIpc(): void {
|
||||
const config = resolveConfig()
|
||||
const client = config ? createGiteaClient(config, fetch) : null
|
||||
const repo = config ? `${config.owner}/${config.repo}` : null
|
||||
// Memoized client so both the gitea and model bridges share one instance.
|
||||
let sharedClient: GiteaClient | null | undefined
|
||||
export function getGiteaClient(): GiteaClient | null {
|
||||
if (sharedClient === undefined) {
|
||||
const config = resolveConfig()
|
||||
sharedClient = config ? createGiteaClient(config, fetch) : null
|
||||
}
|
||||
return sharedClient
|
||||
}
|
||||
|
||||
ipcMain.handle('gitea:status', () => ({ configured: !!config, repo }))
|
||||
/** Full reconcile: issues + milestones + native deps + lifecycle timelines. */
|
||||
export async function reconcileSnapshot(
|
||||
client: GiteaClient,
|
||||
): Promise<ProjectSnapshot & { milestones: Awaited<ReturnType<GiteaClient['listMilestones']>> }> {
|
||||
const [issues, milestones] = await Promise.all([client.listIssues(), client.listMilestones()])
|
||||
// dependency edges among the open scope (the scheduler only plans what's left)
|
||||
const open = issues.filter((i) => i.state === 'open')
|
||||
const perIssue = await Promise.all(
|
||||
open.map(async (i) => ({ issue: i.number, dependsOn: await client.getIssueDependencies(i.number) })),
|
||||
)
|
||||
const deps = perIssue.flatMap(({ issue, dependsOn }) => dependsOn.map((d) => ({ issue, dependsOn: d })))
|
||||
// lifecycle timelines for every issue (open → columns/badges, closed → calibration actuals)
|
||||
const timelineEntries = await Promise.all(
|
||||
issues.map(async (i) => [i.number, await client.getIssueTimeline(i.number)] as const),
|
||||
)
|
||||
const timelines: Record<number, LifecycleEvent[]> = Object.fromEntries(timelineEntries)
|
||||
return { issues, milestones, deps, timelines }
|
||||
}
|
||||
|
||||
export function registerGiteaIpc(): void {
|
||||
const client = getGiteaClient()
|
||||
const repo = client ? `${process.env.GITEA_OWNER ?? 'christian'}/${process.env.GITEA_REPO ?? 'commitea'}` : null
|
||||
|
||||
ipcMain.handle('gitea:status', () => ({ configured: !!client, repo }))
|
||||
|
||||
ipcMain.handle('gitea:reconcile', async () => {
|
||||
if (!client) return { configured: false, issues: [], milestones: [], deps: [], timelines: {} }
|
||||
const [issues, milestones] = await Promise.all([client.listIssues(), client.listMilestones()])
|
||||
// dependency edges among the open scope (the scheduler only plans what's left)
|
||||
const open = issues.filter((i) => i.state === 'open')
|
||||
const perIssue = await Promise.all(
|
||||
open.map(async (i) => ({ issue: i.number, dependsOn: await client.getIssueDependencies(i.number) })),
|
||||
)
|
||||
const deps = perIssue.flatMap(({ issue, dependsOn }) => dependsOn.map((d) => ({ issue, dependsOn: d })))
|
||||
// lifecycle timelines for every issue (open → columns/badges, closed → calibration actuals)
|
||||
const timelineEntries = await Promise.all(
|
||||
issues.map(async (i) => [i.number, await client.getIssueTimeline(i.number)] as const),
|
||||
)
|
||||
const timelines: Record<number, LifecycleEvent[]> = Object.fromEntries(timelineEntries)
|
||||
return { configured: true, issues, milestones, deps, timelines }
|
||||
const snap = await reconcileSnapshot(client)
|
||||
return { configured: true, ...snap }
|
||||
})
|
||||
|
||||
ipcMain.handle('gitea:getIssue', async (_event, index: number) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { join } from 'node:path'
|
||||
import { BrowserWindow, app, shell } from 'electron'
|
||||
|
||||
import { registerGiteaIpc } from './gitea.js'
|
||||
import { registerModelIpc } from './model.js'
|
||||
|
||||
function createWindow(): void {
|
||||
const win = new BrowserWindow({
|
||||
@@ -35,6 +36,7 @@ function createWindow(): void {
|
||||
|
||||
void app.whenReady().then(() => {
|
||||
registerGiteaIpc()
|
||||
registerModelIpc()
|
||||
createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
|
||||
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