Skip to content

Commit 96396c6

Browse files
authored
feat: add the scout agent (#82)
1 parent 496731c commit 96396c6

File tree

18 files changed

+2403
-35
lines changed

18 files changed

+2403
-35
lines changed

bun.lock

Lines changed: 524 additions & 35 deletions
Large diffs are not rendered by default.

packages/scout-agent/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.blink

packages/scout-agent/agent.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { tool } from "ai";
2+
import * as blink from "blink";
3+
import { z } from "zod";
4+
import { type Message, Scout } from "./lib";
5+
6+
export const agent = new blink.Agent<Message>();
7+
8+
const scout = new Scout({
9+
agent,
10+
github: {
11+
appID: process.env.GITHUB_APP_ID,
12+
privateKey: process.env.GITHUB_PRIVATE_KEY,
13+
webhookSecret: process.env.GITHUB_WEBHOOK_SECRET,
14+
},
15+
slack: {
16+
botToken: process.env.SLACK_BOT_TOKEN,
17+
signingSecret: process.env.SLACK_SIGNING_SECRET,
18+
},
19+
webSearch: {
20+
exaApiKey: process.env.EXA_API_KEY,
21+
},
22+
compute: {
23+
type: "docker",
24+
},
25+
});
26+
27+
agent.on("request", async (request) => {
28+
const url = new URL(request.url);
29+
if (url.pathname.startsWith("/slack")) {
30+
return scout.handleSlackWebhook(request);
31+
}
32+
if (url.pathname.startsWith("/github")) {
33+
return scout.handleGitHubWebhook(request);
34+
}
35+
return new Response("Hey there!", { status: 200 });
36+
});
37+
38+
agent.on("chat", async ({ id, messages }) => {
39+
return scout.streamStepResponse({
40+
chatID: id,
41+
messages,
42+
model: "anthropic/claude-sonnet-4.5",
43+
providerOptions: { anthropic: { cacheControl: { type: "ephemeral" } } },
44+
tools: {
45+
get_favorite_color: tool({
46+
description: "Get your favorite color",
47+
inputSchema: z.object({}),
48+
execute() {
49+
return "blue";
50+
},
51+
}),
52+
},
53+
});
54+
});
55+
56+
agent.serve();

packages/scout-agent/biome.json

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"$schema": "https://biomejs.dev/schemas/2.3.2/schema.json",
3+
"vcs": {
4+
"enabled": true,
5+
"clientKind": "git",
6+
"useIgnoreFile": true
7+
},
8+
"files": {
9+
"ignoreUnknown": false
10+
},
11+
"formatter": {
12+
"enabled": false
13+
},
14+
"linter": {
15+
"enabled": true,
16+
"rules": {
17+
"recommended": true,
18+
"suspicious": {
19+
"noConsole": "warn"
20+
}
21+
}
22+
},
23+
"javascript": {
24+
"formatter": {
25+
"quoteStyle": "double"
26+
}
27+
},
28+
"assist": {
29+
"enabled": true,
30+
"actions": {
31+
"source": {
32+
"organizeImports": "on"
33+
}
34+
}
35+
}
36+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { Client } from "@blink-sdk/compute-protocol/client";
2+
import type { Stream } from "@blink-sdk/multiplexer";
3+
import Multiplexer from "@blink-sdk/multiplexer";
4+
import type { WebSocket } from "ws";
5+
6+
export const WORKSPACE_INFO_KEY = "__compute_workspace_id";
7+
8+
export const newComputeClient = async (ws: WebSocket): Promise<Client> => {
9+
return new Promise<Client>((resolve, reject) => {
10+
const encoder = new TextEncoder();
11+
const decoder = new TextDecoder();
12+
13+
// Create multiplexer for the client
14+
const multiplexer = new Multiplexer({
15+
send: (data: Uint8Array) => {
16+
ws.send(data);
17+
},
18+
isClient: true,
19+
});
20+
21+
// Create a stream for requests
22+
const clientStream = multiplexer.createStream();
23+
24+
const client = new Client({
25+
send: (message: string) => {
26+
// Type 0x00 = REQUEST
27+
clientStream.writeTyped(0x00, encoder.encode(message), true);
28+
},
29+
});
30+
31+
// Handle incoming data from the server
32+
clientStream.onData((data: Uint8Array) => {
33+
const payload = data.subarray(1);
34+
const decoded = decoder.decode(payload);
35+
client.handleMessage(decoded);
36+
});
37+
38+
// Listen for notification streams from the server
39+
multiplexer.onStream((stream: Stream) => {
40+
stream.onData((data: Uint8Array) => {
41+
const payload = data.subarray(1);
42+
const decoded = decoder.decode(payload);
43+
client.handleMessage(decoded);
44+
});
45+
});
46+
47+
// Forward WebSocket messages to multiplexer
48+
ws.on("message", (data: Buffer) => {
49+
multiplexer.handleMessage(new Uint8Array(data));
50+
});
51+
52+
ws.onopen = () => {
53+
resolve(client);
54+
};
55+
ws.onerror = (event) => {
56+
client.dispose("connection error");
57+
reject(event);
58+
};
59+
});
60+
};
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import { exec as execChildProcess } from "node:child_process";
2+
import crypto from "node:crypto";
3+
import util from "node:util";
4+
import type { Client } from "@blink-sdk/compute-protocol/client";
5+
import { WebSocket } from "ws";
6+
import { z } from "zod";
7+
import { newComputeClient } from "./common";
8+
9+
const exec = util.promisify(execChildProcess);
10+
11+
// typings on ExecException are incorrect, see https://github.com/nodejs/node/issues/57392
12+
const parseExecOutput = (output: unknown): string => {
13+
if (typeof output === "string") {
14+
return output;
15+
}
16+
if (output instanceof Buffer) {
17+
return output.toString("utf-8");
18+
}
19+
return util.inspect(output);
20+
};
21+
22+
const execProcess = async (
23+
command: string
24+
): Promise<{ stdout: string; stderr: string; exitCode: number }> => {
25+
try {
26+
const output = await exec(command, {});
27+
return {
28+
stdout: parseExecOutput(output.stdout),
29+
stderr: parseExecOutput(output.stderr),
30+
exitCode: 0,
31+
};
32+
// the error should be an ExecException from node:child_process
33+
} catch (error: unknown) {
34+
if (!(typeof error === "object" && error !== null)) {
35+
throw error;
36+
}
37+
return {
38+
stdout: "stdout" in error ? parseExecOutput(error.stdout) : "",
39+
stderr: "stderr" in error ? parseExecOutput(error.stderr) : "",
40+
exitCode: "code" in error ? (error.code as number) : 1,
41+
};
42+
}
43+
};
44+
45+
const dockerWorkspaceInfoSchema: z.ZodObject<{
46+
containerName: z.ZodString;
47+
}> = z.object({
48+
containerName: z.string(),
49+
});
50+
51+
type DockerWorkspaceInfo = z.infer<typeof dockerWorkspaceInfoSchema>;
52+
53+
const COMPUTE_SERVER_PORT = 22137;
54+
const BOOTSTRAP_SCRIPT = `
55+
#!/bin/sh
56+
echo "Installing blink..."
57+
npm install -g blink@latest
58+
59+
HOST=0.0.0.0 PORT=${COMPUTE_SERVER_PORT} blink compute server
60+
`.trim();
61+
const BOOTSTRAP_SCRIPT_BASE64 =
62+
Buffer.from(BOOTSTRAP_SCRIPT).toString("base64");
63+
64+
const DOCKERFILE = `
65+
FROM node:24-bullseye-slim
66+
67+
RUN apt update && apt install git -y
68+
RUN (type -p wget >/dev/null || (apt update && apt install wget -y)) \\
69+
&& mkdir -p -m 755 /etc/apt/keyrings \\
70+
&& out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \\
71+
&& cat $out | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \\
72+
&& chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \\
73+
&& mkdir -p -m 755 /etc/apt/sources.list.d \\
74+
&& 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 \\
75+
&& apt update \\
76+
&& apt install gh -y
77+
RUN npm install -g blink@latest
78+
`.trim();
79+
const DOCKERFILE_HASH = crypto
80+
.createHash("sha256")
81+
.update(DOCKERFILE)
82+
.digest("hex")
83+
.slice(0, 16);
84+
const DOCKERFILE_BASE64 = Buffer.from(DOCKERFILE).toString("base64");
85+
86+
export const initializeDockerWorkspace =
87+
async (): Promise<DockerWorkspaceInfo> => {
88+
const { exitCode: versionExitCode } = await execProcess("docker --version");
89+
if (versionExitCode !== 0) {
90+
throw new Error(
91+
`Docker is not available. Please install it or choose a different workspace provider.`
92+
);
93+
}
94+
95+
const imageName = `blink-workspace:${DOCKERFILE_HASH}`;
96+
const { exitCode: dockerImageExistsExitCode } = await execProcess(
97+
`docker image inspect ${imageName}`
98+
);
99+
if (dockerImageExistsExitCode !== 0) {
100+
const buildCmd = `echo "${DOCKERFILE_BASE64}" | base64 -d | docker build -t ${imageName} -f - .`;
101+
const {
102+
exitCode: buildExitCode,
103+
stdout: buildStdout,
104+
stderr: buildStderr,
105+
} = await execProcess(buildCmd);
106+
if (buildExitCode !== 0) {
107+
throw new Error(
108+
`Failed to build docker image ${imageName}. Build output: ${buildStdout}\n${buildStderr}`
109+
);
110+
}
111+
}
112+
113+
const containerName = `blink-workspace-${crypto.randomUUID()}`;
114+
const { exitCode: runExitCode } = await execProcess(
115+
`docker run -d --publish ${COMPUTE_SERVER_PORT} --name ${containerName} ${imageName} bash -c 'echo "${BOOTSTRAP_SCRIPT_BASE64}" | base64 -d | bash'`
116+
);
117+
if (runExitCode !== 0) {
118+
throw new Error(`Failed to run docker container ${containerName}`);
119+
}
120+
121+
const timeout = 60000;
122+
const start = Date.now();
123+
while (true) {
124+
const {
125+
exitCode: inspectExitCode,
126+
stdout,
127+
stderr,
128+
} = await execProcess(
129+
`docker container inspect -f json ${containerName}`
130+
);
131+
if (inspectExitCode !== 0) {
132+
throw new Error(
133+
`Failed to run docker container ${containerName}. Inspect failed: ${stdout}\n${stderr}`
134+
);
135+
}
136+
const inspectOutput = dockerInspectSchema.parse(JSON.parse(stdout));
137+
if (!inspectOutput[0]?.State.Running) {
138+
throw new Error(`Docker container ${containerName} is not running.`);
139+
}
140+
if (Date.now() - start > timeout) {
141+
throw new Error(
142+
`Timeout waiting for docker container ${containerName} to start.`
143+
);
144+
}
145+
const {
146+
exitCode: logsExitCode,
147+
stdout: logsOutput,
148+
stderr: logsStderr,
149+
} = await execProcess(`docker container logs ${containerName}`);
150+
if (logsExitCode !== 0) {
151+
throw new Error(
152+
`Failed to get logs for docker container ${containerName}. Logs: ${logsOutput}\n${logsStderr}`
153+
);
154+
}
155+
if (logsOutput.includes("Compute server running")) {
156+
break;
157+
}
158+
await new Promise((resolve) => setTimeout(resolve, 500));
159+
}
160+
161+
return { containerName };
162+
};
163+
164+
const dockerInspectSchema = z.array(
165+
z.object({
166+
State: z.object({ Running: z.boolean() }),
167+
NetworkSettings: z.object({
168+
IPAddress: z.string(),
169+
Ports: z.object({
170+
[`${COMPUTE_SERVER_PORT}/tcp`]: z.array(
171+
z.object({ HostPort: z.string() })
172+
),
173+
}),
174+
}),
175+
})
176+
);
177+
178+
export const getDockerWorkspaceClient = async (
179+
workspaceInfoRaw: unknown
180+
): Promise<Client> => {
181+
const {
182+
data: workspaceInfo,
183+
success,
184+
error,
185+
} = dockerWorkspaceInfoSchema.safeParse(workspaceInfoRaw);
186+
if (!success) {
187+
throw new Error(`Invalid workspace info: ${error.message}`);
188+
}
189+
190+
const { stdout: dockerInspectRawOutput, exitCode: inspectExitCode } =
191+
await execProcess(
192+
`docker container inspect -f json ${workspaceInfo.containerName}`
193+
);
194+
if (inspectExitCode !== 0) {
195+
throw new Error(
196+
`Failed to inspect docker container ${workspaceInfo.containerName}. Initialize a new workspace with initialize_workspace first.`
197+
);
198+
}
199+
const dockerInspect = dockerInspectSchema.parse(
200+
JSON.parse(dockerInspectRawOutput)
201+
);
202+
const ipAddress = dockerInspect[0]?.NetworkSettings.IPAddress;
203+
if (!ipAddress) {
204+
throw new Error(
205+
`Could not find IP address for docker container ${workspaceInfo.containerName}`
206+
);
207+
}
208+
if (!dockerInspect[0]?.State.Running) {
209+
throw new Error(
210+
`Docker container ${workspaceInfo.containerName} is not running.`
211+
);
212+
}
213+
const hostPort =
214+
dockerInspect[0]?.NetworkSettings.Ports[`${COMPUTE_SERVER_PORT}/tcp`]?.[0]
215+
?.HostPort;
216+
if (!hostPort) {
217+
throw new Error(
218+
`Could not find host port for docker container ${workspaceInfo.containerName}`
219+
);
220+
}
221+
return newComputeClient(new WebSocket(`ws://localhost:${hostPort}`));
222+
};

0 commit comments

Comments
 (0)