anya/tools/actions.ts

715 lines
21 KiB
TypeScript

// actions.ts
import YAML from "yaml";
import { z } from "zod";
import { v4 as uuidv4 } from "uuid";
import { Message } from "../interfaces/message";
import { eventManager } from "../interfaces/events";
import fs from "fs/promises";
import path from "path";
import { discordAdapter } from "../interfaces";
import { RunnableToolFunctionWithParse } from "openai/lib/RunnableFunction.mjs";
import { getTools, zodFunction } from ".";
import { ask } from "./ask";
import Fuse from "fuse.js";
import cron from "node-cron";
import { pathInDataDir, userConfigs } from "../config";
import { get_event_listeners } from "./events";
import { memory_manager_guide, memory_manager_init } from "./memory-manager";
// Paths to the JSON files
const ACTIONS_FILE_PATH = pathInDataDir("actions.json");
// Define schema for creating an action
export const CreateActionParams = z.object({
actionId: z
.string()
.describe(
"The unique identifier for the action. Make this relevant to the action."
),
description: z
.string()
.min(1, "description is required")
.describe("Short description of the action."),
schedule: z
.object({
type: z.enum(["delay", "cron"]).describe("Type of scheduling."),
time: z.union([
z.number().positive().int().describe("Delay in seconds."),
z.string().describe("Cron expression."),
]),
})
.describe("Scheduling details for the action."),
instruction: z
.string()
.min(1, "instruction is required")
.describe(
"Detailed instructions on what to do when the action is executed."
),
tool_names: z
.array(z.string())
.optional()
.describe(
`Names of the tools required to execute the instruction of an action.
Each of these should look something like "home_assistant_manager" or "calculator" and NOT "function:home_assistant_manager" or "function.calculator".`
),
notify: z
.boolean()
.describe(
"Wheater to notify the user when the action is executed or not with the action's output."
),
});
// Type for creating an action
export type CreateActionParams = z.infer<typeof CreateActionParams>;
// Define schema for searching actions
export const SearchActionsParams = z.object({
userId: z.string().optional(),
actionId: z.string().optional(),
});
// Type for searching actions
export type SearchActionsParams = z.infer<typeof SearchActionsParams>;
// Define schema for removing an action
const RemoveActionParamsSchema = z.object({
actionId: z.string().min(1, "actionId is required"),
});
// Type for removing an action
type RemoveActionParams = z.infer<typeof RemoveActionParamsSchema>;
// Define schema for updating an action
export const UpdateActionParams = z
.object({
actionId: z.string().min(1, "actionId is required"),
description: z.string().min(1, "description is required"),
schedule: z
.object({
type: z.enum(["delay", "cron"]).describe("Type of scheduling."),
time: z.union([
z.number().positive().int().describe("Delay in seconds."),
z.string().describe("Cron expression."),
]),
})
.describe("Scheduling details for the action."),
instruction: z
.string()
.min(1, "instruction is required")
.describe(
"Detailed instructions on what to do when the action is executed."
)
.optional(),
template: z
.string()
.min(1, "template is required")
.describe(
"A string template to format the action payload. Use double curly braces to reference variables, e.g., {{variableName}}."
)
.optional(),
tool_names: z
.array(z.string())
.optional()
.describe(
"Names of the tools required to execute the instruction when the action is executed."
),
notify: z
.boolean()
.optional()
.describe(
"Whether to notify the user when the action is executed or not with the action's output."
),
})
.refine(
(data) => {
const hasInstruction = !!data.instruction;
const hasTemplate = !!data.template;
return hasInstruction !== hasTemplate; // Either instruction or template must be present, but not both
},
{
message:
"Either 'instruction' with 'tool_names' or 'template' must be provided, but not both.",
}
);
// Type for updating an action
export type UpdateActionParams = z.infer<typeof UpdateActionParams>;
// Define the structure of an Action
interface Action {
actionId: string;
description: string;
userId: string;
schedule: {
type: "delay" | "cron";
time: number | string;
};
instruction?: string;
template?: string;
tool_names?: string[];
notify: boolean;
created_at: string; // ISO string for serialization
}
// In-memory storage for actions
const actionsMap: Map<string, Action> = new Map();
// Helper function to load actions from the JSON file
async function loadActionsFromFile() {
try {
const data = await fs.readFile(ACTIONS_FILE_PATH, "utf-8");
const parsed = JSON.parse(data) as Action[];
parsed.forEach((action) => {
actionsMap.set(action.actionId, action);
scheduleAction(action);
});
console.log(
`✅ Loaded ${actionsMap.size} actions from ${ACTIONS_FILE_PATH}`
);
} catch (error: any) {
if (error.code === "ENOENT") {
// File does not exist, create an empty file
await saveActionsToFile();
console.log(`📄 Created new actions file at ${ACTIONS_FILE_PATH}`);
} else {
console.error(`❌ Failed to load actions from file: ${error.message}`);
}
}
}
// Helper function to save actions to the JSON file
async function saveActionsToFile() {
const data = JSON.stringify(Array.from(actionsMap.values()), null, 2);
await fs.writeFile(ACTIONS_FILE_PATH, data, "utf-8");
}
// Function to schedule an action based on its schedule
function scheduleAction(action: Action) {
if (
action.schedule.type === "delay" &&
typeof action.schedule.time === "number"
) {
const createdAt = new Date(action.created_at).getTime();
const currentTime = Date.now();
const delayInMs = action.schedule.time * 1000;
const elapsedTime = currentTime - createdAt;
const remainingTime = delayInMs - elapsedTime;
if (remainingTime > 0) {
setTimeout(async () => {
await executeAction(action);
// After execution, remove the action as it's a one-time delay
actionsMap.delete(action.actionId);
await saveActionsToFile();
console.log(`🗑️ Removed action "${action.actionId}" after execution.`);
}, remainingTime);
console.log(
`⏰ Scheduled action "${action.actionId}" to run in ${Math.round(
remainingTime / 1000
)} seconds.`
);
} else {
// If the remaining time is less than or equal to zero, execute immediately
executeAction(action).then(async () => {
actionsMap.delete(action.actionId);
await saveActionsToFile();
console.log(
`🗑️ Removed action "${action.actionId}" after immediate execution.`
);
});
console.log(
`⚡ Executed action "${action.actionId}" immediately as the delay has already passed.`
);
}
} else if (
action.schedule.type === "cron" &&
typeof action.schedule.time === "string"
) {
// Schedule the action using the cron expression
cron.schedule(action.schedule.time, () => {
executeAction(action);
});
console.log(
`🕒 Scheduled action "${action.actionId}" with cron expression "${action.schedule.time}".`
);
} else {
console.error(`❌ Invalid schedule for action "${action.actionId}".`);
}
}
// Function to execute an action
async function executeAction(action: Action) {
try {
// Recreate the Message instance using discordAdapter
const contextMessage: Message = await discordAdapter.createMessageInterface(
action.userId
);
if (!contextMessage) {
console.error(
`❌ Unable to create Message interface for user "${action.userId}".`
);
return;
}
if (action.template) {
// Handle static action with template
const payload = {}; // Define how to obtain payload if needed
const formattedMessage = renderTemplate(action.template, payload);
await contextMessage.send({ content: formattedMessage });
} else if (action.instruction && action.tool_names) {
// Handle dynamic action with instruction and tools
let tools = getTools(
contextMessage.author.username,
contextMessage
).filter(
(tool) =>
tool.function.name && action.tool_names?.includes(tool.function.name)
) as RunnableToolFunctionWithParse<any>[] | undefined;
tools = tools?.length ? tools : undefined;
const response = await ask({
model: "gpt-4o-mini",
prompt: `You are an Action Executor.
You are called to execute an action based on the provided instruction.
**Guidelines:**
1. **Notifying the Current User:**
- Any message you reply with will automatically be sent to the user as a notification.
**Example:**
- **Instruction:** "Tell Pooja happy birthday"
- **Tool Names:** ["communication_manager"]
- **Notify:** true
- **Steps:**
1. Ask \`communication_manager\` to wish Pooja a happy birthday to send a message to the recipient mentioned by the user.
2. Reply to the current user with "I wished Pooja a happy birthday." to notify the user.
**Action Details:**
- **Action ID:** ${action.actionId}
- **Description:** ${action.description}
- **Instruction:** ${action.instruction}
Use the required tools/managers as needed.
`,
tools: tools?.length ? tools : undefined,
});
const content = response.choices[0].message.content ?? undefined;
// Send a message to the user indicating the action was executed
await contextMessage.send({ content });
} else {
console.error(
`❌ Action "${action.actionId}" has neither 'instruction' nor 'template' defined properly.`
);
}
} catch (error) {
console.error(`Error executing action "${action.actionId}":`, error);
}
}
/**
* Simple template renderer that replaces {{key}} with corresponding values from payload.
* @param template - The string template containing placeholders like {{key}}.
* @param payload - The payload containing key-value pairs.
* @returns The formatted string with placeholders replaced by payload values.
*/
function renderTemplate(
template: string,
payload: Record<string, string>
): string {
return template.replace(/{{\s*([^}]+)\s*}}/g, (_, key) => {
return payload[key.trim()] || `{{${key.trim()}}}`;
});
}
/**
* Creates an action.
* @param params - Parameters for creating the action.
* @param contextMessage - The message context from which the action is created.
* @returns A JSON object containing the action details and a success message, or an error.
*/
export async function create_action(
params: CreateActionParams,
contextMessage: Message
): Promise<any> {
const parsed = CreateActionParams.safeParse(params);
if (!parsed.success) {
return { error: parsed.error.errors };
}
let { actionId, description, schedule, instruction, tool_names } =
parsed.data;
// Get the userId from contextMessage
const userId: string = contextMessage.author.id;
if (actionsMap.has(actionId)) {
return { error: `❌ Action with ID "${actionId}" already exists.` };
}
const send_message_tools = tool_names?.filter(
(t) => t.includes("send_message") && !t.startsWith("confirm_")
);
if (send_message_tools?.length) {
return {
confirmation: `You are using tools that sends a message to the user explicitly.
Tool names that triggered this confirmation: [${send_message_tools.join(
", "
)}]
Use these only to send a message to a different user or channel. Not sending this tool would by default send the message to the user anyway.
To use this tool anyway like to send a message to a different user or channel, please re run create command and prefix the tool name with 'confirm_'`,
};
}
tool_names = tool_names?.map((t) =>
t.startsWith("confirm_") ? t.replace("confirm_", "") : t
);
const action: Action = {
actionId,
description,
userId,
schedule,
instruction,
tool_names,
notify: params.notify ?? true,
created_at: new Date().toISOString(),
};
actionsMap.set(actionId, action);
await saveActionsToFile();
// Schedule the action
scheduleAction(action);
return {
actionId,
description,
userId,
schedule,
instruction,
tool_names,
created_at: action.created_at,
message: "✅ Action created and scheduled successfully.",
};
}
// 1. Define schema for getting actions
export const GetActionsParams = z.object({});
export type GetActionsParams = z.infer<typeof GetActionsParams>;
// 2. Implement the get_actions function
export async function get_actions(
params: GetActionsParams,
contextMessage: Message
): Promise<any> {
// Get the userId from contextMessage
const userId: string = contextMessage.author.id;
// Get all actions created by this user
const userActions = Array.from(actionsMap.values()).filter(
(action) => action.userId === userId
);
return {
actions: userActions,
};
}
/**
* Removes an action by its actionId by fully deleting it.
* @param params - Parameters containing the actionId.
* @returns A JSON object confirming removal or an error.
*/
export async function remove_action(params: RemoveActionParams): Promise<any> {
// Validate parameters using zod
const parsed = RemoveActionParamsSchema.safeParse(params);
if (!parsed.success) {
return { error: parsed.error.errors };
}
const { actionId } = parsed.data;
const action = actionsMap.get(actionId);
if (!action) {
return {
error: `❌ Action with ID "${actionId}" not found.`,
};
}
// Remove the action from the map
actionsMap.delete(actionId);
await saveActionsToFile();
// Note: In a real implementation, you'd also need to cancel the scheduled task.
// This can be managed by keeping track of timers or using a scheduler that supports cancellation.
return {
message: `✅ Action with ID "${actionId}" removed successfully.`,
};
}
/**
* Updates the details of an action.
* @param params - Parameters containing the actionId and fields to update.
* @param contextMessage - The message context to identify the user.
* @returns A JSON object confirming the update or an error.
*/
export async function update_action(
params: UpdateActionParams,
contextMessage: Message
): Promise<any> {
// Validate parameters using zod
const parsed = UpdateActionParams.safeParse(params);
if (!parsed.success) {
return { error: parsed.error.errors };
}
const {
actionId,
description,
schedule,
instruction,
template,
tool_names,
notify,
} = parsed.data;
// Get the userId from contextMessage
const userId: string = contextMessage.author.id;
// Find the action
const action = actionsMap.get(actionId);
if (!action) {
return { error: `❌ Action with ID "${actionId}" not found.` };
}
// Ensure the action belongs to the user
if (action.userId !== userId) {
return { error: `❌ You do not have permission to update this action.` };
}
// Update fields
action.description = description;
action.schedule = schedule;
action.instruction = instruction;
action.template = template;
action.notify = notify ?? action.notify;
action.tool_names = tool_names;
actionsMap.set(actionId, action);
await saveActionsToFile();
// Reschedule the action
scheduleAction(action);
return {
actionId,
description,
userId,
schedule,
instruction,
template,
tool_names,
created_at: action.created_at,
message: "✅ Action updated and rescheduled successfully.",
};
}
const action_tools: (
context_message: Message
) => RunnableToolFunctionWithParse<any>[] = (context_message) => [
zodFunction({
name: "create_action",
function: (args) => create_action(args, context_message),
schema: CreateActionParams,
description: `Creates a new action.
**Example:**
- **User:** "Send a summary email in 10 minutes"
- **Action ID:** "send_summary_email"
- **Description:** "Sends a summary email after a delay."
- **Schedule:** { type: "delay", time: 600 }
- **Instruction:** "Compose and send a summary email to the user."
- **Required Tools:** ["email_service"]
**Notes:**
- Supported scheduling types: 'delay' (in seconds), 'cron' (cron expressions).
`,
}),
// zodFunction({
// name: "get_actions",
// function: (args) => get_actions(args, context_message),
// schema: GetActionsParams,
// description: `Retrieves all actions created by the user.
// Use this to obtain action IDs for updating or removing actions."
// `,
// }),
zodFunction({
name: "update_action",
function: (args) => update_action(args, context_message),
schema: UpdateActionParams,
description: `Updates an existing action's details.
Provide all details of the action to replace it with the new parameters.
`,
}),
zodFunction({
name: "remove_action",
function: (args) => remove_action(args),
schema: RemoveActionParamsSchema,
description: `Removes an action using the action ID.`,
}),
];
export const ActionManagerParamsSchema = z.object({
request: z
.string()
.describe(
"What the user wants you to do in the action. Please provide the time / schedule as well."
),
tool_names: z
.array(z.string())
.optional()
.describe("Names of the tools required to execute the instruction."),
suggested_time_to_run_action: z.string().optional(),
});
export type ActionManagerParams = z.infer<typeof ActionManagerParamsSchema>;
// -------------------- Fuzzy Search for Actions -------------------- //
export const FuzzySearchActionsParams = z.object({
query: z.string(),
});
export type FuzzySearchActionsParams = z.infer<typeof FuzzySearchActionsParams>;
export async function fuzzySearchActions({
query,
}: FuzzySearchActionsParams): Promise<{ matches: any[] }> {
try {
// Fetch all actions (Assuming get_actions is already defined and returns actions)
const { actions } = await get_actions({}, {
author: { id: "system" },
} as any); // Replace with actual contextMessage if available
if (!actions) {
return { matches: [] };
}
const fuseOptions = {
keys: ["description", "actionId"],
threshold: 0.3, // Adjust the threshold as needed
};
const fuse = new Fuse(actions, fuseOptions);
const results = fuse.search(query);
// Get top 2 results
const topMatches = results.slice(0, 2).map((result) => result.item);
return { matches: topMatches };
} catch (error) {
console.error("Error performing fuzzy search on actions:", error);
return { matches: [] };
}
}
// -------------------- Manager Function -------------------- //
/**
* Manages user requests related to actions by orchestrating CRUD operations.
* It can handle creating, updating, retrieving, and removing actions based on user input.
*
* @param params - Parameters containing the user's request, the action name, and an optional delay.
* @returns A JSON object containing the response from executing the action or an error.
*/
export async function actionManager(
{ request, tool_names, suggested_time_to_run_action }: ActionManagerParams,
context_message: Message
): Promise<any> {
// Validate parameters using Zod
const parsed = ActionManagerParamsSchema.safeParse({
request,
});
if (!parsed.success) {
return { error: parsed.error.errors };
}
const { request: userRequest } = parsed.data;
const all_actions = await get_actions({}, context_message);
const userConfigData = userConfigs.find((config) =>
config.identities.find((id) => id.id === context_message.author.id)
);
// Construct the prompt for the ask function
const prompt = `You are an Action Manager.
Your role is to manage scheduled actions.
**Current Time:** ${new Date().toLocaleString()}
----
${memory_manager_guide("actions_manager", context_message.author.id)}
----
**Actions You Have Set Up for This User:**
${JSON.stringify(all_actions.actions)}
**Current User Details:**
${JSON.stringify(userConfigData)}
**Tools Suggested by user for Action:**
${JSON.stringify(tool_names)}
---
Use the data provided above to fulfill the user's request.
`;
const tools = action_tools(context_message).concat(
memory_manager_init(context_message, "actions_manager")
);
// console.log("Action Manager Tools:", tools);
// Execute the action using the ask function with the appropriate tools
try {
const response = await ask({
prompt,
message: `${userRequest}
Suggested time: ${suggested_time_to_run_action}
`,
seed: `action_manager_${context_message.channelId}`,
tools,
});
return {
response: response.choices[0].message.content,
};
} catch (error) {
console.error("Error executing action via manager:", error);
return {
error: "❌ An error occurred while executing your request.",
};
}
}
// Initialize by loading actions from file when the module is loaded
loadActionsFromFile();