diff --git a/AGENTS.md b/AGENTS.md index 91147c3..db06e67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,12 +1,5 @@ -# AGENTS.md - -Orientation notes for AI coding assistants working in this repo. Read this first. - -## What this is - -`llmorbiter` — a small (v0.1.0, 0BSD) **LLM tool-calling orchestrator with provider -abstraction**. Pure ES modules (`"type": "module"`). Only runtime dependency is `dotenv`. -No build step, no test runner, no README (this file is the orientation doc). +`llmorbiter` **LLM tool-calling orchestrator with provider +abstraction**. ## Layout @@ -29,46 +22,67 @@ examples/ Exports: `LLMOrbiter`, `OpenRouterProvider`, `defineTool`, `ToolError`, `DirectReturnError`. -- `defineTool({ name, description, parameters, strict=true, invoke })` -> `{ spec, invoke, name }`. - `spec` is the OpenAI-compatible function schema sent to the LLM; `invoke(args, { signal })` - is the local implementation. +- `defineTool({ name, description, parameters, strict=false, invoke })` -> `{ spec, invoke, name }`. + - `spec`: OpenAI-compatible function schema (`{ type: 'function', function: { name, description, parameters, strict } }`). + - `parameters`: JSON Schema object (defaults to `{ type: 'object', properties: {}, additionalProperties: false }`). + - `invoke(args, { signal })`: local async implementation. - `ToolError(message)` — throw from a tool to signal a recoverable error back to the LLM. - `DirectReturnError(data)` — throw from a tool to stop the loop immediately; `run()` resolves - with `{ directReturn: true, data, ... }`. + with `{ directReturn: true, data, ... }`. Any remaining tool calls in the same turn are closed + with synthetic skipped tool responses so the conversation transcript remains valid. ### `new LLMOrbiter(provider, opts)` -`opts` are lifecycle callbacks: `onLLMCall`, `onUsage`, `onToolCall`, `onToolError`, -`onToolResult`, `onStatus`, `onContent`, `onReasoning`. +`opts` are lifecycle callbacks: +- `onLLMCall({ model, toolCount, tools, promptSize, messageCount, messages, iteration })` — called before each LLM call; `messages` is a comma-separated summary of roles (e.g. `'user,assistant,tool_response'`). +- `onUsage({ cached, input, output, cost })` — called after each LLM call with that call's usage; provider's raw usage object is attached on non-enumerable `.raw`. +- `onToolCall({ name, args })` — called before executing a tool. +- `onToolResult({ name, result })` — called on successful tool execution. +- `onToolError({ name, error, args, rawError })` — called when tool execution throws, returns `{ error }`, has invalid JSON args, or references an unknown tool. +- `onStatus(status)` — forwarded from provider: `'connected'` (HTTP headers received), `'streaming'` (first SSE chunk received), or `'working'` (tool calls started streaming). +- `onContent(chunk)` — forwarded from provider: incremental output content string (`choice.delta.content`). +- `onReasoning(text)` — forwarded from provider: **accumulated** reasoning string so far (aggregated from `delta.reasoning`, `delta.reasoning_content`, and `delta.reasoning_details`). ### `orbiter.run(opts)` `{ prompt, messages, model, tools, maxIterations=10, tool_choice, extraBody, signal }`. +- `prompt`: user message string to append (omit or pass `null` to continue without adding one). +- `messages`: transcript array to seed from; a leading `{ role: 'system' }` is the system prompt. +- `tools`: array of tool objects created with `defineTool()`. +- `maxIterations`: safety limit on tool-calling loop iterations (default: `10`). +- `tool_choice`: tool choice setting (defaults to `'auto'` when tools are present). +- `extraBody`: extra parameters merged into provider request payload. +- `signal`: external `AbortSignal`. + Loop: send messages + tool specs -> if `tool_calls` come back, invoke locals and feed results -back -> repeat until no more tool calls or `maxIterations`. Returns -`{ content, toolResults, usage, rawUsage, iterations, messages, ... }`; the returned `messages` -can seed the next `run()` for conversation continuity (a leading `{role:'system'}` entry is the -system prompt). `orbiter.abort(reason)` cancels the active run; aborts surface as -`DOMException('...', 'AbortError')`. +back -> repeat until no more tool calls or `maxIterations`. + +Returns: +- `content`: final assistant message text (or `null`). +- `toolResults`: `{ [toolName]: [result, ...] }` map of all tool results executed across iterations. +- `usage`: aggregated usage object `{ input, output, cached, tokenIn, tokenOut, tokenCached, costTotal, cost, raw }`. +- `rawUsage`: single provider usage object (if 1 iteration) or array of usage objects (if multiple iterations). +- `rawUsages`: array of all iteration usage objects from the provider. +- `iterations`: count of iterations performed. +- `messages`: conversation transcript array for continuity in subsequent runs. +- `directReturn`: `true` if terminated by `DirectReturnError`. +- `data`: data payload passed to `DirectReturnError`. + +`orbiter.abort(reason)` cancels the active run; aborts surface as `DOMException('...', 'AbortError')`. ### `OpenRouterProvider` -`new OpenRouterProvider({ apiKey, apiUrl, siteUrl, siteName })`. Method: +`new OpenRouterProvider({ apiKey, apiUrl, siteUrl, siteName })`. +- `apiUrl` defaults to `https://openrouter.ai/api/v1/chat/completions`. +- `siteUrl` sets `HTTP-Referer` header. +- `siteName` sets `X-Title` header. + +Method: `chatCompletions({ model, messages, tools, tool_choice, signal, extraBody, onStatus, onContent, onReasoning })` — streams SSE, assembles `{ content, tool_calls, finish_reason, usage, reasoning_details, reasoning }`. -`apiUrl` defaults to `https://openrouter.ai/api/v1/chat/completions`. - -## Gotchas / notes - -- Examples need `OPENROUTER_API_KEY` (in `examples/.env` or via `--key`). Default model in the - examples: `deepseek/deepseek-v4-flash-0731`. -- `.gitignore` ignores `node_modules/`, `examples/.env`, `examples/invoice.pdf`. -- `pdf-pages.js` shells out to `pdftotext -layout`, splits pages on `\f`, and appends prompts - to `examples/prompts.log`. -- `OpenRouterProvider` streams reasoning cumulatively via `onReasoning`. - -## Style conventions observed - -- 4-space indent, single quotes, semicolons, `'use strict';` header. -- Comments use em dashes (`—`) and `───` section dividers in examples. +- If reasoning was returned, automatically appends `{ role: 'assistant', content: result.reasoning }` to `messages`. +- Delta handling: + - `delta.content` -> passes chunk to `onContent(chunk)`. + - `delta.reasoning` / `delta.reasoning_content` / `delta.reasoning_details` -> passes **accumulated** string to `onReasoning(text)`. + - `delta.tool_calls` -> triggers `onStatus('working')` once when tool calls begin streaming (tool calls are assembled into `result.tool_calls` without per-chunk streaming callbacks). diff --git a/package-lock.json b/package-lock.json index f34a63b..91ea325 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,17 @@ "version": "0.1.0", "license": "0BSD", "dependencies": { - "dotenv": "^17.4.2" + "dotenv": "^18.0.4" } }, "node_modules/dotenv": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", - "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "version": "18.0.4", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.4.tgz", + "integrity": "sha512-jGt9uI5BIxpVO15kvy+iEFqNAwxYClKWnkptLjSNPnFLC003Imd0ofr3aEVsbpHdSBdSsbcApNIlz4nbyGMwdA==", "license": "BSD-2-Clause", + "bin": { + "dotenv": "dist/index.cjs" + }, "engines": { "node": ">=12" }, diff --git a/package.json b/package.json index 9015a00..10996b0 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,10 @@ "main": "src/index.js", "type": "module", "license": "0BSD", + "engines": { + "node": ">=18.0.0" + }, "dependencies": { - "dotenv": "^17.4.2" + "dotenv": "^18.0.4" } }