diff --git a/agent-todo/.env.example b/agent-todo/.env.example new file mode 100644 index 0000000..1f1bb1a --- /dev/null +++ b/agent-todo/.env.example @@ -0,0 +1,10 @@ + +# For inference +RESTACK_API_KEY= + +# For Restack Cloud deployment +RESTACK_ENGINE_ID= +RESTACK_ENGINE_ADDRESS= +RESTACK_ENGINE_API_KEY= +RESTACK_ENGINE_API_ADDRESS= + diff --git a/agent-todo/chat_post.png b/agent-todo/chat_post.png new file mode 100644 index 0000000..340be44 Binary files /dev/null and b/agent-todo/chat_post.png differ diff --git a/agent-todo/chat_put.png b/agent-todo/chat_put.png new file mode 100644 index 0000000..f83edf5 Binary files /dev/null and b/agent-todo/chat_put.png differ diff --git a/agent-todo/chat_run.png b/agent-todo/chat_run.png new file mode 100644 index 0000000..994ac2d Binary files /dev/null and b/agent-todo/chat_run.png differ diff --git a/agent-todo/eventAgent.ts b/agent-todo/eventAgent.ts new file mode 100644 index 0000000..c753c09 --- /dev/null +++ b/agent-todo/eventAgent.ts @@ -0,0 +1,41 @@ +import { client } from "./src/client"; + +export type EventInput = { + agentId: string; + runId: string; +}; + +async function eventAgent(input: EventInput) { + try { + await client.sendAgentEvent({ + event: { + name: "message", + input: { content: "Sales on boots?" }, + }, + agent: { + agentId: input.agentId, + runId: input.runId, + }, + }); + + await client.sendAgentEvent({ + event: { + name: "end", + }, + agent: { + agentId: input.agentId, + runId: input.runId, + }, + }); + + process.exit(0); // Exit the process successfully + } catch (error) { + console.error("Error sending event to agent:", error); + process.exit(1); // Exit the process with an error code + } +} + +eventAgent({ + agentId: "your-agent-id", + runId: "your-run-id", +}); diff --git a/agent-todo/package.json b/agent-todo/package.json new file mode 100644 index 0000000..94f1858 --- /dev/null +++ b/agent-todo/package.json @@ -0,0 +1,26 @@ +{ + "name": "agent-todo", + "version": "0.0.1", + "description": "Restack Agent managing todos", + "scripts": { + "dev": "open-cli http://localhost:5233 && tsx watch --include src src/services.ts", + "build": "tsc --build", + "clean": "rm -rf node_modules", + "schedule": "tsx scheduleAgent.ts", + "event": "tsx eventAgent.ts" + }, + "dependencies": { + "@restackio/ai": "^0.0.104", + "@temporalio/workflow": "1.11.6", + "openai": "^4.80.1", + "zod": "^3.24.1" + }, + "devDependencies": { + "@types/node": "20.16.9", + "dotenv-cli": "^7.4.2", + "open-cli": "^8.0.0", + "prettier": "3.3.3", + "tsx": "4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/agent-todo/readme.md b/agent-todo/readme.md new file mode 100644 index 0000000..1dcf620 --- /dev/null +++ b/agent-todo/readme.md @@ -0,0 +1,104 @@ +# Restack Agent Todos example + +A sample repository with an agent managing todos. + +## Requirements + +- **Node 20+** + +## Start Restack + +To start Restack, use the following Docker command: + +```bash +docker run -d --pull always --name restack -p 5233:5233 -p 6233:6233 -p 7233:7233 ghcr.io/restackio/restack:main +``` + +## Install dependencies and start services + +```bash +npm install +npm run dev +``` + +This will start a Node.js app with Restack Services. +Your code will be running and syncing with Restack to execute agents. + +## Run agents + +### from UI + +You can run agents from the UI by clicking the "Run" button. + +![Run agents from UI](./chat_post.png) + +### from API + +You can run agents from the API by using the generated endpoint: + +`POST http://localhost:6233/api/agents/agentTodo` + +### from any client + +You can run agents with any client connected to Restack, for example: + +```bash +npm run schedule +``` + +executes `scheduleAgent.ts` which will connect to Restack and execute the `agentTodo` agent. + +## Send events to the Agent + +### from UI + +You can send events like message or end from the UI. + +![Send events from UI](./chat_put.png) + +And see the events in the run: + +![See events in UI](./chat_run.png) + +### from API + +You can send events to the agent by using the following endpoint: + +`PUT http://localhost:6233/api/agents/agentTodo/:agentId/:runId` + +with the payload: + +```json +{ + "eventName": "message", + "eventInput": { "content": "Create todo to send email to CEO" } +} +``` + +to send messages to the agent. + +or + +```json +{ + "eventName": "end" +} +``` + +to end the conversation with the agent. + +### from any client + +You can send event to the agent with any client connected to Restack, for example: + +Modify agentId and runId in eventAgent.ts and then run: + +```bash +npm run event +``` + +It will connect to Restack and send 2 events to the agent, one to generate another agent and another one to end the conversation. + +## Deploy on Restack Cloud + +To deploy the application on Restack, you can create an account at [https://console.restack.io](https://console.restack.io) diff --git a/agent-todo/scheduleAgent.ts b/agent-todo/scheduleAgent.ts new file mode 100644 index 0000000..91ccec0 --- /dev/null +++ b/agent-todo/scheduleAgent.ts @@ -0,0 +1,29 @@ +import { client } from "./src/client"; +import { agentChatTool } from "./src/agents/agent"; +export type InputSchedule = { + name: string; +}; + +async function scheduleAgent(input: InputSchedule) { + try { + const agentId = `${Date.now()}-${agentChatTool.name}`; + const runId = await client.scheduleAgent({ + agentName: agentChatTool.name, + agentId, + input, + }); + + const result = await client.getAgentResult({ agentId, runId }); + + console.log("Agent result:", result); + + process.exit(0); // Exit the process successfully + } catch (error) { + console.error("Error scheduling agent:", error); + process.exit(1); // Exit the process with an error code + } +} + +scheduleAgent({ + name: "test", +}); diff --git a/agent-todo/src/agents/agent.ts b/agent-todo/src/agents/agent.ts new file mode 100644 index 0000000..0c8bbb9 --- /dev/null +++ b/agent-todo/src/agents/agent.ts @@ -0,0 +1,103 @@ +import { + defineEvent, + onEvent, + condition, + log, + step, +} from "@restackio/ai/agent"; +import * as functions from "../functions"; +import { childExecute } from "@restackio/ai/workflow"; +import { executeTodoWorkflow } from "../workflows/executeTodo"; + +export type EndEvent = { + end: boolean; +}; + +export const messageEvent = defineEvent("message"); +export const endEvent = defineEvent("end"); + +type agentTodoOutput = { + messages: functions.Message[]; +}; + +export async function agentTodo(): Promise { + let endReceived = false; + let messages: functions.Message[] = []; + + const tools = await step({}).getTools(); + + onEvent(messageEvent, async ({ content }: functions.Message) => { + messages.push({ role: "user", content: content?.toString() ?? "" }); + const result = await step({}).llmChat({ + messages, + tools, + }); + + messages.push(result); + + if (result.tool_calls) { + log.info("result.tool_calls", { result }); + for (const toolCall of result.tool_calls) { + switch (toolCall.function.name) { + case "createTodo": + log.info("createTodo", { toolCall }); + const toolResult = await step({}).createTodo( + JSON.parse(toolCall.function.arguments) + ); + + messages.push({ + role: "tool", + tool_call_id: toolCall.id, + content: toolResult, + }); + + const toolChatResult = await step({}).llmChat({ + messages, + tools, + }); + + messages.push(toolChatResult); + + break; + case "executeTodoWorkflow": + log.info("executeTodoWorkflow", { toolCall }); + const workflowId = `executeTodoWorkflow-${new Date().getTime()}`; + const workflowResult = await childExecute({ + child: executeTodoWorkflow, + childId: workflowId, + input: JSON.parse(toolCall.function.arguments), + }); + + messages.push({ + role: "tool", + tool_call_id: toolCall.id, + content: JSON.stringify(workflowResult), + }); + + const toolWorkflowResult = await step({}).llmChat( + { + messages, + tools, + } + ); + + messages.push(toolWorkflowResult); + + break; + default: + break; + } + } + } + return messages; + }); + + onEvent(endEvent, async () => { + endReceived = true; + }); + + await condition(() => endReceived); + + log.info("end condition met"); + return { messages }; +} diff --git a/agent-todo/src/agents/index.ts b/agent-todo/src/agents/index.ts new file mode 100644 index 0000000..ed4e7c1 --- /dev/null +++ b/agent-todo/src/agents/index.ts @@ -0,0 +1 @@ +export * from "./agent"; diff --git a/agent-todo/src/client.ts b/agent-todo/src/client.ts new file mode 100644 index 0000000..7185c13 --- /dev/null +++ b/agent-todo/src/client.ts @@ -0,0 +1,14 @@ +import Restack from "@restackio/ai"; + +import "dotenv/config"; + +export const connectionOptions = { + engineId: process.env.RESTACK_ENGINE_ID!, + address: process.env.RESTACK_ENGINE_ADDRESS!, + apiKey: process.env.RESTACK_ENGINE_API_KEY!, + apiAddress: process.env.RESTACK_ENGINE_API_ADDRESS!, +}; + +export const client = new Restack( + process.env.RESTACK_ENGINE_API_KEY ? connectionOptions : undefined +); diff --git a/agent-todo/src/functions/createTodo.ts b/agent-todo/src/functions/createTodo.ts new file mode 100644 index 0000000..2f98634 --- /dev/null +++ b/agent-todo/src/functions/createTodo.ts @@ -0,0 +1,7 @@ +import { log } from "@restackio/ai/function"; + +export const createTodo = async ({ todoTitle }: { todoTitle: string }) => { + const todo_id = `todo-${Math.floor(Math.random() * 10000)}`; + log.info("createTodo", { todo_id, todoTitle }); + return `Created the todo '${todoTitle}' with id: ${todo_id}`; +}; diff --git a/agent-todo/src/functions/getRandom.ts b/agent-todo/src/functions/getRandom.ts new file mode 100644 index 0000000..5d6e67c --- /dev/null +++ b/agent-todo/src/functions/getRandom.ts @@ -0,0 +1,7 @@ +import { log } from "@restackio/ai/function"; + +export const getRandom = async ({ todoTitle }: { todoTitle: string }) => { + const random = Math.random() * 100; + log.info("getRandom", { todoTitle, random }); + return `The random number for ${todoTitle} is ${random}`; +}; diff --git a/agent-todo/src/functions/getResult.ts b/agent-todo/src/functions/getResult.ts new file mode 100644 index 0000000..2f645ea --- /dev/null +++ b/agent-todo/src/functions/getResult.ts @@ -0,0 +1,17 @@ +import { log } from "@restackio/ai/function"; + +export const getResult = async ({ + todoTitle, + todoId, +}: { + todoTitle: string; + todoId: string; +}) => { + const statuses = ["completed", "failed"]; + const status = statuses[Math.floor(Math.random() * statuses.length)]; + log.info("getResult", { todoId, todoTitle, status }); + return { + todoId, + status, + }; +}; diff --git a/agent-todo/src/functions/getTools.ts b/agent-todo/src/functions/getTools.ts new file mode 100644 index 0000000..5e05b9e --- /dev/null +++ b/agent-todo/src/functions/getTools.ts @@ -0,0 +1,18 @@ +import { zodFunction } from "openai/helpers/zod"; +import { executeTodoWorkflow } from "../workflows/executeTodo"; +import { createTodo } from "./createTodo"; +import { CreateTodoInput, ExecuteTodoInput } from "./toolTypes"; + +export const getTools = async () => { + const tools = [ + zodFunction({ + name: createTodo.name, + parameters: CreateTodoInput, + }), + zodFunction({ + name: executeTodoWorkflow.name, + parameters: ExecuteTodoInput, + }), + ]; + return tools; +}; diff --git a/agent-todo/src/functions/index.ts b/agent-todo/src/functions/index.ts new file mode 100644 index 0000000..d21a4eb --- /dev/null +++ b/agent-todo/src/functions/index.ts @@ -0,0 +1,6 @@ +export * from "./llmChat"; +export * from "./getTools"; +export * from "./createTodo"; +export * from "./getRandom"; +export * from "./toolTypes"; +export * from "./getResult"; diff --git a/agent-todo/src/functions/llmChat.ts b/agent-todo/src/functions/llmChat.ts new file mode 100644 index 0000000..58be36e --- /dev/null +++ b/agent-todo/src/functions/llmChat.ts @@ -0,0 +1,59 @@ +import { FunctionFailure, log } from "@restackio/ai/function"; +import { + ChatCompletionCreateParamsNonStreaming, + ChatCompletionSystemMessageParam, + ChatCompletionTool, + ChatCompletionToolMessageParam, + ChatCompletionUserMessageParam, + ChatCompletionAssistantMessageParam, + ChatCompletionMessage, +} from "openai/resources/chat/completions"; + +import { openaiClient } from "../utils/client"; + +export type Message = + | ChatCompletionSystemMessageParam + | ChatCompletionUserMessageParam + | ChatCompletionAssistantMessageParam + | ChatCompletionToolMessageParam; + +export type OpenAIChatInput = { + systemContent?: string; + model?: string; + messages: Message[]; + tools?: ChatCompletionTool[]; +}; + +export const llmChat = async ({ + systemContent = "", + model = "gpt-4o-mini", + messages, + tools, +}: OpenAIChatInput): Promise => { + try { + const openai = openaiClient({}); + + const chatParams: ChatCompletionCreateParamsNonStreaming = { + messages: [ + ...(systemContent + ? [{ role: "system" as const, content: systemContent }] + : []), + ...(messages ?? []), + ], + model, + tools, + }; + + log.debug("OpenAI chat completion params", { + chatParams, + }); + + const completion = await openai.chat.completions.create(chatParams); + + const message = completion.choices[0].message; + + return message; + } catch (error) { + throw FunctionFailure.nonRetryable(`Error OpenAI chat: ${error}`); + } +}; diff --git a/agent-todo/src/functions/toolTypes.ts b/agent-todo/src/functions/toolTypes.ts new file mode 100644 index 0000000..66716bb --- /dev/null +++ b/agent-todo/src/functions/toolTypes.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export const CreateTodoInput = z.object({ + todoTitle: z.string(), +}); + +export type CreateTodoInputType = z.infer; + +export const ExecuteTodoInput = z.object({ + todoId: z.string(), + todoTitle: z.string(), +}); + +export type ExecuteTodoInputType = z.infer; diff --git a/agent-todo/src/services.ts b/agent-todo/src/services.ts new file mode 100644 index 0000000..6ca6cc6 --- /dev/null +++ b/agent-todo/src/services.ts @@ -0,0 +1,33 @@ +import { + llmChat, + createTodo, + getTools, + getRandom, + getResult, +} from "./functions"; +import { client } from "./client"; + +async function services() { + const agentsPath = require.resolve("./agents"); + const workflowsPath = require.resolve("./workflows"); + try { + await Promise.all([ + client.startService({ + agentsPath: agentsPath, + functions: { llmChat, createTodo, getTools }, + }), + client.startService({ + workflowsPath: workflowsPath, + functions: { getRandom, getResult }, + }), + ]); + + console.log("Services running successfully."); + } catch (e) { + console.error("Failed to run services", e); + } +} + +services().catch((err) => { + console.error("Error running services:", err); +}); diff --git a/agent-todo/src/utils/client.ts b/agent-todo/src/utils/client.ts new file mode 100644 index 0000000..a3e4663 --- /dev/null +++ b/agent-todo/src/utils/client.ts @@ -0,0 +1,22 @@ +import OpenAI from "openai/index"; +import "dotenv/config"; + +let openaiInstance: OpenAI | null = null; + +export const openaiClient = ({ + apiKey = process.env.RESTACK_API_KEY, +}: { + apiKey?: string; +}): OpenAI => { + if (!apiKey) { + throw new Error("API key is required to create OpenAI client."); + } + + if (!openaiInstance) { + openaiInstance = new OpenAI({ + baseURL: "https://ai.restack.io", + apiKey, + }); + } + return openaiInstance; +}; diff --git a/agent-todo/src/workflows/executeTodo.ts b/agent-todo/src/workflows/executeTodo.ts new file mode 100644 index 0000000..c50ea4c --- /dev/null +++ b/agent-todo/src/workflows/executeTodo.ts @@ -0,0 +1,41 @@ +import { log, sleep, step } from "@restackio/ai/workflow"; +import * as functions from "../functions"; + +type Input = { + todoTitle: string; + todoId: string; +}; + +type Output = { + todoId: string; + todoTitle: string; + details: string; + status: string; +}; + +export async function executeTodoWorkflow({ + todoTitle, + todoId, +}: Input): Promise { + const random = await step({}).getRandom({ + todoTitle, + }); + + await sleep(2000); + + const result = await step({}).getResult({ + todoTitle, + todoId, + }); + + const todoDetails = { + todoId, + todoTitle, + details: random, + status: result.status, + }; + + log.info("Todo Details", { todoDetails }); + + return todoDetails; +} diff --git a/agent-todo/src/workflows/index.ts b/agent-todo/src/workflows/index.ts new file mode 100644 index 0000000..5ee17d1 --- /dev/null +++ b/agent-todo/src/workflows/index.ts @@ -0,0 +1 @@ +export * from "./executeTodo"; diff --git a/agent-todo/tsconfig.json b/agent-todo/tsconfig.json new file mode 100644 index 0000000..f4d02b9 --- /dev/null +++ b/agent-todo/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES6", + "module": "Node16", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "./dist", + "rootDir": "./src", + "resolveJsonModule": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} \ No newline at end of file