Skip to content
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
559 changes: 524 additions & 35 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/scout-agent/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.blink
56 changes: 56 additions & 0 deletions packages/scout-agent/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { tool } from "ai";
import * as blink from "blink";
import { z } from "zod";
import { type Message, Scout } from "./lib";

export const agent = new blink.Agent<Message>();

const scout = new Scout({
agent,
github: {
appID: process.env.GITHUB_APP_ID,
privateKey: process.env.GITHUB_PRIVATE_KEY,
webhookSecret: process.env.GITHUB_WEBHOOK_SECRET,
},
slack: {
botToken: process.env.SLACK_BOT_TOKEN,
signingSecret: process.env.SLACK_SIGNING_SECRET,
},
webSearch: {
exaApiKey: process.env.EXA_API_KEY,
},
compute: {
type: "docker",
},
});

agent.on("request", async (request) => {
const url = new URL(request.url);
if (url.pathname.startsWith("/slack")) {
return scout.handleSlackWebhook(request);
}
if (url.pathname.startsWith("/github")) {
return scout.handleGitHubWebhook(request);
}
return new Response("Hey there!", { status: 200 });
});

agent.on("chat", async ({ id, messages }) => {
return scout.streamStepResponse({
chatID: id,
messages,
model: "anthropic/claude-sonnet-4.5",
providerOptions: { anthropic: { cacheControl: { type: "ephemeral" } } },
tools: {
get_favorite_color: tool({
description: "Get your favorite color",
inputSchema: z.object({}),
execute() {
return "blue";
},
}),
},
});
});

agent.serve();
36 changes: 36 additions & 0 deletions packages/scout-agent/biome.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.2/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false
},
"formatter": {
"enabled": false
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noConsole": "warn"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}
60 changes: 60 additions & 0 deletions packages/scout-agent/lib/compute/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Client } from "@blink-sdk/compute-protocol/client";
import type { Stream } from "@blink-sdk/multiplexer";
import Multiplexer from "@blink-sdk/multiplexer";
import type { WebSocket } from "ws";

export const WORKSPACE_INFO_KEY = "__compute_workspace_id";

export const newComputeClient = async (ws: WebSocket): Promise<Client> => {
return new Promise<Client>((resolve, reject) => {
const encoder = new TextEncoder();
const decoder = new TextDecoder();

// Create multiplexer for the client
const multiplexer = new Multiplexer({
send: (data: Uint8Array) => {
ws.send(data);
},
isClient: true,
});

// Create a stream for requests
const clientStream = multiplexer.createStream();

const client = new Client({
send: (message: string) => {
// Type 0x00 = REQUEST
clientStream.writeTyped(0x00, encoder.encode(message), true);
},
});

// Handle incoming data from the server
clientStream.onData((data: Uint8Array) => {
const payload = data.subarray(1);
const decoded = decoder.decode(payload);
client.handleMessage(decoded);
});

// Listen for notification streams from the server
multiplexer.onStream((stream: Stream) => {
stream.onData((data: Uint8Array) => {
const payload = data.subarray(1);
const decoded = decoder.decode(payload);
client.handleMessage(decoded);
});
});

// Forward WebSocket messages to multiplexer
ws.on("message", (data: Buffer) => {
multiplexer.handleMessage(new Uint8Array(data));
});

ws.onopen = () => {
resolve(client);
};
ws.onerror = (event) => {
client.dispose("connection error");
reject(event);
};
});
};
222 changes: 222 additions & 0 deletions packages/scout-agent/lib/compute/docker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import { exec as execChildProcess } from "node:child_process";
import crypto from "node:crypto";
import util from "node:util";
import type { Client } from "@blink-sdk/compute-protocol/client";
import { WebSocket } from "ws";
import { z } from "zod";
import { newComputeClient } from "./common";

const exec = util.promisify(execChildProcess);

// typings on ExecException are incorrect, see https://github.com/nodejs/node/issues/57392
const parseExecOutput = (output: unknown): string => {
if (typeof output === "string") {
return output;
}
if (output instanceof Buffer) {
return output.toString("utf-8");
}
return util.inspect(output);
};

const execProcess = async (
command: string
): Promise<{ stdout: string; stderr: string; exitCode: number }> => {
try {
const output = await exec(command, {});
return {
stdout: parseExecOutput(output.stdout),
stderr: parseExecOutput(output.stderr),
exitCode: 0,
};
// the error should be an ExecException from node:child_process
} catch (error: unknown) {
if (!(typeof error === "object" && error !== null)) {
throw error;
}
return {
stdout: "stdout" in error ? parseExecOutput(error.stdout) : "",
stderr: "stderr" in error ? parseExecOutput(error.stderr) : "",
exitCode: "code" in error ? (error.code as number) : 1,
};
}
};

const dockerWorkspaceInfoSchema: z.ZodObject<{
containerName: z.ZodString;
}> = z.object({
containerName: z.string(),
});

type DockerWorkspaceInfo = z.infer<typeof dockerWorkspaceInfoSchema>;

const COMPUTE_SERVER_PORT = 22137;
const BOOTSTRAP_SCRIPT = `
#!/bin/sh
echo "Installing blink..."
npm install -g blink@latest

HOST=0.0.0.0 PORT=${COMPUTE_SERVER_PORT} blink compute server
`.trim();
const BOOTSTRAP_SCRIPT_BASE64 =
Buffer.from(BOOTSTRAP_SCRIPT).toString("base64");

const DOCKERFILE = `
FROM node:24-bullseye-slim

RUN apt update && apt install git -y
RUN (type -p wget >/dev/null || (apt update && apt install wget -y)) \\
&& mkdir -p -m 755 /etc/apt/keyrings \\
&& out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \\
&& cat $out | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \\
&& chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \\
&& mkdir -p -m 755 /etc/apt/sources.list.d \\
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \\
&& apt update \\
&& apt install gh -y
RUN npm install -g blink@latest
`.trim();
const DOCKERFILE_HASH = crypto
.createHash("sha256")
.update(DOCKERFILE)
.digest("hex")
.slice(0, 16);
const DOCKERFILE_BASE64 = Buffer.from(DOCKERFILE).toString("base64");

export const initializeDockerWorkspace =
async (): Promise<DockerWorkspaceInfo> => {
const { exitCode: versionExitCode } = await execProcess("docker --version");
if (versionExitCode !== 0) {
throw new Error(
`Docker is not available. Please install it or choose a different workspace provider.`
);
}

const imageName = `blink-workspace:${DOCKERFILE_HASH}`;
const { exitCode: dockerImageExistsExitCode } = await execProcess(
`docker image inspect ${imageName}`
);
if (dockerImageExistsExitCode !== 0) {
const buildCmd = `echo "${DOCKERFILE_BASE64}" | base64 -d | docker build -t ${imageName} -f - .`;
const {
exitCode: buildExitCode,
stdout: buildStdout,
stderr: buildStderr,
} = await execProcess(buildCmd);
if (buildExitCode !== 0) {
throw new Error(
`Failed to build docker image ${imageName}. Build output: ${buildStdout}\n${buildStderr}`
);
}
}

const containerName = `blink-workspace-${crypto.randomUUID()}`;
const { exitCode: runExitCode } = await execProcess(
`docker run -d --publish ${COMPUTE_SERVER_PORT} --name ${containerName} ${imageName} bash -c 'echo "${BOOTSTRAP_SCRIPT_BASE64}" | base64 -d | bash'`
);
if (runExitCode !== 0) {
throw new Error(`Failed to run docker container ${containerName}`);
}

const timeout = 60000;
const start = Date.now();
while (true) {
const {
exitCode: inspectExitCode,
stdout,
stderr,
} = await execProcess(
`docker container inspect -f json ${containerName}`
);
if (inspectExitCode !== 0) {
throw new Error(
`Failed to run docker container ${containerName}. Inspect failed: ${stdout}\n${stderr}`
);
}
const inspectOutput = dockerInspectSchema.parse(JSON.parse(stdout));
if (!inspectOutput[0]?.State.Running) {
throw new Error(`Docker container ${containerName} is not running.`);
}
if (Date.now() - start > timeout) {
throw new Error(
`Timeout waiting for docker container ${containerName} to start.`
);
}
const {
exitCode: logsExitCode,
stdout: logsOutput,
stderr: logsStderr,
} = await execProcess(`docker container logs ${containerName}`);
if (logsExitCode !== 0) {
throw new Error(
`Failed to get logs for docker container ${containerName}. Logs: ${logsOutput}\n${logsStderr}`
);
}
if (logsOutput.includes("Compute server running")) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}

return { containerName };
};

const dockerInspectSchema = z.array(
z.object({
State: z.object({ Running: z.boolean() }),
NetworkSettings: z.object({
IPAddress: z.string(),
Ports: z.object({
[`${COMPUTE_SERVER_PORT}/tcp`]: z.array(
z.object({ HostPort: z.string() })
),
}),
}),
})
);

export const getDockerWorkspaceClient = async (
workspaceInfoRaw: unknown
): Promise<Client> => {
const {
data: workspaceInfo,
success,
error,
} = dockerWorkspaceInfoSchema.safeParse(workspaceInfoRaw);
if (!success) {
throw new Error(`Invalid workspace info: ${error.message}`);
}

const { stdout: dockerInspectRawOutput, exitCode: inspectExitCode } =
await execProcess(
`docker container inspect -f json ${workspaceInfo.containerName}`
);
if (inspectExitCode !== 0) {
throw new Error(
`Failed to inspect docker container ${workspaceInfo.containerName}. Initialize a new workspace with initialize_workspace first.`
);
}
const dockerInspect = dockerInspectSchema.parse(
JSON.parse(dockerInspectRawOutput)
);
const ipAddress = dockerInspect[0]?.NetworkSettings.IPAddress;
if (!ipAddress) {
throw new Error(
`Could not find IP address for docker container ${workspaceInfo.containerName}`
);
}
if (!dockerInspect[0]?.State.Running) {
throw new Error(
`Docker container ${workspaceInfo.containerName} is not running.`
);
}
const hostPort =
dockerInspect[0]?.NetworkSettings.Ports[`${COMPUTE_SERVER_PORT}/tcp`]?.[0]
?.HostPort;
if (!hostPort) {
throw new Error(
`Could not find host port for docker container ${workspaceInfo.containerName}`
);
}
return newComputeClient(new WebSocket(`ws://localhost:${hostPort}`));
};
Loading
Loading