Skip to content

RubricLab/actions

Repository files navigation

@rubriclab/actions

This package is part of a 3 package system that represents Rubric's framework for Generative UI. See also:

  • @rubriclab/blocks
  • @rubriclab/ui

The Actions package aims to provide a powerful and simple way to define actions (which are essentially API primitives) and chain them together in a typesafe way.

It is designed to be awesome for developers (providing really simple and powerful DX with excellent typesafety) and powerful for AI systems - allowing structured output models to export chains reliably.

Get Started

Installation

bun add @rubriclab/actions

@rubriclab scope packages are not built, they are all raw typescript. If using in a next.js app, make sure to transpile.

// next.config.ts
import type { NextConfig } from  'next' 
export default {
	transpilePackages: ['@rubriclab/auth'],
	reactStrictMode: true
} satisfies  NextConfig

If using inside the monorepo (@rubric), simply add {"@rubriclab/actions": "*"} to dependencies and then run bun i

Define Actions

To get started, define a few actions.

import { createAction } from '@rubriclab/actions'
import { z } from 'zod'

const convertStringToNumber = createAction({
	schema: {
		input: z.object({
			str: z.string()
		}),
		output: z.number()
	},
	execute: ({ str }) => Number(str)
})

const convertNumberToString = createAction({
	schema: {
		input: z.object({
			num: z.number()
		}),
		output: z.string()
	},
	execute: ({ num }) => num.toString()
})

Create an Executor

Pass all your actions into an executor to get an executor, zod schema, and a response_format (json schema for AI)

const { execute, schema, response_format } = createActionsExecutor({
	convertStringToNumber,
	convertNumberToString
})

Execute a chain

Now that your actions are set up, you have typesafe chain execution.

const validSingle = execute({
	action: 'convertStringToNumber',
	params: {
		str: "2"
	}
})
const validChain = execute({
	action: 'convertStringToNumber',
	params: {
		str: {
			action: 'convertNumberToString',
			params: {
				num: 2
			}
		}
	}
})

Check if a chain is valid

At Build Time

The type z.infer<typeof schema> validates chains

const invalidChain: z.infer<typeof schema> = {
	action: 'convertStringToNumber',
	params: {
		str: {
			// you should see a TS issue here.
			action: 'convertStringToNumber',
			params: {
				num: '2'
			}
		}
	}
}

The input to execute() is also checked

 const invalidChain = execute({
	action: 'convertStringToNumber',
	params: {
		str: {
			// you should see a TS issue here.
			action: 'convertStringToNumber',
			params: {
				num: '2'
			}
		}
	}
})

At Run Time

You can parse at run time using zod: schema.parse(invalidChain) schema.safeParse(invalidChain)

Usage with AI

Use the response_format object for structured outputs.

const  completion = await  new  openai().beta.chat.completions.parse({
	model: 'gpt-4o-2024-08-06',
	messages: [
		{
			role: 'system',
			content: 'You are an actions executor. Your job is to create a single chain of actions that accomplishes the request.'
		},
		{
			role: 'user',
			content: 'parse 4 into a string and then back into a number 3 times.'
		}
	],
	// the response_format works out of the box with structured outputs.
	response_format
})
const { execution } = schema.parse(completion.choices[0]?.message.parsed)
console.dir(execution, { depth: null })
console.log(execute(execution))

Advanced usage

Large amounts of actions

In theory, you can define lots and lots of actions and still get good outputs from AI. Log response_format to see that it is very flat and scalable!

Similar Objects

Out of the box, actions can be chained if they share IO primitives. For example, you can chain convertStringToNumber with convertNumberToString since the output of each is a primitive (z.number() and z.string() respectively) that corresponds to an input field of the other. In more realistic scenarios, you will have more complex output types, for example, a contact.

const Contact = z.object({
	id: z.string(),
	name: z.string(),
	email: z.string(),
	image: z.string()
})

Notice that id could create problems, since it's seemingly compatible with any string. You wouldn't want AI or a developer to accidentally pass in a hallucinated string, an id from a different service, or the result of another action that returns a string that isn't actually a valid id.

In these cases, you can define a locked down type, such as a GoogleContactID:

const GoogleContactId = z.object({
	type: z.literal('googleContactId'),
	id: z.string()
})

Then you can enforce that this ID is specific to actions that use it:

const getFirstGoogleContactFromSearch = createAction({
	schema: {
		input: z.object({
			search: z.string()
		}),
		output: GoogleContactId
	},
	execute: ({ search }) => ({
		type: 'googleContactId'  as  const,
		id: '...'
	})
})

// a similar but not identical contact
const getFirstFacebookContactFromSearch = createAction({
	schema: {
		input: z.object({
			search: z.string()
		}),
		output: z.object({
			type: z.literal('facebookContactId'),
			id: z.string()
		})
	},
	execute: ({ search }) => ({
		type: 'googleContactId'  as  const,
		id: '...'
	})
})

const sendEmail = createAction({
	schema: {
		input: z.object({
			// only accept google contacts
			to: GoogleContactId,
			content: z.string()
		}),
		output: z.boolean()
	},
	execute: ({ to, content }) => {
		console.log(`Sending email to ${to.id}: ${content}`)
		return true
	}
})

In this example, sendEmail will only be chainable with getFirstGoogleContactFromSearch. There will be a ts issue trying to send an email to a Facebook contact, and AI will not be able to erroneously chain. Under the hood, we use a hashing mechanism to ensure that objects retain their exact uniqueness. Log response_format to see how that works!

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published