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:
Croissant Le Doux
2026-07-08 20:43:02 -04:00
parent e8bf71e970
commit 3de887417c
16 changed files with 992 additions and 31 deletions

View File

@@ -1,12 +1,13 @@
import React, { useEffect, useRef, useState } from 'react'
import { CANNED_REPLY, CHAT, type ChatMessage } from '../../data/fixtures.js'
import { useChat } from '../../lib/use-chat.js'
import { Icon, IconButton } from '../ui/index.js'
/**
* Reginald's panel — chat is the write-path (decisions.md D1). This is the P3-2
* fixture shell: it echoes a canned reply so the layout + interactions are real,
* but no model is wired. P4 replaces `send` with the model router + tools.
* Reginald's panel — chat is the write-path (decisions.md D1). Wired to the
* model bridge via `useChat`: when a model is configured, sending drives a real
* agent turn (query_project + prose); otherwise it echoes the scripted fixture
* reply so the layout stays real. Writes still go through propose-approve.
*/
export interface ChatPanelProps {
onOpenDirectives?: () => void
@@ -14,9 +15,8 @@ export interface ChatPanelProps {
}
export function ChatPanel({ onOpenDirectives, offline }: ChatPanelProps) {
const [msgs, setMsgs] = useState<ChatMessage[]>(CHAT)
const { msgs, thinking, live, steps, send: sendChat } = useChat()
const [text, setText] = useState('')
const [thinking, setThinking] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
useEffect(() => {
@@ -27,13 +27,8 @@ export function ChatPanel({ onOpenDirectives, offline }: ChatPanelProps) {
const send = () => {
const t = text.trim()
if (!t) return
setMsgs((m) => [...m, { from: 'user', text: t }])
setText('')
setThinking(true)
setTimeout(() => {
setThinking(false)
setMsgs((m) => [...m, { from: 'agent', text: CANNED_REPLY }])
}, 900)
sendChat(t)
}
return (
@@ -61,7 +56,7 @@ export function ChatPanel({ onOpenDirectives, offline }: ChatPanelProps) {
<Icon name="sparkles" size={16} style={{ color: offline ? 'var(--ink-3)' : 'var(--jade)' }} />
<span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)' }}>Reginald</span>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', marginLeft: 'auto' }}>
{offline ? 'offline · queueing' : 'gemma-4b · local'}
{offline ? 'offline · queueing' : live ? 'gemma-4 · local' : 'demo · scripted'}
</span>
<IconButton icon="history" label="Directive log" size="sm" onClick={onOpenDirectives} />
</header>
@@ -98,6 +93,11 @@ export function ChatPanel({ onOpenDirectives, offline }: ChatPanelProps) {
</div>
) : null}
{thinking ? <div style={{ font: 'var(--text-agent)', color: 'var(--ink-3)' }}>considering</div> : null}
{!thinking && steps.length ? (
<div style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', display: 'flex', alignItems: 'center', gap: 5 }}>
<Icon name="eye" size={11} /> consulted {Array.from(new Set(steps.map((s) => s.replace('query_project', 'the project')))).join(', ')}
</div>
) : null}
</div>
<div style={{ padding: 14, borderTop: '1px solid var(--line-1)', flexShrink: 0 }}>

View File

@@ -1,4 +1,6 @@
import type {
AgentStep,
ChatMessage,
DependencyEdge,
GiteaIssue,
GiteaMilestone,
@@ -27,11 +29,23 @@ export interface GiteaBridge {
applyChange(change: IssueChange): Promise<ApplyChangeResult>
}
/** One agent turn's result. */
export type ChatResult =
| { ok: false; reason: 'unconfigured' | 'error'; message?: string }
| { ok: true; content: string; steps: AgentStep[] }
/** The model bridge (Reginald) exposed by the preload over IPC. */
export interface ModelBridge {
status(): Promise<{ configured: boolean; model: string | null }>
chat(messages: ChatMessage[]): Promise<ChatResult>
}
declare global {
interface Window {
commitea: {
platform: string
gitea: GiteaBridge
model: ModelBridge
}
}
}

View File

@@ -0,0 +1,101 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { ChatMessage as WireMessage } from '@commitea/core'
import { CANNED_REPLY, CHAT, type ChatMessage } from '../data/fixtures.js'
const LIVE_GREETING: ChatMessage = {
from: 'agent',
text: 'Morning. Ask me anything about the project — I check the real board before I answer.',
}
export interface ChatState {
msgs: ChatMessage[]
thinking: boolean
/** true once a model endpoint is confirmed; otherwise the panel echoes the demo reply. */
live: boolean
/** Tools Reginald consulted on the last turn (for a subtle activity line). */
steps: string[]
send: (text: string) => void
}
/**
* Reginald's conversation. When a model is configured, `send` drives one agent
* turn through the main-process bridge (which runs the tool loop). Otherwise it
* echoes the scripted fixture reply, so the layout stays real with no model and
* fixture e2e is unaffected. The fixture greeting is display-only — only real
* turns (`convo`) are sent to the model as history.
*/
export function useChat(): ChatState {
const [seed, setSeed] = useState<ChatMessage[]>(CHAT)
const [convo, setConvo] = useState<ChatMessage[]>([])
const [thinking, setThinking] = useState(false)
const [live, setLive] = useState(false)
const [steps, setSteps] = useState<string[]>([])
const convoRef = useRef(convo)
convoRef.current = convo
useEffect(() => {
let alive = true
window.commitea.model
.status()
.then((s) => {
if (alive && s.configured) {
setLive(true)
setSeed([LIVE_GREETING])
}
})
.catch(() => {})
return () => {
alive = false
}
}, [])
const send = useCallback(
(raw: string) => {
const text = raw.trim()
if (!text) return
const nextConvo: ChatMessage[] = [...convoRef.current, { from: 'user', text }]
setConvo(nextConvo)
setThinking(true)
setSteps([])
if (!live) {
window.setTimeout(() => {
setThinking(false)
setConvo((c) => [...c, { from: 'agent', text: CANNED_REPLY }])
}, 900)
return
}
const wire: WireMessage[] = nextConvo.map((m) => ({
role: m.from === 'user' ? 'user' : 'assistant',
content: m.text,
}))
window.commitea.model
.chat(wire)
.then((res) => {
setThinking(false)
if (res.ok) {
setSteps(res.steps.map((s) => s.tool))
setConvo((c) => [...c, { from: 'agent', text: res.content || '…' }])
} else {
setConvo((c) => [
...c,
{
from: 'agent',
text: res.reason === 'error' ? `I hit a snag: ${res.message ?? 'unknown error'}` : 'No model is configured.',
},
])
}
})
.catch(() => {
setThinking(false)
setConvo((c) => [...c, { from: 'agent', text: 'I could not reach the model.' }])
})
},
[live],
)
return { msgs: [...seed, ...convo], thinking, live, steps, send }
}