Skip to content

Commit 45974e3

Browse files
authored
feat: add getGithubAppContext to scout's buildStreamTextParams args (#90)
1 parent 0d6fb13 commit 45974e3

File tree

9 files changed

+268
-71
lines changed

9 files changed

+268
-71
lines changed

packages/scout-agent/lib/compute/tools.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,12 @@ import * as github from "@blink-sdk/github";
44
import { type Tool, tool } from "ai";
55
import * as blink from "blink";
66
import { z } from "zod";
7-
import { getGithubAppContext } from "../github";
87
import type { Message } from "../types";
98
import { WORKSPACE_INFO_KEY } from "./common";
109

1110
export const createComputeTools = <T>({
1211
agent,
13-
githubConfig,
12+
getGithubAppContext,
1413
initializeWorkspace,
1514
createWorkspaceClient,
1615
}: {
@@ -19,10 +18,11 @@ export const createComputeTools = <T>({
1918
existingWorkspaceInfo: T | undefined
2019
) => Promise<{ workspaceInfo: T; message: string }>;
2120
createWorkspaceClient: (workspaceInfo: T) => Promise<Client>;
22-
githubConfig?: {
23-
appID: string;
24-
privateKey: string;
25-
};
21+
/**
22+
* A function that returns the GitHub auth context for Git authentication.
23+
* If provided, the workspace_authenticate_git tool will be available.
24+
*/
25+
getGithubAppContext?: () => Promise<github.AppAuthOptions>;
2626
}): Record<string, Tool> => {
2727
const newClient = async () => {
2828
const workspaceInfo = await agent.store.get(WORKSPACE_INFO_KEY);
@@ -56,7 +56,7 @@ export const createComputeTools = <T>({
5656
},
5757
}),
5858

59-
...(githubConfig
59+
...(getGithubAppContext
6060
? {
6161
workspace_authenticate_git: tool({
6262
description: `Authenticate with Git repositories for push/pull operations. Call this before any Git operations that require authentication.
@@ -75,10 +75,7 @@ It's safe to call this multiple times - re-authenticating is perfectly fine and
7575
const client = await newClient();
7676

7777
// Here we generate a GitHub token scoped to the repositories.
78-
const githubAppContext = await getGithubAppContext({
79-
githubAppID: githubConfig.appID,
80-
githubAppPrivateKey: githubConfig.privateKey,
81-
});
78+
const githubAppContext = await getGithubAppContext();
8279
if (!githubAppContext) {
8380
throw new Error(
8481
"You can only use public repositories in this context."

packages/scout-agent/lib/core.test.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,78 @@ describe("config", async () => {
344344
}
345345
});
346346

347+
test("buildStreamTextParams honors getGithubAppContext param", async () => {
348+
const mockGetGithubAppContext = mock(() =>
349+
Promise.resolve({
350+
appId: "custom-app-id",
351+
privateKey: "custom-private-key",
352+
})
353+
);
354+
355+
const agent = new blink.Agent<Message>();
356+
const scout = new Scout({
357+
agent,
358+
logger: noopLogger,
359+
github: {
360+
appID: "config-app-id",
361+
privateKey: "config-private-key",
362+
webhookSecret: "config-webhook-secret",
363+
},
364+
});
365+
366+
const params = scout.buildStreamTextParams({
367+
chatID: "test-chat-id" as blink.ID,
368+
messages: [],
369+
model: newMockModel({ textResponse: "test" }),
370+
getGithubAppContext: mockGetGithubAppContext,
371+
});
372+
373+
// Verify GitHub tools are available
374+
expect(params.tools.github_create_pull_request).toBeDefined();
375+
376+
const result = streamText(params);
377+
378+
// Access the tools from the streamText result
379+
// biome-ignore lint/suspicious/noExplicitAny: accessing internal tools for testing
380+
const tools = (result as any).tools as Record<
381+
string,
382+
// biome-ignore lint/suspicious/noExplicitAny: mock input
383+
{ execute: (input: any, opts?: any) => Promise<unknown> }
384+
>;
385+
386+
// Execute a GitHub tool to verify our custom getGithubAppContext is called
387+
const tool = tools.github_create_pull_request;
388+
expect(tool).toBeDefined();
389+
390+
// The tool will fail when trying to authenticate (since we're using fake credentials),
391+
// but we can verify our mock was called before that happens
392+
try {
393+
// biome-ignore lint/style/noNonNullAssertion: we just checked it's defined
394+
await tool!.execute(
395+
{
396+
model_intent: "creating pull request",
397+
properties: {
398+
owner: "test-owner",
399+
repo: "test-repo",
400+
base: "main",
401+
head: "feature",
402+
title: "Test PR",
403+
},
404+
},
405+
{
406+
abortSignal: new AbortController().signal,
407+
toolCallId: "test-tool-call",
408+
messages: [],
409+
}
410+
);
411+
} catch {
412+
// Expected to fail during authentication
413+
}
414+
415+
// Verify our custom getGithubAppContext was called, not the default factory
416+
expect(mockGetGithubAppContext).toHaveBeenCalledTimes(1);
417+
});
418+
347419
test("respond in slack", async () => {
348420
const { promise: doStreamOptionsPromise, resolve } =
349421
newPromise<DoStreamOptions>();
@@ -622,4 +694,96 @@ describe("daytona integration", () => {
622694
expect(mockSandbox.getPreviewLink).toHaveBeenCalledTimes(1);
623695
expect(mockSandbox.getPreviewLink).toHaveBeenCalledWith(2137);
624696
});
697+
698+
test("compute tools honor getGithubAppContext param", async () => {
699+
using apiServer = createMockBlinkApiServer();
700+
using computeServer = createMockComputeServer();
701+
using _env = withBlinkApiUrl(apiServer.url);
702+
703+
const mockGetGithubAppContext = mock(() =>
704+
Promise.resolve({
705+
appId: "custom-app-id",
706+
privateKey: "custom-private-key",
707+
})
708+
);
709+
710+
const mockSandbox = createMockDaytonaSandbox({
711+
id: "workspace-for-git-auth",
712+
getPreviewLink: mock(() =>
713+
Promise.resolve({ url: computeServer.url, token: "test-token" })
714+
),
715+
});
716+
const mockSdk = createMockDaytonaSdk(mockSandbox);
717+
718+
const agent = new blink.Agent<Message>();
719+
const scout = new Scout({
720+
agent,
721+
logger: noopLogger,
722+
github: {
723+
appID: "config-app-id",
724+
privateKey: "config-private-key",
725+
webhookSecret: "config-webhook-secret",
726+
},
727+
compute: {
728+
type: "daytona",
729+
options: {
730+
apiKey: "test-api-key",
731+
computeServerPort: 2137,
732+
snapshot: "test-snapshot",
733+
daytonaSdk: mockSdk,
734+
},
735+
},
736+
});
737+
738+
const params = scout.buildStreamTextParams({
739+
chatID: "test-chat-id" as blink.ID,
740+
messages: [],
741+
model: newMockModel({ textResponse: "test" }),
742+
getGithubAppContext: mockGetGithubAppContext,
743+
});
744+
const result = streamText(params);
745+
746+
// biome-ignore lint/suspicious/noExplicitAny: accessing internal tools for testing
747+
const tools = (result as any).tools as Record<
748+
string,
749+
// biome-ignore lint/suspicious/noExplicitAny: mock input
750+
{ execute: (input: any, opts?: any) => Promise<unknown> }
751+
>;
752+
753+
// First, initialize the workspace (required before workspace_authenticate_git)
754+
// biome-ignore lint/style/noNonNullAssertion: we know it exists
755+
await tools.initialize_workspace!.execute({
756+
model_intent: "initializing workspace",
757+
properties: {},
758+
});
759+
760+
// Verify workspace_authenticate_git tool is available
761+
const gitAuthTool = tools.workspace_authenticate_git;
762+
expect(gitAuthTool).toBeDefined();
763+
764+
// Execute workspace_authenticate_git - it will fail when trying to authenticate
765+
// with GitHub (since we're using fake credentials), but our mock should be called first
766+
try {
767+
// biome-ignore lint/style/noNonNullAssertion: we just checked it's defined
768+
await gitAuthTool!.execute(
769+
{
770+
model_intent: "authenticating git",
771+
properties: {
772+
owner: "test-owner",
773+
repos: ["test-repo"],
774+
},
775+
},
776+
{
777+
abortSignal: new AbortController().signal,
778+
toolCallId: "git-auth-tool-call",
779+
messages: [],
780+
}
781+
);
782+
} catch {
783+
// Expected to fail during GitHub authentication
784+
}
785+
786+
// Verify our custom getGithubAppContext was called, not the default factory
787+
expect(mockGetGithubAppContext).toHaveBeenCalledTimes(1);
788+
});
625789
});

packages/scout-agent/lib/core.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import util from "node:util";
22
import type { ModelMessage, ProviderOptions } from "@ai-sdk/provider-utils";
3+
import type * as github from "@blink-sdk/github";
34
import withModelIntent from "@blink-sdk/model-intent";
45
import * as slack from "@blink-sdk/slack";
56
import type { App } from "@slack/bolt";
@@ -17,7 +18,11 @@ import {
1718
initializeDockerWorkspace,
1819
} from "./compute/docker";
1920
import { createComputeTools } from "./compute/tools";
20-
import { createGitHubTools, handleGitHubWebhook } from "./github";
21+
import {
22+
createGitHubTools,
23+
githubAppContextFactory,
24+
handleGitHubWebhook,
25+
} from "./github";
2126
import { defaultSystemPrompt } from "./prompt";
2227
import { createSlackApp, createSlackTools, getSlackMetadata } from "./slack";
2328
import type { Message } from "./types";
@@ -31,13 +36,18 @@ type NullableTools = { [K in keyof Tools]: Tools[K] | undefined };
3136

3237
type ConfigFields<T> = { [K in keyof T]: T[K] | undefined };
3338

34-
export interface StreamStepResponseOptions {
39+
export interface BuildStreamTextParamsOptions {
3540
messages: Message[];
3641
chatID: blink.ID;
3742
model: LanguageModel;
3843
providerOptions?: ProviderOptions;
3944
tools?: NullableTools;
4045
systemPrompt?: string;
46+
/**
47+
* A function that returns the GitHub auth context for the GitHub tools and for Git authentication inside workspaces.
48+
* If not provided, the GitHub auth context will be created using the app ID and private key from the GitHub config.
49+
*/
50+
getGithubAppContext?: () => Promise<github.AppAuthOptions>;
4151
}
4252

4353
interface Logger {
@@ -260,8 +270,9 @@ export class Scout {
260270
model,
261271
providerOptions,
262272
tools: providedTools,
273+
getGithubAppContext,
263274
systemPrompt = defaultSystemPrompt,
264-
}: StreamStepResponseOptions): {
275+
}: BuildStreamTextParamsOptions): {
265276
model: LanguageModel;
266277
messages: ModelMessage[];
267278
maxOutputTokens: number;
@@ -280,7 +291,13 @@ export class Scout {
280291
case "docker": {
281292
computeTools = createComputeTools<DockerWorkspaceInfo>({
282293
agent: this.agent,
283-
githubConfig: this.github.config,
294+
getGithubAppContext: this.github.config
295+
? (getGithubAppContext ??
296+
githubAppContextFactory({
297+
appId: this.github.config.appID,
298+
privateKey: this.github.config.privateKey,
299+
}))
300+
: undefined,
284301
initializeWorkspace: initializeDockerWorkspace,
285302
createWorkspaceClient: getDockerWorkspaceClient,
286303
});
@@ -290,7 +307,13 @@ export class Scout {
290307
const opts = computeConfig.options;
291308
computeTools = createComputeTools<DaytonaWorkspaceInfo>({
292309
agent: this.agent,
293-
githubConfig: this.github.config,
310+
getGithubAppContext: this.github.config
311+
? (getGithubAppContext ??
312+
githubAppContextFactory({
313+
appId: this.github.config.appID,
314+
privateKey: this.github.config.privateKey,
315+
}))
316+
: undefined,
294317
initializeWorkspace: (info) =>
295318
initializeDaytonaWorkspace(
296319
this.logger,
@@ -340,8 +363,13 @@ export class Scout {
340363
? createGitHubTools({
341364
agent: this.agent,
342365
chatID,
343-
githubAppID: this.github.config.appID,
344-
githubAppPrivateKey: this.github.config.privateKey,
366+
getGithubAppContext:
367+
getGithubAppContext !== undefined
368+
? getGithubAppContext
369+
: githubAppContextFactory({
370+
appId: this.github.config.appID,
371+
privateKey: this.github.config.privateKey,
372+
}),
345373
})
346374
: undefined),
347375
...computeTools,

0 commit comments

Comments
 (0)