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
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
schema_version: 1
slug: integrate-qoder-cloud-runtime
title: 把 Qoder Cloud 接成你自己系统里的一名"员工"
summary: 让 Qoder Cloud Agent 在托管沙箱里干活,同时把你系统的业务能力做成它能调用的工具——权限、校验、边界全攥在你自己手里。
type: best-practice
category: build-deploy
tags:
- runtime
- session
- sse
- tool-use
- idempotency
author:
name: 时之
github: yefengzi7
locale: zh-CN
---

## 适用场景与边界

假设你已经有一套派活系统——把任务丢给不同的编码 Agent 去做。现在你想再"招"一个:让它在云上的沙箱里跑代码,同时还能读写你系统里的工单、改状态、留评论,像个真的团队成员。

这就是把 Qoder Cloud 接成一种运行时的意义。两个方向的活儿它都能干:一边是内置的 Bash、文件工具在云端托管沙箱里跑(我实测时它自己写了个 Python 脚本、跑起来、把输出贴回来,整个过程在云容器里完成,不碰我本地一根汗毛);另一边,是你把自己的业务能力做成"自定义工具"递过去——它想查工单,就发个请求,你的进程执行一次白名单内的操作,再把结果塞回同一个会话。

我拿它真跑过多轮:写文件、跑命令、Resume 会话接着上一轮的上下文干、中途 Cancel。云沙箱这半边确实省心——不用自己维护运行环境。

但有一条边界得先说清楚,不然会有错误预期:**云端那个 Bash 和文件工具,跑在托管容器里,够不着你的宿主机、代码检出、本地文件系统。** 别指望用它直接操作你本地的东西——它就是个隔离的沙箱。

另一条边界是你自己划的:自定义工具的白名单是硬门槛。只放你想清楚了、能校验、出事能查的那几个操作,其余一律拒。先支持定义清楚的场景(单个工单、对话),别急着开批量、定时、多 Agent 那些边界还没理清的面——那些等你有专门的 schema 和生命周期处理再说。

## 推荐做法

接这套东西,真正的难点就三件:**断了能接上、协议不乱来、令牌不串门。**

| 决策 | 推荐方式 | 原因 |
|---|---|---|
| 事件传输 | SSE 消费,记住最新事件 ID,断线用 `Last-Event-ID` 续,重复的按 ID 去掉 | 网抖一下不至于丢事件或重复处理 |
| 会触发副作用的请求 | 带幂等键,只对 429/5xx 有限重试 | 重试不会把同一件事干两遍 |
| 一批自定义工具 | 等会话 idle 且 `stop_reason=requires_action` 把整批亮出来,先整批校验再一个个执行 | 不出现"干一半";有问题的批次在动手前就被拦 |
| 结果回传 | 每个结果发回会话;游标只在整批成功后才前移 | 断线重放能补发没送到的结果,但不会把改数据的工具再跑一遍 |
| 兜底 | 未知动作、空批、还有活没干完就到终态、要人工授权的工具——一律当失败处理 | 拿不准的时候,宁可拒 |
| 令牌 | 云端 PAT 只在云 API 客户端里;业务任务令牌单独拿 | 任务令牌绝不进云请求、Prompt、工具输入输出、日志 |

这里面最容易写错、也最值得画出来的,是自定义工具的执行时序。云端不是发一个工具请求你就立刻执行——它会先把这一回合要用的工具攒齐,等到 idle 且声明 `requires_action` 时,才把整批亮给你:

```mermaid
flowchart LR
A[收到工具请求] --> B[先缓冲,不执行]
B --> C[等 idle 且 requires_action]
C --> D[整批校验]
D --> E[按顺序逐个执行]
E --> F[逐个回传结果]
F --> G[整批成功后前移游标]
```

为什么要这么绕?因为它让"恰好执行一次"变得可能。每个工具的结果执行完先缓存下来,万一 SSE 断了重连、或者结果发出去但没收到确认,重放时你补发的是**缓存的结果**,而不是把那个改数据的工具再执行一遍。改一次工单状态和改两次,区别可大了。

校验这块别手软:只认白名单里的固定工具名,多一个 JSON 字段都拒,取值、长度、任务范围挨个查。一个工单任务就只能碰它被指派的那个工单——就算云端模型脑子一热请求去改别的工单,这道墙也守在你自己进程里,它越不过去。

## 验证与维护

这类桥接靠"跑一次成功了"是不能算数的,得靠**能反复跑、覆盖到各种糟糕情况的确定性测试**来守。真连云端的冒烟测试是补充,不是主力。

- **用 mock 服务端把协议打一遍。** 建会话、发消息、SSE 流、整批 `requires_action`、结果回传、断线续传,全用本地假服务器覆盖。跑得起来还不够,要开竞态检测——并发问题不开这个根本看不见。
- **专门造一个"半路失败"。** 第一个结果成功了、第二个返回 503 然后重连——这时候你要验证的是:重放只补发没送达的那个,绝不会把已经成功的改数据工具再跑一遍。这个场景不主动造,正常测试永远覆盖不到。
- **把兜底逐个验证。** 未知动作 ID、空批、还有工具没执行完就到了 idle——这些都该判失败,而不是当成功放过去。

维护上有句话我想写在最显眼的地方:**别把"恰好一次"吹得比它实际能保证的大。** 进程内的恰好一次,只在这个进程活着的时候成立。进程崩了重启、或者结果发出去被云端收了但你本地没记上——这些跨进程、需要对账的情况,是接真实系统时你得自己补的功课。把它当已知边界写进文档,比假装已经解决了要诚实得多,也省得后面有人踩。

## 可选:Demo 源码

Demo 是个纯 Go 标准库、零依赖的小模块,把上面这套照着能跑的样子实现了一遍:白名单分派器、`requires_action` 整批协议、断线续传的 SSE 读取器。测试里专门有"半路 503 后重连、重放不重复执行"那一幕。跑法在 README 里,`go test` 直接看结果。

[查看 Demo 源码](https://github.com/QoderAI/cloud-agents-cookbook/tree/main/demos/integrate-qoder-cloud-runtime)
64 changes: 64 additions & 0 deletions demos/integrate-qoder-cloud-runtime/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# 接入 Qoder Cloud 运行时 Demo

一个自包含的 Go 模块,演示如何把业务能力以客户端自定义工具的形式安全回接给 Qoder Cloud Agent:白名单分派器(校验 + 任务范围)、`requires_action` 整批协议(exactly-once + 重放不重复执行 + fail-closed),以及带 `Last-Event-ID` 续传和去重的 SSE 读取器。纯 Go 标准库,无第三方依赖。

## 对应文章

- 标题:把 Qoder Cloud 接成你自己系统里的一名“员工”
- Slug:`integrate-qoder-cloud-runtime`

## 关于来源

这个模式提炼自 [Multica](https://github.com/multica-ai/multica) —— 一个开源的多 Agent 协作工作台,它把 Qoder Cloud 作为其中一种 Agent 运行时接入。本目录是把那套集成里最关键的协议部分(分派器 + 整批协议 + SSE 续传)提炼成一个能独立编译、能跑测试的最小模块,方便研读和复用。完整的产品与周边实现见 Multica 项目本体。

## 前置条件

- Go 1.22 或更高版本。
- 无第三方依赖,无需网络:所有测试用进程内 fake store 与 `httptest` mock 服务端运行。

## 安装与配置

无需额外配置。模块路径为 `example.com/qoder-cloud-runtime-demo`,仅依赖 Go 标准库。

```bash
go mod verify
```

## 运行

运行完整测试套件:

```bash
go test ./...
```

在支持 cgo 的环境上可开启竞态检测(对应文章推荐的做法):

```bash
CGO_ENABLED=1 go test -race ./...
```

## 验证结果

`go test ./...` 应全部通过,覆盖以下行为:

| 测试 | 验证的行为 |
|---|---|
| `TestDispatchRejectsInvalidInput` | 未知工具、未知字段、非 UUID、非法枚举、空更新一律在触达业务前被拒 |
| `TestDispatchEnforcesIssueScope` | 工单任务只能读写被指派的那个工单 |
| `TestBatchRunsOnceThenReplaysWithoutReexecution` | 整批执行一次;重放重发缓存结果、不重复执行变更类工具 |
| `TestBatchFailsClosed` | 空批次、未知动作、带未决工具的终态 idle 均 fail-closed |
| `TestStreamResumesWithLastEventIDAndDeduplicates` | 断线用 `Last-Event-ID` 续传,重放事件被去重,事件恰好各处理一次 |

预期输出以 `ok example.com/qoder-cloud-runtime-demo` 结尾。

## 清理资源

Demo 全程在进程内运行,不创建任何外部资源,无需清理。删除 Go 构建缓存可执行 `go clean -testcache`。

## 成本与安全

- 本 Demo 不进行任何真实网络调用,也不消耗账号额度:`httptest` 服务端在本地进程内运行。
- 代码中不含任何真实凭证。示例里的 `placeholder-token` 仅用于占位。
- 分派器是 fail-closed 的白名单:接入真实后端时,请把云端 PAT 与业务任务令牌分别持有,任务令牌绝不写入云请求、Prompt、工具输入/结果或日志。
- 进程内的“恰好一次”只在单进程生命周期内成立;跨进程崩溃的调用 ID 幂等属于接入真实系统时需自行加固的部分。
129 changes: 129 additions & 0 deletions demos/integrate-qoder-cloud-runtime/bridge.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// SPDX-License-Identifier: Apache-2.0

package bridge

import (
"fmt"
"sync"
)

// ToolRequest is one buffered custom_tool_use emitted by the cloud Agent.
// EventID is the source event ID, reused as the tool-use ID and as the
// idempotency key for exactly-once execution within this process.
type ToolRequest struct {
EventID string
Tool string
RawInput []byte
}

// ToolResult is what the bridge posts back as user.custom_tool_result.
type ToolResult struct {
EventID string
Output string
IsError bool
}

// BatchRunner buffers custom-tool requests, then executes a declared batch
// exactly once, caching results so an SSE reconnect or an ambiguous result
// POST can resend an unsent result without re-executing a mutating tool.
//
// It fails closed: an unknown declared event, an empty batch, or a terminal
// idle with unresolved custom tools all return an error before any execution.
type BatchRunner struct {
store IssueStore
scope TaskScope

mu sync.Mutex
buffered map[string]ToolRequest // event ID -> request, filled as events arrive
results map[string]ToolResult // event ID -> cached result, for replay
executed map[string]bool // event ID -> executed, guards exactly-once
}

// NewBatchRunner creates a runner bound to one task scope.
func NewBatchRunner(store IssueStore, scope TaskScope) *BatchRunner {
return &BatchRunner{
store: store,
scope: scope,
buffered: map[string]ToolRequest{},
results: map[string]ToolResult{},
executed: map[string]bool{},
}
}

// Buffer records an incoming custom_tool_use without executing it. The bridge
// emits a local tool-use message here and waits for requires_action to declare
// which buffered events form the batch.
func (r *BatchRunner) Buffer(request ToolRequest) {
r.mu.Lock()
defer r.mu.Unlock()
// The event ID is the exactly-once idempotency key, so a duplicate or
// replayed event must not replace the original tool/input. First write
// wins; later buffers of the same ID are ignored.
if _, exists := r.buffered[request.EventID]; exists {
return
}
r.buffered[request.EventID] = request
}

// RunBatch executes the declared batch once, in the given order, and returns
// the results to post back. Calling it again with the same event IDs resends
// cached results without re-executing — this is the replay path after a
// reconnect or an ambiguous POST. It fails closed on an empty or unknown batch.
func (r *BatchRunner) RunBatch(eventIDs []string) ([]ToolResult, error) {
r.mu.Lock()
defer r.mu.Unlock()

if len(eventIDs) == 0 {
return nil, fmt.Errorf("empty action batch: failing closed")
}
// Validate the whole declared batch before running anything.
for _, id := range eventIDs {
if _, ok := r.buffered[id]; !ok {
return nil, fmt.Errorf("unknown required action %q: failing closed", id)
}
}

out := make([]ToolResult, 0, len(eventIDs))
for _, id := range eventIDs {
if r.executed[id] {
// Replay: resend the cached result, never re-execute.
out = append(out, r.results[id])
continue
}
request := r.buffered[id]
output, err := Dispatch(r.store, r.scope, request.Tool, request.RawInput)
result := ToolResult{EventID: id, Output: output}
if err != nil {
result.Output = err.Error()
result.IsError = true
}
// Mark executed and cache the result BEFORE returning, so a later
// replay of the same batch cannot run a mutating tool twice.
r.executed[id] = true
r.results[id] = result
out = append(out, result)
}
return out, nil
}

// FinalizeIdle is called when the turn reaches a terminal idle. If any buffered
// tool never got executed, the run is incomplete and must fail closed rather
// than silently declaring success.
func (r *BatchRunner) FinalizeIdle() error {
r.mu.Lock()
defer r.mu.Unlock()
for id := range r.buffered {
if !r.executed[id] {
return fmt.Errorf("terminal idle with unresolved custom tool %q: failing closed", id)
}
}
return nil
}

// ExecutedCount reports how many distinct tools actually ran. Tests use it to
// prove replay did not re-execute.
func (r *BatchRunner) ExecutedCount() int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.executed)
}
Loading
Loading