Skip to content

Commit

Permalink
Merge pull request #186 from ubiquity-whilefoo/command-interface
Browse files Browse the repository at this point in the history
Command LLM
  • Loading branch information
gentlementlegen authored Nov 26, 2024
2 parents d8af9f8 + 1fd1fbc commit a2726d7
Show file tree
Hide file tree
Showing 21 changed files with 659 additions and 237 deletions.
1 change: 1 addition & 0 deletions .dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ WEBHOOK_PROXY_URL=https://smee.io/new
APP_WEBHOOK_SECRET=xxxxxx
APP_ID=123456
ENVIRONMENT=development | production
OPENAI_API_KEY=
Binary file modified bun.lockb
Binary file not shown.
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,10 @@
"@octokit/types": "^13.5.0",
"@octokit/webhooks": "13.3.0",
"@octokit/webhooks-types": "7.5.1",
"@sinclair/typebox": "^0.33.20",
"@ubiquity-os/plugin-sdk": "^1.0.11",
"@sinclair/typebox": "0.34.3",
"@ubiquity-os/plugin-sdk": "^1.1.0",
"dotenv": "16.4.5",
"openai": "^4.70.2",
"typebox-validators": "0.3.5",
"yaml": "2.4.5"
},
Expand Down
10 changes: 9 additions & 1 deletion src/github/github-context.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { EmitterWebhookEvent as WebhookEvent, EmitterWebhookEventName as WebhookEventName } from "@octokit/webhooks";
import { customOctokit } from "./github-client";
import { GitHubEventHandler } from "./github-event-handler";
import OpenAI from "openai";

export class GitHubContext<TSupportedEvents extends WebhookEventName = WebhookEventName> {
public key: WebhookEventName;
Expand All @@ -11,8 +12,14 @@ export class GitHubContext<TSupportedEvents extends WebhookEventName = WebhookEv
}[TSupportedEvents]["payload"];
public octokit: InstanceType<typeof customOctokit>;
public eventHandler: InstanceType<typeof GitHubEventHandler>;
public openAi: OpenAI;

constructor(eventHandler: InstanceType<typeof GitHubEventHandler>, event: WebhookEvent<TSupportedEvents>, octokit: InstanceType<typeof customOctokit>) {
constructor(
eventHandler: InstanceType<typeof GitHubEventHandler>,
event: WebhookEvent<TSupportedEvents>,
octokit: InstanceType<typeof customOctokit>,
openAi: OpenAI
) {
this.eventHandler = eventHandler;
this.name = event.name;
this.id = event.id;
Expand All @@ -23,6 +30,7 @@ export class GitHubContext<TSupportedEvents extends WebhookEventName = WebhookEv
this.key = this.name;
}
this.octokit = octokit;
this.openAi = openAi;
}
}

Expand Down
13 changes: 9 additions & 4 deletions src/github/github-event-handler.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import { EmitterWebhookEvent, Webhooks } from "@octokit/webhooks";
import { createAppAuth } from "@octokit/auth-app";
import { signPayload } from "@ubiquity-os/plugin-sdk/signature";
import OpenAI from "openai";

import { customOctokit } from "./github-client";
import { GitHubContext, SimplifiedContext } from "./github-context";
import { createAppAuth } from "@octokit/auth-app";
import { KvStore } from "./utils/kv-store";
import { PluginChainState } from "./types/plugin";
import { signPayload } from "@ubiquity-os/plugin-sdk/signature";

export type Options = {
environment: "production" | "development";
webhookSecret: string;
appId: string | number;
privateKey: string;
pluginChainState: KvStore<PluginChainState>;
openAiClient: OpenAI;
};

export class GitHubEventHandler {
Expand All @@ -25,13 +28,15 @@ export class GitHubEventHandler {
private readonly _webhookSecret: string;
private readonly _privateKey: string;
private readonly _appId: number;
private readonly _openAiClient: OpenAI;

constructor(options: Options) {
this.environment = options.environment;
this._privateKey = options.privateKey;
this._appId = Number(options.appId);
this._webhookSecret = options.webhookSecret;
this.pluginChainState = options.pluginChainState;
this._openAiClient = options.openAiClient;

this.webhooks = new Webhooks<SimplifiedContext>({
secret: this._webhookSecret,
Expand All @@ -57,10 +62,10 @@ export class GitHubEventHandler {
transformEvent(event: EmitterWebhookEvent) {
if ("installation" in event.payload && event.payload.installation?.id !== undefined) {
const octokit = this.getAuthenticatedOctokit(event.payload.installation.id);
return new GitHubContext(this, event, octokit);
return new GitHubContext(this, event, octokit, this._openAiClient);
} else {
const octokit = this.getUnauthenticatedOctokit();
return new GitHubContext(this, event, octokit);
return new GitHubContext(this, event, octokit, this._openAiClient);
}
}

Expand Down
4 changes: 2 additions & 2 deletions src/github/handlers/help-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ async function parseCommandsFromManifest(context: GitHubContext<"issue_comment.c
const commands: string[] = [];
const manifest = await getManifest(context, plugin);
if (manifest?.commands) {
for (const [key, value] of Object.entries(manifest.commands)) {
commands.push(`| \`/${getContent(key)}\` | ${getContent(value.description)} | \`${getContent(value["ubiquity:example"])}\` |`);
for (const [name, command] of Object.entries(manifest.commands)) {
commands.push(`| \`/${getContent(name)}\` | ${getContent(command.description)} | \`${getContent(command["ubiquity:example"])}\` |`);
}
}
return commands;
Expand Down
4 changes: 2 additions & 2 deletions src/github/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function bindHandlers(eventHandler: GitHubEventHandler) {
}

export async function shouldSkipPlugin(context: GitHubContext, pluginChain: PluginConfiguration["plugins"][0]) {
if (pluginChain.skipBotEvents && "sender" in context.payload && context.payload.sender?.type === "Bot") {
if (pluginChain.uses[0].skipBotEvents && "sender" in context.payload && context.payload.sender?.type === "Bot") {
console.log("Skipping plugin chain because sender is a bot");
return true;
}
Expand Down Expand Up @@ -93,7 +93,7 @@ async function handleEvent(event: EmitterWebhookEvent, eventHandler: InstanceTyp

const ref = isGithubPluginObject ? (plugin.ref ?? (await getDefaultBranch(context, plugin.owner, plugin.repo))) : plugin;
const token = await eventHandler.getToken(event.payload.installation.id);
const inputs = new PluginInput(context.eventHandler, stateId, context.key, event.payload, settings, token, ref);
const inputs = new PluginInput(context.eventHandler, stateId, context.key, event.payload, settings, token, ref, null);

state.inputs[0] = inputs;
await eventHandler.pluginChainState.put(stateId, state);
Expand Down
207 changes: 205 additions & 2 deletions src/github/handlers/issue-comment-created.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,212 @@
import { Manifest } from "@ubiquity-os/plugin-sdk/manifest";
import { GitHubContext } from "../github-context";
import { PluginInput } from "../types/plugin";
import { isGithubPlugin, PluginConfiguration } from "../types/plugin-configuration";
import { getConfig } from "../utils/config";
import { getManifest } from "../utils/plugins";
import { dispatchWorker, dispatchWorkflow, getDefaultBranch } from "../utils/workflow-dispatch";
import { postHelpCommand } from "./help-command";

export default async function issueCommentCreated(context: GitHubContext<"issue_comment.created">) {
const body = context.payload.comment.body.trim();
if (/^\/help$/.test(body)) {
const body = context.payload.comment.body.trim().toLowerCase();
if (body.startsWith(`@ubiquityos`)) {
await commandRouter(context);
}
if (body.startsWith(`/help`)) {
await postHelpCommand(context);
}
}

interface OpenAiFunction {
type: "function";
function: {
name: string;
description?: string;
parameters?: Record<string, unknown>;
strict?: boolean | null;
};
}

const embeddedCommands: Array<OpenAiFunction> = [
{
type: "function",
function: {
name: "help",
description: "Shows all available commands and their examples",
strict: false,
parameters: {
type: "object",
properties: {},
additionalProperties: false,
},
},
},
];

async function commandRouter(context: GitHubContext<"issue_comment.created">) {
if (!("installation" in context.payload) || context.payload.installation?.id === undefined) {
console.log(`No installation found, cannot invoke command`);
return;
}

const commands = [...embeddedCommands];
const config = await getConfig(context);
const pluginsWithManifest: { plugin: PluginConfiguration["plugins"][0]["uses"][0]; manifest: Manifest }[] = [];
for (let i = 0; i < config.plugins.length; ++i) {
const plugin = config.plugins[i].uses[0];

const manifest = await getManifest(context, plugin.plugin);
if (!manifest?.commands) {
continue;
}
pluginsWithManifest.push({
plugin: plugin,
manifest,
});
for (const [name, command] of Object.entries(manifest.commands)) {
commands.push({
type: "function",
function: {
name: name,
parameters: command.parameters
? {
...command.parameters,
required: Object.keys(command.parameters.properties),
additionalProperties: false,
}
: undefined,
strict: true,
},
});
}
}

const response = await context.openAi.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{
role: "system",
content: [
{
text: `
You are a GitHub bot named **UbiquityOS**. Your role is to interpret and execute commands based on user comments provided in structured JSON format.
### JSON Structure:
The input will include the following fields:
- repositoryOwner: The username of the repository owner.
- repositoryName: The name of the repository where the comment was made.
- issueNumber: The issue or pull request number where the comment appears.
- author: The username of the user who posted the comment.
- comment: The comment text directed at UbiquityOS.
### Example JSON:
{
"repositoryOwner": "repoOwnerUsername",
"repositoryName": "example-repo",
"issueNumber": 42,
"author": "user1",
"comment": "@UbiquityOS please allow @user2 to change priority and time labels."
}
### Instructions:
- **Interpretation Mode**:
- **Tagged Natural Language**: Interpret the "comment" field provided in JSON. Users will mention you with "@UbiquityOS", followed by their request. Infer the intended command and parameters based on the "comment" content.
- **Action**: Map the user's intent to one of your available functions. When responding, use the "author", "repositoryOwner", "repositoryName", and "issueNumber" fields as context if relevant.
`,
type: "text",
},
],
},
{
role: "user",
content: [
{
text: JSON.stringify({
repositoryOwner: context.payload.repository.owner.login,
repositoryName: context.payload.repository.name,
issueNumber: context.payload.issue.number,
author: context.payload.comment.user?.login,
comment: context.payload.comment.body,
}),
type: "text",
},
],
},
],
temperature: 1,
max_tokens: 2048,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
tools: commands,
parallel_tool_calls: false,
response_format: {
type: "text",
},
});

if (response.choices.length === 0) {
return;
}

const toolCalls = response.choices[0].message.tool_calls;
if (!toolCalls?.length) {
const message = response.choices[0].message.content || "I cannot help you with that.";
await context.octokit.rest.issues.createComment({
owner: context.payload.repository.owner.login,
repo: context.payload.repository.name,
issue_number: context.payload.issue.number,
body: message,
});
return;
}

const toolCall = toolCalls[0];
if (!toolCall) {
console.log("No tool call");
return;
}

const command = {
name: toolCall.function.name,
parameters: toolCall.function.arguments ? JSON.parse(toolCall.function.arguments) : null,
};

if (command.name === "help") {
await postHelpCommand(context);
return;
}

const pluginWithManifest = pluginsWithManifest.find((o) => o.manifest?.commands?.[command.name] !== undefined);
if (!pluginWithManifest) {
console.log(`No plugin found for command '${command.name}'`);
return;
}
const {
plugin: { plugin, with: settings },
} = pluginWithManifest;

// call plugin
const isGithubPluginObject = isGithubPlugin(plugin);
const stateId = crypto.randomUUID();
const ref = isGithubPluginObject ? (plugin.ref ?? (await getDefaultBranch(context, plugin.owner, plugin.repo))) : plugin;
const token = await context.eventHandler.getToken(context.payload.installation.id);
const inputs = new PluginInput(context.eventHandler, stateId, context.key, context.payload, settings, token, ref, command);

try {
if (!isGithubPluginObject) {
await dispatchWorker(plugin, await inputs.getWorkerInputs());
} else {
await dispatchWorkflow(context, {
owner: plugin.owner,
repository: plugin.repo,
workflowId: plugin.workflowId,
ref: ref,
inputs: await inputs.getWorkflowInputs(),
});
}
} catch (e) {
console.error(`An error occurred while processing the plugin chain, will skip plugin ${JSON.stringify(plugin)}`, e);
}
}
2 changes: 1 addition & 1 deletion src/github/handlers/repository-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export async function repositoryDispatch(context: GitHubContext<"repository_disp
} else {
ref = nextPlugin.plugin;
}
const inputs = new PluginInput(context.eventHandler, pluginOutput.state_id, state.eventName, state.eventPayload, settings, token, ref);
const inputs = new PluginInput(context.eventHandler, pluginOutput.state_id, state.eventName, state.eventPayload, settings, token, ref, null);

state.currentPlugin++;
state.inputs[state.currentPlugin] = inputs;
Expand Down
2 changes: 2 additions & 0 deletions src/github/types/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const envSchema = T.Object({
APP_WEBHOOK_SECRET: T.String({ minLength: 1 }),
APP_ID: T.String({ minLength: 1 }),
APP_PRIVATE_KEY: T.String({ minLength: 1 }),
OPENAI_API_KEY: T.String({ minLength: 1 }),
});

export type Env = Static<typeof envSchema> & {
Expand All @@ -18,6 +19,7 @@ declare global {
APP_ID: string;
APP_WEBHOOK_SECRET: string;
APP_PRIVATE_KEY: string;
OPENAI_API_KEY: string;
}
}
}
2 changes: 1 addition & 1 deletion src/github/types/plugin-configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const pluginChainSchema = T.Array(
plugin: githubPluginType(),
with: T.Record(T.String(), T.Unknown(), { default: {} }),
runsOn: T.Array(emitterType, { default: [] }),
skipBotEvents: T.Boolean({ default: true }),
}),
{ minItems: 1, default: [] }
);
Expand All @@ -70,7 +71,6 @@ const handlerSchema = T.Array(
T.Object({
name: T.Optional(T.String()),
uses: pluginChainSchema,
skipBotEvents: T.Boolean({ default: true }),
}),
{ default: [] }
);
Expand Down
Loading

0 comments on commit a2726d7

Please sign in to comment.