diff --git a/.changeset/ten-apes-argue.md b/.changeset/ten-apes-argue.md new file mode 100644 index 0000000..fcc07a8 --- /dev/null +++ b/.changeset/ten-apes-argue.md @@ -0,0 +1,5 @@ +--- +"@inngest/agent-kit": minor +--- + +Stepless model/network/agent instantiations diff --git a/demo/inngest.ts b/demo/inngest.ts index 3e3da7e..c08f454 100644 --- a/demo/inngest.ts +++ b/demo/inngest.ts @@ -1,13 +1,11 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { - anthropic, createAgent, createNetwork, createTool, defaultRoutingAgent, - openai, -} from "../src/index"; -import { EventSchemas, Inngest } from "inngest"; +} from "@inngest/agent-kit"; +import { EventSchemas, Inngest, openai } from "inngest"; import { z } from "zod"; export const inngest = new Inngest({ @@ -22,44 +20,26 @@ export const inngest = new Inngest({ }); export const fn = inngest.createFunction( - { id: "agent", retries: 0, }, + { id: "agent", retries: 0 }, { event: "agent/run" }, - async ({ event, step }) => { - const model = openai({ model: "gpt-4", step }); - + async ({ event }) => { // 1. Single agent - // Run a single agent as a prompt without a network. // await codeWritingAgent.run(event.data.input, { // model, // }); - // 2. A network of agents that works together - const network = createNetwork({ - agents: [ - codeWritingAgent.withModel(model), - executingAgent.withModel(model), - ], - defaultModel: model, - maxIter: 4, - }); - - // This uses the defaut agentic router to determine which agent to handle first. You can - // optionally specifiy the agent that should execute first, and provide your own logic for - // handling logic in between agent calls. - const result = await network.run(event.data.input, ({ network }) => { - if (network.state.kv.has("files")) { - // Okay, we have some files. Did an agent run tests? - return executingAgent; - } - - return defaultRoutingAgent.withModel(model); - }); - - return result; - }, + // A network of agents that works together. + // + // This uses the defaut agentic router to determine which agent to handle + // first. You can optionally specifiy the agent that should execute first, + // and provide your own logic for handling logic in between agent calls. + return network.run(event.data.input); + } ); +const model = openai({ model: "gpt-4" }); + const systemPrompt = "You are an expert TypeScript programmer. You can create files with idiomatic TypeScript code, with comments and associated tests."; @@ -68,14 +48,14 @@ const codeWritingAgent = createAgent({ // description helps LLM routers choose the right agents to run. description: "An expert TypeScript programmer which can write and debug code", // system defines a system prompt generated each time the agent is called by a network. - system: (network) => { - if (!network) { + system: ({ network }) => { + if (!network?.state) { return systemPrompt; } // Each time this agent runs, it may produce "file" content. Check if any // content has already been produced in an agentic workflow. - const files = network.state.kv.get>("files"); + const files = network?.state.kv.get>("files"); if (files === undefined) { // Use the default system prompt. @@ -109,7 +89,7 @@ const codeWritingAgent = createAgent({ filename: z.string(), content: z.string(), }) - .required(), + .required() ), }) .required(), @@ -157,3 +137,17 @@ Think carefully about the request that the user is asking for. Do not respond wi `, }); + +const network = createNetwork({ + agents: [codeWritingAgent.withModel(model), executingAgent.withModel(model)], + defaultModel: model, + maxIter: 4, + defaultRouter: ({ network }) => { + if (network.state.kv.has("files")) { + // Okay, we have some files. Did an agent run tests? + return executingAgent; + } + + return defaultRoutingAgent.withModel(model); + }, +}); diff --git a/demo/mw.ts b/demo/mw.ts index fbedc39..1faebe7 100644 --- a/demo/mw.ts +++ b/demo/mw.ts @@ -1,5 +1,5 @@ -import { agenticOpenai, createAgent, createNetwork } from "@inngest/agent-kit"; -import { InngestMiddleware, type OpenAi } from "inngest"; +import { createAgent, createNetwork } from "@inngest/agent-kit"; +import { InngestMiddleware, openai, type OpenAi } from "inngest"; export const codeWritingNetworkMiddleware = ( defaultModelOptions: OpenAi.AiModelOptions, @@ -7,14 +7,16 @@ export const codeWritingNetworkMiddleware = ( return new InngestMiddleware({ name: "Code Writing Agent Middleware", init() { + const model = openai({ ...defaultModelOptions }); + return { onFunctionRun() { return { - transformInput({ ctx: { step } }) { + transformInput() { const codeWritingNetwork = createNetwork({ agents: [codeWritingAgent, executingAgent], maxIter: 4, - defaultModel: agenticOpenai({ ...defaultModelOptions, step }), + defaultModel: model, }); return { diff --git a/demo/package.json b/demo/package.json index 62e5aa7..667e695 100644 --- a/demo/package.json +++ b/demo/package.json @@ -9,9 +9,9 @@ "license": "ISC", "description": "", "dependencies": { - "@inngest/agent-kit": "~0.0.1", + "@inngest/agent-kit": "file:../inngest-agent-kit-0.0.3.tgz", "express": "^4.21.1", - "inngest": "^3.27.3" + "inngest": "^3.27.6-pr-776.2" }, "devDependencies": { "@types/express": "^5.0.0", diff --git a/examples/swebench/agents/editor.ts b/examples/swebench/agents/editor.ts index b1e4521..367e9a1 100644 --- a/examples/swebench/agents/editor.ts +++ b/examples/swebench/agents/editor.ts @@ -1,4 +1,4 @@ -import { createAgent, createTool } from "../../../src"; +import { createAgent, createTool } from "@inngest/agent-kit"; import { extractClassAndFnsTool, readFileTool, @@ -12,7 +12,8 @@ import { */ export const editingAgent = createAgent({ name: "Editor", - description: "Edits code by replacing contents in files, or creating new files with new code.", + description: + "Edits code by replacing contents in files, or creating new files with new code.", tools: [ extractClassAndFnsTool, replaceClassMethodTool, @@ -38,16 +39,16 @@ export const editingAgent = createAgent({ // things from the planning agent. We update the system prompt to include details from the // plan via network state. onStart: ({ agent, prompt, network }) => { - const history = (network?.state.results || []). - filter(i => i.agent === agent). // Return the current history from this agent only. - map(i => i.output.concat(i.toolCalls)). // Only add the output and tool calls to the conversation history - flat(); + const history = (network?.state.results || []) + .filter((i) => i.agent === agent) // Return the current history from this agent only. + .map((i) => i.output.concat(i.toolCalls)) // Only add the output and tool calls to the conversation history + .flat(); return { prompt, history, stop: false }; }, }, - system: (network) => ` + system: ({ network }) => ` You are an expert Python programmer working on a specific project: ${network?.state.kv.get("repo")}. You have been given a plan to fix the given issue supplied by the user. @@ -63,4 +64,4 @@ export const editingAgent = createAgent({ Once the files have been edited and you are confident in the updated code, you MUST finish your editing via calling the "done" tool. `, -}) +}); diff --git a/examples/swebench/agents/planner.ts b/examples/swebench/agents/planner.ts index 3be4b0f..4679c0d 100644 --- a/examples/swebench/agents/planner.ts +++ b/examples/swebench/agents/planner.ts @@ -1,4 +1,4 @@ -import { createAgent, createTool } from "../../../src"; +import { createAgent, createTool } from "@inngest/agent-kit"; import { z } from "zod"; import { extractClassAndFnsTool, @@ -16,15 +16,18 @@ export const planningAgent = createAgent({ extractClassAndFnsTool, createTool({ name: "create_plan", - description: "Describe a formal plan for how to fix the issue, including which files to edit and reasoning.", + description: + "Describe a formal plan for how to fix the issue, including which files to edit and reasoning.", parameters: z.object({ thoughts: z.string(), plan_details: z.string(), - edits: z.array(z.object({ - filename: z.string(), - idea: z.string(), - reasoning: z.string(), - })) + edits: z.array( + z.object({ + filename: z.string(), + idea: z.string(), + reasoning: z.string(), + }) + ), }), handler: async (plan, opts) => { @@ -35,7 +38,7 @@ export const planningAgent = createAgent({ }), ], - system: (network) => ` + system: ({ network }) => ` You are an expert Python programmer working on a specific project: ${network?.state.kv.get("repo")}. You are given an issue reported within the project. You are planning how to fix the issue by investigating the report, @@ -48,5 +51,4 @@ export const planningAgent = createAgent({ - Read entire files - Find specific classes and functions within a file `, -}) - +}); diff --git a/examples/swebench/agents/setup.ts b/examples/swebench/agents/setup.ts new file mode 100644 index 0000000..a2d14fd --- /dev/null +++ b/examples/swebench/agents/setup.ts @@ -0,0 +1,6 @@ +import { createAgent } from "@inngest/agent-kit"; + +createAgent({ + name: "setup", + system: "This is a system prompt", +}); diff --git a/examples/swebench/index.ts b/examples/swebench/index.ts index 8c632b7..3796b5c 100644 --- a/examples/swebench/index.ts +++ b/examples/swebench/index.ts @@ -6,7 +6,7 @@ const app = express(); const port = 3001; // Important: ensure you add JSON middleware to process incoming JSON POST payloads. -app.use(express.json({limit: '50mb'})); +app.use(express.json({ limit: "50mb" })); app.use( // Expose the middleware on our recommended path at `/api/inngest`. @@ -15,7 +15,7 @@ app.use( serve({ client: inngest, functions: [fn], - }), + }) ); app.listen(port, () => { diff --git a/examples/swebench/inngest.ts b/examples/swebench/inngest.ts index eaca01f..9ee29cf 100644 --- a/examples/swebench/inngest.ts +++ b/examples/swebench/inngest.ts @@ -1,15 +1,8 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ +import { execSync } from "child_process"; import fs from "fs"; -import { execSync } from 'child_process'; +import { EventSchemas, Inngest } from "inngest"; import { z } from "zod"; -import { - createNetwork, - anthropic, - State, -} from "../../src/index"; -import { Inngest, EventSchemas } from "inngest"; -import { planningAgent } from "./agents/planner"; -import { editingAgent } from "./agents/editor"; +import { codeWritingNetwork } from "./networks/codeWritingNetwork"; export const inngest = new Inngest({ id: "agents", @@ -20,16 +13,15 @@ export const inngest = new Inngest({ base_commit: z.string(), environment_setup_commit: z.string(), problem_statement: z.string(), - }) + }), }, }), }); export const fn = inngest.createFunction( - { id: "agent", retries: 2, }, + { id: "agent", retries: 2 }, { event: "swebench/run" }, async ({ event, step }) => { - // This is some basic stuff to initialize and set up the repos // for the swebench test. // @@ -38,57 +30,26 @@ export const fn = inngest.createFunction( await step.run("clone repo", async () => { // Check if the dir already exists. if (fs.existsSync(dir)) { - return + return; } console.log("creating repo"); fs.mkdirSync(dir, { recursive: true }); execSync(`cd ${dir} && git init`); - execSync(`cd ${dir} && git remote add origin git@github.com:${event.data.repo}.git`); + execSync( + `cd ${dir} && git remote add origin git@github.com:${event.data.repo}.git` + ); }); await step.run("check out commit", async () => { console.log("checking out commit"); - execSync(`cd ${dir} && git fetch origin ${event.data.base_commit} --depth=1`); + execSync( + `cd ${dir} && git fetch origin ${event.data.base_commit} --depth=1` + ); execSync(`cd ${dir} && git reset --hard FETCH_HEAD`); }); - // Use Claude as the base model of the network. - const model = anthropic({ - model: "claude-3-5-haiku-latest", - max_tokens: 1000, - step: step as any, - }); - - // Create new network state, and set the repo we're editing directly from the event - // input. - const state = new State(); - state.kv.set("repo", event.data.repo); - - const network = createNetwork({ - agents: [planningAgent, editingAgent], - defaultModel: model, - state, - }); - await network.run(event.data.problem_statement, (opts) => { - if (opts.network.state.kv.get("done")) { - // We're done editing. This is set when the editing agent finishes - // implementing the plan. - // - // At this point, we should hand off to another agent that tests, critiques, - // and validates the edits. - return; - } - - // If there's a plan, we should switch to the editing agent to begin implementing. - // - // This lets us separate the concerns of planning vs editing, including using differing - // prompts and tools at various stages of the editing process. - if (opts.network.state.kv.get("plan") !== undefined) { - return editingAgent; - } - - // By default, use the planning agent. - return planningAgent; + await codeWritingNetwork.run(event.data.problem_statement, { + state: { repo: event.data.repo }, }); - }, + } ); diff --git a/examples/swebench/networks/codeWritingNetwork.ts b/examples/swebench/networks/codeWritingNetwork.ts new file mode 100644 index 0000000..c20312d --- /dev/null +++ b/examples/swebench/networks/codeWritingNetwork.ts @@ -0,0 +1,34 @@ +import { createNetwork } from "@inngest/agent-kit"; +import { anthropic } from "inngest"; +import { editingAgent } from "../agents/editor"; +import { planningAgent } from "../agents/planner"; + +export const codeWritingNetwork = createNetwork({ + agents: [planningAgent, editingAgent], + // Use Claude as the base model of the network. + defaultModel: anthropic({ + model: "claude-3-5-haiku-latest", + max_tokens: 1000, + }), + defaultRouter: ({ network }) => { + if (network.state.kv.get("done")) { + // We're done editing. This is set when the editing agent finishes + // implementing the plan. + // + // At this point, we should hand off to another agent that tests, critiques, + // and validates the edits. + return; + } + + // If there's a plan, we should switch to the editing agent to begin implementing. + // + // This lets us separate the concerns of planning vs editing, including using differing + // prompts and tools at various stages of the editing process. + if (network.state.kv.get("plan") !== undefined) { + return editingAgent; + } + + // By default, use the planning agent. + return planningAgent; + }, +}); diff --git a/examples/swebench/package.json b/examples/swebench/package.json index 97bbbb8..82813fd 100644 --- a/examples/swebench/package.json +++ b/examples/swebench/package.json @@ -10,7 +10,8 @@ "author": "", "license": "ISC", "dependencies": { - "inngest": "^3.27.4", + "@inngest/agent-kit": "file:../../inngest-agent-kit-0.0.3.tgz", + "inngest": "^3.27.6-pr-776.2", "tree-sitter": "^0.22.1", "tree-sitter-python": "^0.23.5" } diff --git a/examples/swebench/tools/tools.ts b/examples/swebench/tools/tools.ts index 80c2046..ff0d5ef 100644 --- a/examples/swebench/tools/tools.ts +++ b/examples/swebench/tools/tools.ts @@ -1,15 +1,15 @@ +import { createTool } from "@inngest/agent-kit"; import fs from "fs"; -import { z } from "zod"; import Parser from "tree-sitter"; import Py from "tree-sitter-python"; -import { createTool } from "../../../src/index"; +import { z } from "zod"; // PyClass represents a class parsed from a python file. interface PyClass { name: string; startLine: number; endLine: number; - methods: PyFn[] + methods: PyFn[]; } // PyFN represents a function parsed from a python file. This may belong to a class or @@ -24,13 +24,16 @@ interface PyFn { export const listFilesTool = createTool({ name: "list_files", - description: "Lists all files within the project, returned as a JSON string containign the path to each file", + description: + "Lists all files within the project, returned as a JSON string containign the path to each file", handler: async (_input, opts) => { // NOTE: In this repo, all files are stored in "./opt/" as the prefix. - const path = "./opt/" + opts.network?.state.kv.get("repo") + const path = "./opt/" + opts.network?.state.kv.get("repo"); const files = await opts.step.run("list files", () => { - return fs.readdirSync(path, { recursive: true }).filter(name => name.indexOf(".git") !== 0) + return fs + .readdirSync(path, { recursive: true }) + .filter((name) => name.indexOf(".git") !== 0); }); // Store all files within state. Note that this happens outside of steps @@ -49,7 +52,7 @@ export const readFileTool = createTool({ handler: async ({ filename }, opts) => { const content = await opts.step.run(`read file: ${filename}`, () => { return readFile(opts.network?.state.kv.get("repo") || "", filename); - }) + }); // Set state for the filename. Note that this happens outside of steps // so that this is not memoized. @@ -65,13 +68,17 @@ export const readFileTool = createTool({ */ export const extractClassAndFnsTool = createTool({ name: "extract_classes_and_functions", - description: "Return all classes names and their functions, including top level functions", + description: + "Return all classes names and their functions, including top level functions", parameters: z.object({ filename: z.string(), }), handler: async (input, opts) => { return await opts.step.run("parse file", () => { - const contents = readFile(opts.network?.state.kv.get("repo") || "", input.filename); + const contents = readFile( + opts.network?.state.kv.get("repo") || "", + input.filename + ); return parseClassAndFns(contents); }); }, @@ -86,39 +93,50 @@ export const replaceClassMethodTool = createTool({ function_name: z.string(), new_contents: z.string(), }), - handler: async ({ filename, class_name, function_name, new_contents }, opts) => { - const updated = await opts?.step.run(`update class method in '${filename}': ${class_name}.${function_name}`, () => { - // Re-parse the contents to find the correct start and end offsets. - const contents = readFile(opts.network?.state.kv.get("repo") || "", filename); - const parsed = parseClassAndFns(contents); - - const c = parsed.classes.find(c => class_name === c.name); - const fn = c?.methods.find(f => f.name === function_name); - if (!c || !fn) { - // TODO: Redo the planning as this wasn't found. - throw new Error("TODO: redo plan"); - } + handler: async ( + { filename, class_name, function_name, new_contents }, + opts + ) => { + const updated = await opts?.step.run( + `update class method in '${filename}': ${class_name}.${function_name}`, + () => { + // Re-parse the contents to find the correct start and end offsets. + const contents = readFile( + opts.network?.state.kv.get("repo") || "", + filename + ); + const parsed = parseClassAndFns(contents); + + const c = parsed.classes.find((c) => class_name === c.name); + const fn = c?.methods.find((f) => f.name === function_name); + if (!c || !fn) { + // TODO: Redo the planning as this wasn't found. + throw new Error("TODO: redo plan"); + } - return contents.split("\n").reduce((updated, line, idx) => { - const beforeRange = (idx + 1) < fn.startLine; - const isRange = (idx + 1) === fn.startLine; - const afterRange = (idx + 1) >= fn.endLine; + return contents + .split("\n") + .reduce((updated, line, idx) => { + const beforeRange = idx + 1 < fn.startLine; + const isRange = idx + 1 === fn.startLine; + const afterRange = idx + 1 >= fn.endLine; - if (beforeRange || afterRange) { - return [...updated, line]; - } + if (beforeRange || afterRange) { + return [...updated, line]; + } - return isRange ? [...updated, new_contents] : updated; - }, [] as string[]).join("\n"); - }); + return isRange ? [...updated, new_contents] : updated; + }, [] as string[]) + .join("\n"); + } + ); - const path = "./opt/" + opts.network?.state.kv.get("repo") + const path = "./opt/" + opts.network?.state.kv.get("repo"); fs.writeFileSync(path + "/" + filename, updated); return new_contents; }, -}) - +}); // // Utility functions @@ -126,16 +144,16 @@ export const replaceClassMethodTool = createTool({ export const readFile = (repo: string, filename: string) => { // NOTE: In this repo, all files are stored in "./opt/" as the prefix. - const path = "./opt/" + repo + const path = "./opt/" + repo; return fs.readFileSync(path + "/" + filename).toString(); -} +}; export const parseClassAndFns = (contents: string) => { const parser = new Parser(); parser.setLanguage(Py); const tree = parser.parse(contents); - const cursor = tree.walk() + const cursor = tree.walk(); const results = { classes: [] as PyClass[], @@ -144,8 +162,8 @@ export const parseClassAndFns = (contents: string) => { // Helper to get the full function name and parameters const getFunctionDetails = (node: Parser.SyntaxNode): PyFn => { - const nameNode = node.childForFieldName('name'); - const parametersNode = node.childForFieldName('parameters'); + const nameNode = node.childForFieldName("name"); + const parametersNode = node.childForFieldName("parameters"); return { name: nameNode?.text || "", parameters: parametersNode?.text || "", @@ -153,7 +171,7 @@ export const parseClassAndFns = (contents: string) => { endLine: node.endPosition.row + 1, body: "", //node.text }; - } + }; const getClassMethods = (classNode: Parser.SyntaxNode) => { const methods: PyFn[] = []; @@ -167,40 +185,40 @@ export const parseClassAndFns = (contents: string) => { cursor.gotoFirstChild(); do { - if (cursor.nodeType === 'function_definition') { + if (cursor.nodeType === "function_definition") { methods.push(getFunctionDetails(cursor.currentNode)); } - } while(cursor.gotoNextSibling()); + } while (cursor.gotoNextSibling()); return methods; - } + }; cursor.gotoFirstChild(); do { const node = cursor.currentNode; if (!node) { - continue + continue; } switch (node.type) { - case 'function_definition': + case "function_definition": // Only process top-level functions if (node.parent === tree.rootNode) { - results.functions.push(getFunctionDetails(node)); - } - break; + results.functions.push(getFunctionDetails(node)); + } + break; - case 'class_definition': + case "class_definition": const classInfo: PyClass = { - name: node.childForFieldName('name')?.text || "", - startLine: node.startPosition.row + 1, - endLine: node.endPosition.row + 1, - methods: getClassMethods(node) - }; - results.classes.push(classInfo); - break; + name: node.childForFieldName("name")?.text || "", + startLine: node.startPosition.row + 1, + endLine: node.endPosition.row + 1, + methods: getClassMethods(node), + }; + results.classes.push(classInfo); + break; } - } while(cursor.gotoNextSibling()); + } while (cursor.gotoNextSibling()); return results; -} +}; diff --git a/package.json b/package.json index a322cbc..a0f8afb 100644 --- a/package.json +++ b/package.json @@ -39,8 +39,8 @@ } }, "dependencies": { - "inngest": "^3.27.4", "express": "^4.21.1", + "inngest": "3.27.6-pr-776.2", "openai-zod-to-json-schema": "^1.0.3", "zod": "^3.23.8" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9966b3d..65ad14b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^4.21.1 version: 4.21.1 inngest: - specifier: ^3.27.4 - version: 3.27.4(express@4.21.1)(typescript@5.7.2) + specifier: 3.27.6-pr-776.2 + version: 3.27.6-pr-776.2(express@4.21.1)(typescript@5.7.2) openai-zod-to-json-schema: specifier: ^1.0.3 version: 1.0.3(zod@3.23.8) @@ -866,8 +866,8 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - inngest@3.27.4: - resolution: {integrity: sha512-S71jJNxmfA9d4jmFKSxgi/u5d+tSmpsThAmTMHhjGieBTSrfGguwhHHskkPpFCtrMWI+Qdy8ek/rtQNxFkE9eQ==} + inngest@3.27.6-pr-776.2: + resolution: {integrity: sha512-hod0BjJkKKSuA5hyIwMuPqaVBKM4UY9zZwU7kJPV+BCmhEOf0oX0Rr4x5eLffjwTzCXWWi4HuNtBXZpmNF58tQ==} engines: {node: '>=14'} peerDependencies: '@sveltejs/kit': '>=1.27.3' @@ -2477,7 +2477,7 @@ snapshots: inherits@2.0.4: {} - inngest@3.27.4(express@4.21.1)(typescript@5.7.2): + inngest@3.27.6-pr-776.2(express@4.21.1)(typescript@5.7.2): dependencies: '@types/debug': 4.1.12 canonicalize: 1.0.8 diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index a9ef8c1..ef6c75d 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -4,15 +4,15 @@ * @module */ import { - type AnthropicAiAdapter, type AiAdapter, type Anthropic, -} from 'inngest'; -import { zodToJsonSchema } from 'openai-zod-to-json-schema'; -import { type Tool } from '../types'; -import { z } from 'zod'; -import { type AgenticModel } from '../model'; -import { type TextMessage, type Message } from '../state'; + type AnthropicAiAdapter, +} from "inngest"; +import { zodToJsonSchema } from "openai-zod-to-json-schema"; +import { z } from "zod"; +import { type AgenticModel } from "../model"; +import { type Message, type TextMessage } from "../state"; +import { type Tool } from "../types"; /** * Parse a request from internal network messages to an Anthropic input. @@ -21,56 +21,56 @@ export const requestParser: AgenticModel.RequestParser = ( model, messages, tools, - tool_choice = 'auto', + 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) => m.role === 'system' && m.type === 'text', + (m) => m.role === "system" && m.type === "text", ) as TextMessage; const system = - typeof systemMessage?.content === 'string' ? systemMessage.content : ''; + typeof systemMessage?.content === "string" ? systemMessage.content : ""; - const anthropicMessages: AiAdapter.Input['messages'] = + const anthropicMessages: AiAdapter.Input["messages"] = messages - .filter((m) => m.role !== 'system') + .filter((m) => m.role !== "system") .reduce( (acc, m) => { switch (m.type) { - case 'text': + case "text": return [ ...acc, { role: m.role, content: Array.isArray(m.content) - ? m.content.map((text) => ({ type: 'text', text })) + ? m.content.map((text) => ({ type: "text", text })) : m.content, }, - ] as AiAdapter.Input['messages']; - case 'tool_call': + ] as AiAdapter.Input["messages"]; + case "tool_call": return [ ...acc, { role: m.role, content: m.tools.map((tool) => ({ - type: 'tool_use', + type: "tool_use", id: tool.id, input: tool.input, name: tool.name, })), }, ]; - case 'tool_result': + case "tool_result": return [ ...acc, { - role: 'user', + role: "user", content: [ { - type: 'tool_result', + type: "tool_result", tool_use_id: m.tool.id, content: - typeof m.content === 'string' + typeof m.content === "string" ? m.content : JSON.stringify(m.content), }, @@ -79,7 +79,7 @@ export const requestParser: AgenticModel.RequestParser = ( ]; } }, - [] as AiAdapter.Input['messages'], + [] as AiAdapter.Input["messages"], ); const request: AiAdapter.Input = { @@ -119,23 +119,23 @@ export const responseParser: AgenticModel.ResponseParser = ( } switch (item.type) { - case 'text': + case "text": return [ ...acc, { - type: 'text', + type: "text", role: input.role, content: item.text, // XXX: Better stop reason parsing - stop_reason: 'stop', + stop_reason: "stop", }, ]; - case 'tool_use': { + case "tool_use": { let args; try { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment args = - typeof item.input === 'string' + typeof item.input === "string" ? JSON.parse(item.input) : item.input; } catch { @@ -145,12 +145,12 @@ export const responseParser: AgenticModel.ResponseParser = ( return [ ...acc, { - type: 'tool_call', + type: "tool_call", role: input.role, - stop_reason: 'tool', + stop_reason: "tool", tools: [ { - type: 'tool', + type: "tool", id: item.id, name: item.name, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -166,16 +166,16 @@ export const responseParser: AgenticModel.ResponseParser = ( const toolChoice = ( choice: Tool.Choice, -): AiAdapter.Input['tool_choice'] => { +): AiAdapter.Input["tool_choice"] => { switch (choice) { - case 'auto': - return { type: 'auto' }; - case 'any': - return { type: 'any' }; + case "auto": + return { type: "auto" }; + case "any": + return { type: "any" }; default: - if (typeof choice === 'string') { + if (typeof choice === "string") { return { - type: 'tool', + type: "tool", name: choice as string, }; } diff --git a/src/adapters/index.ts b/src/adapters/index.ts new file mode 100644 index 0000000..99e8396 --- /dev/null +++ b/src/adapters/index.ts @@ -0,0 +1,22 @@ +import { type AiAdapter, type AiAdapters } from "inngest"; +import { type AgenticModel } from "../model"; +import * as anthropic from "./anthropic"; +import * as openai from "./openai"; + +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, + }, + anthropic: { + request: anthropic.requestParser, + response: anthropic.responseParser, + }, +}; diff --git a/src/adapters/openai.ts b/src/adapters/openai.ts index 79191be..f0f6139 100644 --- a/src/adapters/openai.ts +++ b/src/adapters/openai.ts @@ -4,17 +4,17 @@ * @module */ -import { type AiAdapter, type OpenAi } from 'inngest'; -import { zodToJsonSchema } from 'openai-zod-to-json-schema'; -import { type AgenticModel } from '../model'; -import { stringifyError } from '../util'; -import { type Tool } from '../types'; +import { type AiAdapter, type OpenAi } from "inngest"; +import { zodToJsonSchema } from "openai-zod-to-json-schema"; +import { type AgenticModel } from "../model"; import { + type Message, type TextMessage, type ToolCallMessage, - type Message, type ToolMessage, -} from '../state'; +} from "../state"; +import { type Tool } from "../types"; +import { stringifyError } from "../util"; /** * Parse a request from internal network messages to an OpenAI input. @@ -23,24 +23,24 @@ export const requestParser: AgenticModel.RequestParser = ( model, messages, tools, - tool_choice = 'auto', + tool_choice = "auto", ) => { const request: AiAdapter.Input = { messages: messages.map((m) => { switch (m.type) { - case 'text': + case "text": return { role: m.role, content: m.content, }; - case 'tool_call': + case "tool_call": return { - role: 'assistant', + role: "assistant", content: null, tool_calls: m.tools ? m.tools?.map((tool) => ({ id: tool.id, - type: 'function', + type: "function", function: { name: tool.name, arguments: JSON.stringify(tool.input), @@ -48,13 +48,13 @@ export const requestParser: AgenticModel.RequestParser = ( })) : undefined, }; - case 'tool_result': + case "tool_result": return { - role: 'tool', + role: "tool", content: m.content, }; } - }) as AiAdapter.Input['messages'], + }) as AiAdapter.Input["messages"], }; if (tools?.length) { @@ -64,7 +64,7 @@ export const requestParser: AgenticModel.RequestParser = ( request.parallel_tool_calls = false; request.tools = tools.map((t) => { return { - type: 'function', + type: "function", function: { name: t.name, description: t.description, @@ -93,7 +93,7 @@ export const responseParser: AgenticModel.ResponseParser = ( const base = { role: choice.message.role, stop_reason: - openAiStopReasonToStateStopReason[finish_reason ?? ''] || 'stop', + openAiStopReasonToStateStopReason[finish_reason ?? ""] || "stop", }; if (message.content) { @@ -101,7 +101,7 @@ export const responseParser: AgenticModel.ResponseParser = ( ...acc, { ...base, - type: 'text', + type: "text", content: message.content, } as TextMessage, ]; @@ -111,14 +111,14 @@ export const responseParser: AgenticModel.ResponseParser = ( ...acc, { ...base, - type: 'tool_call', + type: "tool_call", tools: message.tool_calls.map((tool) => { return { - type: 'tool', + type: "tool", id: tool.id, name: tool.function.name, function: tool.function.name, - input: safeParseOpenAIJson(tool.function.arguments || '{}'), + input: safeParseOpenAIJson(tool.function.arguments || "{}"), } as ToolMessage; }), } as ToolCallMessage, @@ -139,7 +139,7 @@ export const responseParser: AgenticModel.ResponseParser = ( */ const safeParseOpenAIJson = (str: string): unknown => { // Remove any leading/trailing quotes if present - const trimmed = str.replace(/^["']|["']$/g, ''); + const trimmed = str.replace(/^["']|["']$/g, ""); try { // First try direct JSON parse @@ -161,22 +161,22 @@ const safeParseOpenAIJson = (str: string): unknown => { }; const openAiStopReasonToStateStopReason: Record = { - tool_calls: 'tool', - stop: 'stop', - length: 'stop', - content_filter: 'stop', - function_call: 'tool', + 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'; + case "auto": + return "auto"; + case "any": + return "required"; default: return { - type: 'function' as const, + type: "function" as const, function: { name: choice as string }, }; } diff --git a/src/agent.ts b/src/agent.ts index 6697f1b..7a554fe 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -1,13 +1,15 @@ -import { type AgenticModel } from './model'; -import { type Network } from './network'; +import { type AiAdapter } from "inngest"; +import { createAgenticModelFromAiAdapter, type AgenticModel } from "./model"; +import { type Network } from "./network"; +import { NetworkRun } from "./networkRun"; import { - type State, InferenceResult, + State, type Message, type ToolResultMessage, -} from './state'; -import { type Tool } from './types'; -import { type AnyZodType, type MaybePromise } from './util'; +} from "./state"; +import { type Tool } from "./types"; +import { getStepTools, type AnyZodType, type MaybePromise } from "./util"; /** * createTool is a helper that properly types the input argument for a handler @@ -40,7 +42,7 @@ export class Agent { /** * system is the system prompt for the agent. */ - system: string | ((network?: Network) => MaybePromise); + system: string | ((ctx: { network?: NetworkRun }) => MaybePromise); /** * Assistant is the assistent message used for completion, if any. @@ -72,13 +74,13 @@ export class Agent { * to use a specific model which may be different to other agents in the * system */ - model: AgenticModel.Any | undefined; + model: AiAdapter.Any | undefined; constructor(opts: Agent.Constructor | Agent.RoutingConstructor) { this.name = opts.name; - this.description = opts.description || ''; + this.description = opts.description || ""; this.system = opts.system; - this.assistant = opts.assistant || ''; + this.assistant = opts.assistant || ""; this.tools = new Map(); this.tool_choice = opts.tool_choice; this.lifecycles = opts.lifecycle; @@ -89,7 +91,7 @@ export class Agent { } } - withModel(model: AgenticModel.Any): Agent { + withModel(model: AiAdapter.Any): Agent { return new Agent({ name: this.name, description: this.description, @@ -109,17 +111,20 @@ export class Agent { input: string, { model, network, state, maxIter = 0 }: Agent.RunOptions | undefined = {}, ): Promise { - const p = model || this.model || network?.defaultModel; - if (!p) { - throw new Error('No step caller provided to agent'); + const rawModel = model || this.model || network?.defaultModel; + if (!rawModel) { + throw new Error("No step caller provided to agent"); } + const p = createAgenticModelFromAiAdapter(rawModel); + // input state always overrides the network state. - const s = state || network?.state; + const s = state || network?.defaultState?.clone(); + const run = network && new NetworkRun(network, s || new State()); let history = s ? s.format() : []; - let prompt = await this.agentPrompt(input, network); - let result = new InferenceResult(this, input, prompt, history, [], [], ''); + let prompt = await this.agentPrompt(input, run); + let result = new InferenceResult(this, input, prompt, history, [], [], ""); let hasMoreActions = true; let iter = 0; @@ -128,7 +133,7 @@ export class Agent { if (this.lifecycles?.onStart) { const modified = await this.lifecycles.onStart({ agent: this, - network, + network: run, input, prompt, history, @@ -148,12 +153,12 @@ export class Agent { p, prompt, history, - network, + run, ); hasMoreActions = this.tools.size > 0 && - inference.output[inference.output.length - 1]!.stop_reason !== 'stop'; + inference.output[inference.output.length - 1]!.stop_reason !== "stop"; result = inference; history = [...inference.output]; @@ -161,7 +166,11 @@ export class Agent { } while (hasMoreActions && iter < maxIter); if (this.lifecycles?.onFinish) { - result = await this.lifecycles.onFinish({ agent: this, network, result }); + result = await this.lifecycles.onFinish({ + agent: this, + network: run, + result, + }); } // Note that the routing lifecycles aren't called by the agent. They're called @@ -175,13 +184,13 @@ export class Agent { p: AgenticModel.Any, prompt: Message[], history: Message[], - network?: Network, + network?: NetworkRun, ): Promise { const { output, raw } = await p.infer( this.name, prompt.concat(history), Array.from(this.tools.values()), - this.tool_choice || 'auto', + this.tool_choice || "auto", ); // Now that we've made the call, we instantiate a new InferenceResult for @@ -193,7 +202,7 @@ export class Agent { history, output, [], - typeof raw === 'string' ? raw : JSON.stringify(raw), + typeof raw === "string" ? raw : JSON.stringify(raw), ); if (this.lifecycles?.onResponse) { result = await this.lifecycles.onResponse({ @@ -215,12 +224,12 @@ export class Agent { private async invokeTools( msgs: Message[], p: AgenticModel.Any, - network?: Network, + network?: NetworkRun, ): Promise { const output: ToolResultMessage[] = []; for (const msg of msgs) { - if (msg.type !== 'tool_call') { + if (msg.type !== "tool_call") { continue; } @@ -246,23 +255,23 @@ export class Agent { const result = await found.handler(tool.input, { agent: this, network, - step: p.step, + step: await getStepTools(), }); // TODO: handle error and send them back to the LLM output.push({ - role: 'tool_result', - type: 'tool_result', + role: "tool_result", + type: "tool_result", tool: { - type: 'tool', + type: "tool", id: tool.id, name: tool.name, input: tool.input.arguments as Record, }, content: result ? result : `${tool.name} successfully executed`, - stop_reason: 'tool', + stop_reason: "tool", }); } } @@ -272,7 +281,7 @@ export class Agent { private async agentPrompt( input: string, - network?: Network, + network?: NetworkRun, ): Promise { // Prompt returns the full prompt for the current agent. This does NOT // include the existing network's state as part of the prompt. @@ -280,23 +289,23 @@ export class Agent { // Note that the agent's system message always comes first. const messages: Message[] = [ { - type: 'text', - role: 'system', + type: "text", + role: "system", content: - typeof this.system === 'string' + typeof this.system === "string" ? this.system - : await this.system(network), + : await this.system({ network }), }, ]; if (input.length > 0) { - messages.push({ type: 'text', role: 'user', content: input }); + messages.push({ type: "text", role: "user", content: input }); } if (this.assistant.length > 0) { messages.push({ - type: 'text', - role: 'assistant', + type: "text", + role: "assistant", content: this.assistant, }); } @@ -306,14 +315,14 @@ export class Agent { } export class RoutingAgent extends Agent { - type = 'routing'; + type = "routing"; override lifecycles: Agent.RoutingLifecycle; constructor(opts: Agent.RoutingConstructor) { super(opts); this.lifecycles = opts.lifecycle; } - override withModel(model: AgenticModel.Any): RoutingAgent { + override withModel(model: AiAdapter.Any): RoutingAgent { return new RoutingAgent({ name: this.name, description: this.description, @@ -330,20 +339,28 @@ export namespace Agent { export interface Constructor { name: string; description?: string; - system: string | ((network?: Network) => MaybePromise); + system: string | ((ctx: { network?: NetworkRun }) => MaybePromise); assistant?: string; tools?: Tool.Any[]; tool_choice?: Tool.Choice; lifecycle?: Lifecycle; - model?: AgenticModel.Any; + model?: AiAdapter.Any; + } + + export interface RoutingConstructor extends Omit { + lifecycle: RoutingLifecycle; + } + + export interface RoutingConstructor extends Omit { + lifecycle: RoutingLifecycle; } - export interface RoutingConstructor extends Omit { + export interface RoutingConstructor extends Omit { lifecycle: RoutingLifecycle; } export interface RunOptions { - model?: AgenticModel.Any; + model?: AiAdapter.Any; network?: Network; /** * State allows you to pass custom state into a single agent run call. This should only @@ -404,7 +421,7 @@ export namespace Agent { // Agent is the agent that made the call. agent: Agent; // Network represents the network that this agent or lifecycle belongs to. - network?: Network; + network?: NetworkRun; } export interface Result extends Base { diff --git a/src/error.ts b/src/error.ts new file mode 100644 index 0000000..188b78d --- /dev/null +++ b/src/error.ts @@ -0,0 +1,6 @@ +export class AIGatewayError extends Error { + constructor(message: string) { + super(message); + this.name = "AIGatewayError"; + } +} diff --git a/src/index.ts b/src/index.ts index a0b13ae..1b32a09 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,7 @@ // Base -export * from './agent'; -export * from './model'; -export * from './network'; -export * from './state'; -export * from './types'; -export * from './util'; - -// Models -export * from './models/gemini'; -export * from './models/openai'; -export * from './models/anthropic'; +export * from "./agent"; +export * from "./model"; +export * from "./network"; +export * from "./state"; +export * from "./types"; +export * from "./util"; diff --git a/src/model.ts b/src/model.ts index 6024f27..28d9024 100644 --- a/src/model.ts +++ b/src/model.ts @@ -1,22 +1,36 @@ -import { type AiAdapter, type GetStepTools, type Inngest } from 'inngest'; -import { type Message } from './state'; -import { type Tool } from './types'; +import { type AiAdapter } from "inngest"; +import { adapters } from "./adapters"; +import { type Message } from "./state"; +import { type Tool } from "./types"; +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 class AgenticModel { #model: TAiAdapter; - - step: GetStepTools; requestParser: AgenticModel.RequestParser; responseParser: AgenticModel.ResponseParser; constructor({ model, - step, requestParser, responseParser, }: AgenticModel.Constructor) { this.#model = model; - this.step = step; this.requestParser = requestParser; this.responseParser = responseParser; } @@ -27,7 +41,9 @@ export class AgenticModel { tools: Tool.Any[], tool_choice: Tool.Choice, ): Promise { - const result = (await this.step.ai.infer(stepID, { + const step = await getStepTools(); + + const result = (await step.ai.infer(stepID, { model: this.#model, body: this.requestParser(this.#model, input, tools, tool_choice), })) as AiAdapter.Input; @@ -51,7 +67,6 @@ export namespace AgenticModel { export interface Constructor { model: TAiAdapter; - step: GetStepTools; requestParser: RequestParser; responseParser: ResponseParser; } diff --git a/src/models/README.md b/src/models/README.md deleted file mode 100644 index c425c1b..0000000 --- a/src/models/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# Models - -These are agentic models supported by the library, often wrapping existing -models within `inngest`. diff --git a/src/models/anthropic.ts b/src/models/anthropic.ts deleted file mode 100644 index 6e24bf7..0000000 --- a/src/models/anthropic.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { anthropic as ianthropic, type Anthropic } from 'inngest'; -import { requestParser, responseParser } from '../adapters/anthropic'; -import { AgenticModel } from '../model'; -import { type AnyStepTools } from '../types'; - -export namespace AnthropicModel { - export interface Options - extends Omit { - /** - * The Anthropic model to use. - */ - model: Anthropic.AiModelOptions['model'] | TAiAdapter; - - /** - * The step tools to use internally within this model. - */ - step: AnyStepTools; - } -} - -/** - * Create an agentic Anthropic model using the Anthropic chat format. - */ -export const anthropic = ({ - step, - ...modelOptions -}: AnthropicModel.Options) => { - const model = - typeof modelOptions.model === 'string' - ? ianthropic({ ...modelOptions, model: modelOptions.model }) - : modelOptions.model; - - return new AgenticModel({ - model, - step, - requestParser, - responseParser, - }); -}; diff --git a/src/models/gemini.ts b/src/models/gemini.ts deleted file mode 100644 index 8518da4..0000000 --- a/src/models/gemini.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { - gemini as igemini, - type Gemini, - type GetStepTools, - type Inngest, -} from 'inngest'; -import { requestParser, responseParser } from '../adapters/openai'; -import { AgenticModel } from '../model'; - -export namespace AgenticGeminiModel { - export interface Options - extends Omit { - /** - * The Gemini model to use. - */ - model: Gemini.AiModelOptions['model'] | TAiAdapter; - - /** - * The step tools to use internally within this model. - */ - step: GetStepTools; - } -} - -/** - * Create an agentic Gemini model using the OpenAI chat format. - * - * By default it targets the `https://generativelanguage.googleapis.com/v1beta/` - * base URL. - */ -export const gemini = ({ - step, - ...modelOptions -}: AgenticGeminiModel.Options) => { - const model = - typeof modelOptions.model === 'string' - ? igemini({ ...modelOptions, model: modelOptions.model }) - : modelOptions.model; - - return new AgenticModel({ - model, - step, - requestParser, - responseParser, - }); -}; diff --git a/src/models/openai.ts b/src/models/openai.ts deleted file mode 100644 index 2988bfb..0000000 --- a/src/models/openai.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { openai as iopenai, type OpenAi } from 'inngest'; -import { requestParser, responseParser } from '../adapters/openai'; -import { AgenticModel } from '../model'; -import { type AnyStepTools } from '../types'; - -export namespace AgenticOpenAiModel { - export interface Options - extends Omit { - /** - * The OpenAI model to use. - */ - model: OpenAi.AiModelOptions['model'] | TAiAdapter; - - /** - * The step tools to use internally within this model. - */ - step: AnyStepTools; - } -} - -/** - * Create an agentic OpenAI model using the OpenAI chat format. - * - * By default it targets the `https://api.openai.com/v1/` base URL. - */ -export const openai = ({ - step, - ...modelOptions -}: AgenticOpenAiModel.Options) => { - const model = - typeof modelOptions.model === 'string' - ? iopenai({ ...modelOptions, model: modelOptions.model }) - : modelOptions.model; - - return new AgenticModel({ - model, - step, - requestParser, - responseParser, - }); -}; diff --git a/src/network.ts b/src/network.ts index 3dde546..685459e 100644 --- a/src/network.ts +++ b/src/network.ts @@ -1,13 +1,14 @@ -import { z } from 'zod'; +import { type AiAdapter } from "inngest"; +import { z } from "zod"; import { type Agent, - RoutingAgent, + type RoutingAgent, createRoutingAgent, createTool, -} from './agent'; -import { type AgenticModel } from './model'; -import { type InferenceResult, State } from './state'; -import { type MaybePromise } from './util'; +} from "./agent"; +import { NetworkRun } from "./networkRun"; +import { type InferenceResult, State } from "./state"; +import { type MaybePromise } from "./util"; /** * Network represents a network of agents. @@ -26,14 +27,16 @@ export class Network { /** * state is the entire agent's state. */ - state: State; + defaultState?: State; /** * defaultModel is the default model to use with the network. This will not * override an agent's specific model if the agent already has a model defined * (eg. via withModel or via its constructor). */ - defaultModel?: AgenticModel.Any; + defaultModel?: AiAdapter.Any; + + defaultRouter?: Network.Router; /** * maxIter is the maximum number of times the we can call agents before ending @@ -42,29 +45,34 @@ export class Network { maxIter: number; // _stack is an array of strings, each representing an agent name to call. - private _stack: string[]; + protected _stack: string[]; - private _counter = 0; + protected _counter = 0; // _agents atores all egents. note that you may not include eg. the // defaultRoutingAgent within the network constructor, and you may return an // agent in the router that's not included. This is okay; we store all // agents referenced in the router here. - private _agents: Map; + protected _agents: Map; constructor({ agents, defaultModel, maxIter, - state = new State(), + defaultState, + defaultRouter, }: Network.Constructor) { this.agents = new Map(); this._agents = new Map(); - this.state = state; this.defaultModel = defaultModel; + this.defaultRouter = defaultRouter; this.maxIter = maxIter || 0; this._stack = []; + if (defaultState) { + this.defaultState = defaultState; + } + for (const agent of agents) { // Store all agents publicly visible. this.agents.set(agent.name, agent); @@ -73,12 +81,14 @@ export class Network { } } - async availableAgents(): Promise { + async availableAgents( + networkRun: NetworkRun = new NetworkRun(this, new State()), + ): Promise { const available: Agent[] = []; const all = Array.from(this.agents.values()); for (const a of all) { const enabled = a?.lifecycles?.enabled; - if (!enabled || (await enabled({ agent: a, network: this }))) { + if (!enabled || (await enabled({ agent: a, network: networkRun }))) { available.push(a); } } @@ -92,158 +102,25 @@ export class Network { this.agents.set(agent.name, agent); } - /** - * Schedule is used to push an agent's run function onto the stack. - */ - schedule(agentName: string) { - this._stack.push(agentName); - } - /** * run handles a given request using the network of agents. It is not * concurrency-safe; you can only call run on a network once, as networks are * stateful. * */ - async run(input: string, router?: Network.Router): Promise { - const available = await this.availableAgents(); - if (available.length === 0) { - throw new Error('no agents enabled in network'); - } - - // If there's no default agent used to run the request, use our internal - // routing agent which attempts to figure out the best agent to choose based - // off of the network. - const next = await this.getNextAgents(input, router); - if (!next) { - // TODO: If call count is 0, error. - return this; - } - - // Schedule the agent to run on our stack, then start popping off the stack. - for (const agent of next) { - this.schedule(agent.name); - } - - while ( - this._stack.length > 0 && - (this.maxIter === 0 || this._counter < this.maxIter) - ) { - // XXX: It would be possible to parallel call these agents here by - // fetching the entire stack, parallel running, then awaiting the - // responses. However, this confuses history and we'll take our time to - // introduce parallelisation after the foundations are set. - - // Fetch the agent we need to call next off of the stack. - const agentName = this._stack.shift(); - // Grab agents from the private map, as this may have been introduced in - // the router. - const agent = agentName && this._agents.get(agentName); - if (!agent) { - // We're done. - return this; - } - - // We force Agent to emit structured output in case of the use of tools by - // setting maxIter to 0. - const call = await agent.run(input, { network: this, maxIter: 0 }); - this._counter += 1; - - // Ensure that we store the call network history. - this.state.append(call); - - // Here we face a problem: what's the definition of done? An agent may - // have just been called with part of the information to solve an input. - // We may need to delegate to another agent. - // - // In this case, we defer to the router provided to give us next steps. - // By default, this is an agentic router which takes the current state, - // agents, then figures out next steps. This can, and often should, be - // custom code. - const next = await this.getNextAgents(input, router); - for (const a of next || []) { - this.schedule(a.name); + public run(...[input, overrides]: Network.RunArgs): Promise { + let state: State; + if (overrides?.state) { + if (overrides.state instanceof State) { + state = overrides.state; + } else { + state = new State(overrides.state); } + } else { + state = this.defaultState?.clone() || new State(); } - return this; - } - - private async getNextAgents( - input: string, - router?: Network.Router, - ): Promise { - // A router may do one of two things: - // - // 1. Return one or more Agents to run - // 2. Return undefined, meaning we're done. - // - // It can do this by using code, or by calling routing agents directly. - if (!router && !this.defaultModel) { - throw new Error( - 'No router or model defined in network. You must pass a router or a default model to use the built-in agentic router.', - ); - } - if (!router) { - router = defaultRoutingAgent; - } - if (router instanceof RoutingAgent) { - return await this.getNextAgentsViaRoutingAgent(router, input); - } - - // This is a function call which determines the next agent to call. Note that the result - // of this function call may be another RoutingAgent. - const stack: Agent[] = this._stack.map((name) => { - const agent = this._agents.get(name); - if (!agent) { - throw new Error(`unknown agent in the network stack: ${name}`); - } - return agent; - }); - - const agent = await router({ - input, - network: this, - stack, - lastResult: this.state.results.pop(), - callCount: this._counter, - }); - if (!agent) { - return; - } - if (agent instanceof RoutingAgent) { - // Functions may also return routing agents. - return await this.getNextAgentsViaRoutingAgent(agent, input); - } - - for (const a of Array.isArray(agent) ? agent : [agent]) { - // Ensure this agent is part of the network. If not, we're going to - // automatically add it. - if (!this._agents.has(a.name)) { - this._agents.set(a.name, a); - } - } - - return Array.isArray(agent) ? agent : [agent]; - } - - private async getNextAgentsViaRoutingAgent( - routingAgent: RoutingAgent, - input: string, - ): Promise { - const result = await routingAgent.run(input, { - network: this, - model: routingAgent.model || this.defaultModel, - }); - const agentNames = routingAgent.lifecycles.onRoute({ - result, - agent: routingAgent, - network: this, - }); - - return (agentNames || []) - .map((name) => this.agents.get(name)) - .filter(Boolean) as Agent[]; + return new NetworkRun(this, state)["execute"](input, overrides); } } @@ -255,10 +132,10 @@ export class Network { * network or being explicitly given one. */ export const defaultRoutingAgent = createRoutingAgent({ - name: 'Default routing agent', + name: "Default routing agent", description: - 'Selects which agents to work on based off of the current prompt and input.', + "Selects which agents to work on based off of the current prompt and input.", lifecycle: { onRoute: ({ result }) => { @@ -266,7 +143,7 @@ export const defaultRoutingAgent = createRoutingAgent({ if (!tool) { return; } - if (typeof tool.content === 'string') { + if (typeof tool.content === "string") { return [tool.content]; } return; @@ -277,25 +154,25 @@ export const defaultRoutingAgent = createRoutingAgent({ // This tool does nothing but ensure that the model responds with the // agent name as valid JSON. createTool({ - name: 'select_agent', + name: "select_agent", description: - 'select an agent to handle the input, based off of the current conversation', + "select an agent to handle the input, based off of the current conversation", parameters: z .object({ name: z .string() - .describe('The name of the agent that should handle the request'), + .describe("The name of the agent that should handle the request"), }) .strict(), handler: ({ name }, { network }) => { if (!network) { throw new Error( - 'The routing agent can only be used within a network of agents', + "The routing agent can only be used within a network of agents", ); } - if (typeof name !== 'string') { - throw new Error('The routing agent requested an invalid agent'); + if (typeof name !== "string") { + throw new Error("The routing agent requested an invalid agent"); } const agent = network.agents.get(name); @@ -312,12 +189,12 @@ export const defaultRoutingAgent = createRoutingAgent({ }), ], - tool_choice: 'select_agent', + tool_choice: "select_agent", - system: async (network?: Network): Promise => { + system: async ({ network }): Promise => { if (!network) { throw new Error( - 'The routing agent can only be used within a network of agents', + "The routing agent can only be used within a network of agents", ); } @@ -336,7 +213,7 @@ The following agents are available: ${JSON.stringify(Array.from(a.tools.values()))} `; }) - .join('\n')} + .join("\n")} Follow the set of instructions: @@ -353,13 +230,20 @@ Follow the set of instructions: export namespace Network { export type Constructor = { agents: Agent[]; - defaultModel?: AgenticModel.Any; + defaultModel?: AiAdapter.Any; 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. - state?: State; + defaultState?: State; + defaultRouter?: Router; }; + export type RunArgs = [ + input: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + overrides?: { router?: Router; state?: State | Record }, + ]; + /** * Router defines how a network coordinates between many agents. A router is * either a RoutingAgent which uses inference calls to choose the next Agent, @@ -394,7 +278,7 @@ export namespace Network { * Network is the network that this router is coordinating. Network state * is accessible via `network.state`. */ - network: Network; + network: NetworkRun; /** * stack is an ordered array of agents that will be called next. diff --git a/src/networkRun.ts b/src/networkRun.ts new file mode 100644 index 0000000..93fa4cc --- /dev/null +++ b/src/networkRun.ts @@ -0,0 +1,181 @@ +import { RoutingAgent, type Agent } from "./agent"; +import { defaultRoutingAgent, Network } from "./network"; +import { type State } from "./state"; + +export class NetworkRun extends Network { + public state: State; + + constructor(network: Network, state: State) { + super({ + agents: Array.from(network.agents.values()), + defaultModel: network.defaultModel, + defaultState: network.defaultState, + defaultRouter: network.defaultRouter, + maxIter: network.maxIter, + }); + + this.state = state; + } + + public override run(): never { + throw new Error("NetworkRun does not support run"); + } + + public override async availableAgents(): Promise { + return super.availableAgents(this); + } + + /** + * Schedule is used to push an agent's run function onto the stack. + */ + schedule(agentName: string) { + this["_stack"].push(agentName); + } + + private async execute(...[input, overrides]: Network.RunArgs): Promise { + const available = await this.availableAgents(); + if (available.length === 0) { + throw new Error("no agents enabled in network"); + } + + // If there's no default agent used to run the request, use our internal + // routing agent which attempts to figure out the best agent to choose based + // off of the network. + const next = await this.getNextAgents( + input, + overrides?.router || this.defaultRouter, + ); + if (!next) { + // TODO: If call count is 0, error. + return this; + } + + // Schedule the agent to run on our stack, then start popping off the stack. + for (const agent of next) { + this.schedule(agent.name); + } + + while ( + this._stack.length > 0 && + (this.maxIter === 0 || this._counter < this.maxIter) + ) { + // XXX: It would be possible to parallel call these agents here by + // fetching the entire stack, parallel running, then awaiting the + // responses. However, this confuses history and we'll take our time to + // introduce parallelisation after the foundations are set. + + // Fetch the agent we need to call next off of the stack. + const agentName = this._stack.shift(); + // Grab agents from the private map, as this may have been introduced in + // the router. + const agent = agentName && this._agents.get(agentName); + if (!agent) { + // We're done. + return this; + } + + // We force Agent to emit structured output in case of the use of tools by + // setting maxIter to 0. + const call = await agent.run(input, { network: this, maxIter: 0 }); + this._counter += 1; + + // Ensure that we store the call network history. + this.state.append(call); + + // Here we face a problem: what's the definition of done? An agent may + // have just been called with part of the information to solve an input. + // We may need to delegate to another agent. + // + // In this case, we defer to the router provided to give us next steps. + // By default, this is an agentic router which takes the current state, + // agents, then figures out next steps. This can, and often should, be + // custom code. + const next = await this.getNextAgents( + input, + overrides?.router || this.defaultRouter, + ); + for (const a of next || []) { + this.schedule(a.name); + } + } + + return this; + } + + private async getNextAgents( + input: string, + router?: Network.Router, + ): Promise { + // A router may do one of two things: + // + // 1. Return one or more Agents to run + // 2. Return undefined, meaning we're done. + // + // It can do this by using code, or by calling routing agents directly. + if (!router && !this.defaultModel) { + throw new Error( + "No router or model defined in network. You must pass a router or a default model to use the built-in agentic router.", + ); + } + if (!router) { + router = defaultRoutingAgent; + } + if (router instanceof RoutingAgent) { + return await this.getNextAgentsViaRoutingAgent(router, input); + } + + // This is a function call which determines the next agent to call. Note that the result + // of this function call may be another RoutingAgent. + const stack: Agent[] = this._stack.map((name) => { + const agent = this._agents.get(name); + if (!agent) { + throw new Error(`unknown agent in the network stack: ${name}`); + } + return agent; + }); + + const agent = await router({ + input, + network: this, + stack, + lastResult: this.state.results.pop(), + callCount: this._counter, + }); + if (!agent) { + return; + } + if (agent instanceof RoutingAgent) { + // Functions may also return routing agents. + return await this.getNextAgentsViaRoutingAgent(agent, input); + } + + for (const a of Array.isArray(agent) ? agent : [agent]) { + // Ensure this agent is part of the network. If not, we're going to + // automatically add it. + if (!this._agents.has(a.name)) { + this._agents.set(a.name, a); + } + } + + return Array.isArray(agent) ? agent : [agent]; + } + + private async getNextAgentsViaRoutingAgent( + routingAgent: RoutingAgent, + input: string, + ): Promise { + const result = await routingAgent.run(input, { + network: this, + model: routingAgent.model || this.defaultModel, + }); + const agentNames = routingAgent.lifecycles.onRoute({ + result, + agent: routingAgent, + network: this, + }); + + return (agentNames || []) + .map((name) => this.agents.get(name)) + .filter(Boolean) as Agent[]; + } +} diff --git a/src/state.ts b/src/state.ts index 27c88d3..441510f 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,4 +1,4 @@ -import { type Agent } from './agent'; +import { type Agent } from "./agent"; export type Message = TextMessage | ToolCallMessage | ToolResultMessage; @@ -7,47 +7,47 @@ export type Message = TextMessage | ToolCallMessage | ToolResultMessage; * an assistant's reply. */ export interface TextMessage { - type: 'text'; - role: 'system' | 'user' | 'assistant'; + type: "text"; + role: "system" | "user" | "assistant"; content: string | Array; // Anthropic: // stop_reason: "end_turn" | "max_tokens" | "stop_sequence" | "tool_use" | null; // OpenAI: // finish_reason: 'stop' | 'length' | 'tool_calls' | 'content_filter' | 'function_call' | null; - stop_reason?: 'tool' | 'stop'; + stop_reason?: "tool" | "stop"; } /** * ToolCallMessage represents a message for a tool call. */ export interface ToolCallMessage { - type: 'tool_call'; - role: 'user' | 'assistant'; + type: "tool_call"; + role: "user" | "assistant"; tools: ToolMessage[]; - stop_reason: 'tool'; + stop_reason: "tool"; } /** * ToolResultMessage represents the output of a tool call. */ export interface ToolResultMessage { - type: 'tool_result'; - role: 'tool_result'; + type: "tool_result"; + role: "tool_result"; // tool contains the tool call request for this result. tool: ToolMessage; content: unknown; - stop_reason: 'tool'; + stop_reason: "tool"; } // Message content. export interface TextContent { - type: 'text'; + type: "text"; text: string; } export interface ToolMessage { - type: 'tool'; + type: "tool"; id: string; name: string; input: Record; @@ -75,9 +75,11 @@ export class State { private _history: InferenceResult[]; - constructor() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(state?: Record) { this._history = []; - this._kv = new Map(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this._kv = new Map(state && Object.entries(state)); this.kv = { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -120,6 +122,13 @@ export class State { append(call: InferenceResult) { this._history.push(call); } + + clone() { + const state = new State(); + state._history = this._history.slice(); + state._kv = new Map(this._kv); + return state; + } } /** @@ -174,7 +183,7 @@ export class InferenceResult { return this._historyFormatter(this); } - if (this.raw === '') { + if (this.raw === "") { // There is no call to the agent, so ignore this. return []; } @@ -185,22 +194,22 @@ export class InferenceResult { const messages = this.prompt .map((msg) => { - if (msg.type !== 'text') { + if (msg.type !== "text") { return; } - let content: string = ''; - if (typeof msg.content === 'string') { + let content: string = ""; + if (typeof msg.content === "string") { content = msg.content; } else if (Array.isArray(msg.content)) { - content = msg.content.map((m) => m.text).join('\n'); + content = msg.content.map((m) => m.text).join("\n"); } // Ensure that system prompts are always as an assistant in history return { ...msg, - type: 'text', - role: 'assistant', + type: "text", + role: "assistant", content: `${agent.name}\n${content}`, }; }) diff --git a/src/types.ts b/src/types.ts index 68bf717..6b4cc5b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,13 +1,13 @@ -import { type GetStepTools, type Inngest } from 'inngest'; -import { type output as ZodOutput } from 'zod'; -import { type Agent } from './agent'; -import { type Network } from './network'; +import { type GetStepTools, type Inngest } from "inngest"; +import { type output as ZodOutput } from "zod"; +import { type Agent } from "./agent"; +import { type NetworkRun } from "./networkRun"; import { - type GenericizeFunctionsInObject, type AnyZodType, + type GenericizeFunctionsInObject, type MaybePromise, type SimplifyDeep, -} from './util'; +} from "./util"; export type Tool = { name: string; @@ -26,12 +26,12 @@ export type Tool = { export namespace Tool { export type Any = Tool; - export type Choice = 'auto' | 'any' | (string & {}); + export type Choice = "auto" | "any" | (string & {}); } export type ToolHandlerArgs = { agent: Agent; - network?: Network; + network?: NetworkRun; step: GetStepTools; }; diff --git a/src/util.ts b/src/util.ts index 5a5d9cd..6a0816f 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,4 +1,5 @@ -import { type ZodType } from 'zod'; +import { getAsyncCtx } from "inngest/experimental"; +import { type ZodType } from "zod"; export type MaybePromise = T | Promise; @@ -27,6 +28,19 @@ export const stringifyError = (e: unknown): string => { return String(e); }; +/** + * Attempts to retrieve the step tools from the async context. If the context is + * not found, an error is thrown. + */ +export const getStepTools = async () => { + const asyncCtx = await getAsyncCtx(); + if (!asyncCtx) { + throw new Error("Could not find Inngest step tooling in async context"); + } + + return asyncCtx.ctx.step; +}; + /** * Given an object `T`, return a new object where all keys with function types * as values are genericized. If the value is an object, recursively apply this