Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Command LLM #186

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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`)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should offer a short hand syntax.

As I understand @UbiquityOS wouldn't automatically populate on the GitHub UI.

Mixed thoughts on this idea, but it might be more ergonomic to use with something like @U or @os

Also this next idea could be out of scope but wondering if we should intercept all comments and run commands on behalf. Would be perfect if we could handle those assigns for the newcomers asking to work on tasks for example.

Copy link
Member Author

@whilefoo whilefoo Nov 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should offer a short hand syntax.

As I understand @UbiquityOS wouldn't automatically populate on the GitHub UI.

Mixed thoughts on this idea, but it might be more ergonomic to use with something like @U or @os

Yes, it's a bit cumbersome to always type out @UbiquityOS so a short hand tag would be great but we will be tagging another person :D

Also this next idea could be out of scope but wondering if we should intercept all comments and run commands on behalf. Would be perfect if we could handle those assigns for the newcomers asking to work on tasks for example.

Do you mean without the tag? For example if the users says "how can I start this task?" the router should run the start command?
It sounds good in theory but in practice it might trigger it even when not intended to and will consume OpenAI API a lot which will result in higher cost.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be interesting to have some type of local logic (perhaps non LLM) to determine if the comment is likely asking for some type of action. Accuracy can be pretty low and in theory it could still cut quite a bit of costs.

Also with mini models costs might already be cheap enough for it to be feasible. We would need to run projections I suppose.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with @whilefoo that tagging another person is not good. Maybe we should create a @UbiquityOS account to get the auto-completion when typing the name.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A solution would be to hook into the issue author's name. At least under the issue this would be intuitive. Under the pull I'm not sure if it makes sense to hook into the pull author, as they are generally not the person you would ask these sort of questions to.

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.
`,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a high quality prompt good job

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