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
2 changes: 2 additions & 0 deletions example/parsar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ Quick Tunnel URLs last only for that tunnel process and may change after restart

A conversation streams replies and tool activity, groups work steps, navigates user turns with the turn rail, supports follow-ups and cancellation and reopens durable history. Messages sent while the Agent is working reach the running task; answer business questions on their card. Enter sends; Shift+Enter adds a line. Drafts survive reloads in the same browser tab. The folder button uploads inputs up to 5 MiB while idle, inserts their path into the draft and downloads Artifacts after the live workspace is gone; ask the Agent to save deliverables under `./outputs`. The info button shows the Agent, model, Harness, environment template, Feishu identity, source and the configuration recorded at creation, without variable values, script contents or credentials. When a creation response is lost, the conversation page offers **恢复创建**, which reuses the frozen request and idempotency key.

When a follow-up response is lost while Core resumes the environment, Web keeps the input in the running ledger and confirms receipt with the original idempotency key before monitoring its outcome. Explicit request rejections still return an error; input that never starts is reported after its reservation expires.

To show thinking, set the Agent’s **高级 → 推理摘要** to `auto` and start a new conversation. When the Harness emits a native summary, **思考过程** appears directly in the conversation, separate from tool steps. While thinking is in progress it shows the latest text; after completion it defaults to a single row. Click to read the full summary, which is restored from history after reconnection. Existing conversations keep the configuration recorded at creation; enabling summaries does not add thinking to old history.

Parsar watches every running turn, from Web and Feishu alike. Before the first output it shows the stage (**正在准备工作环境**, **正在启动**) with the waiting time and the Agent's usual first-reply time from the last seven days. Recovery is automatic and happens only while nothing has run: an Environment that fails to initialize, or a claimed prepared conversation whose first turn has not started within 45 seconds, is replaced once with the identical frozen request; a turn without any output 45 seconds after it started is cancelled and its input resent once; a turn that fails before any output is resent once. Replaced turns are hidden from the conversation and never reported in Feishu. A final failure is explained in plain language with **重试**. **产物** lists the files recent conversations produced, linked to their conversation.
Expand Down
2 changes: 2 additions & 0 deletions example/parsar/README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ Quick Tunnel 地址只在该隧道进程运行期间有效,重启可能换地

对话会流式展示回复和工具活动,归组工作步骤,可用对话导航条跳转用户轮次,支持追问和取消,并可重新打开持久历史。Agent 执行中发送的消息会进入正在运行的任务;业务提问请在提问卡片上回答。Enter 发送,Shift+Enter 换行。草稿在同一浏览器标签页中刷新后保留。文件夹按钮可在空闲时上传最大 5 MiB 的输入文件,并把路径插入草稿;实时工作区回收后仍可下载 Artifacts,请让 Agent 将交付物保存到 `./outputs`。信息按钮展示 Agent、模型、Harness、环境模板、飞书身份、来源以及创建时记录的配置,不含变量值、脚本内容或凭据。创建响应丢失时,对话页提供 **恢复创建**,复用冻结的请求和幂等键。

恢复环境时,如果后续消息的请求响应超时或丢失,网页会保留运行记录,使用原幂等键确认接收后继续跟踪执行结果,不会重复执行。明确被拒绝的请求仍返回错误;始终未开始执行的输入会在预留期结束后报告失败。

如需展示 thinking,将 Agent 的 **高级 → 推理摘要** 设为 `auto`,再新建对话。Harness 返回原生摘要时,**思考过程** 会直接出现在对话中,与工具步骤分开。生成时显示最新片段,结束后默认收成一行;点击可阅读完整摘要,断线后从历史恢复。已有对话保留创建时的配置;开启摘要不会为旧历史补出思考内容。

Parsar 会跟踪每一轮正在执行的对话,网页和飞书都一样。出现第一条输出之前,会显示所处阶段(**正在准备工作环境**、**正在启动**)、已等待的时间,以及这个 Agent 近 7 天通常的首次回复时间。自动恢复只在还没有执行任何内容时进行:环境初始化失败,或接管的提前准备会话 45 秒内仍未开始第一轮时,用同一份冻结的请求重建一次;一轮对话开始 45 秒后仍没有任何输出,会取消并重新发送一次输入;在任何输出之前就失败的一轮,会重新发送一次。被替换的轮次不会出现在对话中,也不会发到飞书。最终失败会用通俗的话说明原因,并提供 **重试**。**产物** 汇总最近对话生成的文件,并链接到对应的对话。
Expand Down
24 changes: 21 additions & 3 deletions example/parsar/server/turns.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,21 @@ export function turnsAPI(store, core) {
const turn =
store.get("turns", id) ||
start(record, { id, kind: "followup", input, source, base: await baseline(record.core_session_id) });
if (source === "web") store.put("turns", { ...turn, pending_key: key });
try {
await post(record.core_session_id, message(turn.input), key);
const current = store.get("turns", id);
if (current?.pending_key) store.put("turns", { ...current, pending_key: undefined });
} catch (error) {
if (error instanceof AppError && error.status >= 400 && error.status < 500) store.remove("turns", id);
throw error;
if (error instanceof AppError && error.status >= 400 && error.status < 500) {
store.remove("turns", id);
throw error;
}
// A lost response can follow accepted input while Core resumes a Session.
// Web confirms receipt with the same key; Feishu owns its delivery retries.
if (source !== "web") throw error;
}
return turn;
return store.get("turns", id);
};

// The first input of a claimed prepared Session. Core answers the send only
Expand Down Expand Up @@ -180,6 +188,16 @@ export function turnsAPI(store, core) {
store.put("turns", { ...turn, core_session_id: record.core_session_id });
return;
}
// Confirm uncertain Web submissions before attributing any Core Turn to
// the input. Reusing the frozen body and key cannot execute it twice.
if (turn.pending_key) {
try {
turn = await deliver(record, turn.input, { id: turn.id, key: turn.pending_key, source: "web" });
} catch (error) {
return finish(turn, "failed", explainFailure(error, runtimeName(record)));
}
if (turn.pending_key) return;
}
const sid = turn.core_session_id;
const session = await core(`/v1/agents/sessions/${sid}`, "GET");
const superseded = new Set(record.superseded_turns || []);
Expand Down
82 changes: 82 additions & 0 deletions example/parsar/tests/product.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -939,6 +939,88 @@ function scriptedCore(plan) {
return { core, calls, sessions };
}

test("Web confirms uncertain sends with the original key before attributing a Turn", async (t) => {
for (const active of [false, true]) {
for (const error of [new DOMException("request timed out", "TimeoutError"), new TypeError("fetch failed"), new AppError(502, "gateway timeout")]) {
const store = openStore(":memory:");
t.after(() => store.close());
const plan = { environments: [], turns: [active ? "run" : "complete"] };
const fake = scriptedCore(plan);
const accepted = new Set(), posts = [];
let reachable = false;
const core = async (path, method, body, key) => {
if (method === "POST" && path.endsWith("/events")) {
posts.push({ key, body });
if (!reachable) throw error;
if (accepted.has(key)) return {};
accepted.add(key);
}
return fake.core(path, method, body, key);
};
const { api, put, input } = await setup(store, core);
const id = randomUUID();
await put("sessions", input, id);
const record = store.get("sessions", id);
const session = fake.sessions.get(record.core_session_id);
const response = await api("POST", `/app/sessions/${id}/messages`, { text: "Continue", key: "slow" });
assert.equal(response.turn.outcome, "running");
assert.equal(response.turn.failure, undefined);
session.turns[0].status = "completed";
session.status = "idle";
await api.sessions.turns.monitor();
assert.equal(store.get("turns", `${id}:slow`).outcome, "running", "the old Turn cannot prove receipt");
// Core already accepted the input, but its response was lost.
await fake.core(`/v1/agents/sessions/${record.core_session_id}/events`, "POST", posts[0].body, "slow");
accepted.add("slow");
reachable = true;
await api.sessions.turns.monitor();
assert.equal(store.get("turns", `${id}:slow`).outcome, "completed");
assert.equal(session.turns.length, 2, "confirmation never executes the input twice");
assert.ok(posts.every((p) => p.key === "slow"));
assert.ok(posts.every((p) => JSON.stringify(p.body) === JSON.stringify(posts[0].body)));

reachable = false;
await api("POST", `/app/sessions/${id}/messages`, { text: "Not admitted", key: "lost" });
plan.turns.push("reserve");
reachable = true;
const lost = store.get("turns", `${id}:lost`);
store.put("turns", { ...lost, started_at: lost.started_at - 6 * 60_000 });
await api.sessions.turns.monitor();
assert.equal(store.get("turns", lost.id).outcome, "failed");
assert.match(store.get("turns", lost.id).failure, /没有被执行/);

reachable = false;
await assert.rejects(api.sessions.turns.deliver(record, "Feishu", {
id: "feishu-message", key: "feishu-message", source: "feishu",
}), (e) => e === error);
}
}
});

test("Web explicit rejections remain failures before and after an uncertain response", async (t) => {
for (const status of [400, 401, 403, 404, 409, 429]) {
const store = openStore(":memory:");
t.after(() => store.close());
const fake = scriptedCore({ environments: [], turns: [] });
const rejected = new AppError(status, "rejected");
let error = rejected;
const { api, put, input } = await setup(store, (path, method, ...args) => {
if (method === "POST" && path.endsWith("/events")) throw error;
return fake.core(path, method, ...args);
});
const id = randomUUID();
await put("sessions", input, id);
await assert.rejects(api("POST", `/app/sessions/${id}/messages`, { text: "Continue", key: "denied" }), (e) => e === rejected);
assert.equal(store.get("turns", `${id}:denied`), null);
error = new TypeError("fetch failed");
await api("POST", `/app/sessions/${id}/messages`, { text: "Continue", key: "uncertain" });
error = rejected;
await api.sessions.turns.monitor();
assert.equal(store.get("turns", `${id}:uncertain`).outcome, "failed");
assert.match(store.get("turns", `${id}:uncertain`).failure, /rejected/);
}
});

test("the Turn monitor replaces a failed Environment, resends stalled or failed Turns once, and explains final failures", async (t) => {
const store = openStore(":memory:");
t.after(() => store.close());
Expand Down
2 changes: 2 additions & 0 deletions services/core/tools/microsandbox-provider/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ The [maintainer guide](../../../../docs/maintainers.md#runtime-images-and-helper

Before every operation the helper checks that it was built with the exact official SDK module declared in its embedded `go.mod` without a replacement, that the runtime and firmware match their hashes, that the runtime's `.msbver` ELF section reports the declared native release, that the SDK source and embedded FFI report that release, and that the SDK resolves exactly those paths with the local backend ([`main.go`](main.go)). It never installs or upgrades these files; keep them unchanged for the lifetime of the provider's backend. Ambient SDK profiles are ignored.

The adapter-owned SDK configuration sets `snapshots.max_filesystem_state_mib` to 64 MiB per virtio-fs device. The native 4 MiB default cannot capture file-heavy agent work. Capture and restore keep the same bounded budget and native integrity checks; sandbox memory and node capacity do not change. The budget is fixed when a sandbox starts, so changing the helper cannot repair an already-running sandbox's smaller budget. Existing snapshots still undergo native verification before restore.

The runtime home must be private (mode 0700), short, on local persistent storage and used by no other installation, profile or manual lifecycle tool. microsandbox uses Unix sockets there, so the node installer refuses a home whose path would exceed their limit. The home holds confidential VM disks, memory snapshots and SDK state; preserve it with the node identity and Core's database when recovering a host. Every managed lifecycle change goes through the provider.

The Runtime image is an immutable `repository@sha256:<64 lowercase hex>` reference that the node's `provider.json` records as `native.image`; bare image IDs and mutable tags are rejected. The node installer imports the distribution's image under that reference. To load an image by hand, `msb image load --tag repository@sha256:<digest>` must register the digest reference explicitly, with the manifest digest from `image inspect`, not the Docker image config ID. The image carries the daemon, Python 3, the native Harnesses and the shared Runtime helpers. The provider installs no registry credentials. Creation uses the SDK's `never` pull policy.
Expand Down
27 changes: 26 additions & 1 deletion services/core/tools/microsandbox-provider/installation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,30 @@
package main

import (
"encoding/json"
"os"
"path/filepath"
"testing"
)

func TestIsolatedConfigReusesOnlyPrivateEmptyObject(t *testing.T) {
func TestIsolatedConfigReusesOnlyPrivateSnapshotBudget(t *testing.T) {
home := t.TempDir()
path, err := isolatedConfig(home)
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var config struct {
Snapshots struct {
MaxFilesystemStateMiB int `json:"max_filesystem_state_mib"`
} `json:"snapshots"`
}
if err := json.Unmarshal(data, &config); err != nil || config.Snapshots.MaxFilesystemStateMiB != 64 {
t.Fatalf("snapshot budget: %s, %v", data, err)
}
if again, err := isolatedConfig(home); err != nil || again != path {
t.Fatalf("reuse: %s %v", again, err)
}
Expand All @@ -25,6 +38,18 @@ func TestIsolatedConfigReusesOnlyPrivateEmptyObject(t *testing.T) {
}
}

func TestIsolatedConfigRejectsChangedBudgetAndTrailingContent(t *testing.T) {
for _, data := range []string{"{}\n", "{\"snapshots\":{\"max_filesystem_state_mib\":4095}}\n", "{\"snapshots\":{\"max_filesystem_state_mib\":64}}\n{}"} {
home := t.TempDir()
if err := os.WriteFile(filepath.Join(home, "oac-sdk-config.json"), []byte(data), 0600); err != nil {
t.Fatal(err)
}
if _, err := isolatedConfig(home); err == nil {
t.Fatalf("accepted modified config: %q", data)
}
}
}

func TestIsolatedConfigRejectsSymlink(t *testing.T) {
home := t.TempDir()
target := filepath.Join(t.TempDir(), "config.json")
Expand Down
12 changes: 8 additions & 4 deletions services/core/tools/microsandbox-provider/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -343,13 +343,17 @@ func runtimeVersion(path string) error {
return nil
}

// An empty JSON object prevents ambient SDK profiles without invalid empty input.
// File-heavy agent work exceeds the native 4 MiB virtio-fs checkpoint default.
// Keep a bounded per-device budget in the same isolated config for every SDK operation.
const isolatedSDKConfig = "{\"snapshots\":{\"max_filesystem_state_mib\":64}}\n"

// Only the adapter-owned config is accepted; ambient SDK profiles stay excluded.
func isolatedConfig(home string) (string, error) {
path := filepath.Join(home, "oac-sdk-config.json")
fd, err := syscall.Open(path, syscall.O_WRONLY|syscall.O_CREAT|syscall.O_EXCL|syscall.O_NOFOLLOW, 0600)
if err == nil {
f := os.NewFile(uintptr(fd), path)
_, err = f.WriteString("{}\n")
_, err = f.WriteString(isolatedSDKConfig)
if err == nil {
err = f.Sync()
}
Expand All @@ -376,11 +380,11 @@ func isolatedConfig(home string) (string, error) {
if !st.Mode().IsRegular() || st.Mode().Perm() != 0600 {
return "", sandbox.ErrOwnership
}
data, err := io.ReadAll(io.LimitReader(f, 4))
data, err := io.ReadAll(io.LimitReader(f, int64(len(isolatedSDKConfig)+1)))
if err != nil {
return "", err
}
if string(data) != "{}\n" {
if string(data) != isolatedSDKConfig {
return "", sandbox.ErrOwnership
}
return path, nil
Expand Down
Loading