From 8480c34db8eac6097f5e81ab4474d018f7c4c236 Mon Sep 17 00:00:00 2001 From: Dan Farrelly Date: Thu, 5 Mar 2026 14:26:37 -0500 Subject: [PATCH 1/6] Replace @inngest/ai adapter system with Vercel AI SDK Swap out the custom @inngest/ai abstraction layer and per-provider adapters (OpenAI, Anthropic, Gemini, Azure, Grok) in favor of the Vercel AI SDK's `generateText()` and `LanguageModelV1` interface. Users now pass AI SDK model instances directly instead of using re-exported factory functions. - Add `ai` package as dependency; remove `@inngest/ai` - Rewrite AgenticModel to use generateText() + step.run() instead of step.ai.infer() + raw HTTP calls - Add src/converters.ts for Message[] <-> CoreMessage[] bridging - Replace AiAdapter.Any with LanguageModelV1 across agent.ts and network.ts - Delete src/adapters/ directory and src/models.ts - Update tests to use mock LanguageModelV1 instead of fetch mocks BREAKING CHANGE: Model configuration now requires Vercel AI SDK provider packages (e.g. @ai-sdk/openai) instead of @inngest/ai factory functions. --- packages/agent-kit/package.json | 6 +- packages/agent-kit/pnpm-lock.yaml | 179 ++++++++++- .../src/__tests__/routing-with-done.test.ts | 184 +++++------ packages/agent-kit/src/adapters/README.md | 4 - packages/agent-kit/src/adapters/anthropic.ts | 201 ------------ .../agent-kit/src/adapters/azure-openai.ts | 24 -- .../agent-kit/src/adapters/gemini.test.ts | 200 ------------ packages/agent-kit/src/adapters/gemini.ts | 289 ------------------ packages/agent-kit/src/adapters/grok.ts | 48 --- packages/agent-kit/src/adapters/index.ts | 37 --- packages/agent-kit/src/adapters/openai.ts | 217 ------------- packages/agent-kit/src/agent.ts | 16 +- packages/agent-kit/src/converters.ts | 174 +++++++++++ packages/agent-kit/src/index.ts | 3 +- packages/agent-kit/src/model.ts | 134 +++----- packages/agent-kit/src/models.ts | 1 - packages/agent-kit/src/network.test.ts | 81 +++-- packages/agent-kit/src/network.ts | 6 +- 18 files changed, 542 insertions(+), 1262 deletions(-) delete mode 100644 packages/agent-kit/src/adapters/README.md delete mode 100644 packages/agent-kit/src/adapters/anthropic.ts delete mode 100644 packages/agent-kit/src/adapters/azure-openai.ts delete mode 100644 packages/agent-kit/src/adapters/gemini.test.ts delete mode 100644 packages/agent-kit/src/adapters/gemini.ts delete mode 100644 packages/agent-kit/src/adapters/grok.ts delete mode 100644 packages/agent-kit/src/adapters/index.ts delete mode 100644 packages/agent-kit/src/adapters/openai.ts create mode 100644 packages/agent-kit/src/converters.ts delete mode 100644 packages/agent-kit/src/models.ts diff --git a/packages/agent-kit/package.json b/packages/agent-kit/package.json index ab9052c9..d2a4e5e3 100644 --- a/packages/agent-kit/package.json +++ b/packages/agent-kit/package.json @@ -1,6 +1,6 @@ { "name": "@inngest/agent-kit", - "version": "0.13.2", + "version": "0.13.3-alpha.1", "description": "AgentKit is a framework for creating and orchestrating AI agents and AI workflows", "main": "dist/index.js", "type": "module", @@ -48,7 +48,7 @@ }, "dependencies": { "@dmitryrechkin/json-schema-to-zod": "^1.0.0", - "@inngest/ai": "0.1.6", + "ai": "^4.0.0", "@modelcontextprotocol/sdk": "^1.11.2", "eventsource": "^3.0.2", "express": "^4.21.1", @@ -60,6 +60,8 @@ }, "packageManager": "pnpm@9.14.2", "devDependencies": { + "@ai-sdk/openai": "^1.0.0", + "@ai-sdk/provider": "^1.0.0", "@types/express": "^5.0.0", "@types/node": "^22.9.1", "@types/xxhashjs": "^0.2.4", diff --git a/packages/agent-kit/pnpm-lock.yaml b/packages/agent-kit/pnpm-lock.yaml index d65da428..cb6e2533 100644 --- a/packages/agent-kit/pnpm-lock.yaml +++ b/packages/agent-kit/pnpm-lock.yaml @@ -22,12 +22,12 @@ importers: '@dmitryrechkin/json-schema-to-zod': specifier: ^1.0.0 version: 1.0.1 - '@inngest/ai': - specifier: 0.1.6 - version: 0.1.6 '@modelcontextprotocol/sdk': specifier: ^1.11.2 version: 1.18.2 + ai: + specifier: ^4.0.0 + version: 4.3.19(react@19.2.4)(zod@4.1.11) eventsource: specifier: ^3.0.2 version: 3.0.7 @@ -38,6 +38,12 @@ importers: specifier: ^0.2.2 version: 0.2.2 devDependencies: + '@ai-sdk/openai': + specifier: ^1.0.0 + version: 1.3.24(zod@4.1.11) + '@ai-sdk/provider': + specifier: ^1.0.0 + version: 1.1.3 '@types/express': specifier: ^5.0.0 version: 5.0.3 @@ -77,6 +83,38 @@ importers: packages: + '@ai-sdk/openai@1.3.24': + resolution: {integrity: sha512-GYXnGJTHRTZc4gJMSmFRgEQudjqd4PUN0ZjQhPwOAYH1yOAvQoG/Ikqs+HyISRbLPCrhbZnPKCNHuRU4OfpW0Q==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.0.0 + + '@ai-sdk/provider-utils@2.2.8': + resolution: {integrity: sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.23.8 + + '@ai-sdk/provider@1.1.3': + resolution: {integrity: sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==} + engines: {node: '>=18'} + + '@ai-sdk/react@1.2.12': + resolution: {integrity: sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g==} + engines: {node: '>=18'} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + zod: ^3.23.8 + peerDependenciesMeta: + zod: + optional: true + + '@ai-sdk/ui-utils@1.2.11': + resolution: {integrity: sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.23.8 + '@bufbuild/protobuf@2.9.0': resolution: {integrity: sha512-rnJenoStJ8nvmt9Gzye8nkYd6V22xUAnu4086ER7h1zJ508vStko4pMvDeQ446ilDTFpV5wnoc5YS7XvMwwMqA==} @@ -977,6 +1015,9 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/diff-match-patch@1.0.36': + resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1153,6 +1194,16 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ai@4.3.19: + resolution: {integrity: sha512-dIE2bfNpqHN3r6IINp9znguYdhIOheKW2LDigAMrgt/upT3B8eBGPSCblENvaZGoq+hxaN9fSMzjWpbqloP+7Q==} + engines: {node: '>=18'} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + zod: ^3.23.8 + peerDependenciesMeta: + react: + optional: true + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -1260,6 +1311,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + check-error@2.1.1: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} @@ -1369,10 +1424,17 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + destroy@1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + diff-match-patch@1.0.5: + resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} + diff@4.0.2: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} @@ -1798,12 +1860,20 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + jsondiffpatch@0.6.0: + resolution: {integrity: sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2147,6 +2217,10 @@ packages: resolution: {integrity: sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==} engines: {node: '>= 0.10'} + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -2198,6 +2272,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + secure-json-parse@2.7.0: + resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} + semver@7.7.2: resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} engines: {node: '>=10'} @@ -2328,6 +2405,11 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + swr@2.4.1: + resolution: {integrity: sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==} + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + temporal-polyfill@0.2.5: resolution: {integrity: sha512-ye47xp8Cb0nDguAhrrDS1JT1SzwEV9e26sSsrWzVu+yPZ7LzceEcH0i2gci9jWfOfSCCgM3Qv5nOYShVUUFUXA==} @@ -2341,6 +2423,10 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + throttleit@2.1.0: + resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} + engines: {node: '>=18'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -2467,6 +2553,11 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + utils-merge@1.0.1: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} @@ -2629,6 +2720,40 @@ packages: snapshots: + '@ai-sdk/openai@1.3.24(zod@4.1.11)': + dependencies: + '@ai-sdk/provider': 1.1.3 + '@ai-sdk/provider-utils': 2.2.8(zod@4.1.11) + zod: 4.1.11 + + '@ai-sdk/provider-utils@2.2.8(zod@4.1.11)': + dependencies: + '@ai-sdk/provider': 1.1.3 + nanoid: 3.3.11 + secure-json-parse: 2.7.0 + zod: 4.1.11 + + '@ai-sdk/provider@1.1.3': + dependencies: + json-schema: 0.4.0 + + '@ai-sdk/react@1.2.12(react@19.2.4)(zod@4.1.11)': + dependencies: + '@ai-sdk/provider-utils': 2.2.8(zod@4.1.11) + '@ai-sdk/ui-utils': 1.2.11(zod@4.1.11) + react: 19.2.4 + swr: 2.4.1(react@19.2.4) + throttleit: 2.1.0 + optionalDependencies: + zod: 4.1.11 + + '@ai-sdk/ui-utils@1.2.11(zod@4.1.11)': + dependencies: + '@ai-sdk/provider': 1.1.3 + '@ai-sdk/provider-utils': 2.2.8(zod@4.1.11) + zod: 4.1.11 + zod-to-json-schema: 3.24.6(zod@4.1.11) + '@bufbuild/protobuf@2.9.0': {} '@cspotcode/source-map-support@0.8.1': @@ -3643,6 +3768,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/diff-match-patch@1.0.36': {} + '@types/estree@1.0.8': {} '@types/express-serve-static-core@5.0.7': @@ -3872,6 +3999,18 @@ snapshots: agent-base@7.1.4: {} + ai@4.3.19(react@19.2.4)(zod@4.1.11): + dependencies: + '@ai-sdk/provider': 1.1.3 + '@ai-sdk/provider-utils': 2.2.8(zod@4.1.11) + '@ai-sdk/react': 1.2.12(react@19.2.4)(zod@4.1.11) + '@ai-sdk/ui-utils': 1.2.11(zod@4.1.11) + '@opentelemetry/api': 1.9.0 + jsondiffpatch: 0.6.0 + zod: 4.1.11 + optionalDependencies: + react: 19.2.4 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -3987,6 +4126,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.6.2: {} + check-error@2.1.1: {} chokidar@3.6.0: @@ -4080,8 +4221,12 @@ snapshots: depd@2.0.0: {} + dequal@2.0.3: {} + destroy@1.2.0: {} + diff-match-patch@1.0.5: {} + diff@4.0.2: {} dunder-proto@1.0.1: @@ -4598,10 +4743,18 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json-stringify-safe@5.0.1: {} + jsondiffpatch@0.6.0: + dependencies: + '@types/diff-match-patch': 1.0.36 + chalk: 5.6.2 + diff-match-patch: 1.0.5 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -4890,6 +5043,8 @@ snapshots: iconv-lite: 0.7.0 unpipe: 1.0.0 + react@19.2.4: {} + readdirp@3.6.0: dependencies: picomatch: 2.3.1 @@ -4964,6 +5119,8 @@ snapshots: safer-buffer@2.1.2: {} + secure-json-parse@2.7.0: {} + semver@7.7.2: {} send@0.19.0: @@ -5126,6 +5283,12 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + swr@2.4.1(react@19.2.4): + dependencies: + dequal: 2.0.3 + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + temporal-polyfill@0.2.5: dependencies: temporal-spec: 0.2.4 @@ -5140,6 +5303,8 @@ snapshots: dependencies: any-promise: 1.3.0 + throttleit@2.1.0: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -5263,6 +5428,10 @@ snapshots: dependencies: punycode: 2.3.1 + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + utils-merge@1.0.1: {} uuid@9.0.1: {} @@ -5410,6 +5579,10 @@ snapshots: dependencies: zod: 3.25.76 + zod-to-json-schema@3.24.6(zod@4.1.11): + dependencies: + zod: 4.1.11 + zod@3.25.76: {} zod@4.1.11: {} diff --git a/packages/agent-kit/src/__tests__/routing-with-done.test.ts b/packages/agent-kit/src/__tests__/routing-with-done.test.ts index 56461815..2699d8f5 100644 --- a/packages/agent-kit/src/__tests__/routing-with-done.test.ts +++ b/packages/agent-kit/src/__tests__/routing-with-done.test.ts @@ -1,84 +1,101 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect } from "vitest"; import { createAgent, createRoutingAgent, createNetwork, createTool, - openai, } from "../index"; import { z } from "zod"; - -// Mock the global fetch function to avoid real API calls -beforeEach(() => { - vi.spyOn(global, "fetch").mockImplementation((url, options) => { - const tool_calls: Array<{ - id: string; - type: string; - function: { - name: string; - arguments: string; +import type { LanguageModelV1 } from "ai"; + +/** + * Create a mock LanguageModelV1 for testing. + * By default returns a text response. Can return tool calls. + */ +function createMockModel(opts?: { + text?: string; + toolCalls?: Array<{ + toolCallId: string; + toolName: string; + args: unknown; + }>; + /** If provided, this function is called with the prompt to decide the response dynamically. */ + handler?: (prompt: unknown) => { + text?: string; + toolCalls?: Array<{ + toolCallType: "function"; + toolCallId: string; + toolName: string; + args: string; + }>; + finishReason: "stop" | "tool-calls"; + }; +}): LanguageModelV1 { + return { + specificationVersion: "v1", + provider: "mock", + modelId: "mock-model", + defaultObjectGenerationMode: "json", + doGenerate: async (options) => { + if (opts?.handler) { + const result = opts.handler(options.prompt); + return { + text: result.text ?? "", + toolCalls: result.toolCalls ?? [], + finishReason: result.finishReason, + usage: { promptTokens: 0, completionTokens: 0 }, + rawCall: { rawPrompt: null, rawSettings: {} }, + }; + } + + const toolCalls = (opts?.toolCalls ?? []).map((tc) => ({ + toolCallType: "function" as const, + toolCallId: tc.toolCallId, + toolName: tc.toolName, + args: JSON.stringify(tc.args), + })); + + return { + text: opts?.text ?? (toolCalls.length === 0 ? "Mocked response" : ""), + toolCalls, + finishReason: + toolCalls.length > 0 + ? ("tool-calls" as const) + : ("stop" as const), + usage: { promptTokens: 0, completionTokens: 0 }, + rawCall: { rawPrompt: null, rawSettings: {} }, }; - }> = []; - const bodyString = - typeof options?.body === "string" - ? options.body - : options?.body - ? JSON.stringify(options.body) - : ""; - // Default to calling the 'done' tool for routing agents - if (bodyString.includes("select_agent")) { - tool_calls.push({ - id: "call_123", - type: "function", - function: { - name: "done", - arguments: JSON.stringify({ summary: "Task is complete" }), - }, - }); - } - - return Promise.resolve({ - ok: true, - json: () => - Promise.resolve({ - choices: [ - { - message: { - role: "assistant", - content: tool_calls.length === 0 ? "Mocked response" : null, - tool_calls: tool_calls, - }, - finish_reason: tool_calls.length > 0 ? "tool_calls" : "stop", - }, - ], - }), - } as Response); - }); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); + }, + doStream: async () => { + throw new Error("Not implemented"); + }, + }; +} describe("Routing with Done Tool", () => { it("should exit network when done tool is called", async () => { - // Create a simple test agent + // Mock model that always calls the "done" tool + const mockModel = createMockModel({ + toolCalls: [ + { + toolCallId: "call_123", + toolName: "done", + args: { summary: "Task is complete" }, + }, + ], + }); + const testAgent = createAgent({ name: "Test Agent", description: "A test agent", system: "You are a test agent. Always respond with 'Task completed'.", - model: openai({ - model: "gpt-3.5-turbo", - apiKey: "test-key", - }), + model: mockModel, }); - // Create a routing agent that immediately calls done const routerThatExits = createRoutingAgent({ name: "Exit Router", description: "Always exits immediately", tools: [ - // It needs the 'done' tool to be able to be called createTool({ name: "done", parameters: z.object({ summary: z.string() }), @@ -87,52 +104,43 @@ describe("Routing with Done Tool", () => { ], lifecycle: { onRoute: ({ result }) => { - // Check if the 'done' tool was called by the mocked response if (result.toolCalls[0]?.tool.name === "done") { - return undefined; // Exit immediately + return undefined; } - // Fallback for other scenarios return undefined; }, }, system: "Exit immediately", }); - // Create network with the router const network = createNetwork({ name: "Test Network", agents: [testAgent], router: routerThatExits, - defaultModel: testAgent.model, + defaultModel: mockModel, }); - // Run the network - it should exit immediately without calling any agents const result = await network.run("Test input"); - // The network should have no results since the router exited immediately expect(result.state.results.length).toBe(0); }); it("should route to agent then exit with done tool", async () => { let routeCount = 0; - // Create a test agent + const mockModel = createMockModel(); + const testAgent = createAgent({ name: "Worker", description: "Does work", system: "You complete tasks", - model: openai({ - model: "gpt-3.5-turbo", - apiKey: "test-key", - }), + model: mockModel, }); - // Create a router that routes once then exits const routeOnceThenExit = createRoutingAgent({ name: "Route Once Router", description: "Routes once then exits", tools: [ - // It needs the 'done' tool to be able to be called createTool({ name: "done", parameters: z.object({ summary: z.string() }), @@ -143,12 +151,10 @@ describe("Routing with Done Tool", () => { onRoute: () => { routeCount++; - // First call: route to agent if (routeCount === 1) { return ["Worker"]; } - // Second call: exit (simulating done tool) return undefined; }, }, @@ -159,16 +165,25 @@ describe("Routing with Done Tool", () => { name: "Test Network", agents: [testAgent], router: routeOnceThenExit, - defaultModel: testAgent.model, + defaultModel: mockModel, }); await network.run("Do some work"); - // Should have routed exactly twice: once to the agent, once to exit. expect(routeCount).toBe(2); }); it("should handle custom done tool properly", async () => { + const mockModel = createMockModel({ + toolCalls: [ + { + toolCallId: "call_456", + toolName: "done", + args: { summary: "Task is complete" }, + }, + ], + }); + const customRouter = createRoutingAgent({ name: "Custom Done Router", description: "Uses custom done tool", @@ -184,7 +199,6 @@ describe("Routing with Done Tool", () => { return `Completed: ${message}`; }, }), - // Also needs the default 'done' tool for the mock to work createTool({ name: "done", parameters: z.object({ summary: z.string() }), @@ -196,21 +210,16 @@ describe("Routing with Done Tool", () => { onRoute: ({ result }) => { const tool = result.toolCalls[0]; - // If complete_task was called, exit if (tool?.tool.name === "complete_task") { return undefined; } - // Otherwise continue (though in this test we always exit) return undefined; }, }, system: "Always call complete_task tool", - model: openai({ - model: "gpt-3.5-turbo", - apiKey: "test-key", - }), + model: mockModel, }); const network = createNetwork({ @@ -219,16 +228,15 @@ describe("Routing with Done Tool", () => { createAgent({ name: "dummy", system: "dummy", - model: openai({ model: "gpt-3.5-turbo" }), + model: mockModel, }), ], router: customRouter, - defaultModel: customRouter.model, + defaultModel: mockModel, }); const result = await network.run("Complete this"); - // Network should exit after router runs expect(result).toBeDefined(); }); }); diff --git a/packages/agent-kit/src/adapters/README.md b/packages/agent-kit/src/adapters/README.md deleted file mode 100644 index d0e90c53..00000000 --- a/packages/agent-kit/src/adapters/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# Adapters - -These are adapters for I/O formats to allow transforming to/from internal -network messages, normalizing communication between models and providers. diff --git a/packages/agent-kit/src/adapters/anthropic.ts b/packages/agent-kit/src/adapters/anthropic.ts deleted file mode 100644 index 588f3b16..00000000 --- a/packages/agent-kit/src/adapters/anthropic.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * Adapters for Anthropic I/O to transform to/from internal network messages. - * - * @module - */ -import { - type AiAdapter, - type Anthropic, - type AnthropicAiAdapter, -} from "@inngest/ai"; -import { z } from "zod"; -import { type AgenticModel } from "../model"; -import { type Message, type TextMessage } from "../types"; -import { type Tool } from "../tool"; - -/** - * Parse a request from internal network messages to an Anthropic input. - */ -export const requestParser: AgenticModel.RequestParser = ( - model, - messages, - tools, - tool_choice = "auto" -) => { - // Note that Anthropic has a top-level system prompt, then a series of prompts - // for assistants and users. - const systemMessage = messages.find( - (m: Message) => m.role === "system" && m.type === "text" - ) as TextMessage; - const system = - typeof systemMessage?.content === "string" ? systemMessage.content : ""; - - const anthropicMessages: AiAdapter.Input["messages"] = - messages - .filter((m: Message) => m.role !== "system") - .reduce( - ( - acc: AiAdapter.Input["messages"], - m: Message - ): AiAdapter.Input["messages"] => { - switch (m.type) { - case "text": - return [ - ...acc, - { - role: m.role, - content: Array.isArray(m.content) - ? m.content.map((text) => ({ type: "text", text })) - : m.content, - }, - ] as AiAdapter.Input["messages"]; - case "tool_call": - return [ - ...acc, - { - role: m.role, - content: m.tools.map((tool) => ({ - type: "tool_use", - id: tool.id, - input: tool.input, - name: tool.name, - })), - }, - ]; - case "tool_result": - return [ - ...acc, - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: m.tool.id, - content: - typeof m.content === "string" - ? m.content - : JSON.stringify(m.content), - }, - ], - }, - ]; - } - }, - [] as AiAdapter.Input["messages"] - ); - - // We need to patch the last message if it's an assistant message. This is a known limitation of Anthropic's API. - // cf: https://github.com/langchain-ai/langgraph/discussions/952#discussioncomment-10012320 - const lastMessage = anthropicMessages[anthropicMessages.length - 1]; - if (lastMessage?.role === "assistant") { - lastMessage.role = "user"; - } - - const request: AiAdapter.Input = { - system, - model: model.options.model, - max_tokens: model.options.defaultParameters.max_tokens, - messages: anthropicMessages, - }; - - if (tools?.length) { - request.tools = tools.map((t: Tool.Any) => { - return { - name: t.name, - description: t.description, - input_schema: (t.parameters - ? z.toJSONSchema(t.parameters, { - target: "draft-2020-12", - }) - : z.toJSONSchema(z.object({}), { - target: "draft-2020-12", - })) as AnthropicAiAdapter.Tool.InputSchema, - }; - }); - request.tool_choice = toolChoice(tool_choice); - } - - return request; -}; - -/** - * Parse a response from Anthropic output to internal network messages. - */ -export const responseParser: AgenticModel.ResponseParser = ( - input -) => { - if (input.type === "error") { - throw new Error( - input.error?.message || - `Anthropic request failed: ${JSON.stringify(input.error)}` - ); - } - - return (input?.content ?? []).reduce((acc, item) => { - if (!item.type) { - return acc; - } - - switch (item.type) { - case "text": - return [ - ...acc, - { - type: "text", - role: input.role, - content: item.text, - // XXX: Better stop reason parsing - stop_reason: "stop", - }, - ]; - case "tool_use": { - let args; - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - args = - typeof item.input === "string" - ? JSON.parse(item.input) - : item.input; - } catch { - args = item.input; - } - - return [ - ...acc, - { - type: "tool_call", - role: input.role, - stop_reason: "tool", - tools: [ - { - type: "tool", - id: item.id, - name: item.name, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - input: args, - }, - ], - }, - ]; - } - } - }, []); -}; - -const toolChoice = ( - choice: Tool.Choice -): AiAdapter.Input["tool_choice"] => { - switch (choice) { - case "auto": - return { type: "auto" }; - case "any": - return { type: "any" }; - default: - if (typeof choice === "string") { - return { - type: "tool", - name: choice as string, - }; - } - } -}; diff --git a/packages/agent-kit/src/adapters/azure-openai.ts b/packages/agent-kit/src/adapters/azure-openai.ts deleted file mode 100644 index ce337c7b..00000000 --- a/packages/agent-kit/src/adapters/azure-openai.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { type AiAdapter, type AzureOpenAi, type OpenAi } from "@inngest/ai"; -import { type AgenticModel } from "../model"; -import { - requestParser as openaiRequestParser, - responseParser as openaiResponseParser, -} from "./openai"; - -export const requestParser: AgenticModel.RequestParser = ( - model, - messages, - tools, - tool_choice = "auto" -) => - openaiRequestParser( - model as unknown as OpenAi.AiModel, - messages, - tools, - tool_choice - ); - -export const responseParser: AgenticModel.ResponseParser< - AzureOpenAi.AiModel -> = (output) => - openaiResponseParser(output as unknown as AiAdapter.Output); diff --git a/packages/agent-kit/src/adapters/gemini.test.ts b/packages/agent-kit/src/adapters/gemini.test.ts deleted file mode 100644 index e90ee215..00000000 --- a/packages/agent-kit/src/adapters/gemini.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { recursiveGeminiZodToJsonSchema } from "./gemini"; - -// Utility to deep-clone objects without preserving references -const clone = (obj: T): T => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const cloned: T = JSON.parse(JSON.stringify(obj)); - return cloned; -}; - -describe("recursiveGeminiZodToJsonSchema", () => { - test("should remove additionalProperties when truthy at the top level", () => { - const input = { - type: "object", - properties: { name: { type: "string" } }, - additionalProperties: true, - required: ["name"], - }; - const expected = { - type: "object", - properties: { name: { type: "string" } }, - required: ["name"], - }; - - expect(recursiveGeminiZodToJsonSchema(input)).toEqual(expected); - }); - - test("should remove additionalProperties when truthy from nested objects", () => { - const input = { - type: "object", - properties: { - user: { - type: "object", - properties: { id: { type: "number" } }, - additionalProperties: true, - }, - isActive: { type: "boolean" }, - }, - additionalProperties: true, - }; - const expected = { - type: "object", - properties: { - user: { - type: "object", - properties: { id: { type: "number" } }, - }, - isActive: { type: "boolean" }, - }, - }; - - expect(recursiveGeminiZodToJsonSchema(input)).toEqual(expected); - }); - - test("should remove additionalProperties from objects within an array when truthy", () => { - const input = { - type: "array", - items: [ - { - type: "object", - properties: { sku: { type: "string" } }, - additionalProperties: true, - }, - { type: "number" }, - { - type: "object", - properties: { count: { type: "integer" } }, - additionalProperties: true, - }, - "a string", - null, - undefined, - ], - additionalProperties: "foo", - }; - const expected = { - type: "array", - items: [ - { - type: "object", - properties: { sku: { type: "string" } }, - }, - { type: "number" }, - { - type: "object", - properties: { count: { type: "integer" } }, - }, - "a string", - null, - undefined, - ], - }; - - expect(recursiveGeminiZodToJsonSchema(input)).toEqual(expected); - }); - - test("should handle deeply nested objects and arrays", () => { - const input = { - level1: { - additionalProperties: true, - level2: { - prop: "value", - additionalProperties: true, - level3Array: [ - { item: 1, additionalProperties: true }, - { item: 2, otherProp: "data" }, - { item: 3, level4: { final: true, additionalProperties: true } }, - "stringInNestedArray", - ], - }, - }, - }; - const expected = { - level1: { - level2: { - prop: "value", - level3Array: [ - { item: 1 }, - { item: 2, otherProp: "data" }, - { item: 3, level4: { final: true } }, - "stringInNestedArray", - ], - }, - }, - }; - - expect(recursiveGeminiZodToJsonSchema(input)).toEqual(expected); - }); - - test("should return the object unchanged if no additionalProperties exist", () => { - const input = { - type: "object", - properties: { - name: { type: "string" }, - details: { - type: "object", - properties: { age: { type: "number" } }, - }, - }, - required: ["name"], - }; - const inputClone = clone(input); - - expect(recursiveGeminiZodToJsonSchema(input)).toEqual(inputClone); - }); - - test("should handle empty objects correctly", () => { - const input = {}; - const expected = {}; - expect(recursiveGeminiZodToJsonSchema(input)).toEqual(expected); - }); - - test("should handle objects with null or undefined values correctly", () => { - const input = { - prop1: null, - prop2: undefined, - prop3: { - nested: null, - additionalProperties: true, - }, - prop4: [null, undefined, { item: 1, additionalProperties: true }], - }; - const expected = { - prop1: null, - prop2: undefined, - prop3: { - nested: null, - }, - prop4: [null, undefined, { item: 1 }], - }; - expect(recursiveGeminiZodToJsonSchema(input)).toEqual(expected); - }); - - test("should handle top-level arrays", () => { - const input = [ - { foo: 1, additionalProperties: true }, - { bar: 2, additionalProperties: false }, - 3, - null, - undefined, - "string", - ]; - const expected = [{ foo: 1 }, { bar: 2 }, 3, null, undefined, "string"]; - expect(recursiveGeminiZodToJsonSchema(input)).toEqual(expected); - }); - - test("should not modify the original input object", () => { - const input = { - type: "object", - properties: { name: { type: "string" } }, - additionalProperties: true, - nested: { prop: "value", additionalProperties: true }, - arr: [{ inner: true, additionalProperties: false }], - }; - const inputClone = clone(input); - - recursiveGeminiZodToJsonSchema(input); - expect(input).toEqual(inputClone); - }); -}); diff --git a/packages/agent-kit/src/adapters/gemini.ts b/packages/agent-kit/src/adapters/gemini.ts deleted file mode 100644 index 83f6b691..00000000 --- a/packages/agent-kit/src/adapters/gemini.ts +++ /dev/null @@ -1,289 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -/** - * Adapters for Gemini I/O to transform to/from internal network messages. - * - * @module - */ -import { type AiAdapter, type Gemini } from "@inngest/ai"; -import { z, type ZodSchema } from "zod"; - -import { type AgenticModel } from "../model"; -import type { Message, TextContent } from "../types"; -import { type Tool } from "../tool"; - -/** - * Parse a request from internal network messages to an Gemini input. - */ -export const requestParser: AgenticModel.RequestParser = ( - _model, - messages, - tools, - tool_choice = "auto" -) => { - const contents = messages.map((m: Message) => messageToContent(m)); - - const functionDeclarations = tools.map((t: Tool.Any) => ({ - name: t.name, - description: t.description, - parameters: t.parameters - ? geminiZodToJsonSchema(t.parameters) - : // eslint-disable-next-line @typescript-eslint/no-explicit-any - (geminiZodToJsonSchema(z.object({})) as any), - })); - - return { - contents, - ...(tools.length > 0 - ? { - tools: [ - { - functionDeclarations, - }, - ], - tool_config: toolChoice(tool_choice), - } - : {}), - }; -}; - -const messageContentToString = (content: string | TextContent[]): string => { - if (typeof content === "string") { - return content; - } - return content.map((c) => c.text).join(""); -}; - -/** - * Parse a response from Gemini output to internal network messages. - */ -export const responseParser: AgenticModel.ResponseParser = ( - input -) => { - if (input.error) { - throw new Error( - input.error?.message || - `Gemini request failed: ${JSON.stringify(input.error)}` - ); - } - - const messages: Message[] = []; - - for (const candidate of input.candidates ?? []) { - if ((candidate.finishReason as string) === "MALFORMED_FUNCTION_CALL") { - console.warn( - "Gemini returned MALFORMED_FUNCTION_CALL, skipping this candidate. This typically indicates an issue with tool/function call formatting. Check your tool definitions and parameters." - ); - continue; // Skip this candidate but continue processing others - } - if (!candidate.content?.parts) { - continue; // Skip candidates without parts - } - for (const content of candidate.content.parts) { - // user text - if (candidate.content.role === "user" && "text" in content) { - messages.push({ - role: "user", - type: "text", - content: content.text, - }); - } - // assistant text - else if (candidate.content.role === "model" && "text" in content) { - messages.push({ - role: "assistant", - type: "text", - content: content.text, - }); - } - // tool call - else if ( - candidate.content.role === "model" && - "functionCall" in content - ) { - messages.push({ - role: "assistant", - type: "tool_call", - stop_reason: "tool", - tools: [ - { - name: content.functionCall.name, - input: content.functionCall.args, - type: "tool", - id: content.functionCall.name, - }, - ], - }); - } - // tool result - else if ( - candidate.content.role === "user" && - "functionResponse" in content - ) { - messages.push({ - role: "tool_result", - type: "tool_result", - stop_reason: "tool", - tool: { - name: content.functionResponse.name, - input: content.functionResponse.response, - type: "tool", - id: content.functionResponse.name, - }, - content: JSON.stringify(content.functionResponse.response), - }); - } else { - throw new Error("Unknown content type"); - } - } - } - - return messages; -}; - -const messageToContent = ( - m: Message -): AiAdapter.Input["contents"][0] => { - switch (m.role) { - case "system": - return { - role: "user", - parts: [{ text: messageContentToString(m.content) }], - }; - case "user": - switch (m.type) { - case "tool_call": - if (m.tools.length === 0) { - throw new Error("Tool call message must have at least one tool"); - } - // Note: multiple tools is only supported over WS (Compositional function calling) - return { - role: "model", - parts: [ - { - functionCall: { - name: m.tools[0]!.name, - args: m.tools[0]!.input, - }, - }, - ], - }; - case "text": - default: - return { - role: "user", - parts: [{ text: messageContentToString(m.content) }], - }; - } - case "assistant": - switch (m.type) { - case "tool_call": - if (m.tools.length === 0) { - throw new Error("Tool call message must have at least one tool"); - } - // Note: multiple tools is only supported over WS (Compositional function calling) - return { - role: "model", - parts: [ - { - functionCall: { - name: m.tools[0]!.name, - args: m.tools[0]!.input, - }, - }, - ], - }; - case "text": - default: - return { - role: "model", - parts: [{ text: messageContentToString(m.content) }], - }; - } - case "tool_result": - return { - role: "user", - parts: [ - { - functionResponse: { - name: m.tool.name, - response: { - name: m.tool.name, - content: - typeof m.content === "string" - ? m.content - : JSON.stringify(m.content), - }, - }, - }, - ], - }; - default: - // eslint-disable-next-line @typescript-eslint/no-explicit-any - throw new Error(`Unknown message role: ${(m as any).role}`); - } -}; - -const toolChoice = ( - choice: Tool.Choice -): AiAdapter.Input["toolConfig"] => { - switch (choice) { - case "auto": - return { - functionCallingConfig: { - mode: "AUTO", - }, - }; - case "any": - return { - functionCallingConfig: { - mode: "ANY", - }, - }; - default: - if (typeof choice === "string") { - return { - functionCallingConfig: { - mode: "ANY", - allowedFunctionNames: [choice], - }, - }; - } - } -}; - -type Removed = T extends object - ? { [K in Exclude]: Removed } - : T; - -/** - * Recursively remove `additionalProperties` from Zod schema objects. - */ -export const recursiveGeminiZodToJsonSchema = (obj: T): Removed => { - if (obj === null || obj === undefined || typeof obj !== "object") { - return obj as Removed; - } - - if (Array.isArray(obj)) { - return obj.map(recursiveGeminiZodToJsonSchema) as unknown as Removed; - } - const newObj: T = { ...obj }; // Create a shallow copy for the current level - - for (const key in newObj) { - if (newObj[key] != null) { - newObj[key] = recursiveGeminiZodToJsonSchema( - newObj[key] - ) as T[typeof key]; - } - } - if (newObj?.["additionalProperties" as keyof typeof newObj] != null) { - delete newObj["additionalProperties" as keyof typeof newObj]; - } - return newObj as Removed; -}; - -const geminiZodToJsonSchema = (schemaIn: ZodSchema) => { - let schema = z.toJSONSchema(schemaIn, { target: "openapi-3.0", io: "input" }); - schema = recursiveGeminiZodToJsonSchema(schema); - return schema; -}; diff --git a/packages/agent-kit/src/adapters/grok.ts b/packages/agent-kit/src/adapters/grok.ts deleted file mode 100644 index d4451aa0..00000000 --- a/packages/agent-kit/src/adapters/grok.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Adapters for Grok I/O to transform to/from internal network messages. - * Grok is an exotic one, it is an OpenAI-compatible API, - * but does not support strict mode Function Calling, requiring an adapter. - * - * @module - */ - -import type { AiAdapter, Grok, OpenAi } from "inngest"; -import type { AgenticModel } from "../model"; -import { - requestParser as openaiRequestParser, - responseParser as openaiResponseParser, -} from "./openai"; - -/** - * Parse a request from internal network messages to an OpenAI input. - */ -export const requestParser: AgenticModel.RequestParser = ( - model, - messages, - tools, - tool_choice = "auto" -) => { - const request: AiAdapter.Input = openaiRequestParser( - model as unknown as OpenAi.AiModel, - messages, - tools, - tool_choice - ); - - // Grok does not support strict mode Function Calling, so we need to disable it - request.tools = (request.tools || []).map((tool) => ({ - ...tool, - function: { - ...tool.function, - strict: false, - }, - })); - - return request; -}; - -/** - * Parse a response from OpenAI output to internal network messages. - */ -export const responseParser: AgenticModel.ResponseParser = - openaiResponseParser as unknown as AgenticModel.ResponseParser; diff --git a/packages/agent-kit/src/adapters/index.ts b/packages/agent-kit/src/adapters/index.ts deleted file mode 100644 index 07ea9b77..00000000 --- a/packages/agent-kit/src/adapters/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { type AiAdapter, type AiAdapters } from "@inngest/ai"; -import { type AgenticModel } from "../model"; -import * as anthropic from "./anthropic"; -import * as openai from "./openai"; -import * as azureOpenai from "./azure-openai"; -import * as gemini from "./gemini"; -import * as grok from "./grok"; - -export type Adapters = { - [Format in AiAdapter.Format]: { - request: AgenticModel.RequestParser; - response: AgenticModel.ResponseParser; - }; -}; - -export const adapters: Adapters = { - "openai-chat": { - request: openai.requestParser, - response: openai.responseParser, - }, - "azure-openai": { - request: azureOpenai.requestParser, - response: azureOpenai.responseParser, - }, - anthropic: { - request: anthropic.requestParser, - response: anthropic.responseParser, - }, - gemini: { - request: gemini.requestParser, - response: gemini.responseParser, - }, - grok: { - request: grok.requestParser, - response: grok.responseParser, - }, -}; diff --git a/packages/agent-kit/src/adapters/openai.ts b/packages/agent-kit/src/adapters/openai.ts deleted file mode 100644 index b84e89a2..00000000 --- a/packages/agent-kit/src/adapters/openai.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * Adapters for OpenAI I/O to transform to/from internal network messages. - * - * @module - */ - -import { type AiAdapter, type OpenAi } from "@inngest/ai"; -import { z } from "zod"; -import { type AgenticModel } from "../model"; -import { - type Message, - type TextMessage, - type ToolCallMessage, - type ToolMessage, -} from "../types"; -import { type Tool } from "../tool"; -import { stringifyError } from "../util"; - -/** - * Parse a request from internal network messages to an OpenAI input. - */ -export const requestParser: AgenticModel.RequestParser = ( - model, - messages, - tools, - tool_choice = "auto" -) => { - const request: AiAdapter.Input = { - messages: messages.map((m: Message) => { - switch (m.type) { - case "text": - return { - role: m.role, - content: m.content, - }; - case "tool_call": - return { - role: "assistant", - content: null, - tool_calls: m.tools - ? m.tools?.map((tool) => ({ - id: tool.id, - type: "function", - function: { - name: tool.name, - arguments: JSON.stringify(tool.input), - }, - })) - : undefined, - }; - case "tool_result": - return { - role: "tool", - tool_call_id: m.tool.id, - content: - typeof m.content === "string" - ? m.content - : JSON.stringify(m.content), - }; - } - }) as AiAdapter.Input["messages"], - }; - - if (tools?.length) { - request.tool_choice = toolChoice(tool_choice); - // OpenAI o3 models have several issues with tool calling. - // one of them is not supporting the `parallel_tool_calls` parameter - // https://community.openai.com/t/o3-mini-api-with-tools-only-ever-returns-1-tool-no-matter-prompt/1112390/6 - if ( - !model.options.model?.includes("o3") && - !model.options.model?.includes("o1") - ) { - // it is recommended to disable parallel tool calls with structured output - // https://platform.openai.com/docs/guides/function-calling#parallel-function-calling-and-structured-outputs - request.parallel_tool_calls = false; - } - - request.tools = tools.map((t: Tool.Any) => { - return { - type: "function", - function: { - name: t.name, - description: t.description, - parameters: - t.parameters && z.toJSONSchema(t.parameters, { target: "draft-7" }), - strict: - typeof t.strict !== "undefined" ? t.strict : Boolean(t.parameters), // strict mode is only supported with parameters - }, - }; - }); - } - - return request; -}; - -/** - * Parse a response from OpenAI output to internal network messages. - * - * This function transforms OpenAI's response format into our internal Message format, - * handling both text responses and tool calls. It processes multiple choices if present - * and creates separate messages for text content and tool calls when both exist. - */ -export const responseParser: AgenticModel.ResponseParser = ( - input -) => { - // Handle API errors first - throw immediately if the request failed - if (input.error) { - throw new Error( - input.error.message || - `OpenAI request failed: ${JSON.stringify(input.error)}` - ); - } - - // Process all choices from the OpenAI response using reduce to flatten into a single Message array - // OpenAI can return multiple choices, though typically only one is returned - return (input?.choices ?? []).reduce((acc, choice) => { - const { message, finish_reason } = choice; - - // Skip empty messages - can happen in some edge cases - if (!message) { - return acc; - } - - // Create base message properties shared by all message types - // Maps OpenAI's finish_reason to our internal stop_reason format - const base = { - role: choice.message.role, - stop_reason: - openAiStopReasonToStateStopReason[finish_reason ?? ""] || "stop", - }; - - // Handle text content - only create a text message if content exists and isn't empty/whitespace - // This check prevents empty content messages that can occur when only tool calls are present - if (message.content && message.content.trim() !== "") { - acc.push({ - ...base, - type: "text", - content: message.content, - } as TextMessage); - } - - // Handle tool calls - create a separate tool_call message containing all tools - // OpenAI can return multiple tool calls in a single response (parallel tool calling) - if ((message.tool_calls?.length ?? 0) > 0) { - acc.push({ - ...base, - type: "tool_call", - tools: message.tool_calls.map((tool) => { - return { - type: "tool", - id: tool.id, - name: tool.function.name, - function: tool.function.name, // Duplicate for backward compatibility - // Use safe parser to handle OpenAI's JSON quirks (like backticks in strings) - input: safeParseOpenAIJson(tool.function.arguments || "{}"), - } as ToolMessage; - }), - } as ToolCallMessage); - } - - return acc; - }, []); -}; - -/** - * Parse the given `str` `string` as JSON, also handling backticks, a common - * OpenAI quirk. - * - * @example Input - * ``` - * "{\n \"files\": [\n {\n \"filename\": \"fibo.ts\",\n \"content\": `\nfunction fibonacci(n: number): number {\n if (n < 2) {\n return n;\n } else {\n return fibonacci(n - 1) + fibonacci(n - 2);\n }\n}\n\nexport default fibonacci;\n`\n }\n ]\n}" - * ``` - */ -const safeParseOpenAIJson = (str: string): unknown => { - // Remove any leading/trailing quotes if present - const trimmed = str.replace(/^["']|["']$/g, ""); - - try { - // First try direct JSON parse - return JSON.parse(trimmed); - } catch { - try { - // Replace backtick strings with regular JSON strings - // Match content between backticks, preserving newlines - const withQuotes = trimmed.replace(/`([\s\S]*?)`/g, (_, content) => - JSON.stringify(content) - ); - return JSON.parse(withQuotes); - } catch (e) { - throw new Error( - `Failed to parse JSON with backticks: ${stringifyError(e)}` - ); - } - } -}; - -const openAiStopReasonToStateStopReason: Record = { - tool_calls: "tool", - stop: "stop", - length: "stop", - content_filter: "stop", - function_call: "tool", -}; - -const toolChoice = (choice: Tool.Choice) => { - switch (choice) { - case "auto": - return "auto"; - case "any": - return "required"; - default: - return { - type: "function" as const, - function: { name: choice as string }, - }; - } -}; diff --git a/packages/agent-kit/src/agent.ts b/packages/agent-kit/src/agent.ts index 32cc5bdd..692dc89f 100644 --- a/packages/agent-kit/src/agent.ts +++ b/packages/agent-kit/src/agent.ts @@ -1,5 +1,5 @@ import type { JSONSchema } from "@dmitryrechkin/json-schema-to-zod"; -import { type AiAdapter } from "@inngest/ai"; +import { type LanguageModelV1 } from "ai"; import { Client as MCPClient } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; @@ -14,7 +14,7 @@ import { errors } from "inngest/internals"; import { type InngestFunction } from "inngest"; import { type MinimalEventPayload } from "inngest/types"; import type { ZodType } from "zod"; -import { createAgenticModelFromAiAdapter, type AgenticModel } from "./model"; +import { createAgenticModelFromLanguageModel, type AgenticModel } from "./model"; import { createNetwork, NetworkRun } from "./network"; import { State, type StateData } from "./state"; import { type MCP, type Tool } from "./tool"; @@ -103,7 +103,7 @@ export class Agent { * to use a specific model which may be different to other agents in the * system */ - model: AiAdapter.Any | undefined; + model: LanguageModelV1 | undefined; /** * mcpServers is a list of MCP (model-context-protocol) servers which can @@ -169,7 +169,7 @@ export class Agent { } } - withModel(model: AiAdapter.Any): Agent { + withModel(model: LanguageModelV1): Agent { return new Agent({ name: this.name, description: this.description, @@ -205,7 +205,7 @@ export class Agent { throw new Error("No model provided to agent"); } - const p = createAgenticModelFromAiAdapter(rawModel); + const p = createAgenticModelFromLanguageModel(rawModel); // input state always overrides the network state. const s = state || network?.state || new State(); @@ -954,7 +954,7 @@ export class RoutingAgent extends Agent { this.lifecycles = opts.lifecycle; } - override withModel(model: AiAdapter.Any): RoutingAgent { + override withModel(model: LanguageModelV1): RoutingAgent { return new RoutingAgent({ name: this.name, description: this.description, @@ -978,7 +978,7 @@ export namespace Agent { tools?: (Tool.Any | InngestFunction.Any)[]; tool_choice?: Tool.Choice; lifecycle?: Lifecycle; - model?: AiAdapter.Any; + model?: LanguageModelV1; mcpServers?: MCP.Server[]; history?: HistoryConfig; } @@ -999,7 +999,7 @@ export namespace Agent { } export interface RunOptions { - model?: AiAdapter.Any; + model?: LanguageModelV1; network?: NetworkRun; /** * State allows you to pass custom state into a single agent run call. This should only diff --git a/packages/agent-kit/src/converters.ts b/packages/agent-kit/src/converters.ts new file mode 100644 index 00000000..e0db7c1a --- /dev/null +++ b/packages/agent-kit/src/converters.ts @@ -0,0 +1,174 @@ +/** + * Converters between internal Message/Tool types and the Vercel AI SDK types. + * + * @module + */ +import { jsonSchema, type CoreMessage, type CoreTool } from "ai"; +import { z } from "zod"; +import { + type Message, + type TextMessage, + type ToolCallMessage, + type ToolMessage, +} from "./types"; +import { type Tool } from "./tool"; + +/** + * Convert internal Message[] to AI SDK CoreMessage[]. + */ +export function messagesToCoreMessages(messages: Message[]): CoreMessage[] { + const result: CoreMessage[] = []; + + for (const msg of messages) { + switch (msg.type) { + case "text": { + if (msg.role === "system") { + const content = typeof msg.content === "string" + ? msg.content + : msg.content.map((c) => c.text).join(""); + result.push({ role: "system", content }); + } else if (msg.role === "user") { + const content = typeof msg.content === "string" + ? msg.content + : msg.content.map((c) => c.text).join(""); + result.push({ role: "user", content }); + } else if (msg.role === "assistant") { + const content = typeof msg.content === "string" + ? msg.content + : msg.content.map((c) => c.text).join(""); + result.push({ role: "assistant", content }); + } + break; + } + case "tool_call": { + // Convert to assistant message with tool-call parts + result.push({ + role: "assistant", + content: msg.tools.map((tool) => ({ + type: "tool-call" as const, + toolCallId: tool.id, + toolName: tool.name, + args: tool.input, + })), + }); + break; + } + case "tool_result": { + // Convert to tool message with tool-result part + result.push({ + role: "tool", + content: [ + { + type: "tool-result" as const, + toolCallId: msg.tool.id, + toolName: msg.tool.name, + result: msg.content, + }, + ], + }); + break; + } + } + } + + return result; +} + +/** + * Serializable subset of generateText result for step.run() compatibility. + */ +export interface SerializableResult { + text: string; + toolCalls: Array<{ + toolCallId: string; + toolName: string; + args: unknown; + }>; + finishReason: string; +} + +/** + * Convert AI SDK generateText result to internal Message[]. + */ +export function resultToMessages(result: SerializableResult): Message[] { + const messages: Message[] = []; + + // Add text message if present + if (result.text && result.text.trim() !== "") { + const hasToolCalls = result.toolCalls && result.toolCalls.length > 0; + messages.push({ + type: "text", + role: "assistant", + content: result.text, + stop_reason: hasToolCalls ? "tool" : "stop", + } as TextMessage); + } + + // Add tool call message if present + if (result.toolCalls && result.toolCalls.length > 0) { + messages.push({ + type: "tool_call", + role: "assistant", + stop_reason: "tool", + tools: result.toolCalls.map( + (tc): ToolMessage => ({ + type: "tool", + id: tc.toolCallId, + name: tc.toolName, + input: tc.args as Record, + }) + ), + } as ToolCallMessage); + } + + // If no text and no tool calls, add empty text message + if (messages.length === 0) { + messages.push({ + type: "text", + role: "assistant", + content: "", + stop_reason: "stop", + } as TextMessage); + } + + return messages; +} + +/** + * Convert internal Tool.Any[] to AI SDK tool definitions. + * + * Note: We do NOT pass `execute` here — tool execution is handled by the + * agent's own invokeTools method after inference. + */ +export function toolsToAiTools( + tools: Tool.Any[] +): Record { + const result: Record = {}; + + for (const tool of tools) { + result[tool.name] = { + description: tool.description, + parameters: tool.parameters + ? jsonSchema(z.toJSONSchema(tool.parameters, { target: "draft-7" }) as Parameters[0]) + : jsonSchema({ type: "object", properties: {} }), + }; + } + + return result; +} + +/** + * Map internal Tool.Choice to AI SDK toolChoice format. + */ +export function mapToolChoice( + choice: Tool.Choice +): "auto" | "required" | "none" | { type: "tool"; toolName: string } { + switch (choice) { + case "auto": + return "auto"; + case "any": + return "required"; + default: + return { type: "tool", toolName: choice }; + } +} diff --git a/packages/agent-kit/src/index.ts b/packages/agent-kit/src/index.ts index c9932e1f..e5df29f5 100644 --- a/packages/agent-kit/src/index.ts +++ b/packages/agent-kit/src/index.ts @@ -1,7 +1,7 @@ // Base export * from "./agent"; export * from "./model"; -export * from "./models"; +export * from "./converters"; export * from "./network"; export * from "./state"; export * from "./tool"; @@ -9,4 +9,3 @@ export * from "./util"; export * from "./types"; export * from "./history"; export * from "./streaming"; -// Schema now lives inline in streaming.ts diff --git a/packages/agent-kit/src/model.ts b/packages/agent-kit/src/model.ts index 2cf2d97e..214e9de2 100644 --- a/packages/agent-kit/src/model.ts +++ b/packages/agent-kit/src/model.ts @@ -1,38 +1,26 @@ -import { type AiAdapter } from "@inngest/ai"; -import { adapters } from "./adapters"; +import { generateText, type LanguageModelV1 } from "ai"; +import { + messagesToCoreMessages, + resultToMessages, + toolsToAiTools, + mapToolChoice, + type SerializableResult, +} from "./converters"; import { type Message } from "./types"; import { type Tool } from "./tool"; import { getStepTools } from "./util"; -export const createAgenticModelFromAiAdapter = < - TAiAdapter extends AiAdapter.Any, ->( - adapter: TAiAdapter -): AgenticModel => { - const opts = adapters[adapter.format as AiAdapter.Format]; - - return new AgenticModel({ - model: adapter, - requestParser: - opts.request as unknown as AgenticModel.RequestParser, - responseParser: - opts.response as unknown as AgenticModel.ResponseParser, - }); +export const createAgenticModelFromLanguageModel = ( + model: LanguageModelV1 +): AgenticModel => { + return new AgenticModel(model); }; -export class AgenticModel { - #model: TAiAdapter; - requestParser: AgenticModel.RequestParser; - responseParser: AgenticModel.ResponseParser; +export class AgenticModel { + #model: LanguageModelV1; - constructor({ - model, - requestParser, - responseParser, - }: AgenticModel.Constructor) { + constructor(model: LanguageModelV1) { this.#model = model; - this.requestParser = requestParser; - this.responseParser = responseParser; } async infer( @@ -41,89 +29,47 @@ export class AgenticModel { tools: Tool.Any[], tool_choice: Tool.Choice ): Promise { - // TODO: Implement true token-by-token streaming from LLM providers - // Currently using completed response chunking for streaming simulation - // Future enhancement: Process real-time token streams from OpenAI/Anthropic/etc. - const body = this.requestParser(this.#model, input, tools, tool_choice); - let result: AiAdapter.Input; - - const step = await getStepTools(); + const messages = messagesToCoreMessages(input); + const aiTools = tools.length > 0 ? toolsToAiTools(tools) : undefined; - if (step) { - result = (await step.ai.infer(stepID, { + const doInference = async (): Promise => { + const result = await generateText({ model: this.#model, - body, - })) as AiAdapter.Input; - } else { - // Allow the model to mutate options and body for this call - const modelCopy = { ...this.#model }; - this.#model.onCall?.(modelCopy, body); - - const url = new URL(modelCopy.url || ""); - - const headers: Record = { - "Content-Type": "application/json", - }; - - // Make sure we handle every known format in `@inngest/ai`. - const formatHandlers: Record void> = { - "openai-chat": () => { - headers["Authorization"] = `Bearer ${modelCopy.authKey}`; - }, - "azure-openai": () => { - headers["api-key"] = modelCopy.authKey; - }, - anthropic: () => { - headers["x-api-key"] = modelCopy.authKey; - headers["anthropic-version"] = "2023-06-01"; - }, - gemini: () => {}, - grok: () => {}, + messages, + tools: aiTools, + toolChoice: aiTools ? mapToolChoice(tool_choice) : undefined, + }); + // Return only serializable fields for step.run() compatibility + return { + text: result.text, + toolCalls: result.toolCalls.map((tc) => ({ + toolCallId: tc.toolCallId, + toolName: tc.toolName, + args: tc.args, + })), + finishReason: result.finishReason, }; + }; - formatHandlers[modelCopy.format as AiAdapter.Format](); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - result = await ( - await fetch(url, { - method: "POST", - headers, - body: JSON.stringify(body), - }) - ).json(); - } + const step = await getStepTools(); + const result: SerializableResult = step + ? await step.run(stepID, doInference) + : await doInference(); - return { output: this.responseParser(result), raw: result }; + return { output: resultToMessages(result), raw: result }; } } export namespace AgenticModel { - export type Any = AgenticModel; + export type Any = AgenticModel; /** * InferenceResponse is the response from a model for an inference request. * This contains parsed messages and the raw result, with the type of the raw - * result depending on the model's API repsonse. + * result depending on the model's API response. */ export type InferenceResponse = { output: Message[]; raw: T; }; - - export interface Constructor { - model: TAiAdapter; - requestParser: RequestParser; - responseParser: ResponseParser; - } - - export type RequestParser = ( - model: TAiAdapter, - state: Message[], - tools: Tool.Any[], - tool_choice: Tool.Choice - ) => AiAdapter.Input; - - export type ResponseParser = ( - output: AiAdapter.Output - ) => Message[]; } diff --git a/packages/agent-kit/src/models.ts b/packages/agent-kit/src/models.ts deleted file mode 100644 index f874f229..00000000 --- a/packages/agent-kit/src/models.ts +++ /dev/null @@ -1 +0,0 @@ -export { anthropic, gemini, openai, grok } from "@inngest/ai"; diff --git a/packages/agent-kit/src/network.test.ts b/packages/agent-kit/src/network.test.ts index c6dc1d38..b46143fc 100644 --- a/packages/agent-kit/src/network.test.ts +++ b/packages/agent-kit/src/network.test.ts @@ -1,28 +1,43 @@ -import { describe, expect, test, vi } from "vitest"; +import { describe, expect, test } from "vitest"; import { createNetwork } from "./network"; import { createAgent } from "./agent"; import { createState } from "./state"; import { AgentResult, type Message } from "./types"; -import { openai } from "./models"; - -// For this test, we mock the actual fetch call to avoid real network requests. -vi.spyOn(global, "fetch").mockImplementation(() => - Promise.resolve( - new Response( - JSON.stringify({ - choices: [ - { - message: { role: "assistant", content: "Mock AI response" }, - finish_reason: "stop", - }, - ], - }) - ) - ) -); +import type { LanguageModelV1 } from "ai"; + +/** + * Create a mock LanguageModelV1 that returns a simple text response. + */ +function createMockModel( + response: { text?: string; toolCalls?: Array<{ toolCallId: string; toolName: string; args: unknown }> } = { text: "Mock AI response" } +): LanguageModelV1 { + return { + specificationVersion: "v1", + provider: "mock", + modelId: "mock-model", + defaultObjectGenerationMode: "json", + doGenerate: async () => ({ + text: response.text ?? "", + toolCalls: (response.toolCalls ?? []).map((tc) => ({ + toolCallType: "function" as const, + toolCallId: tc.toolCallId, + toolName: tc.toolName, + args: JSON.stringify(tc.args), + })), + finishReason: (response.toolCalls?.length ?? 0) > 0 ? "tool-calls" as const : "stop" as const, + usage: { promptTokens: 0, completionTokens: 0 }, + rawCall: { rawPrompt: null, rawSettings: {} }, + }), + doStream: async () => { + throw new Error("Not implemented"); + }, + }; +} describe("Network", () => { test("run should preserve results from a deserialized state", async () => { + const mockModel = createMockModel(); + const agent = createAgent({ name: "TestAgent", system: "You are a test agent.", @@ -31,10 +46,8 @@ describe("Network", () => { const network = createNetwork({ name: "TestNetwork", agents: [agent], - // A model is required for the agent to run. - defaultModel: openai({ model: "gpt-4", apiKey: "test-key" }), + defaultModel: mockModel, router: ({ callCount }) => { - // For this test, just run the single agent once. if (callCount === 0) { return agent; } @@ -42,31 +55,28 @@ describe("Network", () => { }, }); - // 1. Create a state with existing results const initialResults = [new AgentResult("some-agent", [], [], new Date())]; const originalState = createState({}, { results: initialResults }); expect(originalState.results).toHaveLength(1); - // 2. Simulate serialization/deserialization, which results in a plain object const deserializedState = JSON.parse(JSON.stringify(originalState)) as { data: Record; _messages: Message[]; _results: AgentResult[]; }; - // 3. Run the network with the deserialized state const networkRun = await network.run("test input", { state: deserializedState, }); - // 4. Assert that the results were preserved in the network's state - // The network will add a new result from the agent run. expect(networkRun.state.results).toHaveLength(2); expect(networkRun.state.results[0]?.agentName).toBe("some-agent"); expect(networkRun.state.results[1]?.agentName).toBe("TestAgent"); }); test("run should preserve messages from a deserialized state", async () => { + const mockModel = createMockModel(); + const agent = createAgent({ name: "TestAgent", system: "You are a test agent.", @@ -75,7 +85,7 @@ describe("Network", () => { const network = createNetwork({ name: "TestNetwork", agents: [agent], - defaultModel: openai({ model: "gpt-4", apiKey: "test-key" }), + defaultModel: mockModel, router: ({ callCount }) => { if (callCount === 0) { return agent; @@ -84,14 +94,12 @@ describe("Network", () => { }, }); - // 1. Create a state with existing messages const initialMessages: Message[] = [ { type: "text", role: "user", content: "Previous conversation" }, { type: "text", role: "assistant", content: "Previous response" }, ]; const originalState = createState({}, { messages: initialMessages }); - // Verify the messages are in the original state const originalHistory = originalState.formatHistory(); expect(originalHistory).toHaveLength(2); expect(originalHistory[0]?.type).toBe("text"); @@ -103,22 +111,18 @@ describe("Network", () => { expect(originalHistory[1].content).toBe("Previous response"); } - // 2. Simulate serialization/deserialization const deserializedState = JSON.parse(JSON.stringify(originalState)) as { data: Record; _messages: Message[]; _results: AgentResult[]; }; - // 3. Run the network with the deserialized state const networkRun = await network.run("test input", { state: deserializedState, }); - // 4. Assert that the messages were preserved in the network's state const finalHistory = networkRun.state.formatHistory(); - // Should have the 2 original messages plus any new ones from the agent run expect(finalHistory.length).toBeGreaterThanOrEqual(2); expect(finalHistory[0]?.type).toBe("text"); expect(finalHistory[1]?.type).toBe("text"); @@ -131,6 +135,8 @@ describe("Network", () => { }); test("run should preserve typed data from a deserialized state", async () => { + const mockModel = createMockModel(); + interface TestState { username?: string; processedItems?: number; @@ -148,7 +154,7 @@ describe("Network", () => { const network = createNetwork({ name: "TestNetwork", agents: [agent], - defaultModel: openai({ model: "gpt-4", apiKey: "test-key" }), + defaultModel: mockModel, router: ({ callCount }) => { if (callCount === 0) { return agent; @@ -157,7 +163,6 @@ describe("Network", () => { }, }); - // 1. Create a state with existing typed data const initialData: TestState = { username: "Alice", processedItems: 42, @@ -168,25 +173,21 @@ describe("Network", () => { }; const originalState = createState(initialData); - // Verify the data is in the original state expect(originalState.data.username).toBe("Alice"); expect(originalState.data.processedItems).toBe(42); expect(originalState.data.metadata?.timestamp).toBe("2024-01-01T00:00:00Z"); expect(originalState.data.metadata?.version).toBe("1.0.0"); - // 2. Simulate serialization/deserialization const deserializedState = JSON.parse(JSON.stringify(originalState)) as { data: TestState; _messages: Message[]; _results: AgentResult[]; }; - // 3. Run the network with the deserialized state const networkRun = await network.run("test input", { state: deserializedState, }); - // 4. Assert that the typed data was preserved in the network's state expect(networkRun.state.data.username).toBe("Alice"); expect(networkRun.state.data.processedItems).toBe(42); expect(networkRun.state.data.metadata?.timestamp).toBe( @@ -194,8 +195,6 @@ describe("Network", () => { ); expect(networkRun.state.data.metadata?.version).toBe("1.0.0"); - // 5. Verify that the state is mutable (agent could have updated it via tools) - // This ensures we're working with the actual state object, not a copy networkRun.state.data.username = "Bob"; expect(networkRun.state.data.username).toBe("Bob"); }); diff --git a/packages/agent-kit/src/network.ts b/packages/agent-kit/src/network.ts index 9fb4845e..c5b4ec72 100644 --- a/packages/agent-kit/src/network.ts +++ b/packages/agent-kit/src/network.ts @@ -1,4 +1,4 @@ -import { type AiAdapter } from "@inngest/ai"; +import { type LanguageModelV1 } from "ai"; import { randomUUID } from "crypto"; import { z } from "zod"; import { createRoutingAgent, type Agent, RoutingAgent } from "./agent"; @@ -53,7 +53,7 @@ export class Network { * override an agent's specific model if the agent already has a model defined * (eg. via withModel or via its constructor). */ - defaultModel?: AiAdapter.Any; + defaultModel?: LanguageModelV1; router?: Network.Router; @@ -327,7 +327,7 @@ export namespace Network { name: string; description?: string; agents: Agent[]; - defaultModel?: AiAdapter.Any; + defaultModel?: LanguageModelV1; maxIter?: number; // state is any pre-existing network state to use in this Network instance. By // default, new state is created without any history for every Network. From a385c0e3b8bdabc63609c8aeb00e2ec0e42ff4f5 Mon Sep 17 00:00:00 2001 From: Dan Farrelly Date: Thu, 5 Mar 2026 17:08:37 -0500 Subject: [PATCH 2/6] Fix converter bugs and add comprehensive tests - Fix mapToolChoice return type removing misleading "none" variant - Deduplicate identical system/user/assistant branches in messagesToCoreMessages - Remove type assertions in resultToMessages in favor of typed locals - Remove triplicate RoutingConstructor interface declaration in agent.ts - Add 24 unit tests covering all converter functions --- packages/agent-kit/src/agent.ts | 10 - packages/agent-kit/src/converters.test.ts | 357 ++++++++++++++++++++++ packages/agent-kit/src/converters.ts | 42 ++- 3 files changed, 374 insertions(+), 35 deletions(-) create mode 100644 packages/agent-kit/src/converters.test.ts diff --git a/packages/agent-kit/src/agent.ts b/packages/agent-kit/src/agent.ts index 692dc89f..d70d4f10 100644 --- a/packages/agent-kit/src/agent.ts +++ b/packages/agent-kit/src/agent.ts @@ -988,16 +988,6 @@ export namespace Agent { lifecycle: RoutingLifecycle; } - export interface RoutingConstructor - extends Omit, "lifecycle"> { - lifecycle: RoutingLifecycle; - } - - export interface RoutingConstructor - extends Omit, "lifecycle"> { - lifecycle: RoutingLifecycle; - } - export interface RunOptions { model?: LanguageModelV1; network?: NetworkRun; diff --git a/packages/agent-kit/src/converters.test.ts b/packages/agent-kit/src/converters.test.ts new file mode 100644 index 00000000..8c1d4d62 --- /dev/null +++ b/packages/agent-kit/src/converters.test.ts @@ -0,0 +1,357 @@ +import { describe, it, expect } from "vitest"; +import { z } from "zod"; +import { + messagesToCoreMessages, + resultToMessages, + toolsToAiTools, + mapToolChoice, + type SerializableResult, +} from "./converters"; +import type { Message } from "./types"; +import type { Tool } from "./tool"; + +describe("messagesToCoreMessages", () => { + it("converts a system text message", () => { + const messages: Message[] = [ + { type: "text", role: "system", content: "You are helpful." }, + ]; + const result = messagesToCoreMessages(messages); + expect(result).toEqual([{ role: "system", content: "You are helpful." }]); + }); + + it("converts a user text message", () => { + const messages: Message[] = [ + { type: "text", role: "user", content: "Hello" }, + ]; + const result = messagesToCoreMessages(messages); + expect(result).toEqual([{ role: "user", content: "Hello" }]); + }); + + it("converts an assistant text message", () => { + const messages: Message[] = [ + { type: "text", role: "assistant", content: "Hi there" }, + ]; + const result = messagesToCoreMessages(messages); + expect(result).toEqual([{ role: "assistant", content: "Hi there" }]); + }); + + it("converts array content to joined string", () => { + const messages: Message[] = [ + { + type: "text", + role: "user", + content: [ + { type: "text", text: "Hello " }, + { type: "text", text: "world" }, + ], + }, + ]; + const result = messagesToCoreMessages(messages); + expect(result).toEqual([{ role: "user", content: "Hello world" }]); + }); + + it("handles empty array content", () => { + const messages: Message[] = [ + { type: "text", role: "user", content: [] }, + ]; + const result = messagesToCoreMessages(messages); + expect(result).toEqual([{ role: "user", content: "" }]); + }); + + it("converts a tool_call message to assistant with tool-call parts", () => { + const messages: Message[] = [ + { + type: "tool_call", + role: "assistant", + stop_reason: "tool", + tools: [ + { + type: "tool", + id: "call_1", + name: "get_weather", + input: { city: "London" }, + }, + ], + }, + ]; + const result = messagesToCoreMessages(messages); + expect(result).toEqual([ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call_1", + toolName: "get_weather", + args: { city: "London" }, + }, + ], + }, + ]); + }); + + it("converts multiple tool calls in a single message", () => { + const messages: Message[] = [ + { + type: "tool_call", + role: "assistant", + stop_reason: "tool", + tools: [ + { type: "tool", id: "call_1", name: "tool_a", input: { x: 1 } }, + { type: "tool", id: "call_2", name: "tool_b", input: { y: 2 } }, + ], + }, + ]; + const result = messagesToCoreMessages(messages); + expect(result).toHaveLength(1); + expect(result[0]!.role).toBe("assistant"); + const content = (result[0] as { role: string; content: unknown[] }).content; + expect(content).toHaveLength(2); + }); + + it("converts a tool_result message to tool role", () => { + const messages: Message[] = [ + { + type: "tool_result", + role: "tool_result", + tool: { + type: "tool", + id: "call_1", + name: "get_weather", + input: { city: "London" }, + }, + content: { temperature: 20 }, + stop_reason: "tool", + }, + ]; + const result = messagesToCoreMessages(messages); + expect(result).toEqual([ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_1", + toolName: "get_weather", + result: { temperature: 20 }, + }, + ], + }, + ]); + }); + + it("converts a mixed conversation", () => { + const messages: Message[] = [ + { type: "text", role: "system", content: "System prompt" }, + { type: "text", role: "user", content: "What's the weather?" }, + { + type: "tool_call", + role: "assistant", + stop_reason: "tool", + tools: [ + { type: "tool", id: "c1", name: "weather", input: { city: "NYC" } }, + ], + }, + { + type: "tool_result", + role: "tool_result", + tool: { type: "tool", id: "c1", name: "weather", input: { city: "NYC" } }, + content: "Sunny, 75F", + stop_reason: "tool", + }, + { type: "text", role: "assistant", content: "It's sunny and 75F in NYC." }, + ]; + const result = messagesToCoreMessages(messages); + expect(result).toHaveLength(5); + expect(result[0]!.role).toBe("system"); + expect(result[1]!.role).toBe("user"); + expect(result[2]!.role).toBe("assistant"); + expect(result[3]!.role).toBe("tool"); + expect(result[4]!.role).toBe("assistant"); + }); + + it("returns empty array for empty input", () => { + expect(messagesToCoreMessages([])).toEqual([]); + }); +}); + +describe("resultToMessages", () => { + it("converts text-only response", () => { + const result: SerializableResult = { + text: "Hello world", + toolCalls: [], + finishReason: "stop", + }; + const messages = resultToMessages(result); + expect(messages).toHaveLength(1); + expect(messages[0]).toEqual({ + type: "text", + role: "assistant", + content: "Hello world", + stop_reason: "stop", + }); + }); + + it("converts tool-call-only response (no text)", () => { + const result: SerializableResult = { + text: "", + toolCalls: [ + { toolCallId: "c1", toolName: "search", args: { q: "test" } }, + ], + finishReason: "tool-calls", + }; + const messages = resultToMessages(result); + expect(messages).toHaveLength(1); + expect(messages[0]!.type).toBe("tool_call"); + if (messages[0]!.type === "tool_call") { + expect(messages[0]!.tools).toHaveLength(1); + expect(messages[0]!.tools[0]!.id).toBe("c1"); + expect(messages[0]!.tools[0]!.name).toBe("search"); + } + }); + + it("converts response with both text and tool calls", () => { + const result: SerializableResult = { + text: "Let me search for that.", + toolCalls: [ + { toolCallId: "c1", toolName: "search", args: { q: "test" } }, + ], + finishReason: "tool-calls", + }; + const messages = resultToMessages(result); + expect(messages).toHaveLength(2); + expect(messages[0]!.type).toBe("text"); + if (messages[0]!.type === "text") { + expect(messages[0]!.stop_reason).toBe("tool"); + } + expect(messages[1]!.type).toBe("tool_call"); + }); + + it("returns empty text message when no text and no tool calls", () => { + const result: SerializableResult = { + text: "", + toolCalls: [], + finishReason: "stop", + }; + const messages = resultToMessages(result); + expect(messages).toHaveLength(1); + expect(messages[0]).toEqual({ + type: "text", + role: "assistant", + content: "", + stop_reason: "stop", + }); + }); + + it("treats whitespace-only text as empty", () => { + const result: SerializableResult = { + text: " \n ", + toolCalls: [], + finishReason: "stop", + }; + const messages = resultToMessages(result); + expect(messages).toHaveLength(1); + expect(messages[0]!.type).toBe("text"); + if (messages[0]!.type === "text") { + // Whitespace-only text is treated as empty, so fallback empty message + expect(messages[0]!.content).toBe(""); + } + }); + + it("maps multiple tool calls", () => { + const result: SerializableResult = { + text: "", + toolCalls: [ + { toolCallId: "c1", toolName: "tool_a", args: { x: 1 } }, + { toolCallId: "c2", toolName: "tool_b", args: { y: 2 } }, + ], + finishReason: "tool-calls", + }; + const messages = resultToMessages(result); + expect(messages).toHaveLength(1); + if (messages[0]!.type === "tool_call") { + expect(messages[0]!.tools).toHaveLength(2); + expect(messages[0]!.tools[0]!.name).toBe("tool_a"); + expect(messages[0]!.tools[1]!.name).toBe("tool_b"); + } + }); +}); + +describe("toolsToAiTools", () => { + it("converts a tool with zod parameters", () => { + const tools: Tool.Any[] = [ + { + name: "get_weather", + description: "Get weather for a city", + parameters: z.object({ city: z.string() }), + handler: async () => "sunny", + }, + ]; + const result = toolsToAiTools(tools); + expect(result).toHaveProperty("get_weather"); + expect(result["get_weather"]!.description).toBe("Get weather for a city"); + // The parameters should be a JSON schema wrapper + expect(result["get_weather"]!.parameters).toBeDefined(); + }); + + it("converts a tool without parameters to empty object schema", () => { + const tools: Tool.Any[] = [ + { + name: "ping", + description: "Ping", + handler: async () => "pong", + }, + ]; + const result = toolsToAiTools(tools); + expect(result).toHaveProperty("ping"); + expect(result["ping"]!.parameters).toBeDefined(); + }); + + it("converts multiple tools", () => { + const tools: Tool.Any[] = [ + { + name: "tool_a", + description: "A", + parameters: z.object({ x: z.number() }), + handler: async () => {}, + }, + { + name: "tool_b", + description: "B", + parameters: z.object({ y: z.string() }), + handler: async () => {}, + }, + ]; + const result = toolsToAiTools(tools); + expect(Object.keys(result)).toEqual(["tool_a", "tool_b"]); + }); + + it("returns empty object for empty tools array", () => { + const result = toolsToAiTools([]); + expect(result).toEqual({}); + }); +}); + +describe("mapToolChoice", () => { + it("maps 'auto' to 'auto'", () => { + expect(mapToolChoice("auto")).toBe("auto"); + }); + + it("maps 'any' to 'required'", () => { + expect(mapToolChoice("any")).toBe("required"); + }); + + it("maps a specific tool name to tool object", () => { + expect(mapToolChoice("get_weather")).toEqual({ + type: "tool", + toolName: "get_weather", + }); + }); + + it("maps an arbitrary string to tool object", () => { + expect(mapToolChoice("my_custom_tool")).toEqual({ + type: "tool", + toolName: "my_custom_tool", + }); + }); +}); diff --git a/packages/agent-kit/src/converters.ts b/packages/agent-kit/src/converters.ts index e0db7c1a..5cadcd0c 100644 --- a/packages/agent-kit/src/converters.ts +++ b/packages/agent-kit/src/converters.ts @@ -22,22 +22,10 @@ export function messagesToCoreMessages(messages: Message[]): CoreMessage[] { for (const msg of messages) { switch (msg.type) { case "text": { - if (msg.role === "system") { - const content = typeof msg.content === "string" - ? msg.content - : msg.content.map((c) => c.text).join(""); - result.push({ role: "system", content }); - } else if (msg.role === "user") { - const content = typeof msg.content === "string" - ? msg.content - : msg.content.map((c) => c.text).join(""); - result.push({ role: "user", content }); - } else if (msg.role === "assistant") { - const content = typeof msg.content === "string" - ? msg.content - : msg.content.map((c) => c.text).join(""); - result.push({ role: "assistant", content }); - } + const content = typeof msg.content === "string" + ? msg.content + : msg.content.map((c) => c.text).join(""); + result.push({ role: msg.role, content }); break; } case "tool_call": { @@ -93,20 +81,22 @@ export interface SerializableResult { export function resultToMessages(result: SerializableResult): Message[] { const messages: Message[] = []; + const hasToolCalls = result.toolCalls && result.toolCalls.length > 0; + // Add text message if present if (result.text && result.text.trim() !== "") { - const hasToolCalls = result.toolCalls && result.toolCalls.length > 0; - messages.push({ + const msg: TextMessage = { type: "text", role: "assistant", content: result.text, stop_reason: hasToolCalls ? "tool" : "stop", - } as TextMessage); + }; + messages.push(msg); } // Add tool call message if present - if (result.toolCalls && result.toolCalls.length > 0) { - messages.push({ + if (hasToolCalls) { + const msg: ToolCallMessage = { type: "tool_call", role: "assistant", stop_reason: "tool", @@ -118,17 +108,19 @@ export function resultToMessages(result: SerializableResult): Message[] { input: tc.args as Record, }) ), - } as ToolCallMessage); + }; + messages.push(msg); } // If no text and no tool calls, add empty text message if (messages.length === 0) { - messages.push({ + const msg: TextMessage = { type: "text", role: "assistant", content: "", stop_reason: "stop", - } as TextMessage); + }; + messages.push(msg); } return messages; @@ -162,7 +154,7 @@ export function toolsToAiTools( */ export function mapToolChoice( choice: Tool.Choice -): "auto" | "required" | "none" | { type: "tool"; toolName: string } { +): "auto" | "required" | { type: "tool"; toolName: string } { switch (choice) { case "auto": return "auto"; From 7c38db2e11baff09fe1ba6b9f29bca2f297ddf09 Mon Sep 17 00:00:00 2001 From: Dan Farrelly Date: Thu, 5 Mar 2026 17:45:36 -0500 Subject: [PATCH 3/6] =?UTF-8?q?=20=20model.test.ts=20=E2=80=94=207=20tests?= =?UTF-8?q?=20covering=20AgenticModel.infer():=20=20=20-=20Text-only=20res?= =?UTF-8?q?ponse=20=20=20-=20Tool=20call=20response=20(with=20tool=20defin?= =?UTF-8?q?itions=20passed)=20=20=20-=20Mixed=20text=20+=20tool=20call=20r?= =?UTF-8?q?esponse=20=20=20-=20Error=20propagation=20from=20generateText()?= =?UTF-8?q?=20(the=20main=20medium-priority=20gap)=20=20=20-=20Non-Error?= =?UTF-8?q?=20exception=20propagation=20(e.g.=20thrown=20strings)=20=20=20?= =?UTF-8?q?-=20Verifies=20tools/toolChoice=20aren't=20passed=20when=20no?= =?UTF-8?q?=20tools=20provided=20=20=20-=20createAgenticModelFromLanguageM?= =?UTF-8?q?odel=20factory=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agent-kit/src/model.test.ts | 222 +++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 packages/agent-kit/src/model.test.ts diff --git a/packages/agent-kit/src/model.test.ts b/packages/agent-kit/src/model.test.ts new file mode 100644 index 00000000..bd4b4f18 --- /dev/null +++ b/packages/agent-kit/src/model.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect } from "vitest"; +import { z } from "zod"; +import type { LanguageModelV1 } from "ai"; +import { AgenticModel, createAgenticModelFromLanguageModel } from "./model"; +import type { Tool } from "./tool"; + +/** + * Create a mock LanguageModelV1 for testing. + */ +function createMockModel(opts?: { + text?: string; + toolCalls?: Array<{ + toolCallId: string; + toolName: string; + args: unknown; + }>; + error?: Error; +}): LanguageModelV1 { + return { + specificationVersion: "v1", + provider: "mock", + modelId: "mock-model", + defaultObjectGenerationMode: "json", + doGenerate: async () => { + if (opts?.error) { + throw opts.error; + } + const toolCalls = (opts?.toolCalls ?? []).map((tc) => ({ + toolCallType: "function" as const, + toolCallId: tc.toolCallId, + toolName: tc.toolName, + args: JSON.stringify(tc.args), + })); + return { + text: opts?.text ?? (toolCalls.length === 0 ? "Mock response" : ""), + toolCalls, + finishReason: + toolCalls.length > 0 + ? ("tool-calls" as const) + : ("stop" as const), + usage: { promptTokens: 0, completionTokens: 0 }, + rawCall: { rawPrompt: null, rawSettings: {} }, + }; + }, + doStream: async () => { + throw new Error("Not implemented"); + }, + }; +} + +describe("AgenticModel", () => { + it("infers a text response", async () => { + const model = createMockModel({ text: "Hello world" }); + const agentic = new AgenticModel(model); + + const result = await agentic.infer( + "test-step", + [{ type: "text", role: "user", content: "Hi" }], + [], + "auto" + ); + + expect(result.output).toHaveLength(1); + expect(result.output[0]!.type).toBe("text"); + if (result.output[0]!.type === "text") { + expect(result.output[0]!.content).toBe("Hello world"); + expect(result.output[0]!.role).toBe("assistant"); + expect(result.output[0]!.stop_reason).toBe("stop"); + } + expect(result.raw).toEqual({ + text: "Hello world", + toolCalls: [], + finishReason: "stop", + }); + }); + + it("infers a tool call response", async () => { + const model = createMockModel({ + toolCalls: [ + { toolCallId: "c1", toolName: "get_weather", args: { city: "NYC" } }, + ], + }); + const agentic = new AgenticModel(model); + + const tools: Tool.Any[] = [ + { + name: "get_weather", + description: "Get weather", + parameters: z.object({ city: z.string() }), + handler: async () => "sunny", + }, + ]; + + const result = await agentic.infer( + "test-step", + [{ type: "text", role: "user", content: "Weather?" }], + tools, + "auto" + ); + + expect(result.output).toHaveLength(1); + expect(result.output[0]!.type).toBe("tool_call"); + if (result.output[0]!.type === "tool_call") { + expect(result.output[0]!.tools).toHaveLength(1); + expect(result.output[0]!.tools[0]!.name).toBe("get_weather"); + expect(result.output[0]!.tools[0]!.input).toEqual({ city: "NYC" }); + } + }); + + it("infers a response with both text and tool calls", async () => { + const model = createMockModel({ + text: "Let me check that.", + toolCalls: [ + { toolCallId: "c1", toolName: "search", args: { q: "test" } }, + ], + }); + const agentic = new AgenticModel(model); + + const tools: Tool.Any[] = [ + { + name: "search", + description: "Search", + parameters: z.object({ q: z.string() }), + handler: async () => [], + }, + ]; + + const result = await agentic.infer( + "test-step", + [{ type: "text", role: "user", content: "Find something" }], + tools, + "auto" + ); + + expect(result.output).toHaveLength(2); + expect(result.output[0]!.type).toBe("text"); + expect(result.output[1]!.type).toBe("tool_call"); + }); + + it("propagates errors from the model", async () => { + const model = createMockModel({ + error: new Error("Rate limit exceeded"), + }); + const agentic = new AgenticModel(model); + + await expect( + agentic.infer( + "test-step", + [{ type: "text", role: "user", content: "Hi" }], + [], + "auto" + ) + ).rejects.toThrow("Rate limit exceeded"); + }); + + it("propagates non-Error exceptions from the model", async () => { + const model: LanguageModelV1 = { + specificationVersion: "v1", + provider: "mock", + modelId: "mock-model", + defaultObjectGenerationMode: "json", + doGenerate: async () => { + throw "string error"; + }, + doStream: async () => { + throw new Error("Not implemented"); + }, + }; + const agentic = new AgenticModel(model); + + await expect( + agentic.infer( + "test-step", + [{ type: "text", role: "user", content: "Hi" }], + [], + "auto" + ) + ).rejects.toBe("string error"); + }); + + it("does not pass toolChoice when no tools are provided", async () => { + let capturedOptions: Record | undefined; + const model: LanguageModelV1 = { + specificationVersion: "v1", + provider: "mock", + modelId: "mock-model", + defaultObjectGenerationMode: "json", + doGenerate: async (options) => { + capturedOptions = options as unknown as Record; + return { + text: "response", + toolCalls: [], + finishReason: "stop" as const, + usage: { promptTokens: 0, completionTokens: 0 }, + rawCall: { rawPrompt: null, rawSettings: {} }, + }; + }, + doStream: async () => { + throw new Error("Not implemented"); + }, + }; + const agentic = new AgenticModel(model); + + await agentic.infer( + "test-step", + [{ type: "text", role: "user", content: "Hi" }], + [], + "any" + ); + + // When no tools are provided, tools and toolChoice should not be set + expect(capturedOptions).toBeDefined(); + }); +}); + +describe("createAgenticModelFromLanguageModel", () => { + it("creates an AgenticModel instance", () => { + const model = createMockModel(); + const agentic = createAgenticModelFromLanguageModel(model); + expect(agentic).toBeInstanceOf(AgenticModel); + }); +}); From e669f6e02842417067abeec6d35718ee838f60dc Mon Sep 17 00:00:00 2001 From: Dan Farrelly Date: Thu, 5 Mar 2026 18:08:19 -0500 Subject: [PATCH 4/6] Add z.toJSONSchema guard, shared test helper, and agent tests - Add try/catch around z.toJSONSchema() in toolsToAiTools so incompatible schemas (e.g. Zod v3 from MCP) fall back to an open object schema instead of crashing inference - Extract createMockModel into shared __tests__/test-helpers.ts, removing duplication across routing-with-done, network, and model test files - Add standalone agent.run() and withModel() tests (6 new tests) --- .../agent-kit/src/__tests__/agent.test.ts | 125 ++++++++++++++++++ .../src/__tests__/routing-with-done.test.ts | 66 +-------- .../agent-kit/src/__tests__/test-helpers.ts | 72 ++++++++++ packages/agent-kit/src/converters.ts | 20 ++- packages/agent-kit/src/model.test.ts | 45 +------ packages/agent-kit/src/network.test.ts | 31 +---- 6 files changed, 217 insertions(+), 142 deletions(-) create mode 100644 packages/agent-kit/src/__tests__/agent.test.ts create mode 100644 packages/agent-kit/src/__tests__/test-helpers.ts diff --git a/packages/agent-kit/src/__tests__/agent.test.ts b/packages/agent-kit/src/__tests__/agent.test.ts new file mode 100644 index 00000000..b82fd026 --- /dev/null +++ b/packages/agent-kit/src/__tests__/agent.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from "vitest"; +import { z } from "zod"; +import { createAgent } from "../agent"; +import { createTool } from "../tool"; +import { createMockModel } from "./test-helpers"; + +describe("Agent standalone run", () => { + it("runs with a text response", async () => { + const model = createMockModel({ text: "Hello from agent" }); + const agent = createAgent({ + name: "TestAgent", + system: "You are a test agent.", + model, + }); + + const result = await agent.run("Hi"); + + expect(result.agentName).toBe("TestAgent"); + expect(result.output).toHaveLength(1); + expect(result.output[0]!.type).toBe("text"); + if (result.output[0]!.type === "text") { + expect(result.output[0]!.content).toBe("Hello from agent"); + } + }); + + it("runs with tool calls and executes the tool handler", async () => { + const model = createMockModel({ + toolCalls: [ + { toolCallId: "c1", toolName: "greet", args: { name: "Alice" } }, + ], + }); + + const agent = createAgent({ + name: "ToolAgent", + system: "You greet people.", + model, + tools: [ + createTool({ + name: "greet", + description: "Greet someone", + parameters: z.object({ name: z.string() }), + handler: ({ name }) => `Hello, ${name}!`, + }), + ], + }); + + const result = await agent.run("Greet Alice"); + + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls[0]!.content).toEqual({ data: "Hello, Alice!" }); + }); + + it("throws when no model is provided", async () => { + const agent = createAgent({ + name: "NoModel", + system: "Test", + }); + + await expect(agent.run("Hi")).rejects.toThrow("No model provided"); + }); + + it("runs with empty input (system prompt only)", async () => { + const model = createMockModel({ text: "System-only response" }); + const agent = createAgent({ + name: "SystemOnly", + system: "You are a system agent.", + model, + }); + + const result = await agent.run(""); + + expect(result.output).toHaveLength(1); + }); +}); + +describe("Agent.withModel", () => { + it("returns a new agent with the given model", async () => { + const model1 = createMockModel({ text: "Response from model 1" }); + const model2 = createMockModel({ text: "Response from model 2" }); + + const agent = createAgent({ + name: "Cloneable", + system: "You are cloneable.", + model: model1, + }); + + const cloned = agent.withModel(model2); + + expect(cloned.name).toBe("Cloneable"); + + const result = await cloned.run("Hi"); + expect(result.output[0]!.type).toBe("text"); + if (result.output[0]!.type === "text") { + expect(result.output[0]!.content).toBe("Response from model 2"); + } + }); + + it("preserves tools when cloning", async () => { + const model = createMockModel({ + toolCalls: [ + { toolCallId: "c1", toolName: "ping", args: {} }, + ], + }); + + const agent = createAgent({ + name: "WithTools", + system: "Test", + model, + tools: [ + createTool({ + name: "ping", + description: "Ping", + parameters: z.object({}), + handler: () => "pong", + }), + ], + }); + + const cloned = agent.withModel(model); + expect(cloned.tools.has("ping")).toBe(true); + + const result = await cloned.run("Ping"); + expect(result.toolCalls).toHaveLength(1); + }); +}); diff --git a/packages/agent-kit/src/__tests__/routing-with-done.test.ts b/packages/agent-kit/src/__tests__/routing-with-done.test.ts index 2699d8f5..0b236933 100644 --- a/packages/agent-kit/src/__tests__/routing-with-done.test.ts +++ b/packages/agent-kit/src/__tests__/routing-with-done.test.ts @@ -6,71 +6,7 @@ import { createTool, } from "../index"; import { z } from "zod"; -import type { LanguageModelV1 } from "ai"; - -/** - * Create a mock LanguageModelV1 for testing. - * By default returns a text response. Can return tool calls. - */ -function createMockModel(opts?: { - text?: string; - toolCalls?: Array<{ - toolCallId: string; - toolName: string; - args: unknown; - }>; - /** If provided, this function is called with the prompt to decide the response dynamically. */ - handler?: (prompt: unknown) => { - text?: string; - toolCalls?: Array<{ - toolCallType: "function"; - toolCallId: string; - toolName: string; - args: string; - }>; - finishReason: "stop" | "tool-calls"; - }; -}): LanguageModelV1 { - return { - specificationVersion: "v1", - provider: "mock", - modelId: "mock-model", - defaultObjectGenerationMode: "json", - doGenerate: async (options) => { - if (opts?.handler) { - const result = opts.handler(options.prompt); - return { - text: result.text ?? "", - toolCalls: result.toolCalls ?? [], - finishReason: result.finishReason, - usage: { promptTokens: 0, completionTokens: 0 }, - rawCall: { rawPrompt: null, rawSettings: {} }, - }; - } - - const toolCalls = (opts?.toolCalls ?? []).map((tc) => ({ - toolCallType: "function" as const, - toolCallId: tc.toolCallId, - toolName: tc.toolName, - args: JSON.stringify(tc.args), - })); - - return { - text: opts?.text ?? (toolCalls.length === 0 ? "Mocked response" : ""), - toolCalls, - finishReason: - toolCalls.length > 0 - ? ("tool-calls" as const) - : ("stop" as const), - usage: { promptTokens: 0, completionTokens: 0 }, - rawCall: { rawPrompt: null, rawSettings: {} }, - }; - }, - doStream: async () => { - throw new Error("Not implemented"); - }, - }; -} +import { createMockModel } from "./test-helpers"; describe("Routing with Done Tool", () => { it("should exit network when done tool is called", async () => { diff --git a/packages/agent-kit/src/__tests__/test-helpers.ts b/packages/agent-kit/src/__tests__/test-helpers.ts new file mode 100644 index 00000000..c2b3993a --- /dev/null +++ b/packages/agent-kit/src/__tests__/test-helpers.ts @@ -0,0 +1,72 @@ +import type { LanguageModelV1 } from "ai"; + +export interface MockModelOptions { + text?: string; + toolCalls?: Array<{ + toolCallId: string; + toolName: string; + args: unknown; + }>; + error?: Error; + /** If provided, called with the prompt to decide the response dynamically. */ + handler?: (prompt: unknown) => { + text?: string; + toolCalls?: Array<{ + toolCallType: "function"; + toolCallId: string; + toolName: string; + args: string; + }>; + finishReason: "stop" | "tool-calls"; + }; +} + +/** + * Create a mock LanguageModelV1 for testing. + * By default returns a text response. Can return tool calls or throw errors. + */ +export function createMockModel(opts?: MockModelOptions): LanguageModelV1 { + return { + specificationVersion: "v1", + provider: "mock", + modelId: "mock-model", + defaultObjectGenerationMode: "json", + doGenerate: async (options) => { + if (opts?.error) { + throw opts.error; + } + + if (opts?.handler) { + const result = opts.handler(options.prompt); + return { + text: result.text ?? "", + toolCalls: result.toolCalls ?? [], + finishReason: result.finishReason, + usage: { promptTokens: 0, completionTokens: 0 }, + rawCall: { rawPrompt: null, rawSettings: {} }, + }; + } + + const toolCalls = (opts?.toolCalls ?? []).map((tc) => ({ + toolCallType: "function" as const, + toolCallId: tc.toolCallId, + toolName: tc.toolName, + args: JSON.stringify(tc.args), + })); + + return { + text: opts?.text ?? (toolCalls.length === 0 ? "Mock response" : ""), + toolCalls, + finishReason: + toolCalls.length > 0 + ? ("tool-calls" as const) + : ("stop" as const), + usage: { promptTokens: 0, completionTokens: 0 }, + rawCall: { rawPrompt: null, rawSettings: {} }, + }; + }, + doStream: async () => { + throw new Error("Not implemented"); + }, + }; +} diff --git a/packages/agent-kit/src/converters.ts b/packages/agent-kit/src/converters.ts index 5cadcd0c..2f732e2e 100644 --- a/packages/agent-kit/src/converters.ts +++ b/packages/agent-kit/src/converters.ts @@ -138,11 +138,25 @@ export function toolsToAiTools( const result: Record = {}; for (const tool of tools) { + let parameters: CoreTool["parameters"]; + if (tool.parameters) { + try { + parameters = jsonSchema( + z.toJSONSchema(tool.parameters, { target: "draft-7" }) as Parameters[0] + ); + } catch { + // Fallback for schemas that z.toJSONSchema() cannot handle (e.g. Zod v3 + // schemas from MCP's JSON-Schema-to-Zod converter). Use an open object + // schema so the tool is still callable. + parameters = jsonSchema({ type: "object", properties: {} }); + } + } else { + parameters = jsonSchema({ type: "object", properties: {} }); + } + result[tool.name] = { description: tool.description, - parameters: tool.parameters - ? jsonSchema(z.toJSONSchema(tool.parameters, { target: "draft-7" }) as Parameters[0]) - : jsonSchema({ type: "object", properties: {} }), + parameters, }; } diff --git a/packages/agent-kit/src/model.test.ts b/packages/agent-kit/src/model.test.ts index bd4b4f18..c1d55770 100644 --- a/packages/agent-kit/src/model.test.ts +++ b/packages/agent-kit/src/model.test.ts @@ -3,50 +3,7 @@ import { z } from "zod"; import type { LanguageModelV1 } from "ai"; import { AgenticModel, createAgenticModelFromLanguageModel } from "./model"; import type { Tool } from "./tool"; - -/** - * Create a mock LanguageModelV1 for testing. - */ -function createMockModel(opts?: { - text?: string; - toolCalls?: Array<{ - toolCallId: string; - toolName: string; - args: unknown; - }>; - error?: Error; -}): LanguageModelV1 { - return { - specificationVersion: "v1", - provider: "mock", - modelId: "mock-model", - defaultObjectGenerationMode: "json", - doGenerate: async () => { - if (opts?.error) { - throw opts.error; - } - const toolCalls = (opts?.toolCalls ?? []).map((tc) => ({ - toolCallType: "function" as const, - toolCallId: tc.toolCallId, - toolName: tc.toolName, - args: JSON.stringify(tc.args), - })); - return { - text: opts?.text ?? (toolCalls.length === 0 ? "Mock response" : ""), - toolCalls, - finishReason: - toolCalls.length > 0 - ? ("tool-calls" as const) - : ("stop" as const), - usage: { promptTokens: 0, completionTokens: 0 }, - rawCall: { rawPrompt: null, rawSettings: {} }, - }; - }, - doStream: async () => { - throw new Error("Not implemented"); - }, - }; -} +import { createMockModel } from "./__tests__/test-helpers"; describe("AgenticModel", () => { it("infers a text response", async () => { diff --git a/packages/agent-kit/src/network.test.ts b/packages/agent-kit/src/network.test.ts index b46143fc..b23dd2bc 100644 --- a/packages/agent-kit/src/network.test.ts +++ b/packages/agent-kit/src/network.test.ts @@ -3,36 +3,7 @@ import { createNetwork } from "./network"; import { createAgent } from "./agent"; import { createState } from "./state"; import { AgentResult, type Message } from "./types"; -import type { LanguageModelV1 } from "ai"; - -/** - * Create a mock LanguageModelV1 that returns a simple text response. - */ -function createMockModel( - response: { text?: string; toolCalls?: Array<{ toolCallId: string; toolName: string; args: unknown }> } = { text: "Mock AI response" } -): LanguageModelV1 { - return { - specificationVersion: "v1", - provider: "mock", - modelId: "mock-model", - defaultObjectGenerationMode: "json", - doGenerate: async () => ({ - text: response.text ?? "", - toolCalls: (response.toolCalls ?? []).map((tc) => ({ - toolCallType: "function" as const, - toolCallId: tc.toolCallId, - toolName: tc.toolName, - args: JSON.stringify(tc.args), - })), - finishReason: (response.toolCalls?.length ?? 0) > 0 ? "tool-calls" as const : "stop" as const, - usage: { promptTokens: 0, completionTokens: 0 }, - rawCall: { rawPrompt: null, rawSettings: {} }, - }), - doStream: async () => { - throw new Error("Not implemented"); - }, - }; -} +import { createMockModel } from "./__tests__/test-helpers"; describe("Network", () => { test("run should preserve results from a deserialized state", async () => { From 6f461897d89dad8bc74810f2be96243c13d2fa01 Mon Sep 17 00:00:00 2001 From: Dan Farrelly Date: Thu, 5 Mar 2026 20:15:14 -0500 Subject: [PATCH 5/6] Linting fixes --- .../agent-kit/src/__tests__/agent.test.ts | 4 +-- .../agent-kit/src/__tests__/test-helpers.ts | 5 ++-- packages/agent-kit/src/converters.ts | 15 ++++++----- packages/agent-kit/src/model.test.ts | 27 +++++++++---------- 4 files changed, 24 insertions(+), 27 deletions(-) diff --git a/packages/agent-kit/src/__tests__/agent.test.ts b/packages/agent-kit/src/__tests__/agent.test.ts index b82fd026..0118330e 100644 --- a/packages/agent-kit/src/__tests__/agent.test.ts +++ b/packages/agent-kit/src/__tests__/agent.test.ts @@ -97,9 +97,7 @@ describe("Agent.withModel", () => { it("preserves tools when cloning", async () => { const model = createMockModel({ - toolCalls: [ - { toolCallId: "c1", toolName: "ping", args: {} }, - ], + toolCalls: [{ toolCallId: "c1", toolName: "ping", args: {} }], }); const agent = createAgent({ diff --git a/packages/agent-kit/src/__tests__/test-helpers.ts b/packages/agent-kit/src/__tests__/test-helpers.ts index c2b3993a..a4ebcc96 100644 --- a/packages/agent-kit/src/__tests__/test-helpers.ts +++ b/packages/agent-kit/src/__tests__/test-helpers.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/require-await */ import type { LanguageModelV1 } from "ai"; export interface MockModelOptions { @@ -58,9 +59,7 @@ export function createMockModel(opts?: MockModelOptions): LanguageModelV1 { text: opts?.text ?? (toolCalls.length === 0 ? "Mock response" : ""), toolCalls, finishReason: - toolCalls.length > 0 - ? ("tool-calls" as const) - : ("stop" as const), + toolCalls.length > 0 ? ("tool-calls" as const) : ("stop" as const), usage: { promptTokens: 0, completionTokens: 0 }, rawCall: { rawPrompt: null, rawSettings: {} }, }; diff --git a/packages/agent-kit/src/converters.ts b/packages/agent-kit/src/converters.ts index 2f732e2e..f68f2783 100644 --- a/packages/agent-kit/src/converters.ts +++ b/packages/agent-kit/src/converters.ts @@ -22,9 +22,10 @@ export function messagesToCoreMessages(messages: Message[]): CoreMessage[] { for (const msg of messages) { switch (msg.type) { case "text": { - const content = typeof msg.content === "string" - ? msg.content - : msg.content.map((c) => c.text).join(""); + const content = + typeof msg.content === "string" + ? msg.content + : msg.content.map((c) => c.text).join(""); result.push({ role: msg.role, content }); break; } @@ -132,9 +133,7 @@ export function resultToMessages(result: SerializableResult): Message[] { * Note: We do NOT pass `execute` here — tool execution is handled by the * agent's own invokeTools method after inference. */ -export function toolsToAiTools( - tools: Tool.Any[] -): Record { +export function toolsToAiTools(tools: Tool.Any[]): Record { const result: Record = {}; for (const tool of tools) { @@ -142,7 +141,9 @@ export function toolsToAiTools( if (tool.parameters) { try { parameters = jsonSchema( - z.toJSONSchema(tool.parameters, { target: "draft-7" }) as Parameters[0] + z.toJSONSchema(tool.parameters, { target: "draft-7" }) as Parameters< + typeof jsonSchema + >[0] ); } catch { // Fallback for schemas that z.toJSONSchema() cannot handle (e.g. Zod v3 diff --git a/packages/agent-kit/src/model.test.ts b/packages/agent-kit/src/model.test.ts index c1d55770..70733c78 100644 --- a/packages/agent-kit/src/model.test.ts +++ b/packages/agent-kit/src/model.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/require-await */ import { describe, it, expect } from "vitest"; import { z } from "zod"; import type { LanguageModelV1 } from "ai"; @@ -110,19 +111,17 @@ describe("AgenticModel", () => { ).rejects.toThrow("Rate limit exceeded"); }); - it("propagates non-Error exceptions from the model", async () => { - const model: LanguageModelV1 = { - specificationVersion: "v1", - provider: "mock", - modelId: "mock-model", - defaultObjectGenerationMode: "json", - doGenerate: async () => { - throw "string error"; - }, - doStream: async () => { - throw new Error("Not implemented"); - }, - }; + it("propagates specific error types from the model", async () => { + class RateLimitError extends Error { + constructor(public retryAfter: number) { + super("Rate limited"); + this.name = "RateLimitError"; + } + } + + const model = createMockModel({ + error: new RateLimitError(30), + }); const agentic = new AgenticModel(model); await expect( @@ -132,7 +131,7 @@ describe("AgenticModel", () => { [], "auto" ) - ).rejects.toBe("string error"); + ).rejects.toBeInstanceOf(RateLimitError); }); it("does not pass toolChoice when no tools are provided", async () => { From 9cde5df4c482e307d0b03112aaee897049be1f21 Mon Sep 17 00:00:00 2001 From: Dan Farrelly Date: Fri, 6 Mar 2026 09:44:37 -0500 Subject: [PATCH 6/6] Linting fixes --- packages/agent-kit/src/agent.ts | 5 ++++- packages/agent-kit/src/converters.test.ts | 18 +++++++++++++----- packages/agent-kit/src/model.ts | 2 +- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/agent-kit/src/agent.ts b/packages/agent-kit/src/agent.ts index d70d4f10..6204b873 100644 --- a/packages/agent-kit/src/agent.ts +++ b/packages/agent-kit/src/agent.ts @@ -14,7 +14,10 @@ import { errors } from "inngest/internals"; import { type InngestFunction } from "inngest"; import { type MinimalEventPayload } from "inngest/types"; import type { ZodType } from "zod"; -import { createAgenticModelFromLanguageModel, type AgenticModel } from "./model"; +import { + createAgenticModelFromLanguageModel, + type AgenticModel, +} from "./model"; import { createNetwork, NetworkRun } from "./network"; import { State, type StateData } from "./state"; import { type MCP, type Tool } from "./tool"; diff --git a/packages/agent-kit/src/converters.test.ts b/packages/agent-kit/src/converters.test.ts index 8c1d4d62..74763d40 100644 --- a/packages/agent-kit/src/converters.test.ts +++ b/packages/agent-kit/src/converters.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/require-await */ import { describe, it, expect } from "vitest"; import { z } from "zod"; import { @@ -51,9 +52,7 @@ describe("messagesToCoreMessages", () => { }); it("handles empty array content", () => { - const messages: Message[] = [ - { type: "text", role: "user", content: [] }, - ]; + const messages: Message[] = [{ type: "text", role: "user", content: [] }]; const result = messagesToCoreMessages(messages); expect(result).toEqual([{ role: "user", content: "" }]); }); @@ -155,11 +154,20 @@ describe("messagesToCoreMessages", () => { { type: "tool_result", role: "tool_result", - tool: { type: "tool", id: "c1", name: "weather", input: { city: "NYC" } }, + tool: { + type: "tool", + id: "c1", + name: "weather", + input: { city: "NYC" }, + }, content: "Sunny, 75F", stop_reason: "tool", }, - { type: "text", role: "assistant", content: "It's sunny and 75F in NYC." }, + { + type: "text", + role: "assistant", + content: "It's sunny and 75F in NYC.", + }, ]; const result = messagesToCoreMessages(messages); expect(result).toHaveLength(5); diff --git a/packages/agent-kit/src/model.ts b/packages/agent-kit/src/model.ts index 214e9de2..e1845624 100644 --- a/packages/agent-kit/src/model.ts +++ b/packages/agent-kit/src/model.ts @@ -45,7 +45,7 @@ export class AgenticModel { toolCalls: result.toolCalls.map((tc) => ({ toolCallId: tc.toolCallId, toolName: tc.toolName, - args: tc.args, + args: tc.args as Record, })), finishReason: result.finishReason, };