diff --git a/backend/package/yuxi/agents/backends/sandbox/paths.py b/backend/package/yuxi/agents/backends/sandbox/paths.py index 23bc305f..04976085 100644 --- a/backend/package/yuxi/agents/backends/sandbox/paths.py +++ b/backend/package/yuxi/agents/backends/sandbox/paths.py @@ -37,7 +37,7 @@ def _thread_root_dir(thread_id: str) -> Path: def _global_user_data_dir() -> Path: """Return the shared host-side directory used for thread workspace files.""" - return Path(conf.save_dir) / "user-data" + return Path(conf.save_dir) / "threads" / "shared" def sandbox_user_data_dir(thread_id: str) -> Path: diff --git a/docker/sandbox_provisioner/app.py b/docker/sandbox_provisioner/app.py index cdb79f69..f1cbab3b 100644 --- a/docker/sandbox_provisioner/app.py +++ b/docker/sandbox_provisioner/app.py @@ -177,6 +177,33 @@ class LocalContainerProvisionerBackend: raise ValueError("thread skills path resolved outside threads host root") from exc return thread_skills + def _shared_workspace_host_path(self) -> Path: + threads_root = Path(self._threads_host_path).resolve() + workspace = (threads_root / "shared" / "workspace").resolve() + try: + workspace.relative_to(threads_root) + except ValueError as exc: + raise ValueError("shared workspace path resolved outside threads host root") from exc + return workspace + + def _thread_uploads_host_path(self, thread_id: str) -> Path: + threads_root = Path(self._threads_host_path).resolve() + uploads = (threads_root / thread_id / "user-data" / "uploads").resolve() + try: + uploads.relative_to(threads_root) + except ValueError as exc: + raise ValueError("thread uploads path resolved outside threads host root") from exc + return uploads + + def _thread_outputs_host_path(self, thread_id: str) -> Path: + threads_root = Path(self._threads_host_path).resolve() + outputs = (threads_root / thread_id / "user-data" / "outputs").resolve() + try: + outputs.relative_to(threads_root) + except ValueError as exc: + raise ValueError("thread outputs path resolved outside threads host root") from exc + return outputs + def _is_expected_skills_mount(self, container, thread_id: str) -> bool: expected_source = str(self._thread_skills_host_path(thread_id)) for mount in container.attrs.get("Mounts") or []: @@ -187,6 +214,18 @@ class LocalContainerProvisionerBackend: return source == expected_source return False + def _has_expected_user_data_mounts(self, container, thread_id: str) -> bool: + expected_mounts = { + "/home/gem/user-data/workspace": str(self._shared_workspace_host_path()), + "/home/gem/user-data/uploads": str(self._thread_uploads_host_path(thread_id)), + "/home/gem/user-data/outputs": str(self._thread_outputs_host_path(thread_id)), + } + actual_mounts = { + str((mount.get("Destination") or "").rstrip("/")): str((mount.get("Source") or "").rstrip("/")) + for mount in container.attrs.get("Mounts") or [] + } + return all(actual_mounts.get(destination) == source for destination, source in expected_mounts.items()) + def _resolve_host_paths(self) -> None: if self._threads_host_path: return @@ -270,6 +309,10 @@ class LocalContainerProvisionerBackend: logger.info("Recreating sandbox %s because skills mount is stale", sandbox_id) self.delete(sandbox_id) existing = None + elif not self._has_expected_user_data_mounts(existing, safe_thread_id): + logger.info("Recreating sandbox %s because user-data mounts are stale", sandbox_id) + self.delete(sandbox_id) + existing = None if existing is not None: if existing.status == "running": try: @@ -289,13 +332,12 @@ class LocalContainerProvisionerBackend: logger.warning("Failed to delete stale sandbox %s before recreate: %s", sandbox_id, exc) threads_root = Path(self._threads_host_path).resolve() - thread_user_data = (threads_root / safe_thread_id / "user-data").resolve() - try: - thread_user_data.relative_to(threads_root) - except ValueError as exc: - raise ValueError("thread_id resolved outside threads host root") from exc - thread_user_data.mkdir(parents=True, exist_ok=True) - + shared_workspace = self._shared_workspace_host_path() + shared_workspace.mkdir(parents=True, exist_ok=True) + thread_uploads = self._thread_uploads_host_path(safe_thread_id) + thread_outputs = self._thread_outputs_host_path(safe_thread_id) + thread_uploads.mkdir(parents=True, exist_ok=True) + thread_outputs.mkdir(parents=True, exist_ok=True) thread_skills = self._thread_skills_host_path(safe_thread_id) thread_skills.mkdir(parents=True, exist_ok=True) @@ -310,7 +352,9 @@ class LocalContainerProvisionerBackend: "managed-by": "yuxi-sandbox-provisioner", }, "volumes": { - str(thread_user_data): {"bind": "/home/gem/user-data", "mode": "rw"}, + str(shared_workspace): {"bind": "/home/gem/user-data/workspace", "mode": "rw"}, + str(thread_uploads): {"bind": "/home/gem/user-data/uploads", "mode": "rw"}, + str(thread_outputs): {"bind": "/home/gem/user-data/outputs", "mode": "rw"}, str(thread_skills): {"bind": "/home/gem/skills", "mode": "ro"}, }, "ports": {f"{self._container_port}/tcp": None}, @@ -348,6 +392,13 @@ class LocalContainerProvisionerBackend: except Exception as exc: logger.warning("Failed to delete stale sandbox %s during discover: %s", sandbox_id, exc) return None + if not self._has_expected_user_data_mounts(container, safe_thread_id): + logger.info("Discarding stale sandbox %s with legacy user-data mounts", sandbox_id) + try: + self.delete(sandbox_id) + except Exception as exc: + logger.warning("Failed to delete stale sandbox %s during discover: %s", sandbox_id, exc) + return None record = self._to_record(container, sandbox_id) if not record.sandbox_url: return None @@ -433,11 +484,12 @@ class KubernetesProvisionerBackend: command=["sh", "-c"], args=[ "chmod 777 /home/gem " - f"&& mkdir -p /mnt/shared-data/threads/{thread_id}/user-data/workspace " + "&& mkdir -p /mnt/shared-data/threads/shared/workspace " f"/mnt/shared-data/threads/{thread_id}/user-data/uploads " f"/mnt/shared-data/threads/{thread_id}/user-data/outputs " f"/mnt/shared-data/threads/{thread_id}/skills " - f"&& chmod -R 777 /mnt/shared-data/threads/{thread_id}/user-data ", + f"&& chmod -R 777 /mnt/shared-data/threads/shared/workspace " + f"/mnt/shared-data/threads/{thread_id}/user-data ", ], volume_mounts=[ self._client.V1VolumeMount(name="home-dir", mount_path="/home/gem"), @@ -454,8 +506,18 @@ class KubernetesProvisionerBackend: self._client.V1VolumeMount(name="home-dir", mount_path="/home/gem"), self._client.V1VolumeMount( name="shared-data", - mount_path="/home/gem/user-data", - sub_path=f"threads/{thread_id}/user-data", + mount_path="/home/gem/user-data/workspace", + sub_path="threads/shared/workspace", + ), + self._client.V1VolumeMount( + name="shared-data", + mount_path="/home/gem/user-data/uploads", + sub_path=f"threads/{thread_id}/user-data/uploads", + ), + self._client.V1VolumeMount( + name="shared-data", + mount_path="/home/gem/user-data/outputs", + sub_path=f"threads/{thread_id}/user-data/outputs", ), self._client.V1VolumeMount( name="shared-data", diff --git a/docs/agents/sandbox-architecture.md b/docs/agents/sandbox-architecture.md index 1a5386c6..324a67e3 100644 --- a/docs/agents/sandbox-architecture.md +++ b/docs/agents/sandbox-architecture.md @@ -1,3 +1,176 @@ -# 沙盒架构文档 +# Yuxi 沙盒架构说明 -> 文档待补充。 +这份文档说明当前项目中“沙盒”这一层到底是什么、为什么同时会看到 Docker 和 Kubernetes、默认开发环境实际启用的是哪一种模式,以及沙盒如何和 `skills`、知识库、附件、工作区文件系统组合在一起工作。文档内容以当前仓库实现为准,重点解释真实调用链、配置入口、路径语义和运维边界,而不是抽象地介绍容器技术。 + +## 一、先说明白:Docker 和 K8s 在这里是什么关系 + +Docker 和 Kubernetes 不是互斥关系。Docker 解决的是“把一个进程放进容器里运行”这个问题,Kubernetes 解决的是“如何在一组机器上批量调度、暴露、重建和管理这些容器”这个问题。可以把 Docker 理解成容器运行时和镜像分发方式,把 Kubernetes 理解成容器编排平台。 + +放到 Yuxi 里,这个关系更具体一些。Yuxi 本身并不直接决定“沙盒一定跑在 Docker 还是一定跑在 K8s 上”,它只要求后端拿到一个可访问的沙盒地址,然后通过 `agent-sandbox` 的 HTTP API 去执行命令、读写文件。真正负责创建和回收沙盒实例的是 `sandbox-provisioner` 这个单独的服务。也就是说,Yuxi 的应用层只依赖 “provisioner”,而 provisioner 的后端可以选择用本机 Docker 去起容器,也可以选择向 Kubernetes 集群创建 Pod 和 Service。 + +所以项目里看到的概念其实分成两层。第一层是应用层的 `SANDBOX_PROVIDER`,当前代码只支持 `provisioner`。第二层是 provisioner 内部的 `SANDBOX_PROVISIONER_BACKEND`,它决定具体用哪种底层实现去创建沙盒。你在配置中看到的 `local`、`docker`、`kubernetes`,都属于这一层,而不是三套互相独立的产品形态。 + +## 二、当前项目的真实沙盒调用链 + +当前仓库里,后端只支持 `SANDBOX_PROVIDER=provisioner`。当某个对话线程第一次需要执行文件操作或命令执行时,后端会基于 `thread_id` 生成一个稳定的 `sandbox_id`,然后请求 `sandbox-provisioner` 创建或复用对应沙盒。应用层拿到返回的 `sandbox_url` 之后,才会真正通过 `agent-sandbox` 客户端去调用远程沙盒的文件 API 和 shell API。 + +调用链可以概括为:Web/API 请求进入 Yuxi 后端,后端构造 `ProvisionerSandboxBackend`,再经由 `ProvisionerClient` 调用 `sandbox-provisioner` 的 `/api/sandboxes` 接口。`sandbox-provisioner` 根据 `SANDBOX_PROVISIONER_BACKEND` 选择本地内存实现、Docker 容器实现或 Kubernetes 实现。沙盒真正启动后,对外暴露一个 HTTP 地址,Yuxi 再使用这个地址完成执行命令、上传文件、下载文件、目录遍历等操作。 + +当前仓库的默认配置和当前开发环境都是 `local`。默认值来自 `.env.template` 中的 `SANDBOX_PROVISIONER_BACKEND=local`,而当前运行中的 provisioner 健康检查返回的也是 `backend=local`。这意味着你现在用 `docker compose up -d` 启动项目时,应用并不是直接把代码跑在宿主机上,而是通过 `sandbox-provisioner` 再去用 Docker 启一个真正的沙盒容器。 + +## 三、`local`、`docker`、`kubernetes` 到底分别是什么 + +当前实现里,`memory`、`local`、`docker`、`kubernetes` 一共四个值都可以出现在 provisioner 的后端配置中,但它们并不等价。 + +`memory` 是一个纯内存登记实现。它不会真正创建容器,也不会提供真实隔离,主要适合测试或极轻量的占位场景。它只是记录一个 `sandbox_id -> sandbox_url` 的映射,因此不能把它理解成生产可用的沙盒。 + +`local` 和 `docker` 在当前代码里是同一个后端实现。`sandbox-provisioner` 会把这两个值都映射到 `LocalContainerProvisionerBackend`。因此,今天在 Yuxi 里说 “Docker 模式” 和说 “local 模式”,从代码路径上看是一回事。之所以保留两个名字,主要是为了兼容已有术语和便于理解:`local` 强调“在当前这台部署机上起容器”,`docker` 强调“底层依赖 Docker Engine”。 + +`kubernetes` 则是另一条实现路径。它不会再去调用本机 Docker 起容器,而是使用 Kubernetes API 在指定 namespace 中创建一个 Pod 和一个 NodePort Service,然后把这个 Service 对应的可访问地址回传给 Yuxi 后端。 + +因此,如果你在界面、文档或者环境变量里看到 “local / docker / k8s” 这几个词,最准确的理解应该是:Yuxi 的应用层只有一种 provider,也就是 `provisioner`;provisioner 下面有多种 backend;其中 `local` 和 `docker` 是同一种 Docker 本机后端的两个别名,`kubernetes` 才是另一种远程集群后端。 + +## 四、默认开发模式到底是什么 + +默认开发模式是 Docker Compose 启动整个项目,再由 `sandbox-provisioner` 按 `local` 后端去创建沙盒容器。也就是说,项目本身跑在 Compose 里,沙盒也跑在 Docker 里,只不过沙盒不是 Compose 静态声明的长期服务,而是 provisioner 按需动态拉起和回收的短生命周期容器。 + +这也是为什么在 `docker-compose.yml` 中既能看到 `api`、`worker`、`sandbox-provisioner` 这样的常驻服务,又能看到 `sandbox-provisioner` 挂载了 `/var/run/docker.sock`。这不是重复设计,而是为了让 provisioner 有能力继续调用宿主机 Docker daemon 去创建新的“每线程沙盒容器”。 + +换句话说,当前项目不存在单独的 “纯宿主机 local 模式”。默认所谓 `local`,本质上仍然是 Docker 容器模式,只是这些容器是在当前这台机器上由 provisioner 动态拉起,而不是被 Kubernetes 调度。 + +## 五、Docker 本机后端是如何工作的 + +当 `SANDBOX_PROVISIONER_BACKEND=local` 或 `docker` 时,`sandbox-provisioner` 会进入 `LocalContainerProvisionerBackend`。它会检查 Docker 是否可用,解析自身容器里 `/app/saves` 这个挂载点在宿主机上的真实路径,并据此推导出线程数据目录。随后它为每个 `thread_id` 准备一个稳定的 `sandbox_id`,把容器命名为类似 `yuxi-sandbox-` 的形式,并在 Docker 网络中启动真正的沙盒镜像。 + +这个沙盒镜像默认来自 `SANDBOX_IMAGE`,容器内部监听的端口默认是 `8080`。provisioner 在启动容器时,会把这个端口随机映射到宿主机上的一个可用端口,再用 `DOCKER_SANDBOX_HOST` 拼出形如 `http://host.docker.internal:` 的访问地址。Yuxi 后端拿到的就是这个地址。 + +Docker 后端在启动沙盒时,会挂载两类关键目录。第一类是线程用户数据目录,挂载到容器内的 `/home/gem/user-data`,用于承载上传文件、输出文件以及工作目录。第二类是线程可见的 skills 目录,挂载到 `/home/gem/skills`,而且是只读挂载。除此之外,容器的 `/home/gem` 本身还会额外挂一个 `tmpfs`,原因是当前沙盒镜像启动时要求 `/home/gem` 可写,但 Yuxi 希望真正持久化的只有 `user-data` 下面的内容。 + +为了避免长期空闲的沙盒一直占资源,provisioner 还带了一个 idle reaper。它会记录每个沙盒最近一次被 touch 的时间,超过 `SANDBOX_IDLE_TIMEOUT_SECONDS` 之后自动删除。当前默认空闲超时是 120 秒,但如果这个值小于命令执行超时,系统会自动把它提高到“命令超时 + 30 秒”,以免执行中的任务被误回收。 + +## 六、Kubernetes 后端是如何工作的 + +当 `SANDBOX_PROVISIONER_BACKEND=kubernetes` 时,`sandbox-provisioner` 会改用 Kubernetes Python 客户端。它会先加载 kubeconfig 或集群内配置,然后在指定的 namespace 中创建一个沙盒 Pod,再创建一个同名的 NodePort Service,把这个 Service 的 `nodePort` 暴露给 Yuxi 后端使用。 + +Kubernetes 后端下,沙盒还是同一套镜像,还是暴露同样的 HTTP API,但存储方式和暴露方式变了。它不会依赖宿主机 Docker bind mount,而是要求有一个可写的 PVC。当前实现里真正使用的是 `THREAD_PVC`,Pod 会把这块共享存储挂到 `/mnt/shared-data`,然后用 `subPath` 的方式把 `threads//user-data` 挂到 `/home/gem/user-data`,把 `threads//skills` 挂到 `/home/gem/skills`。这样做的好处是线程之间的数据目录结构仍然可以和 Docker 模式保持一致。 + +需要特别说明的是,代码里虽然读取了 `SKILLS_PVC` 这个环境变量,但当前 Pod 规格实际没有使用单独的 skills PVC,而是统一从 `THREAD_PVC` 中切 `threads//skills` 这个子路径。因此,如果你看到环境变量里同时出现 `SKILLS_PVC` 和 `THREAD_PVC`,请以 `THREAD_PVC` 的真实挂载语义为准,`SKILLS_PVC` 目前更像一个预留字段。 + +Kubernetes 后端还需要一个 `NODE_HOST`。这是因为当前实现使用的是 NodePort Service,而不是 Ingress,也不是 ClusterIP。provisioner 创建完 Service 之后,会把最终访问地址拼成 `http://:` 返回给 Yuxi 后端。所以 `NODE_HOST` 必须是 Yuxi 后端能够访问到的 Kubernetes 节点地址、负载均衡地址或者对 NodePort 做了透出的外部域名。 + +## 七、如果我要使用“远程 K8s”,应该怎么接 + +这里最容易误解的一点是,所谓“选择远程 K8s”,并不是在 Yuxi 页面里点一个开关,然后系统自动发现一个集群。当前实现没有内建集群选择器,也没有多集群管理界面。它的工作方式很直接:你把 `sandbox-provisioner` 配置成 `kubernetes` 后端,并让它能拿到目标集群的 kubeconfig 或者运行在集群内即可。对 provisioner 来说,只要 Kubernetes 客户端能连上 API Server,这个集群就是它要操作的“远程 K8s”。 + +如果你的 Yuxi 部署在 Docker Compose 里,而 Kubernetes 集群在另一台机器或云厂商托管环境中,那么最常见的做法是把本地 kubeconfig 文件挂载进 `sandbox-provisioner` 容器,然后设置 `KUBECONFIG_PATH`。同时把 `SANDBOX_NODE_HOST` 改成一个从 `api` 容器也能访问的节点公网 IP、负载均衡域名,或者你自己做过反向代理的地址。 + +一个典型的 Compose 覆盖配置会长这样: + +```yaml +services: + sandbox-provisioner: + environment: + - PROVISIONER_BACKEND=kubernetes + - K8S_NAMESPACE=yuxi-know + - KUBECONFIG_PATH=/root/.kube/config + - THREAD_PVC=yuxi-thread + - NODE_HOST=203.0.113.10 + volumes: + - ~/.kube/config:/root/.kube/config:ro +``` + +这段配置表达的意思不是“把整个应用迁到 K8s”,而是“仍然用 Compose 跑 Yuxi 主服务,但沙盒实例改为由远程 Kubernetes 集群承载”。这是当前代码最自然的混合部署方式。 + +如果你的 `sandbox-provisioner` 本身就运行在 Kubernetes 集群内部,那么通常不需要显式提供 `KUBECONFIG_PATH`。它会优先尝试 `incluster_config`,也就是使用 Pod 的服务账号权限直接访问 Kubernetes API。此时你更需要关注的是 namespace、PVC 和 NodePort 的可达性,而不是 kubeconfig 文件本身。 + +## 八、当前项目的沙盒文件系统是如何设计的 + +从模型和工具调用的视角看,Yuxi 主要向 Agent 暴露三类路径:`/home/gem/user-data`、`/home/gem/skills` 和 `/home/gem/kbs`。其中 `user-data` 是可写的用户工作区,`skills` 是只读的技能目录,`kbs` 是只读的知识库映射目录。 + +在宿主机侧,和线程相关的数据主要放在 `saves` 目录下。当前可读的目录结构可以概括为下面这样: + +```text +saves/ +├── skills/ +│ ├── / +│ └── ... +├── threads/ +│ ├── / +│ │ ├── user-data/ +│ │ │ ├── uploads/ +│ │ │ ├── outputs/ +│ │ │ └── ... +│ │ └── skills/ +│ │ ├── / +│ │ └── ... +│ ├── shared/ +│ │ └── workspace/ +│ └── ... +``` + +这里要重点理解 `workspace` 和 `uploads/outputs` 的区别。按照当前宿主机路径解析逻辑,`workspace` 被定义为共享目录,位置是 `saves/threads/shared/workspace`;而 `uploads` 和 `outputs` 属于线程私有目录,位置分别是 `saves/threads//user-data/uploads` 和 `saves/threads//user-data/outputs`。viewer 文件系统、artifact 下载接口以及路径解析函数都按这个语义工作,因此不同线程可以看到同一个 workspace,但看不到彼此的 uploads。 + +与此同时,运行时 provisioner 在创建 Docker 容器或 Kubernetes Pod 时,会把共享的 `saves/threads/shared/workspace` 单独挂到 `/home/gem/user-data/workspace`,再把当前线程自己的 `uploads/outputs` 分别挂到 `/home/gem/user-data/uploads` 和 `/home/gem/user-data/outputs`。因此在排查文件问题时,你需要有一个清晰意识:当前项目里同时存在“宿主机侧目录组织”和“容器内统一虚拟路径”两层概念。对外接口和 viewer 语义与底层挂载实现现在是一致的,workspace 是共享空间,而 uploads/outputs 仍然保持线程隔离。 + +## 九、路径暴露规则是什么 + +Yuxi 不会把整个容器文件系统都开放给 Agent 或 viewer。当前 viewer 根目录只会列出几个命名空间入口,而不会直接暴露 `/` 的真实文件树。这样做是为了避免只看文件树就触发沙盒冷启动,也为了让权限边界更稳定。 + +`/home/gem/user-data` 是主要工作区。它允许模型和工具写入,但推荐语义并不相同。内置 prompt 中已经明确说明,`workspace` 应当放中间文件,`outputs` 应当放最终产物,`uploads` 是用户上传文件的位置。对于普通对话 Agent,文案甚至提示“非必要不要写 workspace,而优先写 outputs”。 + +`/home/gem/skills` 是只读目录。它不是简单地把 `saves/skills` 整个暴露进去,而是先根据当前线程可见的 skill 列表,把这些技能从全局 skills 根目录同步复制到 `saves/threads//skills`,再把这个线程目录只读挂进沙盒。这样做的结果是,不同线程看到的 skill 集可能不同,而且模型永远不能在运行时修改 skills 内容。 + +`/home/gem/kbs` 也是只读目录。它不是物理直挂一个宿主机目录,而是由 `KnowledgeBaseReadonlyBackend` 动态组织出来的一棵虚拟树。这个树只暴露“当前用户可访问的知识库”和“当前 Agent 上启用的知识库”的交集,并且会同时组织源文件和解析后的 Markdown 视图。对于模型来说,这个目录更像一个只读文件系统投影,而不是原始磁盘路径。 + +## 十、skills、知识库、附件是怎么和沙盒结合的 + +skills 的结合方式分成两层。第一层是提示词层,`SkillsMiddleware` 会把当前线程配置的 skill 列表和依赖闭包注入到系统提示里,让模型知道哪些 skill 存在、它们的入口文件一般在 `/home/gem/skills//SKILL.md`。第二层是文件系统层,运行时会调用 `sync_thread_visible_skills`,把当前线程真正可见的 skill 目录复制到线程自己的 `saves/threads//skills` 下,再由沙盒只读挂载到 `/home/gem/skills`。也就是说,skill 既是 prompt 中的能力说明,也是文件系统中的只读知识目录。 + +附件的结合方式更偏向“先落盘,再把路径告诉模型”。用户上传文件后,系统会先把原始文件写入 `saves/threads//user-data/uploads`。如果该文件可以被解析,系统还会额外生成一个 Markdown 副本,写到 `saves/threads//user-data/uploads/attachments/.md`。随后,LangGraph state 中会维护一份 `uploads` 列表,`AttachmentMiddleware` 会把这些可读路径注入系统提示,告诉模型优先用 `read_file` 去读取这些路径。因此,附件并不是“作为消息大段内联塞给模型”,而是被转换成沙盒文件系统中的路径对象。 + +知识库则是另一种只读投影。它不会被复制到每个线程目录,而是按当前运行上下文动态生成 `/home/gem/kbs` 虚拟树。模型既可以通过专门的知识库工具检索,也可以在某些需要高精度定位原始内容的场景下直接遍历 `/home/gem/kbs//...`。内置 prompt 里已经明确提到,解析后的 Markdown 通常位于 `parsed` 视图下,这样模型在工具检索不足时还有一个明确的文件系统后备路径。 + +## 十一、当前推荐如何使用 Docker 沙盒 + +如果你只是正常开发、调试或单机部署,最简单也是当前默认的方式就是保留 `SANDBOX_PROVIDER=provisioner`,同时把 `SANDBOX_PROVISIONER_BACKEND` 设为 `local`。这会让整个项目继续由 Docker Compose 管理,而沙盒实例由 provisioner 动态创建。你通常不需要手工 `docker run` 沙盒镜像,也不需要在 Compose 文件里静态声明每一个沙盒容器。 + +最小必要配置通常就是下面这几项: + +```env +SANDBOX_PROVIDER=provisioner +SANDBOX_PROVISIONER_URL=http://sandbox-provisioner:8002 +SANDBOX_PROVISIONER_BACKEND=local +SANDBOX_VIRTUAL_PATH_PREFIX=/home/gem/user-data +SANDBOX_DOCKER_SANDBOX_HOST=host.docker.internal +``` + +然后用常规方式启动即可: + +```bash +docker compose up -d +curl http://localhost:8002/health +``` + +如果健康检查返回 `backend: local`,就说明 provisioner 已经处于默认的 Docker 本机后端。真正的沙盒容器不会在系统启动时立即全部出现,而是在你第一次创建线程并触发需要文件系统或命令执行的操作后才会被创建。 + +如果你运行在 Linux,而不是 Docker Desktop,那么 `host.docker.internal` 不一定总是可用。这时要把 `SANDBOX_DOCKER_SANDBOX_HOST` 改成一个从 `api` 容器可达的宿主机地址,或者改成你自己网络环境里更稳定的名字。否则 provisioner 虽然能成功起容器,但后端可能拿到一个自己无法访问的 `sandbox_url`。 + +## 十二、如何理解文件管理与暴露边界 + +从产品行为上看,viewer 文件系统和 artifact 下载接口优先走的是宿主机路径解析,而不是无条件透传到沙盒容器内部。这么设计有两个直接收益。第一,浏览 `/` 或 `/home/gem/user-data` 这样的树形入口时,不需要为了只读查看而冷启动沙盒。第二,权限边界更好做,因为 `resolve_virtual_path` 会把用户可见路径严格限制在预定义的 `user-data`、`skills`、`kbs` 命名空间内。 + +从工程上看,当前实现更像“双层文件系统”。对 Agent 执行来说,真正工作的对象是远程沙盒进程暴露的文件 API;对 viewer、附件下载和一部分 artifact 查看来说,系统会优先在宿主机侧解析虚拟路径,再用本地文件读取或只读 backend 下载内容。这也是为什么你会看到既有 `ProvisionerSandboxBackend`,又有 `viewer_filesystem_service`、`SelectedSkillsReadonlyBackend`、`KnowledgeBaseReadonlyBackend` 这样的配套实现。 + +## 十三、和旧版文档相比,今天最重要的理解方式 + +当前项目不应再按“应用直接管理一个长期存在的本地 sandbox 服务”去理解。更准确的认识应该是:Yuxi 只管理线程和上下文;provisioner 负责创建线程对应的沙盒实例;文件系统不是简单地暴露一个容器根目录,而是把可写工作区、只读 skills 和只读知识库组合成一个受控命名空间。 + +因此,当你在界面上“启用沙盒”或者在文档里“选择 K8s”时,本质上做的不是切换一段业务逻辑,而是在切换 provisioner 的底层实例承载方式。选择 `local/docker` 时,沙盒由当前部署机上的 Docker daemon 动态创建。选择 `kubernetes` 时,沙盒由目标 K8s 集群动态创建。Yuxi 自己始终只面对一个 provisioner 服务地址。 + +## 十四、排障时建议先看什么 + +如果你怀疑是 provisioner 级问题,先看 `http://localhost:8002/health`,确认 backend 类型和 idle timeout 是否符合预期。接着看 `docker logs sandbox-provisioner --tail 200`,因为这里能直接看到创建容器、复用旧实例、健康检查失败和 idle reaper 删除的日志。 + +如果你怀疑是 Docker 地址不可达,重点检查 `SANDBOX_DOCKER_SANDBOX_HOST` 和随机映射端口是否从 `api` 容器可访问。可以在 `api` 容器内直接 `curl` provisioner 返回的 `sandbox_url`。如果你怀疑是 Kubernetes 地址不可达,重点检查 `NODE_HOST` 和 NodePort 的外部连通性,因为当前实现并不是通过集群内部 Service 名称回连。 + +如果你怀疑是文件看得到但模型读不到,或者模型写了但 viewer 看不到,优先把问题拆成两层:一层是宿主机路径是否存在于 `saves/...` 下,另一层是该路径是否真的被当前线程沙盒挂载并暴露到了 `/home/gem/user-data`、`/home/gem/skills` 或 `/home/gem/kbs`。只要先分清“宿主机侧文件语义”和“沙盒侧运行时挂载语义”,定位问题会快很多。 diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index 690a5cf4..18b7769e 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -55,7 +55,7 @@ ### 修复 - 兼容旧版已安装的内置 `reporter` 技能记录:`update_builtin_skill` 现在会识别由 `system` 或 `builtin-system` 管理的历史记录,避免更新时误报“技能 `reporter` 不是内置 skill” -- 调整沙盒 user-data 目录隔离策略:`workspace` 改为全局共享目录 `saves/user-data/workspace`,`uploads/outputs` 继续保持 thread 级隔离;同时更新 thread artifact 权限校验、viewer 文件系统列举逻辑,以及对应的 router/E2E 测试 +- 调整沙盒 user-data 目录隔离策略:`workspace` 改为共享目录 `saves/threads/shared/workspace`,`uploads/outputs` 继续保持 thread 级隔离;同时更新 thread artifact 权限校验、viewer 文件系统列举逻辑,以及对应的 router/E2E 测试 - 重构聊天接口请求模型:流式与非流式聊天统一使用 `query + agent_config_id` 请求体,并移除路径中的 `agent_id`;同时修复非流式接口实际误走流式执行链路的问题,改为调用 `invoke_messages` 一次性执行,并补充对应测试 - 修复对话线程与 Agent 配置错位的问题:发送消息时将当前 `agent_config_id` 绑定到 thread 的 `extra_metadata`,线程列表接口返回该绑定值,前端切换历史 thread 时会自动恢复对应配置 - 为沙盒与 viewer 文件系统补齐知识库只读映射:新增 `/home/gem/kbs` 命名空间,按“用户可访问知识库 ∩ 当前 Agent 已启用知识库”暴露原始文件与解析后的 Markdown,并补充对应后端与 viewer 路由测试