feat: move runner to typescript, refactor
This commit is contained in:
@@ -1,28 +1,48 @@
|
||||
const ACCEPT = "application/json, text/event-stream";
|
||||
|
||||
function parseMcpResponse(text) {
|
||||
interface JsonRpcResponse {
|
||||
result?: unknown;
|
||||
error?: { code: number; message: string };
|
||||
}
|
||||
|
||||
interface McpTool {
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema?: unknown;
|
||||
}
|
||||
|
||||
type McpContentBlock = { type: string; text: string } | { type: string; [key: string]: unknown };
|
||||
|
||||
interface McpToolResult {
|
||||
content?: McpContentBlock[];
|
||||
}
|
||||
|
||||
function parseMcpResponse(text: string): JsonRpcResponse {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) throw new Error("Empty MCP response");
|
||||
if (trimmed.startsWith("{")) {
|
||||
return JSON.parse(trimmed);
|
||||
return JSON.parse(trimmed) as JsonRpcResponse;
|
||||
}
|
||||
if (trimmed.includes("event:") || trimmed.includes("data:")) {
|
||||
let payload = "";
|
||||
for (const line of trimmed.split(/\r?\n/)) {
|
||||
if (line.startsWith("data:")) payload += line.slice(5).trim();
|
||||
}
|
||||
if (payload.startsWith("{")) return JSON.parse(payload);
|
||||
if (payload.startsWith("{")) return JSON.parse(payload) as JsonRpcResponse;
|
||||
}
|
||||
throw new Error(`Unrecognized MCP response format: ${trimmed.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
export class McpClient {
|
||||
constructor(url) {
|
||||
private url: string;
|
||||
private seq: number;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
this.seq = 0;
|
||||
}
|
||||
|
||||
async request(method, params = {}) {
|
||||
async request(method: string, params: Record<string, unknown> = {}): Promise<JsonRpcResponse> {
|
||||
this.seq += 1;
|
||||
const res = await fetch(this.url, {
|
||||
method: "POST",
|
||||
@@ -37,18 +57,18 @@ export class McpClient {
|
||||
return rpc;
|
||||
}
|
||||
|
||||
async listTools() {
|
||||
async listTools(): Promise<McpTool[]> {
|
||||
const rpc = await this.request("tools/list");
|
||||
return rpc.result?.tools ?? [];
|
||||
return (rpc.result as { tools?: McpTool[] })?.tools ?? [];
|
||||
}
|
||||
|
||||
async callTool(name, args = {}) {
|
||||
async callTool(name: string, args: Record<string, unknown> = {}): Promise<McpToolResult> {
|
||||
const rpc = await this.request("tools/call", { name, arguments: args });
|
||||
return rpc.result;
|
||||
return rpc.result as McpToolResult;
|
||||
}
|
||||
}
|
||||
|
||||
export function textContentFrom(result) {
|
||||
export function textContentFrom(result: McpToolResult | null | undefined): string {
|
||||
if (!result?.content) return "";
|
||||
return result.content
|
||||
.map((b) => (b.type === "text" ? b.text : JSON.stringify(b)))
|
||||
@@ -1,40 +1,113 @@
|
||||
interface OllamaConstructorOpts {
|
||||
url?: string;
|
||||
timeoutMs?: number;
|
||||
idleTimeoutMs?: number | null;
|
||||
maxTotalMs?: number | null;
|
||||
}
|
||||
|
||||
interface PingResult {
|
||||
ok: boolean;
|
||||
models: string[];
|
||||
}
|
||||
|
||||
export type OllamaToolCall = {
|
||||
function: { name: string; arguments: string };
|
||||
};
|
||||
|
||||
export type OllamaToolDefinition = {
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: {
|
||||
type: "object";
|
||||
properties?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type OllamaMessage = {
|
||||
role: string;
|
||||
content: string;
|
||||
tool_calls?: OllamaToolCall[];
|
||||
};
|
||||
|
||||
export type OllamaChunk = {
|
||||
token: string | null;
|
||||
content: string;
|
||||
type: "content" | "tool_calls";
|
||||
toolCalls?: OllamaToolCall[];
|
||||
};
|
||||
|
||||
export interface ChatStreamOpts {
|
||||
model: string;
|
||||
messages: OllamaMessage[];
|
||||
tools?: unknown[];
|
||||
temperature?: number;
|
||||
numCtx?: number;
|
||||
timeoutMs?: number;
|
||||
maxTotalMs?: number;
|
||||
onChunk?: ((chunk: OllamaChunk) => void) | null;
|
||||
}
|
||||
|
||||
export interface OllamaChatResult {
|
||||
role: "assistant";
|
||||
content: string;
|
||||
toolCalls: OllamaToolCall[];
|
||||
promptEvalCount: number;
|
||||
evalCount: number;
|
||||
raw: { stream: boolean };
|
||||
}
|
||||
|
||||
export interface OllamaError extends Error {
|
||||
partialContent?: string;
|
||||
partialToolCalls?: OllamaToolCall[];
|
||||
partialPrompt?: number;
|
||||
partialGen?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_URL = "http://localhost:11434";
|
||||
|
||||
export class Ollama {
|
||||
constructor({ url = DEFAULT_URL, timeoutMs = 600000, idleTimeoutMs = null, maxTotalMs = null } = {}) {
|
||||
private url: string;
|
||||
private timeoutMs: number;
|
||||
private idleTimeoutMs: number;
|
||||
private maxTotalMs: number;
|
||||
|
||||
constructor({ url = DEFAULT_URL, timeoutMs = 600000, idleTimeoutMs = null, maxTotalMs = null }: OllamaConstructorOpts = {}) {
|
||||
this.url = url.replace(/\/$/, "");
|
||||
this.timeoutMs = timeoutMs;
|
||||
// Dynamic timeout: if set, the timer resets on every new token; the request
|
||||
// only fails if this much time passes with NO progress (no new token).
|
||||
this.idleTimeoutMs = idleTimeoutMs ?? timeoutMs;
|
||||
// Absolute hard cap on one request's lifetime, even if tokens keep flowing
|
||||
// (guards against a model looping/chatting forever). Defaults to 5 minutes.
|
||||
this.maxTotalMs = maxTotalMs ?? 5 * 60_000;
|
||||
}
|
||||
|
||||
async ping({ timeoutMs = 30000 } = {}) {
|
||||
if (process.env.OLLAMA_SKIP_PING === "1") return { ok: true, model: process.env.OLLAMA_MODEL };
|
||||
async ping({ timeoutMs = 30000 } = {}): Promise<PingResult> {
|
||||
if (process.env.OLLAMA_SKIP_PING === "1") return { ok: true, models: [process.env.OLLAMA_MODEL ?? ""] };
|
||||
const res = await fetch(`${this.url}/api/tags`, { signal: AbortSignal.timeout(timeoutMs) });
|
||||
if (!res.ok) throw new Error(`Ollama not reachable (HTTP ${res.status}) at ${this.url}`);
|
||||
const data = await res.json();
|
||||
const data = await res.json() as { models?: Array<{ name: string }> };
|
||||
const models = (data.models ?? []).map((m) => m.name);
|
||||
return { ok: true, models };
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming chat with a dynamic (progress-based) timeout.
|
||||
* The timeout only fires when `timeoutMs` passes with no new token/chunk.
|
||||
* Optionally calls onChunk({token, content, type, toolCalls}) as data arrives.
|
||||
* Returns the same shape as the old non-streaming chat().
|
||||
*/
|
||||
async chatStream({ model, messages, tools, temperature = 0, numCtx = 8192, timeoutMs, maxTotalMs, onChunk }) {
|
||||
const body = {
|
||||
async chatStream({
|
||||
model,
|
||||
messages,
|
||||
tools,
|
||||
temperature = 0,
|
||||
numCtx = 8192,
|
||||
timeoutMs,
|
||||
maxTotalMs,
|
||||
onChunk,
|
||||
}: ChatStreamOpts): Promise<OllamaChatResult> {
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
messages,
|
||||
stream: true,
|
||||
options: { temperature },
|
||||
};
|
||||
if (numCtx) body.options.num_ctx = numCtx;
|
||||
if (numCtx) (body.options as Record<string, unknown>).num_ctx = numCtx;
|
||||
if (tools && tools.length) body.tools = tools;
|
||||
|
||||
const limitMs = timeoutMs ?? this.idleTimeoutMs;
|
||||
@@ -60,20 +133,20 @@ export class Ollama {
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let content = "";
|
||||
let toolCalls = [];
|
||||
let toolCalls: OllamaToolCall[] = [];
|
||||
let promptEvalCount = 0;
|
||||
let evalCount = 0;
|
||||
let lastActivity = Date.now();
|
||||
|
||||
const guardInterval = setInterval(() => {
|
||||
if (Date.now() - startedAt > totalCapMs) {
|
||||
const err = new Error(`Request exceeded hard cap of ${totalCapMs}ms; aborting`);
|
||||
const err = new Error(`Request exceeded hard cap of ${totalCapMs}ms; aborting`) as OllamaError;
|
||||
err.name = "TimeoutError";
|
||||
controller.abort(err);
|
||||
return;
|
||||
}
|
||||
if (Date.now() - lastActivity > limitMs) {
|
||||
const err = new Error(`No progress from Ollama for ${limitMs}ms; aborting`);
|
||||
const err = new Error(`No progress from Ollama for ${limitMs}ms; aborting`) as OllamaError;
|
||||
err.name = "TimeoutError";
|
||||
controller.abort(err);
|
||||
}
|
||||
@@ -81,15 +154,13 @@ export class Ollama {
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
let chunk;
|
||||
let chunk: ReadableStreamReadResult<Uint8Array>;
|
||||
try {
|
||||
chunk = await reader.read();
|
||||
} catch (readErr) {
|
||||
// Abort / network drop mid-stream. Surface whatever we got so the
|
||||
// caller can tell "slowly progressing" from "hung".
|
||||
const e = new Error(
|
||||
`Aborted mid-stream after ${content.length} chars (toolCalls=${toolCalls.length}): ${readErr.message}`
|
||||
);
|
||||
`Aborted mid-stream after ${content.length} chars (toolCalls=${toolCalls.length}): ${(readErr as Error).message}`
|
||||
) as OllamaError;
|
||||
e.name = "TimeoutError";
|
||||
e.partialContent = content;
|
||||
e.partialToolCalls = toolCalls;
|
||||
@@ -101,27 +172,27 @@ export class Ollama {
|
||||
if (done) break;
|
||||
lastActivity = Date.now();
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let idx;
|
||||
let idx: number;
|
||||
while ((idx = buffer.indexOf("\n")) !== -1) {
|
||||
const line = buffer.slice(0, idx).trim();
|
||||
buffer = buffer.slice(idx + 1);
|
||||
if (!line) continue;
|
||||
let obj;
|
||||
let obj: Record<string, unknown>;
|
||||
try {
|
||||
obj = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (obj.prompt_eval_count != null) promptEvalCount = obj.prompt_eval_count;
|
||||
if (obj.eval_count != null) evalCount = obj.eval_count;
|
||||
const msg = obj.message ?? {};
|
||||
if (obj.prompt_eval_count != null) promptEvalCount = obj.prompt_eval_count as number;
|
||||
if (obj.eval_count != null) evalCount = obj.eval_count as number;
|
||||
const msg = (obj.message ?? {}) as Record<string, unknown>;
|
||||
if (msg.content) {
|
||||
content += msg.content;
|
||||
onChunk?.({ token: msg.content, content, type: "content" });
|
||||
content += msg.content as string;
|
||||
onChunk?.({ token: msg.content as string, content, type: "content" });
|
||||
}
|
||||
if (msg.tool_calls && msg.tool_calls.length) {
|
||||
toolCalls = msg.tool_calls;
|
||||
onChunk?.({ token: null, content, type: "tool_calls", toolCalls: msg.tool_calls });
|
||||
if (msg.tool_calls && (msg.tool_calls as unknown[]).length) {
|
||||
toolCalls = msg.tool_calls as OllamaToolCall[];
|
||||
onChunk?.({ token: null, content, type: "tool_calls", toolCalls });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,7 +210,7 @@ export class Ollama {
|
||||
};
|
||||
}
|
||||
|
||||
async chat({ model, messages, tools, temperature = 0, numCtx = 8192, timeoutMs }) {
|
||||
async chat({ model, messages, tools, temperature = 0, numCtx = 8192, timeoutMs }: ChatStreamOpts): Promise<OllamaChatResult> {
|
||||
return this.chatStream({
|
||||
model,
|
||||
messages,
|
||||
@@ -151,7 +222,7 @@ export class Ollama {
|
||||
});
|
||||
}
|
||||
|
||||
async warmup({ model, text = "say OK", timeoutMs = 60000 }) {
|
||||
async warmup({ model, text = "say OK", timeoutMs = 60000 }: { model: string; text?: string; timeoutMs?: number }): Promise<Partial<OllamaChatResult> & { error?: string }> {
|
||||
try {
|
||||
return await this.chatStream({
|
||||
model,
|
||||
@@ -159,15 +230,15 @@ export class Ollama {
|
||||
timeoutMs,
|
||||
});
|
||||
} catch (err) {
|
||||
return { error: err.message };
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function listModels() {
|
||||
export async function listModels(): Promise<string[]> {
|
||||
const o = new Ollama();
|
||||
const res = await fetch(`${o.url}/api/tags`);
|
||||
const res = await fetch(`${o["url"]}/api/tags`);
|
||||
if (!res.ok) throw new Error(`Ollama not reachable (HTTP ${res.status})`);
|
||||
const data = await res.json();
|
||||
const data = await res.json() as { models?: Array<{ name: string }> };
|
||||
return (data.models ?? []).map((m) => m.name);
|
||||
}
|
||||
+10
-3
@@ -3,12 +3,19 @@
|
||||
"version": "1.0.0",
|
||||
"description": "MCP rubber-duck evaluation harness",
|
||||
"scripts": {
|
||||
"run": "node src/run.mjs",
|
||||
"report-html": "node src/report-html.mjs"
|
||||
"run": "tsx src/run.ts",
|
||||
"review": "tsx src/review.ts",
|
||||
"report-html": "tsx src/report-html.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/client": "^2.0.0"
|
||||
"@duck/types": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"tsx": "^4",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +1,55 @@
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { ReportRoot, ScenarioName, ScenarioSummary, AggregateScenario, PerTaskRow } from "@duck/types";
|
||||
import { SCENARIO_NAMES } from "@duck/types";
|
||||
|
||||
const SCENARIOS = ["control", "thinking", "blind", "mentor"];
|
||||
|
||||
const SCENARIO_LABELS = {
|
||||
const SCENARIO_LABELS: Record<ScenarioName, string> = {
|
||||
control: "Без утки (контроль)",
|
||||
thinking: "Думать вслух",
|
||||
blind: "Слепая утка",
|
||||
mentor: "Утка-помощник",
|
||||
};
|
||||
|
||||
const SCENARIO_DESCS = {
|
||||
const SCENARIO_DESCS: Record<ScenarioName, string> = {
|
||||
control: "Модель решает задачу напрямую, без инструментов.",
|
||||
thinking: "Модель выписывает рассуждения вслух, без дука.",
|
||||
blind: "Модель объясняет подход коллеге, вызывает quack, не зная заранее ответ.",
|
||||
mentor: "Модель работает в паре, зная, что ответ будет только «quack».",
|
||||
};
|
||||
|
||||
function esc(s) {
|
||||
function esc(s: unknown): string {
|
||||
return String(s ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function nl2br(s) {
|
||||
function nl2br(s: string): string {
|
||||
return esc(s).replace(/\n/g, "<br>");
|
||||
}
|
||||
|
||||
function pctColor(p) {
|
||||
function pctColor(p: number): string {
|
||||
return p >= 70 ? "#10b981" : p >= 40 ? "#f59e0b" : "#ef4444";
|
||||
}
|
||||
|
||||
function modelShort(name) {
|
||||
function modelShort(name: string): string {
|
||||
const base = String(name).split(/:/)[0];
|
||||
const ver = String(name).split(/:/)[1] ? ":" + String(name).split(/:/)[1] : "";
|
||||
return esc(base + ver);
|
||||
}
|
||||
|
||||
function summaryTable(report) {
|
||||
interface RowHtmlItem extends PerTaskRow {
|
||||
model: string;
|
||||
}
|
||||
|
||||
function summaryTable(report: ReportRoot): string {
|
||||
const perModel = report.aggregate.perModel;
|
||||
const models = Object.keys(perModel);
|
||||
const rows = models
|
||||
.map((m) => {
|
||||
const g = perModel[m];
|
||||
const scen = report.models[m].scenarios;
|
||||
const acc = (s) => scen[s].accuracy;
|
||||
const acc = (s: ScenarioName) => scen[s].accuracy;
|
||||
const row = `
|
||||
<td class="pm-num" data-sc="control" style="color:${pctColor(acc("control"))}">${acc("control")}%</td>
|
||||
<td class="pm-num" data-sc="thinking" style="color:${pctColor(acc("thinking"))}">${acc("thinking")}%</td>
|
||||
@@ -86,7 +90,7 @@ function summaryTable(report) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function scenarioCard(key, agg) {
|
||||
function scenarioCard(key: ScenarioName, agg: AggregateScenario): string {
|
||||
const pct = agg.accuracy;
|
||||
const color = pctColor(pct);
|
||||
const duckPct = agg.tasks ? Math.round((agg.duckUsed / agg.tasks) * 100) : 0;
|
||||
@@ -122,7 +126,7 @@ function scenarioCard(key, agg) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function rowHtml(r) {
|
||||
function rowHtml(r: RowHtmlItem): string {
|
||||
const pending = r.correct === null || r.correct === undefined;
|
||||
const cls = pending ? "pend" : r.correct ? "ok" : "no";
|
||||
const result = pending ? "?" : r.correct ? "✓" : "✗";
|
||||
@@ -141,10 +145,10 @@ function rowHtml(r) {
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function promptBlock(report) {
|
||||
const p = report.prompts || {};
|
||||
const items = SCENARIOS
|
||||
.filter((k) => p[k])
|
||||
function promptBlock(report: ReportRoot): string {
|
||||
const p = report.prompts;
|
||||
const items = (SCENARIO_NAMES as readonly ScenarioName[])
|
||||
.filter((k): k is ScenarioName => !!p[k])
|
||||
.map(
|
||||
(k) => `
|
||||
<div class="prompt-item">
|
||||
@@ -161,15 +165,15 @@ function promptBlock(report) {
|
||||
</details>`;
|
||||
}
|
||||
|
||||
export function buildHtml(report) {
|
||||
const perTask = Object.entries(report.models).flatMap(([model, rep]) =>
|
||||
export function buildHtml(report: ReportRoot): string {
|
||||
const perTask: RowHtmlItem[] = Object.entries(report.models).flatMap(([model, rep]) =>
|
||||
rep.perTask.map((r) => ({ ...r, model }))
|
||||
);
|
||||
const rows = perTask.map(rowHtml).join("");
|
||||
const models = Object.keys(report.aggregate.perModel);
|
||||
let totalPending = 0, totalReviewed = 0;
|
||||
for (const rep of Object.values(report.models)) {
|
||||
for (const s of SCENARIOS) {
|
||||
for (const s of SCENARIO_NAMES) {
|
||||
if (rep.scenarios[s]) {
|
||||
totalReviewed += rep.scenarios[s].reviewed || 0;
|
||||
totalPending += rep.scenarios[s].pending || 0;
|
||||
@@ -182,8 +186,8 @@ export function buildHtml(report) {
|
||||
const modelOptions = models
|
||||
.map((m) => `<option value="${esc(m)}">${modelShort(m)}</option>`)
|
||||
.join("");
|
||||
const aggCards = SCENARIOS
|
||||
.filter((k) => report.aggregate.perScenario[k])
|
||||
const aggCards = (SCENARIO_NAMES as readonly ScenarioName[])
|
||||
.filter((k): k is ScenarioName => !!report.aggregate.perScenario[k])
|
||||
.map((k) => scenarioCard(k, report.aggregate.perScenario[k]))
|
||||
.join("");
|
||||
|
||||
@@ -353,9 +357,9 @@ export function buildHtml(report) {
|
||||
</html>`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
let inFile = null, outFile = "out/report.html";
|
||||
let inFile: string | null = null, outFile = "out/report.html";
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const a = args[i];
|
||||
const next = () => args[i + 1];
|
||||
@@ -371,7 +375,7 @@ async function main() {
|
||||
inFile = (await readFile(reviewed, "utf8").then(() => reviewed).catch(() => base));
|
||||
}
|
||||
|
||||
const report = JSON.parse(await readFile(inFile, "utf8"));
|
||||
const report: ReportRoot = JSON.parse(await readFile(inFile, "utf8"));
|
||||
const html = buildHtml(report);
|
||||
await mkdir(path.dirname(outFile), { recursive: true });
|
||||
await writeFile(outFile, html, "utf8");
|
||||
@@ -1,16 +1,28 @@
|
||||
function round(x, d = 2) {
|
||||
import type { ScenarioName, ScenarioSummary, PerTaskRow, ModelReport, Aggregate, AggregateScenario, AggregateModel } from "@duck/types";
|
||||
import { SCENARIO_NAMES } from "@duck/types";
|
||||
|
||||
function round(x: number, d = 2): number {
|
||||
if (!Number.isFinite(x)) return x;
|
||||
const f = 10 ** d;
|
||||
return Math.round(x * f) / f;
|
||||
}
|
||||
|
||||
function avg(arr) {
|
||||
function avg(arr: number[]): number {
|
||||
if (!arr.length) return 0;
|
||||
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
||||
}
|
||||
|
||||
export function buildReport({ model, mcpUrl, tasks, rows, meta = {}, prompts = {} }) { const scenarios = ["control", "thinking", "blind", "mentor"];
|
||||
const byScenario = {};
|
||||
export interface BuildReportOpts {
|
||||
model: string;
|
||||
mcpUrl: string;
|
||||
rows: PerTaskRow[];
|
||||
meta?: Record<string, unknown>;
|
||||
prompts?: Partial<Record<ScenarioName, string>>;
|
||||
}
|
||||
|
||||
export function buildReport({ model, mcpUrl, rows, meta = {}, prompts = {} }: BuildReportOpts): ModelReport {
|
||||
const scenarios = SCENARIO_NAMES;
|
||||
const byScenario: Record<string, { total: number; correct: number; reviewed: number; duckUsed: number; toolCalls: number; promptTokens: number[]; genTokens: number[]; duckTokens: number[]; rows: PerTaskRow[] }> = {};
|
||||
for (const s of scenarios) byScenario[s] = { total: 0, correct: 0, reviewed: 0, duckUsed: 0, toolCalls: 0, promptTokens: [], genTokens: [], duckTokens: [], rows: [] };
|
||||
|
||||
for (const row of rows) {
|
||||
@@ -29,10 +41,9 @@ export function buildReport({ model, mcpUrl, tasks, rows, meta = {}, prompts = {
|
||||
byScenario[s].rows.push(row);
|
||||
}
|
||||
|
||||
const summaries = {};
|
||||
const summaries: Record<ScenarioName, ScenarioSummary> = {} as Record<ScenarioName, ScenarioSummary>;
|
||||
for (const s of scenarios) {
|
||||
const g = byScenario[s];
|
||||
const denom = g.reviewed || 1;
|
||||
summaries[s] = {
|
||||
tasks: g.total,
|
||||
reviewed: g.reviewed,
|
||||
@@ -53,7 +64,7 @@ export function buildReport({ model, mcpUrl, tasks, rows, meta = {}, prompts = {
|
||||
model,
|
||||
mcpUrl,
|
||||
scenarios: summaries,
|
||||
prompts,
|
||||
prompts: prompts as Record<ScenarioName, string>,
|
||||
perTask: rows.map((r) => ({
|
||||
id: r.id,
|
||||
scenario: r.scenario,
|
||||
@@ -71,13 +82,11 @@ export function buildReport({ model, mcpUrl, tasks, rows, meta = {}, prompts = {
|
||||
};
|
||||
}
|
||||
|
||||
const SCENARIOS = ["control", "thinking", "blind", "mentor"];
|
||||
|
||||
export function buildAggregate(modelsReport) {
|
||||
const perScenario = {};
|
||||
for (const s of SCENARIOS) {
|
||||
export function buildAggregate(modelsReport: Record<string, ModelReport>): Aggregate {
|
||||
const perScenario: Record<ScenarioName, AggregateScenario> = {} as Record<ScenarioName, AggregateScenario>;
|
||||
for (const s of SCENARIO_NAMES) {
|
||||
let total = 0, reviewed = 0, correct = 0, duckUsed = 0, toolCalls = 0;
|
||||
const byModel = {};
|
||||
const byModel: Record<string, AggregateScenario["byModel"][string]> = {};
|
||||
for (const [name, rep] of Object.entries(modelsReport)) {
|
||||
const g = rep.scenarios[s];
|
||||
if (!g) continue;
|
||||
@@ -107,12 +116,12 @@ export function buildAggregate(modelsReport) {
|
||||
};
|
||||
}
|
||||
|
||||
const perModel = {};
|
||||
const perModel: Record<string, AggregateModel> = {};
|
||||
for (const [name, rep] of Object.entries(modelsReport)) {
|
||||
let duckUsed = 0, toolCalls = 0;
|
||||
const gen = [];
|
||||
const gen: number[] = [];
|
||||
let reviewed = 0, correct = 0;
|
||||
for (const s of SCENARIOS) {
|
||||
for (const s of SCENARIO_NAMES) {
|
||||
const g = rep.scenarios[s];
|
||||
duckUsed += g.duckUsed;
|
||||
toolCalls += g.toolCalls;
|
||||
@@ -123,7 +132,7 @@ export function buildAggregate(modelsReport) {
|
||||
}
|
||||
}
|
||||
const taskCount = rep.scenarios.control?.tasks ?? 0;
|
||||
const totalRows = SCENARIOS.reduce((acc, s) => acc + (rep.scenarios[s].tasks || 0), 0);
|
||||
const totalRows = SCENARIO_NAMES.reduce((acc, s) => acc + (rep.scenarios[s].tasks || 0), 0);
|
||||
perModel[name] = {
|
||||
tasks: taskCount,
|
||||
totalRows,
|
||||
@@ -1,24 +1,36 @@
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import readline from "node:readline";
|
||||
import { buildReport, buildAggregate } from "./report.mjs";
|
||||
import type { ReportRoot, ScenarioName, PerTaskRow } from "@duck/types";
|
||||
import { SCENARIO_NAMES } from "@duck/types";
|
||||
import { buildReport, buildAggregate } from "./report.js";
|
||||
|
||||
const SCENARIO_LABELS = {
|
||||
const SCENARIO_LABELS: Record<ScenarioName, string> = {
|
||||
control: "Без утки (контроль)",
|
||||
thinking: "Думать вслух",
|
||||
blind: "Слепая утка",
|
||||
mentor: "Утка-помощник",
|
||||
};
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
interface ReviewEntry {
|
||||
model: string;
|
||||
taskId: string;
|
||||
scenario: ScenarioName;
|
||||
row: PerTaskRow;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
interface ReviewArgs {
|
||||
report: string | null;
|
||||
out: string | null;
|
||||
reviewFile: string | null;
|
||||
model: string | null;
|
||||
scenario: string | null;
|
||||
limit: number | null;
|
||||
reAsk?: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): ReviewArgs {
|
||||
const args: ReviewArgs = {
|
||||
report: null,
|
||||
out: null,
|
||||
reviewFile: null,
|
||||
@@ -40,22 +52,22 @@ function parseArgs(argv) {
|
||||
return args;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const reportFile = args.report || path.join("out", "report.json");
|
||||
const outFile = args.out || path.join("out", "report.reviewed.json");
|
||||
const reviewFile = args.reviewFile || path.join("out", "review.json");
|
||||
|
||||
const report = JSON.parse(await readFile(reportFile, "utf8"));
|
||||
const report: ReportRoot = JSON.parse(await readFile(reportFile, "utf8"));
|
||||
|
||||
let decisions = {};
|
||||
let decisions: Record<string, boolean> = {};
|
||||
try {
|
||||
decisions = JSON.parse(await readFile(reviewFile, "utf8"));
|
||||
} catch {
|
||||
/* no prior review */
|
||||
}
|
||||
|
||||
const entries = [];
|
||||
const entries: ReviewEntry[] = [];
|
||||
for (const [model, rep] of Object.entries(report.models)) {
|
||||
for (const row of rep.perTask) {
|
||||
if (args.model && model !== args.model) continue;
|
||||
@@ -65,13 +77,13 @@ async function main() {
|
||||
}
|
||||
if (args.limit && entries.length > args.limit) entries.length = args.limit;
|
||||
|
||||
const key = (e) => `${e.model}|${e.taskId}|${e.scenario}`;
|
||||
const key = (e: ReviewEntry) => `${e.model}|${e.taskId}|${e.scenario}`;
|
||||
|
||||
const isTTY = !!process.stdin.isTTY;
|
||||
|
||||
let batchLines = [];
|
||||
let batchLines: string[] = [];
|
||||
if (!isTTY) {
|
||||
const input = await new Promise((res, rej) => {
|
||||
const input = await new Promise<string>((res, rej) => {
|
||||
let data = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (c) => (data += c));
|
||||
@@ -83,17 +95,21 @@ async function main() {
|
||||
}
|
||||
|
||||
let batchIdx = 0;
|
||||
const nextInput = async () => {
|
||||
const nextInput = async (): Promise<string> => {
|
||||
if (isTTY) {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const ans = await new Promise((res) => rl.question("", res));
|
||||
const ans = await new Promise<string>((res) => rl.question("", res));
|
||||
rl.close();
|
||||
return ans.trim().toLowerCase();
|
||||
}
|
||||
return batchLines[batchIdx++] ?? "";
|
||||
};
|
||||
|
||||
async function askOne(e, nextInput, decisions) {
|
||||
let done = 0;
|
||||
let skipped = 0;
|
||||
let changed = 0;
|
||||
|
||||
async function askOne(e: ReviewEntry): Promise<number> {
|
||||
const k = key(e);
|
||||
const prev = decisions[k];
|
||||
if (prev !== undefined) {
|
||||
@@ -115,7 +131,7 @@ async function main() {
|
||||
process.stdout.write(` [y] верно [n] неверно [e] пропуск${hint}\n > `);
|
||||
const a = (await nextInput()) || "";
|
||||
|
||||
let decision = null;
|
||||
let decision: boolean | null = null;
|
||||
if (a === "y" || a === "д") decision = true;
|
||||
else if (a === "n" || a === "н") decision = false;
|
||||
else if (a === "" && cur) decision = e.row.correct;
|
||||
@@ -131,10 +147,6 @@ async function main() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
let done = 0;
|
||||
let skipped = 0;
|
||||
let changed = 0;
|
||||
|
||||
const reAsk = !!args.reAsk;
|
||||
|
||||
for (const e of entries) {
|
||||
@@ -142,7 +154,7 @@ async function main() {
|
||||
const prev = decisions[k];
|
||||
|
||||
if (prev === undefined || reAsk) {
|
||||
done += await askOne(e, nextInput, decisions);
|
||||
done += await askOne(e);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -161,7 +173,6 @@ async function main() {
|
||||
scenarios: buildReport({
|
||||
model,
|
||||
mcpUrl: report.mcpUrl,
|
||||
tasks: [],
|
||||
rows: rep.perTask.map((r) => ({
|
||||
id: r.id,
|
||||
scenario: r.scenario,
|
||||
@@ -192,7 +203,7 @@ async function main() {
|
||||
const scen = rep.scenarios;
|
||||
console.log(
|
||||
`${model} | ` +
|
||||
["control", "thinking", "blind", "mentor"]
|
||||
SCENARIO_NAMES
|
||||
.map((s) => `${s}=${String(scen[s].accuracy).padStart(5)}% (${scen[s].reviewed})`)
|
||||
.join(" ") +
|
||||
` всего rev=${g.reviewed}`
|
||||
@@ -1,13 +1,15 @@
|
||||
import { readFile, writeFile, mkdir, appendFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { Ollama } from "../lib/ollama.mjs";
|
||||
import { McpClient } from "../lib/mcpClient.mjs";
|
||||
import { runScenario } from "./runner.mjs";
|
||||
import { PROMPTS } from "./runner.mjs";
|
||||
import { buildReport, buildAggregate } from "./report.mjs";
|
||||
import type { ScenarioName, ReportRoot, PerTaskRow, Task } from "@duck/types";
|
||||
import { SCENARIO_NAMES } from "@duck/types";
|
||||
import { Ollama } from "../lib/ollama.js";
|
||||
import type { OllamaError } from "../lib/ollama.js";
|
||||
import { McpClient } from "../lib/mcpClient.js";
|
||||
import { runScenario, PROMPTS } from "./runner.js";
|
||||
import { buildReport, buildAggregate } from "./report.js";
|
||||
|
||||
let logTarget = null;
|
||||
async function log(msg) {
|
||||
let logTarget: string | null = null;
|
||||
async function log(msg: string): Promise<void> {
|
||||
const line = `[${new Date().toISOString()}] ${msg}`;
|
||||
console.log(line);
|
||||
if (logTarget) {
|
||||
@@ -19,8 +21,6 @@ async function log(msg) {
|
||||
}
|
||||
}
|
||||
|
||||
const SCENARIOS = ["control", "thinking", "blind", "mentor"];
|
||||
|
||||
const DEFAULT_MODELS = ["llama3.2:3b", "qwen3:1.7b", "qwen3:4b", "granite4.1:3b", "phi4-mini:3.8b"];
|
||||
const DEFAULT_MCP_URL = "https://rubber-duck-mcp.vercel.app/api/mcp";
|
||||
const REQUEST_TIMEOUT_MS = 60_000;
|
||||
@@ -28,9 +28,9 @@ const MAX_ATTEMPTS = 3;
|
||||
const PAUSE_BETWEEN_TASKS_MS = 2_000;
|
||||
const PAUSE_BETWEEN_RETRIES_MS = 10_000;
|
||||
|
||||
function classifyError(err) {
|
||||
const name = err?.name ?? "";
|
||||
const msg = String(err?.message ?? "");
|
||||
function classifyError(err: unknown): string {
|
||||
const name = (err as OllamaError)?.name ?? "";
|
||||
const msg = String((err as OllamaError)?.message ?? "");
|
||||
const low = `${name} ${msg}`.toLowerCase();
|
||||
if (name === "TimeoutError" || low.includes("timeout") || low.includes("aborted")) return "timeout";
|
||||
if (low.includes("fetch failed") || low.includes("connect") || low.includes("etimedout")) return "network";
|
||||
@@ -39,10 +39,20 @@ function classifyError(err) {
|
||||
return "other";
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
interface Args {
|
||||
models: string[];
|
||||
scenarios: ScenarioName[];
|
||||
limit: number;
|
||||
model: string | null;
|
||||
mcpUrl: string | null;
|
||||
tasksFile: string | null;
|
||||
out: string | null;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Args {
|
||||
const args: Args = {
|
||||
models: [],
|
||||
scenarios: SCENARIOS,
|
||||
scenarios: [...SCENARIO_NAMES],
|
||||
limit: Infinity,
|
||||
model: process.env.OLLAMA_MODEL || null,
|
||||
mcpUrl: null,
|
||||
@@ -55,7 +65,7 @@ function parseArgs(argv) {
|
||||
if (a === "--models") args.models = next().split(",").map((s) => s.trim()).filter(Boolean), i += 1;
|
||||
else if (a === "--model") args.model = next(), i += 1;
|
||||
else if (a === "--mcp-url") args.mcpUrl = next(), i += 1;
|
||||
else if (a === "--scenarios") args.scenarios = next().split(","), i += 1;
|
||||
else if (a === "--scenarios") args.scenarios = next().split(",") as ScenarioName[], i += 1;
|
||||
else if (a === "--limit") args.limit = Number(next()), i += 1;
|
||||
else if (a === "--tasks") args.tasksFile = next(), i += 1;
|
||||
else if (a === "--out") args.out = next(), i += 1;
|
||||
@@ -63,26 +73,26 @@ function parseArgs(argv) {
|
||||
return args;
|
||||
}
|
||||
|
||||
async function runOneModel({ model, ollama, mcp, tasks, scenarios }) {
|
||||
const rows = [];
|
||||
async function runOneModel({ model, ollama, mcp, tasks, scenarios }: { model: string; ollama: Ollama; mcp: McpClient; tasks: Task[]; scenarios: ScenarioName[] }): Promise<PerTaskRow[]> {
|
||||
const rows: PerTaskRow[] = [];
|
||||
for (const task of tasks) {
|
||||
for (const scenario of scenarios) {
|
||||
let r = null;
|
||||
let lastErr = null;
|
||||
let lastErr: OllamaError | null = null;
|
||||
const t0 = Date.now();
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
r = await runScenario({ scenario, ollama, model, task: task.question, mcp });
|
||||
break;
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
lastErr = err as OllamaError;
|
||||
const ms = Date.now() - t0;
|
||||
await log(` [${task.id}/${scenario}] attempt ${attempt} FAILED (${ms}ms): ${err.message}`);
|
||||
if (err.partialContent != null || err.partialToolCalls != null) {
|
||||
await log(` [${task.id}/${scenario}] attempt ${attempt} FAILED (${ms}ms): ${(err as Error).message}`);
|
||||
if (lastErr.partialContent != null || lastErr.partialToolCalls != null) {
|
||||
await log(
|
||||
` partial before abort: chars=${err.partialContent?.length ?? 0} ` +
|
||||
`toolCalls=${err.partialToolCalls?.length ?? 0} ` +
|
||||
`gen=${err.partialGen ?? 0}`
|
||||
` partial before abort: chars=${lastErr.partialContent?.length ?? 0} ` +
|
||||
`toolCalls=${lastErr.partialToolCalls?.length ?? 0} ` +
|
||||
`gen=${lastErr.partialGen ?? 0}`
|
||||
);
|
||||
}
|
||||
if (attempt < MAX_ATTEMPTS) await new Promise((res) => setTimeout(res, PAUSE_BETWEEN_RETRIES_MS));
|
||||
@@ -121,7 +131,7 @@ async function runOneModel({ model, ollama, mcp, tasks, scenarios }) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
let models = args.models;
|
||||
@@ -131,11 +141,11 @@ async function main() {
|
||||
const mcpUrl = args.mcpUrl || DEFAULT_MCP_URL;
|
||||
const tasksFile = args.tasksFile || path.join("data", "tasks.json");
|
||||
|
||||
const tasksRaw = JSON.parse(await readFile(tasksFile, "utf8"));
|
||||
const tasksRaw: Task[] = JSON.parse(await readFile(tasksFile, "utf8"));
|
||||
const tasks = tasksRaw.slice(0, args.limit === Infinity ? tasksRaw.length : args.limit);
|
||||
|
||||
const out = args.out || path.join("out", "report.json");
|
||||
const logFile = args.logTarget || path.join(path.dirname(out), "run.log");
|
||||
const logFile = path.join(path.dirname(out), "run.log");
|
||||
logTarget = logFile;
|
||||
await mkdir(path.dirname(logFile), { recursive: true });
|
||||
await appendFile(logFile, "", "utf8").catch(() => {});
|
||||
@@ -156,7 +166,7 @@ async function main() {
|
||||
const mcpTools = await mcp.listTools();
|
||||
await log(`MCP tools: ${mcpTools.map((t) => t.name).join(", ")}`);
|
||||
|
||||
const modelsReport = {};
|
||||
const modelsReport: ReportRoot["models"] = {};
|
||||
for (const model of models) {
|
||||
const tStart = Date.now();
|
||||
await log(`==== Running model: ${model} ====`);
|
||||
@@ -168,18 +178,18 @@ async function main() {
|
||||
await log(` warmup OK (${warmMs}ms, gen=${warm?.evalCount ?? 0})`);
|
||||
}
|
||||
const rows = await runOneModel({ model, ollama, mcp, tasks, scenarios: args.scenarios });
|
||||
const rep = buildReport({ model, mcpUrl, tasks, rows });
|
||||
const rep = buildReport({ model, mcpUrl, rows });
|
||||
modelsReport[model] = {
|
||||
model,
|
||||
scenarios: rep.scenarios,
|
||||
perTask: rep.perTask,
|
||||
};
|
||||
} as ReportRoot["models"][string];
|
||||
await log(`==== Done ${model} in ${Math.round((Date.now() - tStart) / 1000)}s ====`);
|
||||
}
|
||||
|
||||
const aggregate = buildAggregate(modelsReport);
|
||||
|
||||
const report = {
|
||||
const report: ReportRoot = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
mcpUrl,
|
||||
prompts: PROMPTS,
|
||||
@@ -196,7 +206,7 @@ async function main() {
|
||||
const scen = modelsReport[modelName].scenarios;
|
||||
await log(
|
||||
`${modelName} | ` +
|
||||
SCENARIOS.map((s) => `${s}=${String(scen[s].accuracy).padStart(5)}%`).join(" ") +
|
||||
SCENARIO_NAMES.map((s) => `${s}=${String(scen[s].accuracy).padStart(5)}%`).join(" ") +
|
||||
` duck=${g.duckUsed} calls=${g.toolCalls} avgGen=${g.avgGenTokens}`
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Ollama } from "../lib/ollama.mjs";
|
||||
import { McpClient, textContentFrom } from "../lib/mcpClient.mjs";
|
||||
import type { ScenarioName } from "@duck/types";
|
||||
import { Ollama } from "../lib/ollama.js";
|
||||
import type { OllamaMessage, OllamaToolDefinition } from "../lib/ollama.js";
|
||||
import { McpClient, textContentFrom } from "../lib/mcpClient.js";
|
||||
|
||||
const DUCK_TOOL = {
|
||||
const DUCK_TOOL: OllamaToolDefinition = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "quack",
|
||||
@@ -48,7 +50,7 @@ const MENTOR_SYSTEM =
|
||||
"Treat it as a way to voice your thoughts out loud, not as a source of answers. " +
|
||||
"After the tool's reply, give the user a clear, concrete final answer to the question.";
|
||||
|
||||
export const PROMPTS = {
|
||||
export const PROMPTS: Record<ScenarioName, string> = {
|
||||
control: CONTROL_SYSTEM,
|
||||
thinking: THINKING_SYSTEM,
|
||||
blind: BLIND_SYSTEM,
|
||||
@@ -57,11 +59,22 @@ export const PROMPTS = {
|
||||
|
||||
const MAX_DUCK_TURNS = 6;
|
||||
|
||||
function toolDefinition() {
|
||||
interface RunnerResult {
|
||||
response: string;
|
||||
toolCalls: number;
|
||||
duckUsed: boolean;
|
||||
duckTokens: number;
|
||||
promptTokens: number;
|
||||
genTokens: number;
|
||||
turns?: number;
|
||||
messages: OllamaMessage[];
|
||||
}
|
||||
|
||||
function toolDefinition(): OllamaToolDefinition {
|
||||
return DUCK_TOOL;
|
||||
}
|
||||
|
||||
async function runNoTool(ollama, model, task, system) {
|
||||
async function runNoTool(ollama: Ollama, model: string, task: string, system: string): Promise<RunnerResult> {
|
||||
const messages = [
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: task },
|
||||
@@ -78,16 +91,16 @@ async function runNoTool(ollama, model, task, system) {
|
||||
};
|
||||
}
|
||||
|
||||
function runControl(ollama, model, task) {
|
||||
function runControl(ollama: Ollama, model: string, task: string): Promise<RunnerResult> {
|
||||
return runNoTool(ollama, model, task, CONTROL_SYSTEM);
|
||||
}
|
||||
|
||||
function runThinking(ollama, model, task) {
|
||||
function runThinking(ollama: Ollama, model: string, task: string): Promise<RunnerResult> {
|
||||
return runNoTool(ollama, model, task, THINKING_SYSTEM);
|
||||
}
|
||||
|
||||
async function runDuck(ollama, model, task, mcp, system, scenario) {
|
||||
const messages = [
|
||||
async function runDuck(ollama: Ollama, model: string, task: string, mcp: McpClient, system: string, scenario: ScenarioName): Promise<RunnerResult> {
|
||||
const messages: OllamaMessage[] = [
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: task },
|
||||
];
|
||||
@@ -107,18 +120,21 @@ async function runDuck(ollama, model, task, mcp, system, scenario) {
|
||||
|
||||
if (r.toolCalls && r.toolCalls.length) {
|
||||
toolCalls += r.toolCalls.length;
|
||||
const assistantMsg = { role: "assistant", content: r.content || "" };
|
||||
assistantMsg.tool_calls = r.toolCalls.map((tc) => ({
|
||||
function: { name: tc.function.name, arguments: tc.function.arguments },
|
||||
}));
|
||||
const assistantMsg: OllamaMessage = {
|
||||
role: "assistant",
|
||||
content: r.content || "",
|
||||
tool_calls: r.toolCalls.map((tc) => ({
|
||||
function: { name: tc.function.name, arguments: tc.function.arguments },
|
||||
})),
|
||||
};
|
||||
messages.push(assistantMsg);
|
||||
|
||||
for (const tc of r.toolCalls) {
|
||||
if (tc.function.name === "quack") {
|
||||
duckUsed = true;
|
||||
let args = {};
|
||||
let args: Record<string, unknown> = {};
|
||||
try {
|
||||
args = tc.function.arguments ? JSON.parse(tc.function.arguments) : {};
|
||||
args = tc.function.arguments ? JSON.parse(tc.function.arguments) as Record<string, unknown> : {};
|
||||
} catch {
|
||||
args = {};
|
||||
}
|
||||
@@ -146,7 +162,6 @@ async function runDuck(ollama, model, task, mcp, system, scenario) {
|
||||
};
|
||||
}
|
||||
|
||||
// loop bound hit without a final answer
|
||||
const last = messages[messages.length - 1];
|
||||
return {
|
||||
response: last?.content ?? "",
|
||||
@@ -160,7 +175,15 @@ async function runDuck(ollama, model, task, mcp, system, scenario) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function runScenario({ scenario, ollama, model, task, mcp }) {
|
||||
export interface RunScenarioOpts {
|
||||
scenario: ScenarioName;
|
||||
ollama: Ollama;
|
||||
model: string;
|
||||
task: string;
|
||||
mcp: McpClient;
|
||||
}
|
||||
|
||||
export async function runScenario({ scenario, ollama, model, task, mcp }: RunScenarioOpts): Promise<RunnerResult> {
|
||||
if (scenario === "control") {
|
||||
return runControl(ollama, model, task);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"@duck/types": ["../../packages/types/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "lib"]
|
||||
}
|
||||
Reference in New Issue
Block a user