The 26b is slow (~30s/call); the chat now shows the answer forming instead of freezing until it's done. The final prose streams over SSE; tool-calling turns stay structured (no partial tokens), so streaming kicks in for the narration. core (@commitea/core): - chat-client.complete gains an optional onToken — when set, it requests stream:true and parses the OpenAI SSE stream, emitting content deltas and assembling streamed tool-call argument fragments into the final result. - GiteaHttpResponse exposes the optional `body` stream (real fetch has it; stubs don't). agent-loop threads onToken to each completion. app: - model:chat forwards each delta to the renderer (event.sender.send); preload exposes model.onToken(cb) → unsubscribe. useChat accumulates the live stream into a growing bubble (with a cursor), replaced by the authoritative final content when the turn resolves. Unconfigured → scripted reply, unchanged. Verified: 118 core tests green (2 streaming: SSE content deltas + tool-call fragment assembly), desktop typecheck clean, 14 fixture e2e green. Live: a real turn against gemma-4-26b assembles the correct answer via the streaming path (live-reginald green) — the reply now renders token-by-token. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
72 lines
2.7 KiB
TypeScript
72 lines
2.7 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[], onToken?: (delta: string) => void) => Promise<CompletionResult>
|
|
messages: ChatMessage[]
|
|
tools: ToolDecl[]
|
|
execute: ToolExecutor
|
|
maxSteps?: number
|
|
/** Streams content deltas as the model produces prose (final-answer streaming). */
|
|
onToken?: (delta: string) => void
|
|
}): 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, opts.onToken)
|
|
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, [], opts.onToken)
|
|
convo.push({ role: 'assistant', content: final.content })
|
|
return { content: final.content, steps, messages: convo }
|
|
}
|