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>
124 lines
3.8 KiB
TypeScript
124 lines
3.8 KiB
TypeScript
/**
|
|
* Reginald's model client — a thin OpenAI-wire chat-completions client over an
|
|
* injected fetch (the same seam the gitea client uses). `@commitea/core` stays
|
|
* pure: no network, no globals. The desktop main process passes the real
|
|
* `fetch`; tests pass a stub. Points at any OpenAI-compatible endpoint (LM
|
|
* Studio, Ollama, the OpenAI API) — the model router picks which.
|
|
*/
|
|
|
|
import type { FetchLike } from '../gitea/types.js'
|
|
|
|
/** Where a model lives + which model to ask for. */
|
|
export interface ModelConfig {
|
|
/** OpenAI-compatible base, including the version segment, e.g. `http://localhost:1234/v1`. */
|
|
baseUrl: string
|
|
model: string
|
|
/** Bearer key; omitted for local servers that don't check it. */
|
|
apiKey?: string
|
|
}
|
|
|
|
/** A model's request to run a tool. `arguments` is a raw JSON string (OpenAI shape). */
|
|
export interface ToolCall {
|
|
id: string
|
|
name: string
|
|
arguments: string
|
|
}
|
|
|
|
/** One turn in the conversation, in our normalized shape. */
|
|
export interface ChatMessage {
|
|
role: 'system' | 'user' | 'assistant' | 'tool'
|
|
content: string
|
|
/** assistant turns that requested tools. */
|
|
toolCalls?: ToolCall[]
|
|
/** tool turns: which call they answer. */
|
|
toolCallId?: string
|
|
/** tool turns: the function name. */
|
|
name?: string
|
|
}
|
|
|
|
/** A tool the model may call — name, description, and a JSON-Schema parameter spec. */
|
|
export interface ToolDecl {
|
|
name: string
|
|
description: string
|
|
parameters: object
|
|
}
|
|
|
|
export interface CompletionResult {
|
|
content: string
|
|
toolCalls: ToolCall[]
|
|
}
|
|
|
|
export interface ChatClient {
|
|
complete(messages: ChatMessage[], tools?: ToolDecl[]): Promise<CompletionResult>
|
|
}
|
|
|
|
/** Map our message shape to the OpenAI wire shape. */
|
|
function toWireMessage(m: ChatMessage): Record<string, unknown> {
|
|
if (m.role === 'assistant' && m.toolCalls?.length) {
|
|
return {
|
|
role: 'assistant',
|
|
content: m.content || null,
|
|
tool_calls: m.toolCalls.map((tc) => ({
|
|
id: tc.id,
|
|
type: 'function',
|
|
function: { name: tc.name, arguments: tc.arguments },
|
|
})),
|
|
}
|
|
}
|
|
if (m.role === 'tool') {
|
|
return { role: 'tool', tool_call_id: m.toolCallId, content: m.content }
|
|
}
|
|
return { role: m.role, content: m.content }
|
|
}
|
|
|
|
function toWireTool(t: ToolDecl): Record<string, unknown> {
|
|
return { type: 'function', function: { name: t.name, description: t.description, parameters: t.parameters } }
|
|
}
|
|
|
|
/** Shape of the one choice we read back. */
|
|
interface RawChoiceMessage {
|
|
content: string | null
|
|
tool_calls?: { id: string; function: { name: string; arguments: string } }[]
|
|
}
|
|
|
|
export function createChatClient(config: ModelConfig, fetchImpl: FetchLike): ChatClient {
|
|
const url = `${config.baseUrl.replace(/\/+$/, '')}/chat/completions`
|
|
|
|
return {
|
|
async complete(messages, tools) {
|
|
const body: Record<string, unknown> = {
|
|
model: config.model,
|
|
messages: messages.map(toWireMessage),
|
|
temperature: 0,
|
|
}
|
|
if (tools?.length) {
|
|
body.tools = tools.map(toWireTool)
|
|
body.tool_choice = 'auto'
|
|
}
|
|
const res = await fetchImpl(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}),
|
|
},
|
|
body: JSON.stringify(body),
|
|
})
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => '')
|
|
throw new Error(`model completion failed (${res.status}): ${text.slice(0, 200)}`)
|
|
}
|
|
const json = (await res.json()) as { choices?: { message: RawChoiceMessage }[] }
|
|
const msg = json.choices?.[0]?.message
|
|
return {
|
|
content: msg?.content ?? '',
|
|
toolCalls: (msg?.tool_calls ?? []).map((tc) => ({
|
|
id: tc.id,
|
|
name: tc.function.name,
|
|
arguments: tc.function.arguments,
|
|
})),
|
|
}
|
|
},
|
|
}
|
|
}
|