From cd773d3dc611033c9388d6d6af68cab5ad491e86 Mon Sep 17 00:00:00 2001 From: Ricardo Vega Date: Wed, 30 Sep 2026 07:05:21 -0500 Subject: [PATCH] Slice 9: compile with any OpenAI-compatible model. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @tyto/llm: chatWithTools (OpenAI-compatible chat with tool calls, no vendor SDK) and a configurable request timeout; parameter properties replaced with fields so the CLI can run the sources directly (erasableSyntaxOnly now enforced for llm too). - @tyto/compiler: OpenAiCompatCompiler — a tool loop whose only tool runs `tyto compile-tool` with an argv array (never a shell) and stdin for drafts; shares the private work folder with the Claude compiler. - cli: TYTO_COMPILER=claude (default) | openai with TYTO_BASE_URL, TYTO_MODEL, optional TYTO_API_KEY; resolved lazily so a bad setting only affects compile and repair. Verified live with a local model (Qwen3-coder 30B via Ollama, $0): the loopback compile test passes in 42 s, and the real Wikipedia task compiled in 194 s then answered 6/6 species correctly with no model. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 10 +- docs/IMPLEMENTATION.md | 11 ++ package-lock.json | 3 +- packages/cli/src/compose.ts | 18 +++- packages/cli/src/index.ts | 2 +- packages/cli/test/select.test.ts | 20 ++++ packages/compiler/package.json | 7 +- packages/compiler/src/claude.ts | 11 +- packages/compiler/src/index.ts | 2 + packages/compiler/src/openai.ts | 101 ++++++++++++++++++ packages/compiler/src/workdir.ts | 23 ++++ packages/compiler/test/fixtures/fake-tyto.mjs | 14 +++ packages/compiler/test/openai.test.ts | 97 +++++++++++++++++ packages/llm/src/anthropic-complete.ts | 5 +- packages/llm/src/chat-tools.ts | 43 ++++++++ packages/llm/src/http.ts | 4 +- packages/llm/src/index.ts | 1 + packages/llm/src/openai-complete.ts | 5 +- packages/llm/test/chat-tools.test.ts | 52 +++++++++ packages/llm/tsconfig.json | 11 +- 20 files changed, 415 insertions(+), 25 deletions(-) create mode 100644 packages/cli/test/select.test.ts create mode 100644 packages/compiler/src/openai.ts create mode 100644 packages/compiler/src/workdir.ts create mode 100755 packages/compiler/test/fixtures/fake-tyto.mjs create mode 100644 packages/compiler/test/openai.test.ts create mode 100644 packages/llm/src/chat-tools.ts create mode 100644 packages/llm/test/chat-tools.test.ts diff --git a/README.md b/README.md index 684812b..7e2ca14 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ two things it doesn't do: bodies, JavaScript errors, console errors, API calls, masked cookies, interactive elements, text. `tyto find` searches the whole page. Any model, including small local ones, gets there in one call. -> **Status:** being rebuilt (2026-09-30) per [docs/IMPLEMENTATION.md](./docs/IMPLEMENTATION.md). The previous +> **Status:** rebuilt (2026-09-30) per [docs/IMPLEMENTATION.md](./docs/IMPLEMENTATION.md), slices 1–9. The previous > design (its own CDP browser stack) is archived at tag `archive/tyto-v0`; why: > [docs/DECISION-2026-09-29-agent-browser.md](./docs/DECISION-2026-09-29-agent-browser.md). @@ -28,7 +28,9 @@ two things it doesn't do: ## Install Needs [agent-browser](https://github.com/vercel-labs/agent-browser) ≥ 0.38.1 and Node 22.22+/24.15+/26; -`tyto compile` also needs [Claude Code](https://claude.com/claude-code). +`tyto compile` uses [Claude Code](https://claude.com/claude-code) by default, or any OpenAI-compatible model +(for example a local Ollama model: `TYTO_COMPILER=openai TYTO_BASE_URL=http://127.0.0.1:11434/v1 +TYTO_MODEL=qwen3-coder:30b`). ```bash brew install agent-browser && agent-browser install @@ -78,10 +80,10 @@ npm run test:live # opt-in: needs agent-browser installed | Package | Role | |---|---| | `@tyto/core` | Recipes, lint, verify, brief, find, redaction, ports, fakes (pure) | -| `@tyto/llm` | OpenAI-compatible and Anthropic HTTP model adapters | +| `@tyto/llm` | OpenAI-compatible (with tool calls) and Anthropic HTTP model adapters | | `@tyto/agent-browser` | Runs the agent-browser CLI (argv, batch JSON); reads its event stream | | `@tyto/store` | Recipes, traces, session locks, log marks, replay config under `~/.tyto` | | `@tyto/cli` | The `tyto` command (`learn`, `compile`, `run`, `test`, `recipes`, `open`, `brief`, `find`, actions) | -| `@tyto/compiler` | Runs the compiler session (Claude Code headless) limited to `tyto compile-tool` | +| `@tyto/compiler` | Compiler sessions limited to `tyto compile-tool`: Claude Code headless, or any OpenAI-compatible model | A [YOLOVibeCode](https://github.com/YOLOVibeCode) public repo. Product: Noctusoft, Inc. MIT license. diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 41aa1fd..789b7af 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -228,8 +228,19 @@ restart with Tyto's settings. - `doctor --fix closes Tyto's running sessions so they restart with Tyto's settings` ### Slice 9 — OpenAI-compatible compiler +`TYTO_COMPILER=openai` with `TYTO_BASE_URL` and `TYTO_MODEL` (optional `TYTO_API_KEY`) compiles with any +OpenAI-compatible endpoint (Ollama, proxies). The model's only tool, `tyto_compile_tool`, runs +`node compile-tool ` with an argv array (a string is split like a command line, never by a shell) and +optional stdin for drafts. The compiler is resolved lazily, so a bad setting only affects compile and repair. +`@tyto/llm` no longer uses TypeScript parameter properties (Node's strip-only mode rejects them) and its request +timeout is configurable. Verified: local Qwen3-coder 30B compiled the loopback fixture in 42 s and the real +Wikipedia task in 194 s (6/6 species correct afterwards, no model). - `the tool-call loop runs compile-tool calls and stops on the final recipe JSON` +- `tool calls run tyto compile-tool with an argv array, never a shell`; `a string of args is split like a command line` +- `gives up after the turn limit` +- `chatWithTools posts messages and tools and returns tool calls`; `the request timeout is configurable` - `config selects the compiler: claude (default) or openai with baseUrl and model` +- `openai without a base URL or model is a clear error` Later: request → recipe routing without a model (local embeddings), session pool, MCP. diff --git a/package-lock.json b/package-lock.json index c2c54d5..c49f9a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1751,7 +1751,8 @@ "name": "@tyto/compiler", "version": "0.1.0", "dependencies": { - "@tyto/core": "*" + "@tyto/core": "*", + "@tyto/llm": "*" } }, "packages/core": { diff --git a/packages/cli/src/compose.ts b/packages/cli/src/compose.ts index b091e9e..7238a0a 100644 --- a/packages/cli/src/compose.ts +++ b/packages/cli/src/compose.ts @@ -9,7 +9,8 @@ import { constants } from "node:fs"; import { delimiter } from "node:path"; import { promisify } from "node:util"; import type { TytoSession } from "./doctor.ts"; -import { ClaudeCodeCompiler } from "@tyto/compiler"; +import { ClaudeCodeCompiler, OpenAiCompatCompiler } from "@tyto/compiler"; +import type { Compiler } from "@tyto/core"; import { createInterface } from "node:readline/promises"; import { text } from "node:stream/consumers"; import { Recorder, SecretRedactor } from "@tyto/core"; @@ -59,6 +60,18 @@ async function runningSessions(env: NodeJS.ProcessEnv): Promise { return out; } +/** TYTO_COMPILER=claude (default: Claude Code headless) or openai (TYTO_BASE_URL, TYTO_MODEL, optional TYTO_API_KEY). */ +export function selectCompiler(env: NodeJS.ProcessEnv, opts: { tytoBin: string; workRoot: string }): Compiler { + const which = env.TYTO_COMPILER && env.TYTO_COMPILER !== "" ? env.TYTO_COMPILER : "claude"; + if (which === "claude") return new ClaudeCodeCompiler({ ...opts, model: env.TYTO_COMPILER_MODEL ?? "sonnet", baseEnv: env }); + if (which === "openai") { + if (!env.TYTO_BASE_URL) throw new Error("TYTO_COMPILER=openai needs TYTO_BASE_URL (e.g. http://127.0.0.1:11434/v1)"); + if (!env.TYTO_MODEL) throw new Error("TYTO_COMPILER=openai needs TYTO_MODEL"); + return new OpenAiCompatCompiler({ ...opts, baseUrl: new URL(env.TYTO_BASE_URL), apiKey: env.TYTO_API_KEY ?? "", model: env.TYTO_MODEL, baseEnv: env }); + } + throw new Error(`TYTO_COMPILER must be claude or openai, not ${which}`); +} + const BIN = fileURLToPath(new URL("../bin/tyto.mjs", import.meta.url)); const LISTEN_MAX_MS = 2 * 60 * 60 * 1000; import type { CliDeps } from "./main.ts"; @@ -104,7 +117,8 @@ export async function composeDeps(env: NodeJS.ProcessEnv = process.env): Promise }, }, traces: new FileTraceStore(join(home, "traces")), - compiler: new ClaudeCodeCompiler({ tytoBin: BIN, workRoot: join(home, "compile"), model: env.TYTO_COMPILER_MODEL ?? "sonnet", baseEnv: env }), + // Resolved on use, so a misconfigured compiler only affects `tyto compile` and `tyto repair`. + compiler: { run: (req) => selectCompiler(env, { tytoBin: BIN, workRoot: join(home, "compile") }).run(req) }, compileTool: { dir: env.TYTO_COMPILE_DIR && env.TYTO_COMPILE_DIR !== "" ? env.TYTO_COMPILE_DIR : undefined, readStdin: () => text(process.stdin), diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 7cb2f58..efec0a7 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,5 +1,5 @@ export { USAGE, main, type CliDeps } from "./main.ts"; -export { composeDeps } from "./compose.ts"; +export { composeDeps, selectCompiler } from "./compose.ts"; export { requestControl, serveControl, type ControlReply, type ControlRequest } from "./learn/control.ts"; export { runListener, type ListenerDeps } from "./learn/listener.ts"; export { learnCommand, type LearnDeps } from "./learn/commands.ts"; diff --git a/packages/cli/test/select.test.ts b/packages/cli/test/select.test.ts new file mode 100644 index 0000000..aa35aaf --- /dev/null +++ b/packages/cli/test/select.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { ClaudeCodeCompiler, OpenAiCompatCompiler } from "@tyto/compiler"; +import { selectCompiler } from "../src/index.ts"; + +const OPTS = { tytoBin: "/tyto/bin/tyto.mjs", workRoot: "/tmp/tyto-compile" }; + +describe("selectCompiler", () => { + it("config selects the compiler: claude (default) or openai with baseUrl and model", () => { + expect(selectCompiler({}, OPTS)).toBeInstanceOf(ClaudeCodeCompiler); + expect(selectCompiler({ TYTO_COMPILER: "claude" }, OPTS)).toBeInstanceOf(ClaudeCodeCompiler); + const openai = selectCompiler({ TYTO_COMPILER: "openai", TYTO_BASE_URL: "http://127.0.0.1:11434/v1", TYTO_MODEL: "qwen3-coder:30b" }, OPTS); + expect(openai).toBeInstanceOf(OpenAiCompatCompiler); + }); + + it("openai without a base URL or model is a clear error", () => { + expect(() => selectCompiler({ TYTO_COMPILER: "openai", TYTO_MODEL: "m" }, OPTS)).toThrow(/TYTO_BASE_URL/); + expect(() => selectCompiler({ TYTO_COMPILER: "openai", TYTO_BASE_URL: "http://x.test/v1" }, OPTS)).toThrow(/TYTO_MODEL/); + expect(() => selectCompiler({ TYTO_COMPILER: "gemini" }, OPTS)).toThrow(/claude or openai/); + }); +}); diff --git a/packages/compiler/package.json b/packages/compiler/package.json index 04be454..6841e58 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -4,8 +4,11 @@ "private": true, "type": "module", "description": "Compiler adapters: run a model session that may only use `tyto compile-tool`.", - "exports": { ".": "./src/index.ts" }, + "exports": { + ".": "./src/index.ts" + }, "dependencies": { - "@tyto/core": "*" + "@tyto/core": "*", + "@tyto/llm": "*" } } diff --git a/packages/compiler/src/claude.ts b/packages/compiler/src/claude.ts index f9b1c79..9a125b0 100644 --- a/packages/compiler/src/claude.ts +++ b/packages/compiler/src/claude.ts @@ -1,7 +1,7 @@ import { spawn } from "node:child_process"; -import { chmod, mkdir, mkdtemp, writeFile } from "node:fs/promises"; import { join } from "node:path"; import type { CompileRequest, Compiler } from "@tyto/core"; +import { prepareWorkdir } from "./workdir.ts"; export type ClaudeCodeCompilerOptions = { /** Path to the Tyto CLI entry (bin/tyto.mjs); exposed to the model as `tyto` on PATH. */ @@ -27,14 +27,7 @@ export class ClaudeCodeCompiler implements Compiler { } async run(req: CompileRequest): Promise { - await mkdir(this.#opts.workRoot, { recursive: true, mode: 0o700 }); - const dir = await mkdtemp(join(this.#opts.workRoot, `${req.context.name}-`)); - await mkdir(join(dir, "bin"), { mode: 0o700 }); - await writeFile(join(dir, "context.json"), JSON.stringify(req.context), { mode: 0o600 }); - const shim = join(dir, "bin", "tyto"); - await writeFile(shim, `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(this.#opts.tytoBin)} "$@"\n`, { mode: 0o700 }); - await chmod(shim, 0o700); - + const dir = await prepareWorkdir(this.#opts.workRoot, req.context, this.#opts.tytoBin, process.execPath); const { ANTHROPIC_API_KEY: _key, ...base } = this.#opts.baseEnv ?? process.env; const env = { ...base, PATH: `${join(dir, "bin")}:${base.PATH ?? ""}`, TYTO_COMPILE_DIR: dir }; const args = [ diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 9a3078e..3b7210c 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -1 +1,3 @@ export { ClaudeCodeCompiler, type ClaudeCodeCompilerOptions } from "./claude.ts"; +export { OpenAiCompatCompiler, type OpenAiCompatCompilerOptions } from "./openai.ts"; +export { prepareWorkdir, splitArgs } from "./workdir.ts"; diff --git a/packages/compiler/src/openai.ts b/packages/compiler/src/openai.ts new file mode 100644 index 0000000..59a1774 --- /dev/null +++ b/packages/compiler/src/openai.ts @@ -0,0 +1,101 @@ +import { spawn } from "node:child_process"; +import type { CompileRequest, Compiler } from "@tyto/core"; +import { chatWithTools, type ChatMessage, type ToolSpec } from "@tyto/llm"; +import { prepareWorkdir, splitArgs } from "./workdir.ts"; + +export type OpenAiCompatCompilerOptions = { + baseUrl: URL; + apiKey: string; + model: string; + /** Path to bin/tyto.mjs; each tool call runs `node compile-tool …`. */ + tytoBin: string; + workRoot: string; + nodePath?: string; + maxTurns?: number; + timeoutMs?: number; + toolTimeoutMs?: number; + baseEnv?: NodeJS.ProcessEnv; +}; + +const OUTPUT_LIMIT = 8000; + +const TOOL: ToolSpec = { + type: "function", + function: { + name: "tyto_compile_tool", + description: + "Run `tyto compile-tool `. draft: pass the recipe JSON as stdin; test --param value: run a draft with no model; ab : probe live pages.", + parameters: { + type: "object", + properties: { + args: { type: "array", items: { type: "string" }, description: 'Arguments after `tyto compile-tool`, e.g. ["test", "d1", "--species", "Tiger"]' }, + stdin: { type: "string", description: "Recipe JSON (only for draft)" }, + }, + required: ["args"], + }, + }, +}; + +/** Compiles with any OpenAI-compatible model (Ollama, proxies). Its only tool runs `tyto compile-tool` — argv, no shell. */ +export class OpenAiCompatCompiler implements Compiler { + readonly #opts: OpenAiCompatCompilerOptions; + + constructor(opts: OpenAiCompatCompilerOptions) { + this.#opts = opts; + } + + async run(req: CompileRequest): Promise { + const nodePath = this.#opts.nodePath ?? process.execPath; + const dir = await prepareWorkdir(this.#opts.workRoot, req.context, this.#opts.tytoBin, nodePath); + const chat = { + baseUrl: this.#opts.baseUrl, + apiKey: this.#opts.apiKey, + model: this.#opts.model, + timeoutMs: this.#opts.timeoutMs ?? 300_000, + }; + const messages: ChatMessage[] = [ + { role: "system", content: req.system }, + { role: "user", content: req.prompt }, + ]; + const maxTurns = this.#opts.maxTurns ?? 40; + for (let turn = 0; turn < maxTurns; turn += 1) { + const reply = await chatWithTools(chat, messages, [TOOL]); + if (!reply.toolCalls.length) return reply.content; + messages.push(reply.message); + for (const call of reply.toolCalls) { + const output = await this.#tool(call.arguments, dir, nodePath); + messages.push({ role: "tool", tool_call_id: call.id, content: output }); + } + } + throw new Error("compiler: turn limit reached without a final recipe"); + } + + async #tool(rawArgs: string, dir: string, nodePath: string): Promise { + let parsed: { args?: unknown; stdin?: unknown }; + try { + parsed = JSON.parse(rawArgs) as { args?: unknown; stdin?: unknown }; + } catch { + return "error: tool arguments must be JSON like {\"args\": [\"test\", \"d1\"]}"; + } + const argv = Array.isArray(parsed.args) ? parsed.args.map(String) : splitArgs(String(parsed.args ?? "")); + const stdin = typeof parsed.stdin === "string" ? parsed.stdin : ""; + const { ANTHROPIC_API_KEY: _key, ...base } = this.#opts.baseEnv ?? process.env; + return new Promise((resolve) => { + const child = spawn(nodePath, [this.#opts.tytoBin, "compile-tool", ...argv], { + env: { ...base, TYTO_COMPILE_DIR: dir }, + stdio: ["pipe", "pipe", "pipe"], + signal: AbortSignal.timeout(this.#opts.toolTimeoutMs ?? 180_000), + }); + let out = ""; + const add = (c: string): void => { + if (out.length < OUTPUT_LIMIT) out += c; + }; + child.stdout.setEncoding("utf8").on("data", add); + child.stderr.setEncoding("utf8").on("data", add); + child.on("error", (e) => resolve(`error: ${e.message}`)); + child.on("close", (code) => resolve(`${out.slice(0, OUTPUT_LIMIT)}\n(exit ${code ?? "?"})`)); + child.stdin.on("error", () => undefined); + child.stdin.end(stdin); + }); + } +} diff --git a/packages/compiler/src/workdir.ts b/packages/compiler/src/workdir.ts new file mode 100644 index 0000000..124beff --- /dev/null +++ b/packages/compiler/src/workdir.ts @@ -0,0 +1,23 @@ +import { chmod, mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { CompileContext } from "@tyto/core"; + +/** A private folder per compile: context.json (sites the tools may touch) and a `tyto` shim for the model. */ +export async function prepareWorkdir(workRoot: string, context: CompileContext, tytoBin: string, nodePath: string): Promise { + await mkdir(workRoot, { recursive: true, mode: 0o700 }); + const dir = await mkdtemp(join(workRoot, `${context.name}-`)); + await mkdir(join(dir, "bin"), { mode: 0o700 }); + await writeFile(join(dir, "context.json"), JSON.stringify(context), { mode: 0o600 }); + const shim = join(dir, "bin", "tyto"); + await writeFile(shim, `#!/bin/sh\nexec ${JSON.stringify(nodePath)} ${JSON.stringify(tytoBin)} "$@"\n`, { mode: 0o700 }); + await chmod(shim, 0o700); + return dir; +} + +/** Split a command line into argv (double and single quotes; no shell expansion). */ +export function splitArgs(line: string): string[] { + const out: string[] = []; + const re = /"((?:\\.|[^"\\])*)"|'([^']*)'|(\S+)/g; + for (const m of line.matchAll(re)) out.push(m[1] !== undefined ? m[1].replace(/\\(.)/g, "$1") : (m[2] ?? m[3] ?? "")); + return out; +} diff --git a/packages/compiler/test/fixtures/fake-tyto.mjs b/packages/compiler/test/fixtures/fake-tyto.mjs new file mode 100755 index 0000000..d39a53e --- /dev/null +++ b/packages/compiler/test/fixtures/fake-tyto.mjs @@ -0,0 +1,14 @@ +#!/usr/bin/env node +// Stand-in for `tyto` in offline compiler tests: logs argv and stdin, answers like compile-tool. +import { appendFileSync } from "node:fs"; + +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (c) => (input += c)); +process.stdin.on("end", () => { + const argv = process.argv.slice(2); + if (process.env.FAKE_TYTO_LOG) appendFileSync(process.env.FAKE_TYTO_LOG, JSON.stringify({ argv, stdin: input, dir: process.env.TYTO_COMPILE_DIR ?? null }) + "\n"); + if (argv[1] === "draft") process.stdout.write("draft d1 saved (2 steps, params: species)\n"); + else if (argv[1] === "test") process.stdout.write(JSON.stringify({ hit: { status: "Endangered" } }) + "\n"); + else process.stdout.write(`ran ${argv.join(" ")}\n`); +}); diff --git a/packages/compiler/test/openai.test.ts b/packages/compiler/test/openai.test.ts new file mode 100644 index 0000000..587e9b5 --- /dev/null +++ b/packages/compiler/test/openai.test.ts @@ -0,0 +1,97 @@ +import { createServer, type Server } from "node:http"; +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { OpenAiCompatCompiler } from "../src/index.ts"; + +const FAKE_TYTO = fileURLToPath(new URL("./fixtures/fake-tyto.mjs", import.meta.url)); +const REQUEST = { system: "CARD", prompt: "PROMPT", context: { name: "wiki-status", origins: ["https://en.wikipedia.org"], domains: [] } }; +let server: Server | null = null; + +afterEach(async () => { + await new Promise((resolve) => (server ? server.close(() => resolve()) : resolve())); + server = null; +}); + +function call(id: string, args: unknown): unknown { + return { id, type: "function", function: { name: "tyto_compile_tool", arguments: JSON.stringify(args) } }; +} + +/** Scripted OpenAI-compatible server: one reply per request, in order. */ +async function model(replies: unknown[]): Promise<{ base: URL; requests: Array<{ messages: Array> }> }> { + const requests: Array<{ messages: Array> }> = []; + server = createServer((req, res) => { + let raw = ""; + req.on("data", (c) => (raw += c)); + req.on("end", () => { + requests.push(JSON.parse(raw) as { messages: Array> }); + const message = replies[Math.min(requests.length - 1, replies.length - 1)]; + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ choices: [{ message }] })); + }); + }); + await new Promise((resolve) => server?.listen(0, "127.0.0.1", resolve)); + const addr = server.address(); + return { base: new URL(`http://127.0.0.1:${typeof addr === "object" && addr ? addr.port : 0}/v1`), requests }; +} + +async function compiler(base: URL) { + const root = await mkdtemp(join(tmpdir(), "tyto-oc-")); + const log = join(root, "tyto.log"); + const c = new OpenAiCompatCompiler({ + baseUrl: base, + apiKey: "", + model: "local-model", + tytoBin: FAKE_TYTO, + nodePath: process.execPath, + workRoot: join(root, "work"), + baseEnv: { ...process.env, FAKE_TYTO_LOG: log }, + }); + const calls = async () => (await readFile(log, "utf8")).trim().split("\n").map((l) => JSON.parse(l) as { argv: string[]; stdin: string; dir: string | null }); + return { c, calls }; +} + +describe("OpenAiCompatCompiler", () => { + it("the tool-call loop runs compile-tool calls and stops on the final recipe JSON", async () => { + const { base, requests } = await model([ + { content: "", tool_calls: [call("c1", { args: ["draft"], stdin: '{"name":"wiki-status"}' })] }, + { content: "", tool_calls: [call("c2", { args: ["test", "d1", "--species", "Tiger"] })] }, + { content: '```json\n{"name":"wiki-status"}\n```' }, + ]); + const { c, calls } = await compiler(base); + const final = await c.run(REQUEST); + expect(final).toContain('"name":"wiki-status"'); + const ran = await calls(); + expect(ran.map((r) => r.argv)).toEqual([["compile-tool", "draft"], ["compile-tool", "test", "d1", "--species", "Tiger"]]); + expect(ran[0]?.stdin).toBe('{"name":"wiki-status"}'); + expect(ran[0]?.dir).toMatch(/wiki-status-/); + expect(requests[0]?.messages.slice(0, 2)).toEqual([{ role: "system", content: "CARD" }, { role: "user", content: "PROMPT" }]); + expect(JSON.stringify(requests[1]?.messages)).toContain("draft d1 saved"); + }); + + it("tool calls run tyto compile-tool with an argv array, never a shell", async () => { + const { base } = await model([ + { content: "", tool_calls: [call("c1", { args: ["test", "d1; touch /tmp/pwned", "$(id)"] })] }, + { content: "done" }, + ]); + const { c, calls } = await compiler(base); + await c.run(REQUEST); + expect((await calls())[0]?.argv).toEqual(["compile-tool", "test", "d1; touch /tmp/pwned", "$(id)"]); + }); + + it("a string of args is split like a command line", async () => { + const { base } = await model([{ content: "", tool_calls: [call("c1", { args: 'test d1 --species "Snowy owl"' })] }, { content: "done" }]); + const { c, calls } = await compiler(base); + await c.run(REQUEST); + expect((await calls())[0]?.argv).toEqual(["compile-tool", "test", "d1", "--species", "Snowy owl"]); + }); + + it("gives up after the turn limit", async () => { + const { base } = await model([{ content: "", tool_calls: [call("c1", { args: ["test", "d1"] })] }]); + const root = await mkdtemp(join(tmpdir(), "tyto-oc-")); + const c = new OpenAiCompatCompiler({ baseUrl: base, apiKey: "", model: "m", tytoBin: FAKE_TYTO, nodePath: process.execPath, workRoot: root, maxTurns: 3 }); + await expect(c.run(REQUEST)).rejects.toThrow(/turn limit/); + }); +}); diff --git a/packages/llm/src/anthropic-complete.ts b/packages/llm/src/anthropic-complete.ts index d4361d9..8152cd3 100644 --- a/packages/llm/src/anthropic-complete.ts +++ b/packages/llm/src/anthropic-complete.ts @@ -12,7 +12,10 @@ export type AnthropicOptions = { export class AnthropicModel implements ModelPort { private readonly inject: InjectionGuard; - constructor(private readonly opts: AnthropicOptions) { + private readonly opts: AnthropicOptions; + + constructor(opts: AnthropicOptions) { + this.opts = opts; this.inject = resolveInject(opts.inject); } diff --git a/packages/llm/src/chat-tools.ts b/packages/llm/src/chat-tools.ts new file mode 100644 index 0000000..f019cfc --- /dev/null +++ b/packages/llm/src/chat-tools.ts @@ -0,0 +1,43 @@ +import { asRecord, bearerHeaders, joinPath, requestJson } from "./http.ts"; + +export type ChatOptions = { baseUrl: URL; apiKey: string; model: string; timeoutMs?: number }; + +export type ChatMessage = + | { role: "system" | "user"; content: string } + | { role: "assistant"; content: string; tool_calls?: unknown[] } + | { role: "tool"; content: string; tool_call_id: string }; + +export type ToolSpec = { + type: "function"; + function: { name: string; description: string; parameters: Record }; +}; + +export type ToolCall = { id: string; name: string; arguments: string }; + +export type ChatTurn = { content: string; toolCalls: ToolCall[]; message: ChatMessage }; + +/** One OpenAI-compatible chat completion with tools. No vendor SDK; any compatible endpoint (Ollama, proxies). */ +export async function chatWithTools(opts: ChatOptions, messages: readonly ChatMessage[], tools: readonly ToolSpec[]): Promise { + const { json } = await requestJson(joinPath(opts.baseUrl, "chat/completions"), { + method: "POST", + headers: { ...bearerHeaders(opts.apiKey), "content-type": "application/json" }, + body: JSON.stringify({ model: opts.model, messages, ...(tools.length ? { tools } : {}), temperature: 0 }), + ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}), + }); + const choices = asRecord(json)?.choices; + const msg = Array.isArray(choices) ? asRecord(asRecord(choices[0])?.message) : undefined; + const content = typeof msg?.content === "string" ? msg.content : ""; + const raw = Array.isArray(msg?.tool_calls) ? msg.tool_calls : []; + const toolCalls: ToolCall[] = raw.map((c, i) => { + const call = asRecord(c); + const fn = asRecord(call?.function); + const args = fn?.arguments; + return { + id: typeof call?.id === "string" ? call.id : `call_${i}`, + name: typeof fn?.name === "string" ? fn.name : "", + arguments: typeof args === "string" ? args : JSON.stringify(args ?? {}), + }; + }); + const message: ChatMessage = raw.length ? { role: "assistant", content, tool_calls: raw } : { role: "assistant", content }; + return { content, toolCalls, message }; +} diff --git a/packages/llm/src/http.ts b/packages/llm/src/http.ts index 4113278..c765e9c 100644 --- a/packages/llm/src/http.ts +++ b/packages/llm/src/http.ts @@ -10,13 +10,13 @@ export function asRecord(v: unknown): Record | undefined { export async function requestJson( url: URL, - init: { method: string; headers: Record; body?: string }, + init: { method: string; headers: Record; body?: string; timeoutMs?: number }, ): Promise<{ status: number; json: unknown }> { const res = await fetch(url, { method: init.method, headers: init.headers, ...(init.body !== undefined ? { body: init.body } : {}), - signal: AbortSignal.timeout(60_000), + signal: AbortSignal.timeout(init.timeoutMs ?? 60_000), }); if (res.status === 404) { await res.arrayBuffer(); diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index 4521d5a..8034704 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -1,3 +1,4 @@ export { OpenAiCatalog } from "./openai-catalog.ts"; export { OpenAiCompatModel, type OpenAiCompatOptions } from "./openai-complete.ts"; export { AnthropicModel, type AnthropicOptions } from "./anthropic-complete.ts"; +export { chatWithTools, type ChatMessage, type ChatOptions, type ChatTurn, type ToolCall, type ToolSpec } from "./chat-tools.ts"; diff --git a/packages/llm/src/openai-complete.ts b/packages/llm/src/openai-complete.ts index 251437e..ddcaa5a 100644 --- a/packages/llm/src/openai-complete.ts +++ b/packages/llm/src/openai-complete.ts @@ -12,7 +12,10 @@ export type OpenAiCompatOptions = { export class OpenAiCompatModel implements ModelPort { private readonly inject: InjectionGuard; - constructor(private readonly opts: OpenAiCompatOptions) { + private readonly opts: OpenAiCompatOptions; + + constructor(opts: OpenAiCompatOptions) { + this.opts = opts; this.inject = resolveInject(opts.inject); } diff --git a/packages/llm/test/chat-tools.test.ts b/packages/llm/test/chat-tools.test.ts new file mode 100644 index 0000000..e174e5a --- /dev/null +++ b/packages/llm/test/chat-tools.test.ts @@ -0,0 +1,52 @@ +import { createServer, type Server } from "node:http"; +import { afterEach, describe, expect, it } from "vitest"; +import { chatWithTools } from "../src/index.ts"; + +let server: Server | null = null; +afterEach(async () => { + await new Promise((resolve) => (server ? server.close(() => resolve()) : resolve())); + server = null; +}); + +async function mock(handler: (body: Record) => { status?: number; delayMs?: number; json: unknown }): Promise<{ base: URL; bodies: Array> }> { + const bodies: Array> = []; + server = createServer((req, res) => { + let raw = ""; + req.on("data", (c) => (raw += c)); + req.on("end", () => { + const body = JSON.parse(raw) as Record; + bodies.push(body); + const reply = handler(body); + setTimeout(() => { + res.writeHead(reply.status ?? 200, { "content-type": "application/json" }); + res.end(JSON.stringify(reply.json)); + }, reply.delayMs ?? 0); + }); + }); + await new Promise((resolve) => server?.listen(0, "127.0.0.1", resolve)); + const addr = server.address(); + return { base: new URL(`http://127.0.0.1:${typeof addr === "object" && addr ? addr.port : 0}/v1`), bodies }; +} + +const TOOL = { type: "function" as const, function: { name: "tyto_compile_tool", description: "x", parameters: { type: "object", properties: {} } } }; + +describe("chatWithTools", () => { + it("posts messages and tools and returns tool calls", async () => { + const { base, bodies } = await mock(() => ({ + json: { choices: [{ message: { content: "", tool_calls: [{ id: "c1", type: "function", function: { name: "tyto_compile_tool", arguments: '{"args":["test","d1"]}' } }] } }] }, + })); + const out = await chatWithTools({ baseUrl: base, apiKey: "k", model: "m" }, [{ role: "user", content: "hi" }], [TOOL]); + expect(out.toolCalls).toEqual([{ id: "c1", name: "tyto_compile_tool", arguments: '{"args":["test","d1"]}' }]); + expect(bodies[0]).toMatchObject({ model: "m", tools: [TOOL], messages: [{ role: "user", content: "hi" }] }); + }); + + it("returns the final content when the model stops calling tools", async () => { + const { base } = await mock(() => ({ json: { choices: [{ message: { content: "done" } }] } })); + expect(await chatWithTools({ baseUrl: base, apiKey: "", model: "m" }, [], [])).toEqual({ content: "done", toolCalls: [], message: { role: "assistant", content: "done" } }); + }); + + it("the request timeout is configurable", async () => { + const { base } = await mock(() => ({ delayMs: 300, json: { choices: [] } })); + await expect(chatWithTools({ baseUrl: base, apiKey: "", model: "m", timeoutMs: 50 }, [], [])).rejects.toThrow(); + }); +}); diff --git a/packages/llm/tsconfig.json b/packages/llm/tsconfig.json index ba00162..9a7b302 100644 --- a/packages/llm/tsconfig.json +++ b/packages/llm/tsconfig.json @@ -1,5 +1,12 @@ { "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "noEmit": true }, - "include": ["src"] + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "noEmit": true, + "erasableSyntaxOnly": true + }, + "include": [ + "src" + ] }