diff --git a/x/henry/mp-sandbox-agent/agent/index.ts b/x/henry/mp-sandbox-agent/agent/index.ts index 3465c5b0c07f..70d8504a4171 100644 --- a/x/henry/mp-sandbox-agent/agent/index.ts +++ b/x/henry/mp-sandbox-agent/agent/index.ts @@ -43,7 +43,7 @@ export class Agent { console.log("--------------------------------"); console.log(`Creating agent with goal: ${goal}`); console.log("--------------------------------"); - const agent = new Agent(goal); + const agent = new Agent(goal, process.env.OPENAI_API_KEY!); agent.sandbox = await PythonSandbox.create(); return agent; } diff --git a/x/henry/mp-sandbox-agent/bun.lock b/x/henry/mp-sandbox-agent/bun.lock index d9e461183c61..f1641ae8f35d 100644 --- a/x/henry/mp-sandbox-agent/bun.lock +++ b/x/henry/mp-sandbox-agent/bun.lock @@ -5,9 +5,11 @@ "name": "mp-sandbox", "dependencies": { "@micropython/micropython-webassembly-pyscript": "^1.24.1", + "@types/uuid": "^10.0.0", "dotenv": "^16.4.7", "google-search-results-nodejs": "^2.1.0", "openai": "^4.85.1", + "uuid": "^11.1.0", "zod": "^3.24.2", }, "devDependencies": { @@ -27,6 +29,8 @@ "@types/node-fetch": ["@types/node-fetch@2.6.12", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA=="], + "@types/uuid": ["@types/uuid@10.0.0", "", {}, "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ=="], + "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], @@ -101,6 +105,8 @@ "undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], diff --git a/x/henry/mp-sandbox-agent/main.ts b/x/henry/mp-sandbox-agent/main.ts index 1989eb321319..b3bc65430d5d 100644 --- a/x/henry/mp-sandbox-agent/main.ts +++ b/x/henry/mp-sandbox-agent/main.ts @@ -4,6 +4,7 @@ import { fetchWeather } from "./tools/fetch_weather"; import { Agent } from "./agent"; import { scrapePages } from "./tools/scrape"; import { searchWeb } from "./tools/serp"; +import { Workflow } from "./workflow/workflow"; // Load environment variables from .env file dotenv.config(); @@ -23,23 +24,25 @@ async function main() { process.exit(1); } - // Initialize agent with a goal - const agent = await Agent.create(request, process.env.OPENAI_API_KEY!); - - // Define available tools - const tools = { - fetch_weather: fetchWeather, - scrape_pages: scrapePages, - search_web: searchWeb, - }; - - // Run a step with the user's request - let answer: string | null = null; - while (answer === null) { - answer = await agent.step(tools); - } - - console.log(answer); + const workflow = new Workflow("Main Flow").addStep(() => ({ + id: "agent", + name: "Agent", + execute: async (input, context) => { + const agent = await Agent.create(request); + const tools = { + fetch_weather: fetchWeather, + scrape_pages: scrapePages, + search_web: searchWeb, + }; + let answer: string | null = null; + while (answer === null) { + answer = await agent.step(tools); + } + return answer; + }, + })); + + const result = await workflow.execute(request); } main().catch((error) => { diff --git a/x/henry/mp-sandbox-agent/package.json b/x/henry/mp-sandbox-agent/package.json index 6381237b370a..4df79c7ebe34 100644 --- a/x/henry/mp-sandbox-agent/package.json +++ b/x/henry/mp-sandbox-agent/package.json @@ -10,9 +10,11 @@ }, "dependencies": { "@micropython/micropython-webassembly-pyscript": "^1.24.1", + "@types/uuid": "^10.0.0", "dotenv": "^16.4.7", "google-search-results-nodejs": "^2.1.0", "openai": "^4.85.1", + "uuid": "^11.1.0", "zod": "^3.24.2" } } \ No newline at end of file diff --git a/x/henry/mp-sandbox-agent/tools/scrape.ts b/x/henry/mp-sandbox-agent/tools/scrape.ts index 82a06375c9f9..69012461f88a 100644 --- a/x/henry/mp-sandbox-agent/tools/scrape.ts +++ b/x/henry/mp-sandbox-agent/tools/scrape.ts @@ -50,7 +50,11 @@ export const scrapePages = defineTool( log( input.urls - .map((url, i) => `${url}:\n${JSON.stringify(results[i])}`) + .map((url, i) => + results[i].success + ? `${url}:\n${JSON.stringify(results[i].data.markdown)}` + : `${url}: failed to scrape` + ) .join("\n\n") ); diff --git a/x/henry/mp-sandbox-agent/tools/serp.ts b/x/henry/mp-sandbox-agent/tools/serp.ts index 5ea3ab8ce0ec..2c0b56ed027a 100644 --- a/x/henry/mp-sandbox-agent/tools/serp.ts +++ b/x/henry/mp-sandbox-agent/tools/serp.ts @@ -63,7 +63,13 @@ export const searchWeb = defineTool( log( `Retrieved ${data.organic_results?.length || 0} results for query "${ input.query - }" (page ${input.page}):\n${JSON.stringify(data)}` + }" (page ${input.page}):\n${JSON.stringify( + data.organic_results?.map((r: any) => ({ + title: r.title, + link: r.link, + snippet: r.snippet, + })) + )}` ); return { diff --git a/x/henry/mp-sandbox-agent/workflow/types.ts b/x/henry/mp-sandbox-agent/workflow/types.ts new file mode 100644 index 000000000000..e64be8944388 --- /dev/null +++ b/x/henry/mp-sandbox-agent/workflow/types.ts @@ -0,0 +1,26 @@ +export type WorkflowStepStatus = "pending" | "running" | "completed" | "failed"; + +export interface WorkflowStepResult { + status: WorkflowStepStatus; + output?: T; + error?: Error; +} + +// Type to represent the context available to a step +export type StepContext> = { + previousOutputs: T; + workflowInput: unknown; +}; + +export interface WorkflowStep< + Input, + Output, + Context extends Record +> { + id: string; + name: string; + execute: (input: Input, context: StepContext) => Promise; + status?: WorkflowStepStatus; + output?: Output; + error?: Error; +} diff --git a/x/henry/mp-sandbox-agent/workflow/workflow.test.ts b/x/henry/mp-sandbox-agent/workflow/workflow.test.ts new file mode 100644 index 000000000000..fdddf93da8db --- /dev/null +++ b/x/henry/mp-sandbox-agent/workflow/workflow.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, test } from "bun:test"; +import { Workflow } from "./workflow"; + +describe("Workflow", () => { + describe("Basic Function Steps", () => { + interface NumberInput { + value: number; + } + + test("should execute a single step workflow", async () => { + const result = await new Workflow("Simple Math") + .addStep(() => ({ + id: "double", + name: "Double Number", + execute: async (input) => input.value * 2, + })) + .execute({ value: 5 }); + + expect(result.status).toBe("completed"); + expect(result.output).toBe(10); + }); + + test("should execute multiple steps in sequence", async () => { + type WrappedNumber = { number: number }; + + const result = await new Workflow("Math Chain") + .addStep(() => ({ + id: "double" as const, + name: "Double Number", + execute: async (input) => input.value * 2, + })) + .addStep(() => ({ + id: "add-ten" as const, + name: "Add Ten", + execute: async (input, { previousOutputs }) => { + return previousOutputs.double + 10; + }, + })) + .addStep(() => ({ + id: "wrap-number" as const, + name: "Wrap Number", + execute: async ( + input, + { previousOutputs } + ): Promise => { + return { number: previousOutputs["add-ten"] }; + }, + })) + .addStep(() => ({ + id: "add-twenty" as const, + name: "Add Twenty", + execute: async (input, { previousOutputs }) => { + return previousOutputs["wrap-number"].number + 20; + }, + })) + .addStep(() => ({ + id: "all-results" as const, + name: "All Results", + execute: async (input, { previousOutputs }) => { + return { + results: [ + { + stepId: "double" as const, + result: previousOutputs["double"], + }, + { + stepId: "add-ten" as const, + result: previousOutputs["add-ten"], + }, + { + stepId: "wrap-number" as const, + result: previousOutputs["wrap-number"], + }, + { + stepId: "add-twenty" as const, + result: previousOutputs["add-twenty"], + }, + ] as const, + }; + }, + })) + .addStep(() => ({ + id: "check-types" as const, + name: "Check Types", + execute: async (input, { previousOutputs }) => { + ((_a: { stepId: "double"; x: number }) => undefined)({ + stepId: previousOutputs["all-results"].results[0].stepId, + x: previousOutputs["all-results"].results[0].result, + }); + ((_a: { stepId: "add-ten"; x: number }) => undefined)({ + stepId: previousOutputs["all-results"].results[1].stepId, + x: previousOutputs["all-results"].results[1].result, + }); + ((_a: { stepId: "wrap-number"; x: WrappedNumber }) => undefined)({ + stepId: previousOutputs["all-results"].results[2].stepId, + x: previousOutputs["all-results"].results[2].result, + }); + ((_a: { stepId: "add-twenty"; x: number }) => undefined)({ + stepId: previousOutputs["all-results"].results[3].stepId, + x: previousOutputs["all-results"].results[3].result, + }); + + return { validTypes: true }; + }, + })) + .addStep(() => ({ + id: "final-result" as const, + name: "Final Result", + execute: async (input, { previousOutputs }) => { + return { + finalResult: previousOutputs["add-twenty"], + }; + }, + })) + .execute({ value: 5 }); + + expect(result.status).toBe("completed"); + expect(result.output).toEqual({ finalResult: 40 }); // (5 * 2) + 10 + 20 + }); + + test("should handle step failure", async () => { + const result = await new Workflow("Failing Math") + .addStep(() => ({ + id: "fail", + name: "Fail Step", + execute: async () => { + throw new Error("Step failed"); + }, + })) + .execute({ value: 5 }); + + expect(result.status).toBe("failed"); + expect(result.error?.message).toBe("Step failed"); + }); + }); + + describe("Type-Safe Step Dependencies", () => { + interface ComplexInput { + numbers: number[]; + operation: "sum" | "multiply"; + } + + test("should maintain type safety between steps", async () => { + const result = await new Workflow("Type Safe Math") + .addStep(() => ({ + id: "perform-operation", + name: "Perform Operation", + execute: async (input: ComplexInput) => { + const result = + input.operation === "sum" + ? input.numbers.reduce((a, b) => a + b, 0) + : input.numbers.reduce((a, b) => a * b, 1); + + return { + result, + operation: input.operation, + }; + }, + })) + .addStep(() => ({ + id: "format-result", + name: "Format Result", + execute: async (input, { previousOutputs }) => { + const opResult = previousOutputs["perform-operation"]; + return { + message: `The ${opResult.operation} of the numbers is:`, + value: opResult.result, + }; + }, + })) + .execute({ + numbers: [1, 2, 3, 4], + operation: "sum", + }); + + expect(result.status).toBe("completed"); + expect(result.output).toEqual({ + message: "The sum of the numbers is:", + value: 10, + }); + }); + }); + + describe("Sub-Workflows", () => { + interface NumberInput { + value: number; + } + + test("should execute nested workflows", async () => { + // Create a sub-workflow that doubles a number + const doubleWorkflow = new Workflow("Double").addStep( + () => ({ + id: "double", + name: "Double Number", + execute: async (input) => input.value * 2, + }) + ); + + const result = await new Workflow("Main Flow") + .addStep(() => ({ + id: "sub-workflow", + name: "Double Sub-workflow", + execute: async (input) => { + const subResult = await doubleWorkflow.execute(input); + if (subResult.status === "failed") throw subResult.error; + return subResult.output!; + }, + })) + .addStep(() => ({ + id: "add-ten", + name: "Add Ten", + execute: async (input, { previousOutputs }) => { + return previousOutputs["sub-workflow"] + 10; + }, + })) + .execute({ value: 5 }); + + expect(result.status).toBe("completed"); + expect(result.output).toBe(20); // (5 * 2) + 10 + }); + + test("should handle sub-workflow failures", async () => { + // Create a failing sub-workflow + const failingWorkflow = new Workflow("Failing Sub").addStep( + () => ({ + id: "fail", + name: "Fail Step", + execute: async () => { + throw new Error("Sub-workflow failed"); + }, + }) + ); + + const result = await new Workflow("Main Flow") + .addStep(() => ({ + id: "sub-workflow", + name: "Failing Sub-workflow", + execute: async (input) => { + const subResult = await failingWorkflow.execute(input); + if (subResult.status === "failed") throw subResult.error; + return subResult.output!; + }, + })) + .execute({ value: 5 }); + + expect(result.status).toBe("failed"); + expect(result.error?.message).toBe("Sub-workflow failed"); + }); + }); + + describe("Workflow Reset", () => { + interface NumberInput { + value: number; + } + + test("should reset workflow state", async () => { + const workflow = new Workflow("Resettable").addStep(() => ({ + id: "double", + name: "Double Number", + execute: async (input) => input.value * 2, + })); + + // First execution + let result = await workflow.execute({ value: 5 }); + expect(result.status).toBe("completed"); + expect(result.output).toBe(10); + expect(workflow.status).toBe("completed"); + + // Reset + workflow.reset(); + expect(workflow.status).toBe("pending"); + + // Second execution + result = await workflow.execute({ value: 3 }); + expect(result.status).toBe("completed"); + expect(result.output).toBe(6); + }); + }); +}); diff --git a/x/henry/mp-sandbox-agent/workflow/workflow.ts b/x/henry/mp-sandbox-agent/workflow/workflow.ts new file mode 100644 index 000000000000..5108e3c24d81 --- /dev/null +++ b/x/henry/mp-sandbox-agent/workflow/workflow.ts @@ -0,0 +1,191 @@ +import { v4 as uuidv4 } from "uuid"; +import type { + WorkflowStepResult, + WorkflowStepStatus, + StepContext, + WorkflowStep, +} from "./types"; + +// Helper type to extract the output type from a step +export type StepOutput = T extends WorkflowStep + ? O + : never; + +// Helper type to extract the ID from a step +export type StepId = T extends { id: infer I extends string } ? I : never; + +// Helper type to create a mapping of step IDs to their output types +type StepOutputsRecord[]> = { + [I in T[number]["id"]]: Extract["execute"] extends ( + input: any, + context: any + ) => Promise + ? O + : never; +}; + +// Helper type to extract step type by ID +export type StepWithId = Extract; + +// Helper type to create a union of all step IDs +type StepIds[]> = { + [K in keyof T]: T[K] extends { id: infer I extends string } ? I : never; +}[number]; + +export class Workflow< + TInput, + TSteps extends readonly WorkflowStep[] = readonly [] +> { + readonly id: string; + readonly name: string; + readonly status: WorkflowStepStatus = "pending"; + + readonly steps: TSteps = [] as unknown as TSteps; + private currentStepIndex = 0; + private stepOutputs = new Map(); + + constructor(name: string) { + this.id = uuidv4(); + this.name = name; + } + + private updateStatus(status: WorkflowStepStatus): void { + (this.status as WorkflowStepStatus) = status; + } + + addStep< + TNewStep extends WorkflowStep> + >( + createStep: (context: StepContext>) => TNewStep + ): Workflow { + const outputs = {} as StepOutputsRecord; + for (const step of this.steps) { + const output = this.stepOutputs.get(step.id); + if (output !== undefined) { + outputs[step.id as keyof typeof outputs] = output as StepOutput< + typeof step + >; + } + } + + const step = createStep({ + previousOutputs: outputs, + workflowInput: undefined as TInput, // Will be provided during execution + }); + + step.status = "pending"; + + const newWorkflow = this as unknown as Workflow< + TInput, + readonly [...TSteps, TNewStep] + >; + (newWorkflow.steps as unknown as WorkflowStep[]).push( + step + ); + return newWorkflow; + } + + private async executeStep( + step: T, + input: TInput, + stepOutputs: StepOutputsRecord + ): Promise>> { + try { + step.status = "running"; + console.log(`Executing step ${step.id} with outputs:`, stepOutputs); + const output = await step.execute(input, { + previousOutputs: stepOutputs, + workflowInput: input, + }); + console.log(`Step ${step.id} output:`, output); + step.status = "completed"; + step.output = output; + + return { + status: "completed", + output, + }; + } catch (error) { + console.error(`Step ${step.id} failed:`, error); + step.status = "failed"; + step.error = error as Error; + + return { + status: "failed", + error: error as Error, + }; + } + } + + async execute( + workflowInput: TInput + ): Promise>> { + this.updateStatus("running"); + this.stepOutputs.clear(); + this.currentStepIndex = 0; + + try { + let currentValue: unknown = undefined; + while (this.currentStepIndex < this.steps.length) { + const currentStep = this.steps[this.currentStepIndex]; + const stepOutputs = {} as StepOutputsRecord; + + // Build the outputs object with all previous step outputs + for (let i = 0; i < this.currentStepIndex; i++) { + const step = this.steps[i]; + const output = this.stepOutputs.get(step.id); + if (output !== undefined) { + (stepOutputs as any)[step.id] = output as StepOutput; + } + } + + console.log(`Step ${this.currentStepIndex} outputs:`, stepOutputs); + const stepResult = await this.executeStep( + currentStep, + workflowInput, + stepOutputs + ); + + if (stepResult.status === "failed") { + this.updateStatus("failed"); + return stepResult; + } + + currentValue = stepResult.output; + this.stepOutputs.set(currentStep.id, currentValue); + console.log(`Stored output for step ${currentStep.id}:`, currentValue); + this.currentStepIndex++; + } + + this.updateStatus("completed"); + console.log("Final output:", currentValue); + return { + status: "completed", + output: currentValue as StepOutput, + }; + } catch (error) { + console.error("Workflow execution failed:", error); + this.updateStatus("failed"); + return { + status: "failed", + error: error as Error, + }; + } + } + + reset(): void { + this.updateStatus("pending"); + this.currentStepIndex = 0; + this.stepOutputs.clear(); + + for (const step of this.steps) { + step.status = "pending"; + step.output = undefined; + step.error = undefined; + } + } + + getSteps(): ReadonlyArray { + return this.steps; + } +}