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>
70 lines
2.5 KiB
TypeScript
70 lines
2.5 KiB
TypeScript
/**
|
|
* The agent turn loop. Given a model `complete` fn, the conversation, the tool
|
|
* declarations, and an `execute` that actually runs a tool, it drives the
|
|
* call→tool→result→call cycle until the model answers in prose (or a step
|
|
* budget is hit). Pure orchestration with injected I/O — the model and the tool
|
|
* executor are both stubbable, so the loop is fully unit-testable offline.
|
|
*/
|
|
|
|
import type { ChatMessage, CompletionResult, ToolDecl } from './chat-client.js'
|
|
|
|
/** A tool the loop ran, with the raw args and its stringified result — for the UI's activity trail. */
|
|
export interface AgentStep {
|
|
tool: string
|
|
arguments: string
|
|
result: string
|
|
}
|
|
|
|
export type ToolExecutor = (name: string, args: unknown) => Promise<unknown>
|
|
|
|
export interface AgentTurn {
|
|
content: string
|
|
steps: AgentStep[]
|
|
/** The full conversation including this turn's assistant/tool messages. */
|
|
messages: ChatMessage[]
|
|
}
|
|
|
|
const DEFAULT_MAX_STEPS = 4
|
|
|
|
function stringify(result: unknown): string {
|
|
return typeof result === 'string' ? result : JSON.stringify(result)
|
|
}
|
|
|
|
export async function runAgentTurn(opts: {
|
|
complete: (messages: ChatMessage[], tools?: ToolDecl[]) => Promise<CompletionResult>
|
|
messages: ChatMessage[]
|
|
tools: ToolDecl[]
|
|
execute: ToolExecutor
|
|
maxSteps?: number
|
|
}): Promise<AgentTurn> {
|
|
const maxSteps = opts.maxSteps ?? DEFAULT_MAX_STEPS
|
|
const convo: ChatMessage[] = [...opts.messages]
|
|
const steps: AgentStep[] = []
|
|
|
|
for (let step = 0; step < maxSteps; step++) {
|
|
const { content, toolCalls } = await opts.complete(convo, opts.tools)
|
|
if (toolCalls.length === 0) {
|
|
convo.push({ role: 'assistant', content })
|
|
return { content, steps, messages: convo }
|
|
}
|
|
convo.push({ role: 'assistant', content, toolCalls })
|
|
for (const tc of toolCalls) {
|
|
let result: unknown
|
|
try {
|
|
const args = tc.arguments ? JSON.parse(tc.arguments) : {}
|
|
result = await opts.execute(tc.name, args)
|
|
} catch (e) {
|
|
result = { error: e instanceof Error ? e.message : String(e) }
|
|
}
|
|
const resultStr = stringify(result)
|
|
steps.push({ tool: tc.name, arguments: tc.arguments, result: resultStr })
|
|
convo.push({ role: 'tool', toolCallId: tc.id, name: tc.name, content: resultStr })
|
|
}
|
|
}
|
|
|
|
// Out of tool budget — force a final prose answer with tools withheld.
|
|
const final = await opts.complete(convo, [])
|
|
convo.push({ role: 'assistant', content: final.content })
|
|
return { content: final.content, steps, messages: convo }
|
|
}
|