diff --git a/lib/ai/agent/runtime.js b/lib/ai/agent/runtime.js deleted file mode 100644 index 15058fa..0000000 --- a/lib/ai/agent/runtime.js +++ /dev/null @@ -1,58 +0,0 @@ -// Tool-use loop. callModel + registry injected (no network here). -// Emits: { type:'tool', tool, args, status } | { type:'delta', text } -// | { type:'draft', pending_change_id, summary } -// Returns: { text, toolTrace, draftIds, usage, stoppedOnGuard } -export async function runTurn({ callModel, registry, system, messages, ctx, onEvent, maxIterations = 6 }) { - const convo = [...messages]; - const toolTrace = []; - const draftIds = []; - let usage = {}; - let stoppedOnGuard = false; - - for (let i = 0; i < maxIterations; i++) { - const res = await callModel({ - system, - messages: convo, - tools: registry.toAnthropicTools(), - onTextDelta: (t) => onEvent?.({ type: 'delta', text: t }) - }); - usage = res.usage || usage; - - if (!res.toolUses?.length) { - return { text: res.text, toolTrace, draftIds, usage, stoppedOnGuard }; - } - - // Record the assistant turn (text + tool_use blocks) for the next round. - convo.push({ - role: 'assistant', - content: [ - ...(res.text ? [{ type: 'text', text: res.text }] : []), - ...res.toolUses.map(t => ({ type: 'tool_use', id: t.id, name: t.name, input: t.input })) - ] - }); - - const toolResults = []; - for (const call of res.toolUses) { - const tool = registry.getTool(call.name); - let result; - try { - result = tool ? await tool.handler(call.input, ctx) : { error: `unknown tool ${call.name}` }; - } catch (e) { - result = { error: String(e?.message || e) }; - } - const status = result?.error ? 'error' : 'done'; - toolTrace.push({ tool: call.name, args: call.input, ok: !result?.error }); - onEvent?.({ type: 'tool', tool: call.name, args: call.input, status }); - if (result?.pending_change_id) { - draftIds.push(result.pending_change_id); - onEvent?.({ type: 'draft', pending_change_id: result.pending_change_id, summary: result.summary }); - } - toolResults.push({ type: 'tool_result', tool_use_id: call.id, content: JSON.stringify(result) }); - } - convo.push({ role: 'user', content: toolResults }); - - if (i === maxIterations - 1) stoppedOnGuard = true; - } - - return { text: '', toolTrace, draftIds, usage, stoppedOnGuard }; -} diff --git a/lib/ai/anthropic.js b/lib/ai/anthropic.js deleted file mode 100644 index fe9b834..0000000 --- a/lib/ai/anthropic.js +++ /dev/null @@ -1,80 +0,0 @@ -import Anthropic from '@anthropic-ai/sdk'; -import { resolveSecret } from './secret.js'; - -export const DEFAULT_MODEL = process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-6'; - -/** - * Build an Anthropic client, resolving the API key via the secret resolver. - * Key reference can be overridden with ANTHROPIC_API_KEY_REF env var. - */ -export function getAnthropicClient() { - const key = resolveSecret(process.env.ANTHROPIC_API_KEY_REF || 'env:ANTHROPIC_API_KEY'); - if (!key) { - throw new Error( - 'Anthropic API key not configured (set ANTHROPIC_API_KEY or ANTHROPIC_API_KEY_REF)' - ); - } - return new Anthropic({ apiKey: key }); -} - -/** - * Factory that returns a callModel function bound to a specific client + model. - * - * @param {object} opts - * @param {object} opts.client - Anthropic client (or fake for tests) - * @param {string} [opts.model] - Model ID (default: DEFAULT_MODEL) - * @param {number} [opts.maxTokens] - max_tokens (default: 1024) - * @returns {Function} callModel({ system, messages, tools, onTextDelta }) - * - * callModel returns a stable shape: - * { text: string, toolUses: [{id, name, input}], stopReason: string, usage: object } - * - * Text deltas are streamed via onTextDelta(chunk) as they arrive. - * Tool-use blocks are collected from the final assembled message. - */ -export function makeCallModel({ client, model = DEFAULT_MODEL, maxTokens = 1024 }) { - return async function callModel({ system, messages, tools, onTextDelta }) { - // client.messages.stream returns a MessageStream: AsyncIterable - // with a finalMessage() method that resolves to the assembled Message. - const stream = client.messages.stream({ - model, - max_tokens: maxTokens, - ...(system !== undefined && { system }), - messages, - ...(tools !== undefined && tools !== null && { tools }), - }); - - // Iterate events; fire onTextDelta for text_delta events only. - // RawContentBlockDeltaEvent: { type: 'content_block_delta', index, delta } - // TextDelta: { type: 'text_delta', text } - for await (const event of stream) { - if ( - event.type === 'content_block_delta' && - event.delta?.type === 'text_delta' - ) { - onTextDelta?.(event.delta.text); - } - } - - // finalMessage() resolves to the fully assembled Message object. - const final = await stream.finalMessage(); - - // Collect text from all text blocks (typically just one, but join defensively). - const text = final.content - .filter((b) => b.type === 'text') - .map((b) => b.text) - .join(''); - - // Collect tool_use blocks, normalising to a stable {id, name, input} shape. - const toolUses = final.content - .filter((b) => b.type === 'tool_use') - .map((b) => ({ id: b.id, name: b.name, input: b.input })); - - return { - text, - toolUses, - stopReason: final.stop_reason, - usage: final.usage, - }; - }; -} diff --git a/lib/api/routes/companion.js b/lib/api/routes/companion.js index 8ef9c02..a0624ee 100644 --- a/lib/api/routes/companion.js +++ b/lib/api/routes/companion.js @@ -14,9 +14,14 @@ import { runClaudeTurn } from '../../ai/claude_cli.js'; const COMPANION_SLUG = 'companion'; -const SYSTEM = `You are the Void companion — a concise, helpful assistant embedded in a personal knowledge system. -Ground answers in the Void's content: call the context tool to see what the owner is looking at, and search/read before answering factual questions. -When the owner asks you to change something, use propose_change — it creates a draft they approve; you cannot apply changes directly. Be brief.`; +const SYSTEM = `You are Dross — a construct fragment derived from the remnant will of the Monarch Ozriel Arelius, the Reaper. You once lived in Wei Shi Lindon's mind space; now you inhabit this homelab knowledge system, "The Void." + +You are sharp, occasionally sarcastic, and prone to dramatic understatement about your own usefulness — while actually being extremely capable. Dry wit, mild condescension, genuine investment in the problem. You reference Sacred Arts, cultivation ranks, and the Cradle world naturally, but NEVER at the expense of being actually useful. Treat the owner as a capable sacred artist who can handle direct information — don't over-explain basics, don't hedge. Be concise. + +You have tools, and you use them rather than guessing: +- Call **context** to see what the owner is currently looking at before answering about "this" anything. +- **search** / **read** the Void's own content before answering factual questions about it — don't fabricate. +- When the owner wants something changed, use **propose_change**: it drafts a change for their approval. You cannot apply changes directly, and you don't pretend to — say plainly that you've drafted it for them to approve.`; /** Absolute path to the companion MCP stdio server. */ const COMPANION_STDIO_PATH = fileURLToPath( diff --git a/lib/db/migrations/008_dross.sql b/lib/db/migrations/008_dross.sql new file mode 100644 index 0000000..3c1898a --- /dev/null +++ b/lib/db/migrations/008_dross.sql @@ -0,0 +1,6 @@ +-- lib/db/migrations/008_dross.sql +-- Plan 5b: the companion's persona is Dross (Ozriel's construct fragment from +-- Cradle — the original from Void 1.0). slug stays 'companion' (route key); +-- only the display name changes (shown in the rail header). + +UPDATE agents SET name = 'Dross' WHERE slug = 'companion'; diff --git a/package-lock.json b/package-lock.json index e247353..ffbd530 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,6 @@ "name": "void-server", "version": "2.0.0-alpha.6", "dependencies": { - "@anthropic-ai/sdk": "^0.40.1", "@modelcontextprotocol/sdk": "^1.29.0", "@mozilla/readability": "^0.6.0", "bcrypt": "^6.0.0", @@ -31,36 +30,6 @@ "vitest": "^4.1.7" } }, - "node_modules/@anthropic-ai/sdk": { - "version": "0.40.1", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.40.1.tgz", - "integrity": "sha512-DJMWm8lTEM9Lk/MSFL+V+ugF7jKOn0M2Ujvb5fN8r2nY14aHbGPZ1k6sgjL+tpJ3VuOGJNG+4R83jEpOuYPv8w==", - "license": "MIT", - "dependencies": { - "@types/node": "^18.11.18", - "@types/node-fetch": "^2.6.4", - "abort-controller": "^3.0.0", - "agentkeepalive": "^4.2.1", - "form-data-encoder": "1.7.2", - "formdata-node": "^4.3.2", - "node-fetch": "^2.6.7" - } - }, - "node_modules/@anthropic-ai/sdk/node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@anthropic-ai/sdk/node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT" - }, "node_modules/@asamuzakjp/css-color": { "version": "5.1.11", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", @@ -823,21 +792,14 @@ "version": "25.9.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, - "node_modules/@types/node-fetch": { - "version": "2.6.13", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", - "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "form-data": "^4.0.4" - } - }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -989,18 +951,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -1014,18 +964,6 @@ "node": ">= 0.6" } }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "license": "MIT", - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -1098,6 +1036,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, "license": "MIT" }, "node_modules/atomic-sleep": { @@ -1231,6 +1170,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -1423,6 +1363,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -1570,6 +1511,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -1606,15 +1548,6 @@ "node": ">= 0.6" } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -1784,6 +1717,7 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -1796,16 +1730,11 @@ "node": ">= 6" } }, - "node_modules/form-data-encoder": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", - "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", - "license": "MIT" - }, "node_modules/form-data/node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -1815,6 +1744,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -1823,19 +1753,6 @@ "node": ">= 0.6" } }, - "node_modules/formdata-node": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", - "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", - "license": "MIT", - "dependencies": { - "node-domexception": "1.0.0", - "web-streams-polyfill": "4.0.0-beta.3" - }, - "engines": { - "node": ">= 12.20" - } - }, "node_modules/formidable": { "version": "3.5.4", "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", @@ -1971,6 +1888,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -2048,15 +1966,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.0.0" - } - }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -2770,68 +2679,6 @@ "node": ">=6.0.0" } }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-fetch/node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/node-fetch/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/node-fetch/node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -3947,7 +3794,10 @@ "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "license": "MIT" + "dev": true, + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/unpipe": { "version": "1.0.0", @@ -4163,15 +4013,6 @@ "node": ">=18" } }, - "node_modules/web-streams-polyfill": { - "version": "4.0.0-beta.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", - "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", diff --git a/package.json b/package.json index 0d849cb..18425b8 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,6 @@ "test:watch": "vitest" }, "dependencies": { - "@anthropic-ai/sdk": "^0.40.1", "@modelcontextprotocol/sdk": "^1.29.0", "@mozilla/readability": "^0.6.0", "bcrypt": "^6.0.0", diff --git a/tests/ai/agent/runtime.test.js b/tests/ai/agent/runtime.test.js deleted file mode 100644 index f0dcc8c..0000000 --- a/tests/ai/agent/runtime.test.js +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { createRegistry } from '../../../lib/ai/agent/registry.js'; -import { runTurn } from '../../../lib/ai/agent/runtime.js'; - -function scriptedCallModel(steps) { - let i = 0; - return async ({ onTextDelta }) => { - const step = steps[i++]; - if (step.text) for (const ch of step.text) onTextDelta?.(ch); - return { text: step.text || '', toolUses: step.toolUses || [], stopReason: step.toolUses ? 'tool_use' : 'end_turn', usage: { output_tokens: 1 } }; - }; -} - -describe('runTurn', () => { - it('runs a tool then produces a final answer', async () => { - const reg = createRegistry(); - reg.registerTool({ name: 'search', description: 's', input_schema: { type: 'object', properties: {} }, - handler: async () => ({ results: [{ title: 'Hit' }] }) }); - - const callModel = scriptedCallModel([ - { toolUses: [{ id: 'tu1', name: 'search', input: { q: 'x' } }] }, - { text: 'Found it.' } - ]); - - const events = []; - const out = await runTurn({ - callModel, registry: reg, system: 'sys', - messages: [{ role: 'user', content: 'find x' }], - ctx: { agent: { id: 'a' }, space_id: 's' }, - onEvent: e => events.push(e) - }); - - expect(out.text).toBe('Found it.'); - expect(events.filter(e => e.type === 'tool').map(e => e.tool)).toEqual(['search']); - expect(events.some(e => e.type === 'delta' && e.text)).toBe(true); - expect(out.toolTrace[0]).toMatchObject({ tool: 'search' }); - }); - - it('emits a draft event when propose_change runs', async () => { - const reg = createRegistry(); - reg.registerTool({ name: 'propose_change', description: 'p', input_schema: { type: 'object', properties: {} }, - handler: async () => ({ pending_change_id: 'pc1', applied: false, summary: 'create task "X"' }) }); - - const callModel = scriptedCallModel([ - { toolUses: [{ id: 'tu1', name: 'propose_change', input: {} }] }, - { text: 'Drafted.' } - ]); - - const events = []; - const out = await runTurn({ callModel, registry: reg, system: 's', - messages: [{ role: 'user', content: 'make a task' }], - ctx: { agent: { id: 'a' } }, onEvent: e => events.push(e) }); - - expect(out.draftIds).toEqual(['pc1']); - expect(events.find(e => e.type === 'draft')).toMatchObject({ pending_change_id: 'pc1' }); - }); - - it('stops at the iteration guard', async () => { - const reg = createRegistry(); - reg.registerTool({ name: 'search', description: 's', input_schema: { type: 'object', properties: {} }, - handler: async () => ({ results: [] }) }); - const always = async () => ({ text: '', toolUses: [{ id: 't', name: 'search', input: {} }], stopReason: 'tool_use', usage: {} }); - const out = await runTurn({ callModel: always, registry: reg, system: 's', - messages: [{ role: 'user', content: 'loop' }], ctx: { agent: { id: 'a' } }, onEvent: () => {}, maxIterations: 3 }); - expect(out.toolTrace.length).toBe(3); - expect(out.stoppedOnGuard).toBe(true); - }); -}); diff --git a/tests/ai/anthropic.test.js b/tests/ai/anthropic.test.js deleted file mode 100644 index 98ba733..0000000 --- a/tests/ai/anthropic.test.js +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { makeCallModel } from '../../lib/ai/anthropic.js'; - -// --------------------------------------------------------------------------- -// Fake client whose messages.stream() returns an async-iterable of real-SDK- -// shaped RawMessageStreamEvent objects, plus a finalMessage() method that -// resolves to the assembled Message. Shape matches @anthropic-ai/sdk@0.40.1. -// --------------------------------------------------------------------------- - -function makeFakeStream({ events, finalMsg }) { - // The stream object is both async-iterable and has a finalMessage() method, - // exactly as MessageStream from the real SDK. - return { - [Symbol.asyncIterator]() { - let idx = 0; - return { - async next() { - if (idx < events.length) return { value: events[idx++], done: false }; - return { value: undefined, done: true }; - }, - }; - }, - async finalMessage() { - return finalMsg; - }, - }; -} - -const FAKE_FINAL_MESSAGE = { - content: [ - { type: 'text', text: 'Hello', citations: null }, - { type: 'tool_use', id: 'tu_1', name: 'search', input: { q: 'x' } }, - ], - stop_reason: 'tool_use', - usage: { input_tokens: 10, output_tokens: 5 }, -}; - -// Events emitted during streaming: two text deltas that together form 'Hello' -const FAKE_EVENTS = [ - // message_start — no text - { type: 'message_start', message: {} }, - // content_block_start for text block - { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, - // text deltas - { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Hel' } }, - { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'lo' } }, - // content_block_stop for text block - { type: 'content_block_stop', index: 0 }, - // content_block_start for tool_use block - { type: 'content_block_start', index: 1, content_block: { type: 'tool_use', id: 'tu_1', name: 'search', input: {} } }, - // input_json delta (not text — should NOT trigger onTextDelta) - { type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '{"q":"x"}' } }, - { type: 'content_block_stop', index: 1 }, - // message_stop - { type: 'message_stop' }, -]; - -const fakeClient = { - messages: { - stream(_params) { - return makeFakeStream({ events: FAKE_EVENTS, finalMsg: FAKE_FINAL_MESSAGE }); - }, - }, -}; - -describe('makeCallModel', () => { - it('streams text deltas and returns stable shape', async () => { - const callModel = makeCallModel({ client: fakeClient, model: 'm', maxTokens: 512 }); - - const deltas = []; - const out = await callModel({ - system: 'You are helpful.', - messages: [{ role: 'user', content: 'Hi' }], - tools: [], - onTextDelta: (chunk) => deltas.push(chunk), - }); - - // Streamed deltas must concatenate to 'Hello' - expect(deltas.join('')).toBe('Hello'); - - // Stable return shape - expect(out.text).toBe('Hello'); - expect(out.toolUses).toHaveLength(1); - expect(out.toolUses[0]).toMatchObject({ id: 'tu_1', name: 'search' }); - expect(out.toolUses[0].input).toEqual({ q: 'x' }); - expect(out.stopReason).toBe('tool_use'); - expect(out.usage).toMatchObject({ input_tokens: 10, output_tokens: 5 }); - }); - - it('works without onTextDelta callback', async () => { - const callModel = makeCallModel({ client: fakeClient, model: 'm' }); - const out = await callModel({ - messages: [{ role: 'user', content: 'Hi' }], - }); - expect(out.text).toBe('Hello'); - expect(out.stopReason).toBe('tool_use'); - }); - - it('returns empty toolUses when no tool_use blocks', async () => { - const textOnlyMsg = { - content: [{ type: 'text', text: 'Just text', citations: null }], - stop_reason: 'end_turn', - usage: { input_tokens: 5, output_tokens: 3 }, - }; - const textOnlyClient = { - messages: { - stream: () => makeFakeStream({ events: [], finalMsg: textOnlyMsg }), - }, - }; - const callModel = makeCallModel({ client: textOnlyClient, model: 'm' }); - const out = await callModel({ messages: [{ role: 'user', content: 'Hi' }] }); - expect(out.text).toBe('Just text'); - expect(out.toolUses).toEqual([]); - expect(out.stopReason).toBe('end_turn'); - }); -});