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:
62
packages/core/src/agent/agent-live.test.ts
Normal file
62
packages/core/src/agent/agent-live.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { extractLabelFacts } from '../labels/label-schema.js'
|
||||
import type { FetchLike, GiteaIssue } from '../gitea/types.js'
|
||||
import { runAgentTurn } from './agent-loop.js'
|
||||
import { REGINALD_SYSTEM, REGINALD_TOOLS } from './agent-tools.js'
|
||||
import { createChatClient } from './chat-client.js'
|
||||
import { buildProjectView, type ProjectSnapshot } from './query-project.js'
|
||||
|
||||
/**
|
||||
* Opt-in (COMMITEA_MODEL_LIVE=1). Drives the real chat client + agent loop
|
||||
* against a local OpenAI-compatible server (LM Studio on :1234 by default),
|
||||
* proving the model calls query_project and narrates the real result.
|
||||
*/
|
||||
const LIVE = !!process.env.COMMITEA_MODEL_LIVE
|
||||
const BASE = process.env.COMMITEA_MODEL_URL ?? 'http://localhost:1234/v1'
|
||||
const MODEL = process.env.COMMITEA_MODEL_SMALL ?? 'google/gemma-4-e4b'
|
||||
|
||||
function issue(over: Partial<GiteaIssue>): GiteaIssue {
|
||||
const labels = over.labels ?? []
|
||||
return {
|
||||
number: 1, title: '#1', body: '', state: 'open', labels, facts: extractLabelFacts(labels),
|
||||
milestone: null, assignee: null, assignees: [], createdAt: '2026-01-05T09:00:00Z',
|
||||
updatedAt: '2026-01-05T09:00:00Z', closedAt: null, url: '', ...over,
|
||||
}
|
||||
}
|
||||
|
||||
const SNAP: ProjectSnapshot = {
|
||||
issues: [
|
||||
issue({ number: 2, title: 'ChangeSource interface + polling source', labels: ['est/3d', 'p/1'] }),
|
||||
issue({ number: 3, title: 'SQLite cache bootstrap', labels: ['est/2d', 'p/1'] }),
|
||||
],
|
||||
timelines: {},
|
||||
deps: [{ issue: 3, dependsOn: 2 }],
|
||||
}
|
||||
|
||||
describe('agent loop (live model)', () => {
|
||||
it.skipIf(!LIVE)(
|
||||
'calls query_project and narrates the real focus',
|
||||
async () => {
|
||||
const client = createChatClient({ baseUrl: BASE, model: MODEL }, globalThis.fetch as unknown as FetchLike)
|
||||
const turn = await runAgentTurn({
|
||||
complete: (m, t) => client.complete(m, t),
|
||||
messages: [
|
||||
{ role: 'system', content: REGINALD_SYSTEM },
|
||||
{ role: 'user', content: 'What should I work on right now?' },
|
||||
],
|
||||
tools: REGINALD_TOOLS,
|
||||
execute: async (name, args) =>
|
||||
name === 'query_project'
|
||||
? buildProjectView((args as { view: any }).view, (args as any).filters, SNAP, new Date())
|
||||
: { error: `unknown tool ${name}` },
|
||||
})
|
||||
|
||||
// it consulted the project, then answered in prose about the real top item (#2)
|
||||
expect(turn.steps.some((s) => s.tool === 'query_project')).toBe(true)
|
||||
expect(turn.content.trim().length).toBeGreaterThan(0)
|
||||
expect(turn.content).toMatch(/#?2\b|ChangeSource/i)
|
||||
},
|
||||
60_000,
|
||||
)
|
||||
})
|
||||
69
packages/core/src/agent/agent-loop.ts
Normal file
69
packages/core/src/agent/agent-loop.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 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 }
|
||||
}
|
||||
46
packages/core/src/agent/agent-tools.ts
Normal file
46
packages/core/src/agent/agent-tools.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Reginald's tool surface. v0 ships the one read tool (`query_project`); the
|
||||
* three write tools (capture_work, apply_changes, record_directive) layer on
|
||||
* later against the same loop. Few, fat tools so a small local model survives
|
||||
* with one thing to reach for (docs/agent-tools.md).
|
||||
*/
|
||||
|
||||
import type { ToolDecl } from './chat-client.js'
|
||||
|
||||
export const QUERY_PROJECT_TOOL: ToolDecl = {
|
||||
name: 'query_project',
|
||||
description:
|
||||
'Read the current project state. A `view` selects the shape; deterministic code (scheduler, ' +
|
||||
'lifecycle inference, calibration) backs every number — you report it, you never compute it.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
view: {
|
||||
type: 'string',
|
||||
enum: ['focus', 'board', 'calibration', 'issue', 'search'],
|
||||
description:
|
||||
'focus = Now/Next/Later; board = issues by lifecycle column; calibration = estimate-vs-actual; ' +
|
||||
'issue = one issue (needs filters.issueId); search = issues matching filters.query.',
|
||||
},
|
||||
filters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
issueId: { type: 'number' },
|
||||
query: { type: 'string' },
|
||||
limit: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['view'],
|
||||
},
|
||||
}
|
||||
|
||||
export const REGINALD_TOOLS: ToolDecl[] = [QUERY_PROJECT_TOOL]
|
||||
|
||||
export const REGINALD_SYSTEM = [
|
||||
'You are Reginald, the calm, dry project manager inside CommiTea — a tool that runs projects on Gitea.',
|
||||
'Call query_project to ground every answer in the real project; never invent issues, numbers, or dates.',
|
||||
'The scheduler and forecasts are deterministic code — report their output, do not recompute it.',
|
||||
'Forecasts are ranges, never single dates. Refer to issues as #<number>.',
|
||||
'Be brief and plain. A sentence or two is usually enough. No preamble, no bullet-point dumps.',
|
||||
].join(' ')
|
||||
239
packages/core/src/agent/agent.test.ts
Normal file
239
packages/core/src/agent/agent.test.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { extractLabelFacts } from '../labels/label-schema.js'
|
||||
import type { GiteaIssue } from '../gitea/types.js'
|
||||
import type { FetchLike } from '../gitea/types.js'
|
||||
import { type ChatMessage, type CompletionResult, createChatClient, type ToolDecl } from './chat-client.js'
|
||||
import { runAgentTurn } from './agent-loop.js'
|
||||
import { pickModel } from './model-router.js'
|
||||
import { buildProjectView, type ProjectSnapshot } from './query-project.js'
|
||||
|
||||
// ---- chat client ----
|
||||
|
||||
describe('createChatClient', () => {
|
||||
function stub(body: unknown) {
|
||||
const calls: { url: string; body: unknown }[] = []
|
||||
const fetch: FetchLike = (url, init) => {
|
||||
calls.push({ url, body: init?.body ? JSON.parse(init.body) : undefined })
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(body),
|
||||
text: () => Promise.resolve(''),
|
||||
})
|
||||
}
|
||||
return { fetch, calls }
|
||||
}
|
||||
|
||||
it('POSTs to /chat/completions and parses content', async () => {
|
||||
const { fetch, calls } = stub({ choices: [{ message: { content: 'the focus is #2' } }] })
|
||||
const client = createChatClient({ baseUrl: 'http://localhost:1234/v1', model: 'gemma' }, fetch)
|
||||
const res = await client.complete([{ role: 'user', content: 'what now?' }])
|
||||
|
||||
expect(res.content).toBe('the focus is #2')
|
||||
expect(res.toolCalls).toEqual([])
|
||||
expect(calls[0].url).toBe('http://localhost:1234/v1/chat/completions')
|
||||
expect((calls[0].body as { model: string }).model).toBe('gemma')
|
||||
})
|
||||
|
||||
it('parses tool calls from the wire shape', async () => {
|
||||
const { fetch } = stub({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: null,
|
||||
tool_calls: [{ id: 'c1', function: { name: 'query_project', arguments: '{"view":"focus"}' } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
const client = createChatClient({ baseUrl: 'http://x/v1', model: 'm' }, fetch)
|
||||
const res = await client.complete([{ role: 'user', content: 'x' }], [{ name: 'query_project', description: '', parameters: {} }])
|
||||
expect(res.toolCalls).toEqual([{ id: 'c1', name: 'query_project', arguments: '{"view":"focus"}' }])
|
||||
})
|
||||
|
||||
it('serializes assistant tool_calls + tool results in the request', async () => {
|
||||
const { fetch, calls } = stub({ choices: [{ message: { content: 'ok' } }] })
|
||||
const client = createChatClient({ baseUrl: 'http://x/v1', model: 'm' }, fetch)
|
||||
const convo: ChatMessage[] = [
|
||||
{ role: 'user', content: 'hi' },
|
||||
{ role: 'assistant', content: '', toolCalls: [{ id: 'c1', name: 'query_project', arguments: '{}' }] },
|
||||
{ role: 'tool', toolCallId: 'c1', name: 'query_project', content: '{"now":null}' },
|
||||
]
|
||||
await client.complete(convo)
|
||||
const sent = (calls[0].body as { messages: Record<string, unknown>[] }).messages
|
||||
expect(sent[1].tool_calls).toBeDefined()
|
||||
expect(sent[2]).toEqual({ role: 'tool', tool_call_id: 'c1', content: '{"now":null}' })
|
||||
})
|
||||
})
|
||||
|
||||
// ---- model router ----
|
||||
|
||||
describe('pickModel', () => {
|
||||
const router = { small: { baseUrl: 'u', model: 'small' }, big: { baseUrl: 'u', model: 'big' } }
|
||||
it('routes plan → big, everything else → small', () => {
|
||||
expect(pickModel(router, 'plan').model).toBe('big')
|
||||
expect(pickModel(router, 'ritual').model).toBe('small')
|
||||
})
|
||||
})
|
||||
|
||||
// ---- agent loop ----
|
||||
|
||||
describe('runAgentTurn', () => {
|
||||
const tools: ToolDecl[] = [{ name: 'query_project', description: '', parameters: {} }]
|
||||
|
||||
it('runs a tool then returns the model prose, recording the step', async () => {
|
||||
// first completion asks for a tool; second answers in prose
|
||||
const scripted: CompletionResult[] = [
|
||||
{ content: '', toolCalls: [{ id: 'c1', name: 'query_project', arguments: '{"view":"focus"}' }] },
|
||||
{ content: 'Right now: #2.', toolCalls: [] },
|
||||
]
|
||||
let i = 0
|
||||
const executed: { name: string; args: unknown }[] = []
|
||||
const turn = await runAgentTurn({
|
||||
complete: async () => scripted[i++],
|
||||
messages: [{ role: 'user', content: 'what now?' }],
|
||||
tools,
|
||||
execute: async (name, args) => {
|
||||
executed.push({ name, args })
|
||||
return { now: { issue: 2 } }
|
||||
},
|
||||
})
|
||||
|
||||
expect(turn.content).toBe('Right now: #2.')
|
||||
expect(turn.steps).toEqual([
|
||||
{ tool: 'query_project', arguments: '{"view":"focus"}', result: '{"now":{"issue":2}}' },
|
||||
])
|
||||
expect(executed).toEqual([{ name: 'query_project', args: { view: 'focus' } }])
|
||||
// conversation carries user → assistant(tool) → tool → assistant(prose)
|
||||
expect(turn.messages.map((m) => m.role)).toEqual(['user', 'assistant', 'tool', 'assistant'])
|
||||
})
|
||||
|
||||
it('answers directly when the model needs no tool', async () => {
|
||||
const turn = await runAgentTurn({
|
||||
complete: async () => ({ content: 'Hello.', toolCalls: [] }),
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
tools,
|
||||
execute: async () => ({}),
|
||||
})
|
||||
expect(turn.content).toBe('Hello.')
|
||||
expect(turn.steps).toEqual([])
|
||||
})
|
||||
|
||||
it('captures a tool executor error as the tool result instead of throwing', async () => {
|
||||
const scripted: CompletionResult[] = [
|
||||
{ content: '', toolCalls: [{ id: 'c1', name: 'query_project', arguments: '{}' }] },
|
||||
{ content: 'Something went wrong reading that.', toolCalls: [] },
|
||||
]
|
||||
let i = 0
|
||||
const turn = await runAgentTurn({
|
||||
complete: async () => scripted[i++],
|
||||
messages: [{ role: 'user', content: 'x' }],
|
||||
tools,
|
||||
execute: async () => {
|
||||
throw new Error('boom')
|
||||
},
|
||||
})
|
||||
expect(turn.steps[0].result).toContain('boom')
|
||||
expect(turn.content).toBe('Something went wrong reading that.')
|
||||
})
|
||||
|
||||
it('forces a final prose answer when the step budget is exhausted', async () => {
|
||||
// always asks for a tool; the loop must still terminate with prose
|
||||
let toolRounds = 0
|
||||
const turn = await runAgentTurn({
|
||||
complete: async (_m, tls) => {
|
||||
if (tls && tls.length) {
|
||||
toolRounds++
|
||||
return { content: '', toolCalls: [{ id: `c${toolRounds}`, name: 'query_project', arguments: '{}' }] }
|
||||
}
|
||||
return { content: 'final answer', toolCalls: [] } // called with tools withheld
|
||||
},
|
||||
messages: [{ role: 'user', content: 'x' }],
|
||||
tools,
|
||||
execute: async () => ({}),
|
||||
maxSteps: 2,
|
||||
})
|
||||
expect(toolRounds).toBe(2)
|
||||
expect(turn.content).toBe('final answer')
|
||||
})
|
||||
})
|
||||
|
||||
// ---- query_project view builder ----
|
||||
|
||||
describe('buildProjectView', () => {
|
||||
const asOf = new Date('2026-02-01T00:00:00Z')
|
||||
|
||||
function issue(over: Partial<GiteaIssue>): GiteaIssue {
|
||||
const labels = over.labels ?? []
|
||||
return {
|
||||
number: 1,
|
||||
title: '#1',
|
||||
body: '',
|
||||
state: 'open',
|
||||
labels,
|
||||
facts: extractLabelFacts(labels),
|
||||
milestone: null,
|
||||
assignee: null,
|
||||
assignees: [],
|
||||
createdAt: '2026-01-05T09:00:00Z',
|
||||
updatedAt: '2026-01-05T09:00:00Z',
|
||||
closedAt: null,
|
||||
url: '',
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
const snap: ProjectSnapshot = {
|
||||
issues: [
|
||||
issue({ number: 2, title: 'ChangeSource', labels: ['est/3d', 'p/1'] }),
|
||||
issue({ number: 3, title: 'SQLite cache', labels: ['est/2d', 'p/1'] }),
|
||||
issue({ number: 9, title: 'Done thing', state: 'closed', labels: ['est/2d'], closedAt: '2026-01-12T09:00:00Z' }),
|
||||
],
|
||||
timelines: { 9: [{ type: 'commit', at: '2026-01-07T09:00:00Z' }, { type: 'close', at: '2026-01-12T09:00:00Z' }] },
|
||||
deps: [{ issue: 3, dependsOn: 2 }],
|
||||
}
|
||||
|
||||
it('focus returns Now/Next/Later grounded in the scheduler', () => {
|
||||
const v = buildProjectView('focus', undefined, snap, asOf) as {
|
||||
now: { issue: number } | null
|
||||
openCount: number
|
||||
}
|
||||
expect(v.now?.issue).toBe(2) // #3 depends on #2, so #2 comes first
|
||||
expect(v.openCount).toBe(2)
|
||||
})
|
||||
|
||||
it('board groups issues by lifecycle column', () => {
|
||||
const v = buildProjectView('board', undefined, snap, asOf) as {
|
||||
columns: { column: string; count: number }[]
|
||||
}
|
||||
const done = v.columns.find((c) => c.column === 'done')
|
||||
expect(done?.count).toBe(1) // #9
|
||||
const triage = v.columns.find((c) => c.column === 'triage')
|
||||
expect(triage?.count).toBe(2) // #2, #3 labelled, no commits yet
|
||||
})
|
||||
|
||||
it('calibration reports n + cold-start honestly', () => {
|
||||
const v = buildProjectView('calibration', undefined, snap, asOf) as { n: number; coldStart: boolean }
|
||||
expect(v.n).toBe(1) // one closed, estimated, with an actual
|
||||
expect(v.coldStart).toBe(true)
|
||||
})
|
||||
|
||||
it('issue returns intent + derived for a specific id', () => {
|
||||
const v = buildProjectView('issue', { issueId: 3 }, snap, asOf) as {
|
||||
issue: number
|
||||
blockedBy: number[]
|
||||
}
|
||||
expect(v.issue).toBe(3)
|
||||
expect(v.blockedBy).toEqual([2])
|
||||
})
|
||||
|
||||
it('search matches title/labels', () => {
|
||||
const v = buildProjectView('search', { query: 'sqlite' }, snap, asOf) as { results: { issue: number }[] }
|
||||
expect(v.results.map((r) => r.issue)).toEqual([3])
|
||||
})
|
||||
|
||||
it('unbuilt views return a notImplemented marker, not fabricated data', () => {
|
||||
expect(buildProjectView('standup', undefined, snap, asOf)).toEqual({ notImplemented: 'standup' })
|
||||
})
|
||||
})
|
||||
123
packages/core/src/agent/chat-client.ts
Normal file
123
packages/core/src/agent/chat-client.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 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,
|
||||
})),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
22
packages/core/src/agent/model-router.ts
Normal file
22
packages/core/src/agent/model-router.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Model router (per PLAN.md / decisions.md): prose + the read tool run on a
|
||||
* small local model; decomposition/negotiation earns the big one. Reginald v0
|
||||
* only exercises the small model (read + prose); the `big` slot is declared so
|
||||
* `capture_work` / `record_directive` can route to it without a rewrite.
|
||||
*/
|
||||
|
||||
import type { ModelConfig } from './chat-client.js'
|
||||
|
||||
export type TaskKind = 'ritual' | 'plan'
|
||||
|
||||
export interface ModelRouter {
|
||||
/** Small local model — standups, focus prose, the read tool. */
|
||||
small: ModelConfig
|
||||
/** Big model — capture decomposition, directive negotiation. */
|
||||
big: ModelConfig
|
||||
}
|
||||
|
||||
/** `plan` → the big model; everything else → the small one. */
|
||||
export function pickModel(router: ModelRouter, kind: TaskKind): ModelConfig {
|
||||
return kind === 'plan' ? router.big : router.small
|
||||
}
|
||||
146
packages/core/src/agent/query-project.ts
Normal file
146
packages/core/src/agent/query-project.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* `query_project` — Reginald's single read tool. It selects a compact,
|
||||
* model-friendly view over the reconciled backlog. Every number comes from
|
||||
* deterministic code (scheduler, lifecycle inference, calibration); the model
|
||||
* only requests a shape and narrates it — it never computes (decisions.md).
|
||||
*
|
||||
* v0 serves focus / board / calibration / issue. The remaining views
|
||||
* (milestone / runway / standup / search) return a `notImplemented` marker so
|
||||
* the model degrades honestly instead of inventing data.
|
||||
*/
|
||||
|
||||
import { fitCalibration, calibrationSamples } from '../calibration/calibration-v0.js'
|
||||
import type { GiteaIssue } from '../gitea/types.js'
|
||||
import { inferLifecycle, type LifecycleColumn, type LifecycleEvent } from '../lifecycle/lifecycle-v0.js'
|
||||
import { type DependencyEdge, schedule, selectFocus } from '../scheduler/scheduler-v0.js'
|
||||
|
||||
export type ProjectView =
|
||||
| 'focus'
|
||||
| 'board'
|
||||
| 'calibration'
|
||||
| 'issue'
|
||||
| 'milestone'
|
||||
| 'runway'
|
||||
| 'standup'
|
||||
| 'search'
|
||||
|
||||
export interface QueryFilters {
|
||||
issueId?: number
|
||||
query?: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/** The reconciled inputs a view is built from. */
|
||||
export interface ProjectSnapshot {
|
||||
issues: GiteaIssue[]
|
||||
timelines: Record<number, LifecycleEvent[]>
|
||||
deps: DependencyEdge[]
|
||||
}
|
||||
|
||||
const COLUMN_ORDER: LifecycleColumn[] = ['diagnosis', 'triage', 'steeping', 'review', 'done']
|
||||
|
||||
function toSchedulable(issues: GiteaIssue[]) {
|
||||
return issues
|
||||
.filter((i) => i.state === 'open')
|
||||
.map((i) => ({
|
||||
number: i.number,
|
||||
title: i.title,
|
||||
labels: i.labels,
|
||||
estimateDays: i.facts.estimateDays,
|
||||
priority: i.facts.priority,
|
||||
}))
|
||||
}
|
||||
|
||||
function focusView(snap: ProjectSnapshot) {
|
||||
const plan = schedule(toSchedulable(snap.issues), snap.deps)
|
||||
const f = selectFocus(plan)
|
||||
const slot = (item: (typeof plan.items)[number] | null) =>
|
||||
item ? { issue: item.number, title: item.title, rationale: item.rationale } : null
|
||||
return { now: slot(f.now), next: slot(f.next), later: slot(f.later), openCount: plan.items.length }
|
||||
}
|
||||
|
||||
function boardView(snap: ProjectSnapshot, asOf: Date) {
|
||||
const columns: Record<string, { issue: number; title: string; labels: string[] }[]> = {}
|
||||
for (const key of COLUMN_ORDER) columns[key] = []
|
||||
for (const issue of snap.issues) {
|
||||
const inf = inferLifecycle(issue, snap.timelines[issue.number] ?? [], asOf)
|
||||
columns[inf.column].push({ issue: issue.number, title: issue.title, labels: issue.labels })
|
||||
}
|
||||
return {
|
||||
columns: COLUMN_ORDER.map((key) => ({ column: key, count: columns[key].length, issues: columns[key] })),
|
||||
}
|
||||
}
|
||||
|
||||
function calibrationView(snap: ProjectSnapshot, asOf: Date) {
|
||||
const model = fitCalibration(calibrationSamples(snap.issues, snap.timelines, asOf))
|
||||
return {
|
||||
n: model.n,
|
||||
coldStart: model.coldStart,
|
||||
globalMultiplier: Number(Math.exp(model.global.mu).toFixed(2)),
|
||||
byBucket: Object.entries(model.byBucket).map(([bucket, fit]) => ({
|
||||
estimate: `est/${bucket}d`,
|
||||
n: fit.n,
|
||||
multiplier: Number(Math.exp(fit.mu).toFixed(2)),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function issueView(snap: ProjectSnapshot, filters: QueryFilters, asOf: Date) {
|
||||
const issue = snap.issues.find((i) => i.number === filters.issueId)
|
||||
if (!issue) return { notFound: filters.issueId ?? null }
|
||||
const inf = inferLifecycle(issue, snap.timelines[issue.number] ?? [], asOf)
|
||||
const blockedBy = snap.deps.filter((d) => d.issue === issue.number).map((d) => d.dependsOn)
|
||||
const blocks = snap.deps.filter((d) => d.dependsOn === issue.number).map((d) => d.issue)
|
||||
return {
|
||||
issue: issue.number,
|
||||
title: issue.title,
|
||||
state: issue.state,
|
||||
column: inf.column,
|
||||
labels: issue.labels,
|
||||
assignee: issue.assignee,
|
||||
milestone: issue.milestone?.title ?? null,
|
||||
estimateDays: issue.facts.estimateDays,
|
||||
priority: issue.facts.priority,
|
||||
steepingDays: inf.steepingDays,
|
||||
blockedBy,
|
||||
blocks,
|
||||
}
|
||||
}
|
||||
|
||||
function searchView(snap: ProjectSnapshot, filters: QueryFilters) {
|
||||
const q = (filters.query ?? '').toLowerCase().trim()
|
||||
const limit = Math.min(filters.limit ?? 20, 100)
|
||||
const hits = q
|
||||
? snap.issues.filter(
|
||||
(i) => i.title.toLowerCase().includes(q) || i.labels.some((l) => l.toLowerCase().includes(q)),
|
||||
)
|
||||
: []
|
||||
return {
|
||||
query: filters.query ?? '',
|
||||
results: hits.slice(0, limit).map((i) => ({ issue: i.number, title: i.title, state: i.state, labels: i.labels })),
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the compact payload for one view. Unknown/unbuilt views return a marker. */
|
||||
export function buildProjectView(
|
||||
view: ProjectView,
|
||||
filters: QueryFilters | undefined,
|
||||
snap: ProjectSnapshot,
|
||||
asOf: Date,
|
||||
): unknown {
|
||||
const f = filters ?? {}
|
||||
switch (view) {
|
||||
case 'focus':
|
||||
return focusView(snap)
|
||||
case 'board':
|
||||
return boardView(snap, asOf)
|
||||
case 'calibration':
|
||||
return calibrationView(snap, asOf)
|
||||
case 'issue':
|
||||
return issueView(snap, f, asOf)
|
||||
case 'search':
|
||||
return searchView(snap, f)
|
||||
default:
|
||||
return { notImplemented: view }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user