diff --git a/examples/simple_agent_use_examples/custom_sandbox_example/README-zh.md b/examples/simple_agent_use_examples/custom_sandbox_example/README-zh.md new file mode 100644 index 0000000..48d8647 --- /dev/null +++ b/examples/simple_agent_use_examples/custom_sandbox_example/README-zh.md @@ -0,0 +1,547 @@ +# 自定义沙箱示例 + +一个完整的示例,演示沙箱系统的两层自定义能力:**沙箱运行时后端**(与 Docker / K8s / AgentRun / FC 并列)和**沙箱类型**(工具调用、钩子埋点、会话复用)。所有实现均为 fake/stub 教学用途,无需真实运行时环境。 + +## 架构概览 + +沙箱系统分为两个独立的扩展层: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 应用层(Sandbox) │ +│ 你的业务代码通过 Sandbox 对象调用工具、执行 Python/Shell 等 │ +│ │ +│ 内置类型:BaseSandbox / BrowserSandbox / FilesystemSandbox ... │ +│ 自定义:继承 Sandbox + @RegisterSandbox + SandboxProvider SPI │ +├─────────────────────────────────────────────────────────────────┤ +│ 基础设施层(BaseClientStarter + BaseClient) │ +│ 决定沙箱实例在 **哪里** 运行、**如何** 管理其生命周期 │ +│ │ +│ 内置实现:DockerClientStarter / KubernetesClientStarter / │ +│ AgentRunClientStarter / FcClientStarter │ +│ 自定义:继承 BaseClientStarter + BaseClient │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**两层的关系:** +- **基础设施层** 回答 "沙箱跑在哪" — Docker 本地运行?K8s 集群?云函数?ECS?还是你自己的编排系统? +- **应用层** 回答 "沙箱里干什么" — 跑什么工具?调用前后做什么?怎么注入配置? + +两层完全正交,你可以: +- 两层都用 fake/stub 实现(本示例的做法) +- 用内置后端(Docker、K8s 等)运行自定义沙箱 +- 用自定义运行时后端运行内置沙箱 +- 两层都自定义 + +## 你将学到 + +**基础设施层 — 自定义沙箱运行时后端:** +1. `BaseClientStarter`:配置如何连接到运行时后端 +2. `BaseClient`:实现沙箱实例的完整生命周期管理(创建、启动、停止、删除) + +**应用层 — 自定义沙箱类型:** +3. 继承 `Sandbox`,通过 `@RegisterSandbox` + SPI 注册 +4. 重写 `callTool()` 添加前后置钩子 +5. 封装 `runPython()` / `runShell()` 等便捷方法 +6. 会话复用与环境变量注入 + +## 前置条件 + +- Java 17+ +- Maven 3.6+ +- 核心模块已安装(在项目根目录执行 `mvn clean install -DskipTests`) + +## 快速开始 + +### 1) 编译 + +```bash +mvn clean compile +``` + +### 2) 运行 + +```bash +mvn exec:java -Dexec.mainClass="io.agentscope.Main" +``` + +### 3) 预期输出 + +示例作为单一的端到端流程运行: + +``` +╔══════════════════════════════════════════════════════════════╗ +║ Custom Sandbox Example — Full Lifecycle Demo ║ +╚══════════════════════════════════════════════════════════════╝ + +--- Step 1: Create CustomClientStarter --- +--- Step 3: Start SandboxService (triggers connect()) --- +[CustomClientStarter] Creating client (host=my-platform.example.com, port=443, label=demo) +[CustomClient] Connecting to platform at my-platform.example.com:443 ... +[CustomClient] Connected successfully (label=demo) + +--- Step 4: Create CustomSandbox (triggers container lifecycle) --- +[CustomClient] Checking if image/template exists: ... +[CustomClient] Creating runtime instance: ... +[CustomClient] Instance created: id=fake-xxxxxxxxxxxx, ip=127.0.0.1, ports=[8080] +[CustomClient] Starting instance: fake-xxxxxxxxxxxx + +--- Step 5: Run Python (hooks fire automatically) --- +[HOOK:before] Tool 'run_ipython_cell' called with args: {code=x = 42...} +[HOOK:after] Tool 'run_ipython_cell' completed in 0ms + Result: [fake output] Python executed: x = 42... + +--- Step 6: Run Shell Command --- +[HOOK:before] Tool 'run_shell_command' called with args: {command=echo ...} + Result: [fake output] Shell executed: echo ... + +--- Step 8: Session Reuse (same userId + sessionId) --- +--- Step 9: Environment Variable Injection --- +--- Step 10: Close sandbox (triggers cleanup lifecycle) --- + +╔══════════════════════════════════════════════════════════════╗ +║ ✅ All steps completed successfully! ║ +╚══════════════════════════════════════════════════════════════╝ +``` + +--- + +## Part 1:自定义沙箱运行时后端 + +> 与 Docker / Kubernetes / AgentRun / FC 并列的自定义实现。 +> 沙箱运行时后端可以是任何计算资源 — Docker 容器、ECS 实例、虚拟机、K8s Pod、Serverless 函数,或者你自己的编排平台。 + +本示例使用 **fake/stub** 实现(`CustomClient` + `CustomClientStarter`)来演示生命周期流程和每个方法的职责。将 TODO 桩代码替换为你平台的 API 调用即可变为真实实现。 + +### 核心接口 + +``` +BaseClientStarter BaseClient +┌───────────────────┐ ┌──────────────────────────┐ +│ + startClient() │─创建──▶ │ + connect() │ +│ + getContainerType│ │ + createContainer() │ +└───────────────────┘ │ + startContainer() │ + │ + stopContainer() │ + │ + removeContainer() │ + │ + getContainerStatus() │ + │ + imageExists() │ + │ + pullImage() │ + │ + inspectContainer() │ + │ + isConnected() │ + └──────────────────────────┘ +``` + +### 现有实现对照 + +| 实现 | ClientStarter | BaseClient | 容器类型 | 场景 | +|------|---------------|------------|---------|------| +| Docker | `DockerClientStarter` | `DockerClient` | `DOCKER` | 本地开发、单机部署 | +| Kubernetes | `KubernetesClientStarter` | `KubernetesClient` | `KUBERNETES` | K8s 集群 | +| AgentRun | `AgentRunClientStarter` | `AgentRunClient` | `AGENTRUN` | 阿里云 AgentRun | +| FC | `FcClientStarter` | `FcClient` | `FC` | 阿里云函数计算 | +| **你的实现** | `MyClientStarter` | `MyClient` | 自定义 | 你的编排平台 | + +### 第一步:实现 BaseClient + +`BaseClient` 是管理沙箱运行时实例的核心抽象。运行时实例可以是任何东西 — Docker 容器、虚拟机、K8s Pod、甚至是一台通过 SSH 连接的远程机器。 + +> **说明:** 尽管 API 中使用了 "container" 一词(如 `createContainer`、`ContainerCreateResult`),但它是一个通用抽象,并不局限于 Docker 容器。 + +框架(`SandboxService`)按照以下固定顺序调用这些方法: + +``` +connect() → imageExists() → pullImage() → createContainer() → startContainer() + → (沙箱运行,工具通过 HTTP 调用) → +stopContainer() → removeContainer() +``` + +每个方法都注释了不同平台(Docker、K8s、ECS、Serverless)的对应操作: + +```java +public class CustomClient extends BaseClient { + + @Override + public boolean connect() { + // Docker: 通过 TCP 或 Unix Socket 连接 Docker daemon + // Kubernetes: 加载 kubeconfig,连接 K8s API server + // ECS/VM: 初始化云 SDK 客户端,验证凭证 + // SSH: 建立 SSH 连接池到远程机器 + return true; + } + + @Override + public boolean imageExists(String imageName) { + // Docker: 检查本地是否有该镜像(docker images) + // Kubernetes: 通常返回 true(K8s 在创建 Pod 时自动拉取) + // ECS/VM: 检查该区域是否存在 VM 镜像/AMI/快照 + return true; + } + + @Override + public ContainerCreateResult createContainer(String containerName, String imageName, + List ports, List volumeBindings, + Map environment, Map runtimeConfig) { + // Docker: docker create + 端口映射 + 卷挂载 + // Kubernetes: 创建 Pod spec,配置容器、Service、PVC + // ECS/VM: 调用 RunInstances API,配置安全组、VPC、UserData + // Serverless: 用沙箱镜像创建/更新函数 + + String instanceId = "fake-" + UUID.randomUUID().toString().substring(0, 12); + List exposedPorts = List.of("8080"); + String instanceIp = "127.0.0.1"; + + // 框架用 ip + ports 构建沙箱 HTTP 端点: + // http://{ip}:{port}/fastapi/tools/run_ipython_cell + return new ContainerCreateResult(instanceId, exposedPorts, instanceIp); + } + + @Override + public void startContainer(String containerId) { /* 启动实例 */ } + + @Override + public void stopContainer(String containerId) { /* 停止实例 */ } + + @Override + public void removeContainer(String containerId) { /* 清理资源 */ } + + @Override + public String getContainerStatus(String containerId) { return "running"; } + + @Override + public boolean isConnected() { return true; } + + @Override + public boolean pullImage(String imageName) { return true; } + + @Override + public boolean inspectContainer(String containerIdOrName) { return true; } + + @Override + public boolean containerNameExists(String containerName) { return false; } +} +``` + +> 完整实现请查看 [`CustomClient.java`](src/main/java/io/agentscope/CustomClient.java),每个方法都有详细的 Javadoc。 + +**`ContainerCreateResult` — 框架需要你返回的信息:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `containerId` | `String` | 实例唯一标识符(容器 ID、VM 实例 ID、Pod 名称) | +| `ports` | `List` | 沙箱 HTTP API 监听的端口号 | +| `ip` | `String` | 实例可访问的 IP 地址或主机名 | + +### 第二步:实现 BaseClientStarter + +`BaseClientStarter` 是一个工厂,持有连接配置并创建 `BaseClient` 实例。在 `SandboxService.start()` 时被调用。 + +根据你的平台需要,修改配置字段: + +| 平台 | 典型配置字段 | +|------|------------| +| Docker | host, port, certPath | +| Kubernetes | kubeConfigPath, namespace | +| ECS/VM | accessKeyId, accessKeySecret, regionId, securityGroupId | +| SSH | sshHost, sshPort, sshUser, privateKeyPath | +| 自定义 API | apiEndpoint, apiToken, maxRetries | + +```java +public class CustomClientStarter extends BaseClientStarter { + + private final String host; + private final int port; + private final String label; + + private CustomClientStarter(Builder builder) { + // ContainerClientType 决定 SandboxService 的行为差异: + // DOCKER: 本地拉取镜像,使用本地卷挂载 + // KUBERNETES: 跳过镜像拉取(K8s 自行处理),使用 PVC + // AGENTRUN/FC: 跳过本地卷挂载,使用绝对路径 + // 选择最匹配你平台行为的类型。 + super(ContainerClientType.DOCKER); + this.host = builder.host; + this.port = builder.port; + this.label = builder.label; + } + + @Override + public BaseClient startClient(PortManager portManager) { + CustomClient client = new CustomClient(this); + client.connect(); + return client; + } + + public static Builder builder() { return new Builder(); } + + public static class Builder { + private String host = "localhost"; + private int port = 8080; + private String label = "custom"; + + public Builder host(String host) { this.host = host; return this; } + public Builder port(int port) { this.port = port; return this; } + public Builder label(String label) { this.label = label; return this; } + public CustomClientStarter build() { return new CustomClientStarter(this); } + } +} +``` + +> 完整实现请查看 [`CustomClientStarter.java`](src/main/java/io/agentscope/CustomClientStarter.java)。 + +> **关于 `ContainerClientType`:** 当前版本的 `ContainerClientType` 是一个枚举,只包含 `DOCKER`、`KUBERNETES`、`AGENTRUN`、`FC` 四种类型。如果你的自定义运行时在行为上类似 Docker(本地运行、支持卷挂载),可以复用 `ContainerClientType.DOCKER`;如果类似云服务(无需本地镜像拉取、不支持本地卷挂载),可以使用其他类型。如果需要增加新的枚举值,需要修改 `ContainerClientType` 源码并提交 PR。 + +### 第三步:接入使用 + +```java +// 第 1 步:创建自定义 BaseClientStarter +BaseClientStarter localStarter = CustomClientStarter.builder() + .host("my-platform.example.com") + .port(443) + .label("demo") + .build(); + +// 第 2 步:插入 ManagerConfig(替换默认的 DockerClientStarter) +ManagerConfig config = ManagerConfig.builder() + .clientStarter(localStarter) + .build(); + +// 第 3 步:启动 SandboxService — 触发 connect() +SandboxService service = new SandboxService(config); +service.start(); + +// 第 4 步:创建沙箱 — 触发: imageExists → createContainer → startContainer +try (CustomSandbox sandbox = new CustomSandbox(service, "user1", "session1")) { + sandbox.listTools(""); // 触发懒初始化 +} +``` + +> 完整可运行示例请查看 [`Main.java`](src/main/java/io/agentscope/Main.java) Part 1 部分。 + +### 完整类图 + +``` +ManagerConfig + └── clientStarter: BaseClientStarter ← 你选择或自定义的运行时后端 + ├── DockerClientStarter ← 内置:本地 Docker + ├── KubernetesClientStarter ← 内置:K8s 集群 + ├── AgentRunClientStarter ← 内置:阿里云 AgentRun + ├── FcClientStarter ← 内置:阿里云函数计算 + └── CustomClientStarter ← 示例:教学用 fake/stub 实现 + └── startClient() → CustomClient extends BaseClient + ├── connect() + ├── createContainer() → 返回 ContainerCreateResult + ├── startContainer() + ├── stopContainer() + ├── removeContainer() + └── ... +``` + +--- + +## Part 2:自定义沙箱类型 + +> 定义沙箱里 **干什么**:工具调用、钩子埋点、会话管理 + +### 第一步:定义自定义沙箱 + +继承 `Sandbox` 并添加 `@RegisterSandbox` 注解: + +```java +@RegisterSandbox( + imageName = "your-registry/your-sandbox-image:latest", + sandboxType = "custom", + securityLevel = "medium", + timeout = 60, + description = "带工具执行钩子的自定义沙箱" +) +public class CustomSandbox extends Sandbox { + + public CustomSandbox(SandboxService managerApi, String userId, String sessionId) { + super(managerApi, userId, sessionId, "custom"); + } +} +``` + +**注解参数说明:** + +| 参数 | 说明 | 默认值 | +|------|------|--------| +| `imageName` | 沙箱使用的容器镜像 | (必填) | +| `sandboxType` | 唯一的沙箱类型标识符 | `"base"` | +| `securityLevel` | 安全级别:low / medium / high | `"medium"` | +| `timeout` | 操作超时时间(秒) | `300` | +| `environment` | 环境变量(`KEY=VALUE` 格式) | `{}` | + +### 第二步:添加前后置钩子 + +重写 `callTool()` 方法,拦截每次工具调用: + +```java +@Override +public String callTool(String name, Map arguments) { + // ---- 前置钩子 ---- + long startTime = System.currentTimeMillis(); + beforeToolCall(name, arguments); + + String result; + try { + // ---- 实际执行 ---- + // 真实应用中,调用 super.callTool(name, arguments) + // 会发送 HTTP POST 到沙箱实例。 + // 本示例使用 fake 实现: + result = fakeToolExecution(name, arguments); + } catch (Exception e) { + // ---- 异常钩子 ---- + onToolError(name, arguments, e); + throw e; + } + + // ---- 后置钩子 ---- + long elapsed = System.currentTimeMillis() - startTime; + afterToolCall(name, arguments, result, elapsed); + return result; +} + +private String fakeToolExecution(String name, Map arguments) { + switch (name) { + case "run_ipython_cell": + return "[fake output] Python executed: " + arguments.get("code"); + case "run_shell_command": + return "[fake output] Shell executed: " + arguments.get("command"); + default: + return "[fake output] Unknown tool: " + name; + } +} +``` + +**钩子的典型用途:** +- 记录或审计每次工具调用(谁、调了什么、什么时候、传了什么参数) +- 在执行前校验或转换参数 +- 计算执行耗时,用于性能监控 +- 拦截执行结果做后处理 +- 实现限流、权限控制或重试逻辑 + +### 第三步:添加便捷方法 + +将 `callTool()` 封装为领域化的便捷方法: + +```java +public String runPython(String code) { + Map arguments = new HashMap<>(); + arguments.put("code", code); + return callTool("run_ipython_cell", arguments); +} + +public String runShell(String command) { + Map arguments = new HashMap<>(); + arguments.put("command", command); + return callTool("run_shell_command", arguments); +} +``` + +### 第四步:通过 SPI 注册 + +创建 `SandboxProvider` 实现类: + +```java +public class CustomSandboxProvider implements SandboxProvider { + @Override + public Collection> getSandboxClasses() { + return Collections.singletonList(CustomSandbox.class); + } +} +``` + +在 `META-INF/services/io.agentscope.runtime.sandbox.manager.registry.SandboxProvider` 中注册: + +``` +io.agentscope.CustomSandboxProvider +``` + +### 第五步:使用自定义沙箱 + +```java +// 创建 SandboxService(使用自定义后端) +BaseClientStarter clientStarter = CustomClientStarter.builder() + .host("my-platform.example.com").port(443).label("demo").build(); +ManagerConfig managerConfig = ManagerConfig.builder() + .clientStarter(clientStarter) + .build(); +SandboxService sandboxService = new SandboxService(managerConfig); +sandboxService.start(); + +// 使用自定义沙箱 +CustomSandbox sandbox = new CustomSandbox(sandboxService, "user1", "session1"); + +// 运行 Python — 钩子自动触发 +String result = sandbox.runPython("print('Hello from sandbox!')"); +System.out.println(result); + +// 运行 Shell +sandbox.runShell("echo 'Hello World'"); + +// 直接调用底层工具 +sandbox.callTool("run_ipython_cell", Map.of("code", "1 + 1")); +``` + +> 完整可运行示例请查看 [`Main.java`](src/main/java/io/agentscope/Main.java)。 + +### 会话复用 + +使用 **相同的 `userId` + `sessionId`** 创建新的沙箱对象,会复用已有的容器。Python 变量在多次调用间持久保留: + +```java +// 第一个沙箱对象创建容器 +CustomSandbox s1 = new CustomSandbox(service, "user1", "session1"); +s1.runPython("x = 42"); + +// 第二个沙箱对象复用同一个容器 — x 仍然存在 +CustomSandbox s2 = new CustomSandbox(service, "user1", "session1"); +s2.runPython("print(x)"); // 输出: 42 +``` + +> **注意:** 调用 `close()`(或退出 `try-with-resources`)会停止并销毁容器。 + +### 自定义环境变量 + +在创建容器时传入自定义环境变量: + +```java +try (CustomSandbox sandbox = new CustomSandbox( + sandboxService, "user1", "session1", + Map.of("MY_API_KEY", "secret123", "APP_ENV", "production"))) { + sandbox.runShell("echo $MY_API_KEY"); // 输出: secret123 +} +``` + +--- + +## 内置工具参考 + +`base` 沙箱镜像开箱即用地提供以下工具: + +| 工具名称 | 说明 | 参数 | +|---------|------|------| +| `run_ipython_cell` | 执行 Python 代码(IPython) | `code`:Python 代码字符串 | +| `run_shell_command` | 执行 Shell 命令 | `command`:Shell 命令字符串 | + +## 项目结构 + +``` +custom_sandbox_example/ +├── src/main/java/io/agentscope/ +│ ├── CustomClient.java # Fake/stub BaseClient — 教学用生命周期合约(Part 1) +│ ├── CustomClientStarter.java # 自定义 BaseClientStarter — 工厂 + 配置(Part 1) +│ ├── CustomSandbox.java # 带钩子的自定义沙箱(Part 2 示例) +│ ├── CustomSandboxProvider.java # SPI 注册提供者 +│ └── Main.java # 入口:完整生命周期演示 +├── src/main/resources/META-INF/services/ +│ └── io.agentscope.runtime.sandbox.manager.registry.SandboxProvider +└── pom.xml +``` + +## 相关文档 + +- [沙箱使用指南(中文)](../../../cookbook/zh/sandbox/sandbox.md) +- [沙箱使用指南(English)](../../../cookbook/en/sandbox/sandbox.md) +- [AgentScope Runtime Java 项目主页](../../../README.md) diff --git a/examples/simple_agent_use_examples/custom_sandbox_example/README.md b/examples/simple_agent_use_examples/custom_sandbox_example/README.md new file mode 100644 index 0000000..cf74b9e --- /dev/null +++ b/examples/simple_agent_use_examples/custom_sandbox_example/README.md @@ -0,0 +1,548 @@ +# Custom Sandbox Example + +A complete example demonstrating the two layers of sandbox customization: **sandbox runtime backend** (alongside Docker / K8s / AgentRun / FC) and **sandbox type** (tool invocation, hooks, session reuse). All implementations are fake/stub for teaching purposes — no real runtime environment is needed. + +## Architecture Overview + +The sandbox system has two independent extension layers: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Application Layer (Sandbox) │ +│ Your code calls tools, runs Python/Shell through Sandbox │ +│ │ +│ Built-in: BaseSandbox / BrowserSandbox / FilesystemSandbox ... │ +│ Custom: extend Sandbox + @RegisterSandbox + SandboxProvider SPI│ +├─────────────────────────────────────────────────────────────────┤ +│ Infrastructure Layer (BaseClientStarter + BaseClient)│ +│ Determines WHERE containers run and HOW their lifecycle is │ +│ managed │ +│ │ +│ Built-in: DockerClientStarter / KubernetesClientStarter / │ +│ AgentRunClientStarter / FcClientStarter │ +│ Custom: extend BaseClientStarter + BaseClient │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**How the two layers relate:** +- **Infrastructure layer** answers "where does the sandbox run" — local Docker? K8s cluster? Cloud functions? ECS? Your own orchestration system? +- **Application layer** answers "what happens inside the sandbox" — which tools to run? What to do before/after calls? How to inject config? + +The two layers are fully orthogonal. You can: +- Use fake/stub backends for both layers (this example) +- Run custom sandboxes on built-in backends (Docker, K8s, etc.) +- Run built-in sandboxes on a custom runtime backend +- Customize both layers + +## What You'll Learn + +**Infrastructure Layer — Custom Sandbox Runtime Backend:** +1. `BaseClientStarter`: Configure how to connect to the runtime backend +2. `BaseClient`: Implement the full sandbox instance lifecycle (create, start, stop, remove) + +**Application Layer — Custom Sandbox Type:** +3. Extend `Sandbox` with `@RegisterSandbox` + SPI registration +4. Override `callTool()` to add before/after hooks +5. Wrap convenience methods like `runPython()` / `runShell()` +6. Session reuse and environment variable injection + +## Prerequisites + +- Java 17+ +- Maven 3.6+ +- Core modules installed (`mvn clean install -DskipTests` from root) + +## Quick Start + +### 1) Build + +```bash +mvn clean compile +``` + +### 2) Run + +```bash +mvn exec:java -Dexec.mainClass="io.agentscope.Main" +``` + +### 3) Expected Output + +The example runs as a single end-to-end flow: + +``` +╔══════════════════════════════════════════════════════════════╗ +║ Custom Sandbox Example — Full Lifecycle Demo ║ +╚══════════════════════════════════════════════════════════════╝ + +--- Step 1: Create CustomClientStarter --- +--- Step 3: Start SandboxService (triggers connect()) --- +[CustomClientStarter] Creating client (host=my-platform.example.com, port=443, label=demo) +[CustomClient] Connecting to platform at my-platform.example.com:443 ... +[CustomClient] Connected successfully (label=demo) + +--- Step 4: Create CustomSandbox (triggers container lifecycle) --- +[CustomClient] Checking if image/template exists: ... +[CustomClient] Creating runtime instance: ... +[CustomClient] Instance created: id=fake-xxxxxxxxxxxx, ip=127.0.0.1, ports=[8080] +[CustomClient] Starting instance: fake-xxxxxxxxxxxx + +--- Step 5: Run Python (hooks fire automatically) --- +[HOOK:before] Tool 'run_ipython_cell' called with args: {code=x = 42...} +[HOOK:after] Tool 'run_ipython_cell' completed in 0ms + Result: [fake output] Python executed: x = 42... + +--- Step 6: Run Shell Command --- +[HOOK:before] Tool 'run_shell_command' called with args: {command=echo ...} + Result: [fake output] Shell executed: echo ... + +--- Step 8: Session Reuse (same userId + sessionId) --- +--- Step 9: Environment Variable Injection --- +--- Step 10: Close sandbox (triggers cleanup lifecycle) --- + +╔══════════════════════════════════════════════════════════════╗ +║ ✅ All steps completed successfully! ║ +╚══════════════════════════════════════════════════════════════╝ +``` + +--- + +## Part 1: Custom Sandbox Runtime Backend + +> A custom implementation alongside Docker / Kubernetes / AgentRun / FC. +> The sandbox runtime backend can be any compute resource — a Docker container, an ECS instance, a VM, a K8s Pod, a serverless function, or your own orchestration platform. + +This example uses a **fake/stub** implementation (`CustomClient` + `CustomClientStarter`) to demonstrate the lifecycle flow and what each method is responsible for. Replace the TODO stubs with your platform's API calls to make it real. + +### Core Interfaces + +``` +BaseClientStarter BaseClient +┌───────────────────┐ ┌──────────────────────────┐ +│ + startClient() │─creates─▶│ + connect() │ +│ + getContainerType│ │ + createContainer() │ +└───────────────────┘ │ + startContainer() │ + │ + stopContainer() │ + │ + removeContainer() │ + │ + getContainerStatus() │ + │ + imageExists() │ + │ + pullImage() │ + │ + inspectContainer() │ + │ + isConnected() │ + └──────────────────────────┘ +``` + +### Existing Implementations + +| Implementation | ClientStarter | BaseClient | Type | Use Case | +|---------------|---------------|------------|------|----------| +| Docker | `DockerClientStarter` | `DockerClient` | `DOCKER` | Local dev, single-machine | +| Kubernetes | `KubernetesClientStarter` | `KubernetesClient` | `KUBERNETES` | K8s clusters | +| AgentRun | `AgentRunClientStarter` | `AgentRunClient` | `AGENTRUN` | Alibaba Cloud AgentRun | +| FC | `FcClientStarter` | `FcClient` | `FC` | Alibaba Cloud Function Compute | +| **Yours** | `MyClientStarter` | `MyClient` | Custom | Your orchestration platform | + +### Step 1: Implement BaseClient + +`BaseClient` is the core abstraction for managing sandbox runtime instances. A runtime instance could be anything — a Docker container, a VM, a K8s Pod, or even an SSH-connected remote machine. + +> **Note:** Although the API uses the term "container" (e.g. `createContainer`, `ContainerCreateResult`), it is a general-purpose abstraction — not limited to Docker containers. + +The framework (`SandboxService`) calls these methods in a specific order: + +``` +connect() → imageExists() → pullImage() → createContainer() → startContainer() + → (sandbox runs, tools called via HTTP) → +stopContainer() → removeContainer() +``` + +Each method has comments explaining what different platforms (Docker, K8s, ECS, Serverless) would do: + +```java +public class CustomClient extends BaseClient { + + @Override + public boolean connect() { + // Docker: Connect to Docker daemon via TCP or Unix socket + // Kubernetes: Load kubeconfig, connect to K8s API server + // ECS/VM: Initialize cloud SDK client, validate credentials + // SSH: Establish SSH connection pool to remote machines + return true; + } + + @Override + public boolean imageExists(String imageName) { + // Docker: Check if image exists locally (docker images) + // Kubernetes: Usually true (K8s pulls on pod creation) + // ECS/VM: Check if VM image/AMI/snapshot exists in region + return true; + } + + @Override + public ContainerCreateResult createContainer(String containerName, String imageName, + List ports, List volumeBindings, + Map environment, Map runtimeConfig) { + // Docker: docker create with port bindings and volume mounts + // Kubernetes: Create Pod spec with containers, services, PVCs + // ECS/VM: Call RunInstances API with security groups, VPC, user-data + // Serverless: Create/update function with sandbox image + + String instanceId = "fake-" + UUID.randomUUID().toString().substring(0, 12); + List exposedPorts = List.of("8080"); + String instanceIp = "127.0.0.1"; + + // The framework uses ip + ports to build the sandbox HTTP endpoint: + // http://{ip}:{port}/fastapi/tools/run_ipython_cell + return new ContainerCreateResult(instanceId, exposedPorts, instanceIp); + } + + @Override + public void startContainer(String containerId) { /* start the instance */ } + + @Override + public void stopContainer(String containerId) { /* stop the instance */ } + + @Override + public void removeContainer(String containerId) { /* clean up resources */ } + + @Override + public String getContainerStatus(String containerId) { return "running"; } + + @Override + public boolean isConnected() { return true; } + + @Override + public boolean pullImage(String imageName) { return true; } + + @Override + public boolean inspectContainer(String containerIdOrName) { return true; } + + @Override + public boolean containerNameExists(String containerName) { return false; } +} +``` + +> See [`CustomClient.java`](src/main/java/io/agentscope/CustomClient.java) for the full implementation with detailed per-method Javadoc. + +**`ContainerCreateResult` — what the framework needs from you:** + +| Field | Type | Description | +|-------|------|-------------| +| `containerId` | `String` | Unique instance identifier (container ID, VM instance ID, Pod name) | +| `ports` | `List` | Exposed port numbers where the sandbox HTTP API listens | +| `ip` | `String` | IP/hostname where the instance is reachable | + +### Step 2: Implement BaseClientStarter + +`BaseClientStarter` is a factory that holds connection configuration and creates a `BaseClient`. It's called once during `SandboxService.start()`. + +Change the configuration fields to match your platform's needs: + +| Platform | Typical config fields | +|----------|----------------------| +| Docker | host, port, certPath | +| Kubernetes | kubeConfigPath, namespace | +| ECS/VM | accessKeyId, accessKeySecret, regionId, securityGroupId | +| SSH | sshHost, sshPort, sshUser, privateKeyPath | +| Custom API | apiEndpoint, apiToken, maxRetries | + +```java +public class CustomClientStarter extends BaseClientStarter { + + private final String host; + private final int port; + private final String label; + + private CustomClientStarter(Builder builder) { + // ContainerClientType determines SandboxService behavior: + // DOCKER: pulls images locally, uses local volume mounts + // KUBERNETES: skips image pull, uses PVCs + // AGENTRUN/FC: skips local volume mounts, uses absolute paths + // Choose the type that best matches your platform. + super(ContainerClientType.DOCKER); + this.host = builder.host; + this.port = builder.port; + this.label = builder.label; + } + + @Override + public BaseClient startClient(PortManager portManager) { + CustomClient client = new CustomClient(this); + client.connect(); + return client; + } + + public static Builder builder() { return new Builder(); } + + public static class Builder { + private String host = "localhost"; + private int port = 8080; + private String label = "custom"; + + public Builder host(String host) { this.host = host; return this; } + public Builder port(int port) { this.port = port; return this; } + public Builder label(String label) { this.label = label; return this; } + public CustomClientStarter build() { return new CustomClientStarter(this); } + } +} +``` + +> See [`CustomClientStarter.java`](src/main/java/io/agentscope/CustomClientStarter.java) for the full implementation. + +> **About `ContainerClientType`:** The current `ContainerClientType` is an enum with only `DOCKER`, `KUBERNETES`, `AGENTRUN`, `FC`. If your custom runtime behaves like Docker (local execution, supports volume mounts), reuse `ContainerClientType.DOCKER`. If it behaves like a cloud service (no local image pull, no local volume mounts), use another type. To add a new enum value, modify the `ContainerClientType` source and submit a PR. + +### Step 3: Wire It Up + +```java +// Step 1: Create your custom BaseClientStarter +BaseClientStarter localStarter = CustomClientStarter.builder() + .host("my-platform.example.com") + .port(443) + .label("demo") + .build(); + +// Step 2: Plug into ManagerConfig (replaces DockerClientStarter) +ManagerConfig config = ManagerConfig.builder() + .clientStarter(localStarter) + .build(); + +// Step 3: Start SandboxService — triggers connect() +SandboxService service = new SandboxService(config); +service.start(); + +// Step 4: Create sandbox — triggers: imageExists → createContainer → startContainer +try (CustomSandbox sandbox = new CustomSandbox(service, "user1", "session1")) { + sandbox.listTools(""); // triggers lazy initialization +} +``` + +> See [`Main.java`](src/main/java/io/agentscope/Main.java) Part 1 for the full runnable example. + +### Class Hierarchy + +``` +ManagerConfig + └── clientStarter: BaseClientStarter ← choose or customize the runtime backend + ├── DockerClientStarter ← built-in: local Docker + ├── KubernetesClientStarter ← built-in: K8s cluster + ├── AgentRunClientStarter ← built-in: Alibaba Cloud AgentRun + ├── FcClientStarter ← built-in: Alibaba Cloud Function Compute + └── CustomClientStarter ← example: fake/stub for teaching + └── startClient() → CustomClient extends BaseClient + ├── connect() + ├── createContainer() → returns ContainerCreateResult + ├── startContainer() + ├── stopContainer() + ├── removeContainer() + └── ... +``` + +--- + +## Part 2: Custom Sandbox Type + +> Define **what happens** inside the sandbox: tool invocation, hooks, session management + +### Step 1: Define Your Custom Sandbox + +Extend `Sandbox` and add the `@RegisterSandbox` annotation: + +```java +@RegisterSandbox( + imageName = "your-registry/your-sandbox-image:latest", + sandboxType = "custom", + securityLevel = "medium", + timeout = 60, + description = "Custom sandbox with tool execution hooks" +) +public class CustomSandbox extends Sandbox { + + public CustomSandbox(SandboxService managerApi, String userId, String sessionId) { + super(managerApi, userId, sessionId, "custom"); + } +} +``` + +**Annotation parameters:** + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `imageName` | Container image for the sandbox | (required) | +| `sandboxType` | Unique type identifier | `"base"` | +| `securityLevel` | Security level: low / medium / high | `"medium"` | +| `timeout` | Operation timeout in seconds | `300` | +| `environment` | Environment variables (`KEY=VALUE`) | `{}` | + +### Step 2: Add Before/After Hooks + +Override `callTool()` to intercept every tool invocation: + +```java +@Override +public String callTool(String name, Map arguments) { + // ---- Before Hook ---- + long startTime = System.currentTimeMillis(); + beforeToolCall(name, arguments); + + String result; + try { + // ---- Actual Execution ---- + // In a REAL application, call super.callTool(name, arguments) + // which sends HTTP POST to the sandbox instance. + // For this example, we use a fake implementation: + result = fakeToolExecution(name, arguments); + } catch (Exception e) { + // ---- Error Hook ---- + onToolError(name, arguments, e); + throw e; + } + + // ---- After Hook ---- + long elapsed = System.currentTimeMillis() - startTime; + afterToolCall(name, arguments, result, elapsed); + return result; +} + +private String fakeToolExecution(String name, Map arguments) { + switch (name) { + case "run_ipython_cell": + return "[fake output] Python executed: " + arguments.get("code"); + case "run_shell_command": + return "[fake output] Shell executed: " + arguments.get("command"); + default: + return "[fake output] Unknown tool: " + name; + } +} +``` + +**What you can do in hooks:** +- Log or audit every tool call (who, what, when, args) +- Validate or transform arguments before execution +- Measure execution time for performance monitoring +- Intercept results for post-processing +- Implement rate limiting, access control, or retry logic + +### Step 3: Add Convenience Methods + +Wrap `callTool()` with domain-specific methods: + +```java +public String runPython(String code) { + Map arguments = new HashMap<>(); + arguments.put("code", code); + return callTool("run_ipython_cell", arguments); +} + +public String runShell(String command) { + Map arguments = new HashMap<>(); + arguments.put("command", command); + return callTool("run_shell_command", arguments); +} +``` + +### Step 4: Register via SPI + +Create a `SandboxProvider` implementation: + +```java +public class CustomSandboxProvider implements SandboxProvider { + @Override + public Collection> getSandboxClasses() { + return Collections.singletonList(CustomSandbox.class); + } +} +``` + +Register it in `META-INF/services/io.agentscope.runtime.sandbox.manager.registry.SandboxProvider`: + +``` +io.agentscope.CustomSandboxProvider +``` + +### Step 5: Use Your Sandbox + +```java +// Create SandboxService with your custom backend +BaseClientStarter clientStarter = CustomClientStarter.builder() + .host("my-platform.example.com").port(443).label("demo").build(); +ManagerConfig managerConfig = ManagerConfig.builder() + .clientStarter(clientStarter) + .build(); +SandboxService sandboxService = new SandboxService(managerConfig); +sandboxService.start(); + +// Use the custom sandbox +CustomSandbox sandbox = new CustomSandbox(sandboxService, "user1", "session1"); + +// Run Python — hooks fire automatically +String result = sandbox.runPython("print('Hello from sandbox!')"); +System.out.println(result); + +// Run Shell +sandbox.runShell("echo 'Hello World'"); + +// Direct low-level tool call +sandbox.callTool("run_ipython_cell", Map.of("code", "1 + 1")); +``` + +> See [`Main.java`](src/main/java/io/agentscope/Main.java) for the full runnable example. + +### Session Reuse + +Creating a new sandbox object with the **same `userId` + `sessionId`** reuses the existing container. Python variables persist across calls: + +```java +// First sandbox creates the container +CustomSandbox s1 = new CustomSandbox(service, "user1", "session1"); +s1.runPython("x = 42"); + +// Second sandbox reuses the same container — x is still there +CustomSandbox s2 = new CustomSandbox(service, "user1", "session1"); +s2.runPython("print(x)"); // Output: 42 +``` + +> **Note:** Calling `close()` (or exiting `try-with-resources`) stops and removes the container. + +### Custom Environment Variables + +Pass environment variables into the container at creation time: + +```java +try (CustomSandbox sandbox = new CustomSandbox( + sandboxService, "user1", "session1", + Map.of("MY_API_KEY", "secret123", "APP_ENV", "production"))) { + sandbox.runShell("echo $MY_API_KEY"); // Output: secret123 +} +``` + +--- + +## Built-in Tool Reference + +The `base` sandbox image provides these tools out of the box: + +| Tool Name | Description | Parameters | +|-----------|-------------|------------| +| `run_ipython_cell` | Execute Python code (IPython) | `code`: Python code string | +| `run_shell_command` | Execute Shell commands | `command`: Shell command string | + +## Project Structure + +``` +custom_sandbox_example/ +├── src/main/java/io/agentscope/ +│ ├── CustomClient.java # Fake/stub BaseClient — teaches the lifecycle contract (Part 1) +│ ├── CustomClientStarter.java # Custom BaseClientStarter — factory + config (Part 1) +│ ├── CustomSandbox.java # Custom sandbox with hooks (Part 2) +│ ├── CustomSandboxProvider.java # SPI provider for registration +│ └── Main.java # Entry point: full lifecycle demo +├── src/main/resources/META-INF/services/ +│ └── io.agentscope.runtime.sandbox.manager.registry.SandboxProvider +└── pom.xml +``` + +## Related Documentation + +- [Sandbox Guide (English)](../../../cookbook/en/sandbox/sandbox.md) +- [Sandbox Guide (中文)](../../../cookbook/zh/sandbox/sandbox.md) +- [AgentScope Runtime Java (root)](../../../README.md) diff --git a/examples/simple_agent_use_examples/custom_sandbox_example/src/main/java/io/agentscope/CustomClient.java b/examples/simple_agent_use_examples/custom_sandbox_example/src/main/java/io/agentscope/CustomClient.java new file mode 100644 index 0000000..9a5d370 --- /dev/null +++ b/examples/simple_agent_use_examples/custom_sandbox_example/src/main/java/io/agentscope/CustomClient.java @@ -0,0 +1,373 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.agentscope; + +import io.agentscope.runtime.sandbox.manager.client.container.BaseClient; +import io.agentscope.runtime.sandbox.manager.client.container.ContainerCreateResult; +import io.agentscope.runtime.sandbox.manager.model.fs.VolumeBinding; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * CustomClient - A fake/stub implementation of {@link BaseClient} for teaching purposes. + * + *

What is BaseClient?

+ *

{@link BaseClient} is the core abstraction for managing "sandbox runtime instances". + * A "runtime instance" could be anything:

+ *
    + *
  • A Docker container — the most common case, managed via Docker API
  • + *
  • A Kubernetes Pod — managed via K8s API
  • + *
  • An ECS instance / VM — provisioned via cloud provider API (e.g., Alibaba Cloud ECS, AWS EC2)
  • + *
  • A serverless function — invoked via Function Compute / AWS Lambda
  • + *
  • A remote bare-metal machine — connected via SSH
  • + *
  • Your custom orchestration platform — any system that can run sandbox images
  • + *
+ * + *

SandboxService Lifecycle

+ *

The framework ({@code SandboxService}) calls these methods in a specific order:

+ *
+ *   1. connect()          — Called once during SandboxService.start()
+ *   2. imageExists()      — Check if the sandbox image/template is available
+ *   3. pullImage()        — Download the image if not available (Docker-specific, can be no-op)
+ *   4. createContainer()  — Provision a new runtime instance (container, VM, pod, etc.)
+ *   5. startContainer()   — Start the provisioned instance
+ *   6. getContainerStatus() — Poll instance health/readiness
+ *      ... (sandbox runs, tools are called via HTTP to the instance) ...
+ *   7. stopContainer()    — Stop the instance when sandbox.close() is called
+ *   8. removeContainer()  — Clean up resources (delete VM, remove container, etc.)
+ * 
+ * + *

How to adapt this to your platform

+ *

Replace the fake/TODO implementations below with your platform's API calls. + * Each method has comments explaining what different platforms would do.

+ * + * @see BaseClient + * @see CustomClientStarter + */ +public class CustomClient extends BaseClient { + + private static final Logger logger = LoggerFactory.getLogger(CustomClient.class); + + private final CustomClientStarter config; + private boolean connected = false; + + public CustomClient(CustomClientStarter config) { + this.config = config; + } + + // =========================================================================== + // 1. CONNECTION MANAGEMENT + // Called once when SandboxService.start() initializes the client. + // =========================================================================== + + /** + * Establish connection to your runtime management platform. + * + *

What different platforms would do here:

+ *
    + *
  • Docker: Connect to Docker daemon via TCP or Unix socket
  • + *
  • Kubernetes: Load kubeconfig and connect to K8s API server
  • + *
  • ECS/VM: Initialize cloud SDK client with AccessKey, validate credentials
  • + *
  • SSH-based: Establish SSH connection pool to remote machines
  • + *
+ * + * @return true if connection is successful + */ + @Override + public boolean connect() { + logger.info("[CustomClient] Connecting to platform at {}:{} ...", config.getHost(), config.getPort()); + + // TODO: Replace with your platform's connection logic, for example: + // this.ecsClient = new EcsClient(config.getAccessKeyId(), config.getAccessKeySecret()); + // this.ecsClient.describeRegions(); // validate credentials + + this.connected = true; + logger.info("[CustomClient] Connected successfully (label={})", config.getLabel()); + return true; + } + + /** + * Check if the client is still connected to the platform. + * + *

Called periodically by the framework for health checks.

+ */ + @Override + public boolean isConnected() { + // TODO: Replace with actual health check, for example: + // return ecsClient.ping(); + return connected; + } + + // =========================================================================== + // 2. IMAGE / TEMPLATE MANAGEMENT + // Called before creating a container to ensure the sandbox image is ready. + // For non-Docker platforms, "image" could mean a VM template, AMI, etc. + // =========================================================================== + + /** + * Check if the sandbox image/template exists and is available. + * + *

What different platforms would do here:

+ *
    + *
  • Docker: Check if image exists locally via {@code docker images}
  • + *
  • Kubernetes: Usually return true (K8s pulls images on pod creation)
  • + *
  • ECS/VM: Check if the VM image/AMI/snapshot exists in the region
  • + *
  • Serverless: Check if the function deployment package is ready
  • + *
+ * + * @param imageName the sandbox image identifier (Docker image tag, AMI ID, etc.) + * @return true if the image is available for use + */ + @Override + public boolean imageExists(String imageName) { + logger.info("[CustomClient] Checking if image/template exists: {}", imageName); + + // TODO: Replace with your platform's image check logic, for example: + // DescribeImagesRequest req = new DescribeImagesRequest().setImageId(imageName); + // return !ecsClient.describeImages(req).getImages().isEmpty(); + + // For this demo, we always return true (assume image is pre-deployed) + return true; + } + + /** + * Download/prepare the sandbox image if it doesn't exist locally. + * + *

What different platforms would do here:

+ *
    + *
  • Docker: Pull image from registry ({@code docker pull})
  • + *
  • Kubernetes: No-op (K8s handles image pulling during pod creation)
  • + *
  • ECS/VM: Copy image from another region, or import from OSS/S3
  • + *
  • Serverless: Deploy function code package
  • + *
+ * + * @param imageName the sandbox image identifier + * @return true if the image is now available + */ + @Override + public boolean pullImage(String imageName) { + logger.info("[CustomClient] Pulling/preparing image: {}", imageName); + + // TODO: Replace with your platform's image preparation logic + // For non-Docker platforms, this might be a no-op or trigger a different workflow + + return true; + } + + // =========================================================================== + // 3. INSTANCE LIFECYCLE MANAGEMENT + // These methods manage the full lifecycle of a sandbox runtime instance. + // The framework calls them in order: create → start → (use) → stop → remove + // =========================================================================== + + /** + * Create a new sandbox runtime instance. + * + *

This is the most important method. You need to:

+ *
    + *
  1. Provision a new runtime instance (container, VM, pod, etc.)
  2. + *
  3. Configure networking (expose ports for the sandbox HTTP API)
  4. + *
  5. Set up storage (mount volumes/disks if needed)
  6. + *
  7. Inject environment variables
  8. + *
  9. Return the instance metadata in a {@link ContainerCreateResult}
  10. + *
+ * + *

What different platforms would do here:

+ *
    + *
  • Docker: {@code docker create} with port bindings and volume mounts
  • + *
  • Kubernetes: Create a Pod spec with containers, services, and PVCs
  • + *
  • ECS/VM: Call RunInstances API with security groups, VPC, user-data script
  • + *
  • Serverless: Create/update function with the sandbox image
  • + *
+ * + *

Key return value — {@link ContainerCreateResult}:

+ *
    + *
  • {@code containerId} — Unique instance identifier (container ID, instance ID, pod name)
  • + *
  • {@code ports} — List of exposed port numbers (the sandbox HTTP API port)
  • + *
  • {@code ip} — The IP/hostname where the sandbox API is accessible
  • + *
+ *

The framework uses {@code ip + ports} to build the sandbox HTTP endpoint + * (e.g., {@code http://{ip}:{port}/fastapi/tools/run_ipython_cell}), + * so it's critical that the sandbox service is reachable at this address.

+ * + * @param containerName unique name for the instance (generated by the framework) + * @param imageName the sandbox image to use + * @param ports ports that need to be exposed (e.g., ["80/tcp"]) + * @param volumeBindings host-to-container path mappings for file sharing + * @param environment environment variables to inject into the instance + * @param runtimeConfig additional runtime configuration (platform-specific) + * @return metadata about the created instance + */ + @Override + public ContainerCreateResult createContainer(String containerName, String imageName, + List ports, + List volumeBindings, + Map environment, + Map runtimeConfig) { + logger.info("[CustomClient] Creating runtime instance:"); + logger.info(" name = {}", containerName); + logger.info(" image = {}", imageName); + logger.info(" ports = {}", ports); + logger.info(" volumes = {} binding(s)", volumeBindings != null ? volumeBindings.size() : 0); + logger.info(" env vars = {} variable(s)", environment != null ? environment.size() : 0); + + // TODO: Replace with your platform's instance creation logic, for example: + // + // --- Docker example --- + // CreateContainerResponse resp = dockerClient.createContainerCmd(imageName) + // .withName(containerName).withEnv(envList).exec(); + // String instanceId = resp.getId(); + // + // --- ECS example --- + // RunInstancesRequest req = new RunInstancesRequest() + // .setImageId(imageName) + // .setInstanceName(containerName) + // .setSecurityGroupId(config.getSecurityGroupId()) + // .setVSwitchId(config.getVSwitchId()); + // String instanceId = ecsClient.runInstances(req).getInstanceIdSets().get(0); + // + // --- Kubernetes example --- + // V1Pod pod = new V1PodBuilder().withNewMetadata().withName(containerName).endMetadata() + // .withNewSpec().addNewContainer().withImage(imageName).endContainer().endSpec().build(); + // String instanceId = k8sClient.createNamespacedPod(namespace, pod).getMetadata().getUid(); + + String instanceId = "fake-" + UUID.randomUUID().toString().substring(0, 12); + + // The port that the sandbox HTTP API listens on + // For Docker: this is the mapped host port + // For ECS/VM: this is the port on the instance's public/private IP + List exposedPorts = new ArrayList<>(); + exposedPorts.add("8080"); // fake port for demonstration + + // The IP/hostname where the instance is reachable + // For Docker: usually "127.0.0.1" (localhost) + // For ECS/VM: the instance's public IP or private IP within VPC + // For K8s: the Service ClusterIP or NodePort + String instanceIp = "127.0.0.1"; + + logger.info("[CustomClient] Instance created: id={}, ip={}, ports={}", instanceId, instanceIp, exposedPorts); + + return new ContainerCreateResult(instanceId, exposedPorts, instanceIp); + } + + /** + * Start a previously created instance. + * + *

What different platforms would do here:

+ *
    + *
  • Docker: {@code docker start }
  • + *
  • Kubernetes: Usually no-op (pods start automatically after creation)
  • + *
  • ECS/VM: Call StartInstance API, wait for "Running" status
  • + *
  • Serverless: No-op (functions start on invocation)
  • + *
+ */ + @Override + public void startContainer(String containerId) { + logger.info("[CustomClient] Starting instance: {}", containerId); + + // TODO: Replace with your platform's start logic, for example: + // ecsClient.startInstance(new StartInstanceRequest().setInstanceId(containerId)); + // waitForStatus(containerId, "Running", 60); + } + + /** + * Stop a running instance. + * + *

Called when {@code sandbox.close()} is invoked or try-with-resources exits.

+ * + *

What different platforms would do here:

+ *
    + *
  • Docker: {@code docker stop }
  • + *
  • Kubernetes: Delete the Pod (K8s doesn't have a "stop" concept)
  • + *
  • ECS/VM: Call StopInstance API
  • + *
  • Serverless: No-op (functions stop after timeout)
  • + *
+ */ + @Override + public void stopContainer(String containerId) { + logger.info("[CustomClient] Stopping instance: {}", containerId); + + // TODO: Replace with your platform's stop logic + } + + /** + * Remove/destroy an instance and clean up all associated resources. + * + *

Called after stop to fully release resources (delete VM, remove container, etc.).

+ * + *

What different platforms would do here:

+ *
    + *
  • Docker: {@code docker rm }
  • + *
  • Kubernetes: Delete the Pod + associated Services/PVCs
  • + *
  • ECS/VM: Call DeleteInstance API, release EIP, delete disks
  • + *
  • Serverless: Delete the function instance
  • + *
+ */ + @Override + public void removeContainer(String containerId) { + logger.info("[CustomClient] Removing instance: {}", containerId); + + // TODO: Replace with your platform's cleanup logic + } + + /** + * Get the current status of an instance. + * + *

Used by the framework for health monitoring and cleanup scheduling.

+ * + * @param containerId the instance identifier + * @return status string (e.g., "running", "stopped", "terminated") + */ + @Override + public String getContainerStatus(String containerId) { + // TODO: Replace with actual status query + return "running"; + } + + // =========================================================================== + // 4. INSTANCE INSPECTION + // Utility methods for checking if instances exist. + // =========================================================================== + + /** + * Check if an instance exists (regardless of its state). + * + * @param containerIdOrName instance identifier or name + * @return true if the instance exists + */ + @Override + public boolean inspectContainer(String containerIdOrName) { + // TODO: Replace with actual inspection logic + return true; + } + + /** + * Check if an instance with the given name already exists. + * + *

Used by the framework to avoid name collisions when creating new instances.

+ */ + @Override + public boolean containerNameExists(String containerName) { + // TODO: Replace with actual name lookup + return false; + } +} diff --git a/examples/simple_agent_use_examples/custom_sandbox_example/src/main/java/io/agentscope/CustomClientStarter.java b/examples/simple_agent_use_examples/custom_sandbox_example/src/main/java/io/agentscope/CustomClientStarter.java new file mode 100644 index 0000000..5c7ec41 --- /dev/null +++ b/examples/simple_agent_use_examples/custom_sandbox_example/src/main/java/io/agentscope/CustomClientStarter.java @@ -0,0 +1,125 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.agentscope; + +import io.agentscope.runtime.sandbox.manager.client.container.BaseClient; +import io.agentscope.runtime.sandbox.manager.client.container.BaseClientStarter; +import io.agentscope.runtime.sandbox.manager.model.container.ContainerClientType; +import io.agentscope.runtime.sandbox.manager.utils.PortManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * CustomClientStarter - A custom {@link BaseClientStarter} implementation that sits alongside + * {@code DockerClientStarter}, {@code KubernetesClientStarter}, {@code AgentRunClientStarter}, + * and {@code FcClientStarter} as a pluggable sandbox runtime backend. + * + *

What is BaseClientStarter?

+ *

BaseClientStarter is a factory that holds connection configuration and creates a + * {@link BaseClient} instance. It's the entry point for plugging in a custom runtime backend.

+ * + *

Existing implementations for reference

+ *
+ *   DockerClientStarter       → host, port, certPath
+ *   KubernetesClientStarter   → kubeConfigPath, namespace
+ *   AgentRunClientStarter     → accessKeyId, accessKeySecret, regionId, vpcId, cpu, memory, ...
+ *   FcClientStarter           → accessKeyId, accessKeySecret, regionId, ...
+ * 
+ * + *

Usage

+ *
+ *   // Plug your custom starter into ManagerConfig:
+ *   ManagerConfig config = ManagerConfig.builder()
+ *       .clientStarter(CustomClientStarter.builder()
+ *           .host("my-platform.example.com")
+ *           .port(443)
+ *           .label("production")
+ *           .build())
+ *       .build();
+ * 
+ * + *

How to adapt this to your platform

+ *

Change the configuration fields to match your platform's needs. For example:

+ *
    + *
  • ECS: accessKeyId, accessKeySecret, regionId, securityGroupId, vSwitchId
  • + *
  • SSH: sshHost, sshPort, sshUser, privateKeyPath
  • + *
  • Custom API: apiEndpoint, apiToken, maxRetries, timeout
  • + *
+ */ +public class CustomClientStarter extends BaseClientStarter { + + private static final Logger logger = LoggerFactory.getLogger(CustomClientStarter.class); + + // --- Configuration fields --- + // These are the parameters your platform needs to connect. + // Change them to match your platform's requirements. + private final String host; + private final int port; + private final String label; + + private CustomClientStarter(Builder builder) { + // ContainerClientType determines how SandboxService handles certain operations: + // - DOCKER: pulls images locally, uses local volume mounts + // - KUBERNETES: skips image pull (K8s handles it), uses PVCs + // - AGENTRUN/FC: skips local volume mounts, uses absolute paths + // + // Choose the type that best matches your platform's behavior. + // If your platform behaves like Docker (local execution), use DOCKER. + // If it's cloud-based (no local volumes), consider AGENTRUN or FC. + super(ContainerClientType.DOCKER); + this.host = builder.host; + this.port = builder.port; + this.label = builder.label; + } + + /** + * Create and connect the {@link BaseClient}. + * + *

This method is called by {@code SandboxService.start()} during initialization. + * It should create your client instance and establish the connection.

+ * + * @param portManager provides port allocation (used by Docker for host port mapping; may be unused by other platforms) + * @return a connected BaseClient instance + */ + @Override + public BaseClient startClient(PortManager portManager) { + logger.info("[CustomClientStarter] Creating client (host={}, port={}, label={})", + host, port, label); + CustomClient client = new CustomClient(this); + client.connect(); + logger.info("[CustomClientStarter] Client ready"); + return client; + } + + public String getHost() { return host; } + public int getPort() { return port; } + public String getLabel() { return label; } + + public static Builder builder() { return new Builder(); } + + public static class Builder { + private String host = "localhost"; + private int port = 8080; + private String label = "custom"; + + private Builder() {} + public Builder host(String host) { this.host = host; return this; } + public Builder port(int port) { this.port = port; return this; } + public Builder label(String label) { this.label = label; return this; } + public CustomClientStarter build() { return new CustomClientStarter(this); } + } +} diff --git a/examples/simple_agent_use_examples/custom_sandbox_example/src/main/java/io/agentscope/CustomSandbox.java b/examples/simple_agent_use_examples/custom_sandbox_example/src/main/java/io/agentscope/CustomSandbox.java index 985fde1..5baa509 100644 --- a/examples/simple_agent_use_examples/custom_sandbox_example/src/main/java/io/agentscope/CustomSandbox.java +++ b/examples/simple_agent_use_examples/custom_sandbox_example/src/main/java/io/agentscope/CustomSandbox.java @@ -17,22 +17,212 @@ import io.agentscope.runtime.sandbox.box.Sandbox; import io.agentscope.runtime.sandbox.manager.SandboxService; +import io.agentscope.runtime.sandbox.manager.fs.FileSystemConfig; import io.agentscope.runtime.sandbox.manager.registry.RegisterSandbox; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.util.HashMap; +import java.util.Map; +/** + * CustomSandbox - A fake/stub implementation demonstrating how to create a custom sandbox type. + * + *

What is a Sandbox?

+ *

A {@link Sandbox} is the application-layer abstraction that defines what happens + * inside a runtime instance (container, VM, pod, etc.). It's independent of the infrastructure + * layer ({@link CustomClient} / {@link CustomClientStarter}) which defines where it runs.

+ * + *

Key extension points demonstrated

+ *
    + *
  • Extend {@link Sandbox} and annotate with {@link RegisterSandbox}
  • + *
  • Override {@link #callTool} to add before/after/error hooks for observability
  • + *
  • Provide domain-specific convenience methods (e.g. {@link #runPython}, {@link #runShell})
  • + *
  • Inject custom environment variables into the sandbox instance
  • + *
+ * + *

Fake/stub approach

+ *

This example uses a fake {@link #callTool} implementation instead of calling + * {@code super.callTool()}, so it runs without any real runtime backend. + * In a real application, you would call {@code super.callTool(name, arguments)} which + * sends an HTTP request to the sandbox instance's API endpoint:

+ *
+ *   POST http://{ip}:{port}/fastapi/tools/{toolName}
+ *   Body: {"arguments": {...}}
+ * 
+ * + * @see Sandbox + * @see CustomClient + * @see CustomClientStarter + */ @RegisterSandbox( - imageName = "agentscope-registry.ap-southeast-1.cr.aliyuncs.com/agentscope/runtime-sandbox-browser:latest", + imageName = "agentscope-registry.ap-southeast-1.cr.aliyuncs.com/agentscope/runtime-sandbox-base:latest", sandboxType = "custom", securityLevel = "medium", - timeout = 30, - description = "Base Sandbox" + timeout = 60, + description = "Custom sandbox with tool execution hooks" ) public class CustomSandbox extends Sandbox { + private static final Logger logger = LoggerFactory.getLogger(CustomSandbox.class); + + /** + * Simple constructor - uses default settings. + */ + public CustomSandbox(SandboxService managerApi, String userId, String sessionId) { + super(managerApi, userId, sessionId, "custom"); + } + + /** + * Constructor with custom environment variables injected into the container. + */ public CustomSandbox( SandboxService managerApi, String userId, - String sessionId) { - super(managerApi, userId, sessionId, "custom"); + String sessionId, + Map environment) { + super(managerApi, userId, sessionId, "custom", + io.agentscope.runtime.sandbox.manager.fs.local.LocalFileSystemConfig.builder().build(), + environment); + } + + // ==================== Hook: Override callTool ==================== + + /** + * Override callTool to add before/after hooks around every tool invocation. + * + *

This is the core extension point. By overriding this method, you can:

+ *
    + *
  • Log or audit every tool call (who called what, when, with what args)
  • + *
  • Validate or transform arguments before execution
  • + *
  • Measure execution time for performance monitoring
  • + *
  • Intercept results for post-processing or error handling
  • + *
  • Implement rate limiting, access control, or retry logic
  • + *
+ * + *

In a real application: replace {@link #fakeToolExecution} with + * {@code super.callTool(name, arguments)}, which sends an HTTP POST to the + * sandbox instance created by your {@link CustomClient}.

+ */ + @Override + public String callTool(String name, Map arguments) { + // ---- Before Hook ---- + long startTime = System.currentTimeMillis(); + beforeToolCall(name, arguments); + + String result; + try { + // ---- Actual Execution ---- + // In a REAL application, you would call: + // result = super.callTool(name, arguments); + // which sends an HTTP request to the sandbox instance: + // POST http://{ip}:{port}/fastapi/tools/{name} + // Body: {"arguments": {"code": "..."}} + // + // For this teaching example, we use a fake implementation + // so the entire example runs without any real runtime backend. + result = fakeToolExecution(name, arguments); + } catch (Exception e) { + // ---- Error Hook ---- + onToolError(name, arguments, e); + throw e; + } + + // ---- After Hook ---- + long elapsed = System.currentTimeMillis() - startTime; + afterToolCall(name, arguments, result, elapsed); + + return result; + } + + /** + * Fake tool execution for demonstration purposes. + * + *

Simulates the response that a real sandbox instance would return. + * This lets you see the full hook lifecycle without needing Docker or any runtime.

+ * + *

What a real sandbox does:

+ *
    + *
  • {@code run_ipython_cell} — Executes Python code in an IPython kernel inside the sandbox. + * Variables persist across calls within the same session.
  • + *
  • {@code run_shell_command} — Executes a shell command (bash) inside the sandbox.
  • + *
+ * + * @param name the tool name + * @param arguments the tool arguments + * @return simulated tool output + */ + private String fakeToolExecution(String name, Map arguments) { + switch (name) { + case "run_ipython_cell": + String code = String.valueOf(arguments.getOrDefault("code", "")); + return "[fake output] Python executed: " + code; + case "run_shell_command": + String command = String.valueOf(arguments.getOrDefault("command", "")); + return "[fake output] Shell executed: " + command; + default: + return "[fake output] Unknown tool '" + name + "' called with: " + arguments; + } + } + + /** + * Hook: called BEFORE every tool execution. + * Override this in further subclasses for custom pre-processing. + */ + protected void beforeToolCall(String toolName, Map arguments) { + logger.info("[HOOK:before] Tool '{}' called with args: {}", toolName, arguments); + } + + /** + * Hook: called AFTER every successful tool execution. + * Override this in further subclasses for custom post-processing. + */ + protected void afterToolCall(String toolName, Map arguments, + String result, long elapsedMs) { + // Truncate long results for cleaner logging + String preview = result != null && result.length() > 200 + ? result.substring(0, 200) + "..." + : result; + logger.info("[HOOK:after] Tool '{}' completed in {}ms, result: {}", toolName, elapsedMs, preview); + } + + /** + * Hook: called when a tool execution throws an exception. + * Override this in further subclasses for custom error handling. + */ + protected void onToolError(String toolName, Map arguments, Exception e) { + logger.error("[HOOK:error] Tool '{}' failed: {}", toolName, e.getMessage()); + } + + // ==================== Convenience Methods ==================== + + /** + * Execute Python code in the sandbox via IPython. + * + *

In a real sandbox, the container runs an IPython kernel. Variables persist + * across multiple calls within the same session, enabling stateful computation.

+ * + *

This convenience method wraps {@link #callTool} with the correct tool name + * and argument format — a pattern you can follow for your own domain-specific tools.

+ * + * @param code Python code to execute + * @return execution output or error message + */ + public String runPython(String code) { + Map arguments = new HashMap<>(); + arguments.put("code", code); + return callTool("run_ipython_cell", arguments); + } + + /** + * Execute a shell command in the sandbox. + * + * @param command shell command to execute + * @return command output + */ + public String runShell(String command) { + Map arguments = new HashMap<>(); + arguments.put("command", command); + return callTool("run_shell_command", arguments); } } diff --git a/examples/simple_agent_use_examples/custom_sandbox_example/src/main/java/io/agentscope/Main.java b/examples/simple_agent_use_examples/custom_sandbox_example/src/main/java/io/agentscope/Main.java index d7f841f..dff02ac 100644 --- a/examples/simple_agent_use_examples/custom_sandbox_example/src/main/java/io/agentscope/Main.java +++ b/examples/simple_agent_use_examples/custom_sandbox_example/src/main/java/io/agentscope/Main.java @@ -16,29 +16,176 @@ package io.agentscope; -import com.google.gson.Gson; -import io.agentscope.runtime.sandbox.box.Sandbox; import io.agentscope.runtime.sandbox.manager.ManagerConfig; import io.agentscope.runtime.sandbox.manager.SandboxService; import io.agentscope.runtime.sandbox.manager.client.container.BaseClientStarter; -import io.agentscope.runtime.sandbox.manager.client.container.docker.DockerClientStarter; +import java.util.Map; + +/** + * Custom Sandbox Example — Full end-to-end demonstration with NO Docker dependency. + * + *

This example demonstrates two layers of sandbox customization using only fake/stub + * implementations.

+ * + *

Infrastructure Layer (WHERE the sandbox runs)

+ *

{@link CustomClientStarter} + {@link CustomClient} — A fake runtime backend + * that demonstrates the container lifecycle: connect → imageExists → createContainer + * → startContainer → ... → stopContainer → removeContainer

+ * + *

Application Layer (WHAT the sandbox does)

+ *

{@link CustomSandbox} — A custom sandbox with callTool hooks (before/after/error) + * and fake tool execution, demonstrating runPython(), runShell(), and convenience methods.

+ * + *

What you'll see in the output

+ *
+ *   Step 1-3: Infrastructure setup (connect to platform)
+ *   Step 4:   Sandbox creation triggers container lifecycle
+ *   Step 5-7: Tool calls with before/after hooks
+ *   Step 8:   Session reuse (same userId + sessionId → same container)
+ *   Step 9:   Environment variable injection
+ *   Step 10:  Sandbox close triggers cleanup lifecycle
+ * 
+ */ public class Main { + public static void main(String[] args) { - BaseClientStarter clientConfig = DockerClientStarter.builder().build(); - ManagerConfig managerConfig = ManagerConfig.builder() - .clientConfig(clientConfig) + + System.out.println("╔══════════════════════════════════════════════════════════════╗"); + System.out.println("║ Custom Sandbox Example — Full Lifecycle Demo ║"); + System.out.println("╚══════════════════════════════════════════════════════════════╝"); + System.out.println(); + + // ===================================================================== + // Step 1: Create a custom BaseClientStarter + // + // This is YOUR runtime backend configuration, analogous to: + // DockerClientStarter.builder().host("localhost").port(2375).build() + // KubernetesClientStarter.builder().kubeConfigPath("~/.kube/config").build() + // AgentRunClientStarter.builder().agentRunAccessKeyId("xxx").build() + // ===================================================================== + System.out.println("--- Step 1: Create CustomClientStarter ---"); + BaseClientStarter customStarter = CustomClientStarter.builder() + .host("my-platform.example.com") + .port(443) + .label("demo") .build(); - SandboxService sandboxService = new SandboxService(managerConfig); + System.out.println(" Created with host=my-platform.example.com, port=443\n"); + + // ===================================================================== + // Step 2: Plug into ManagerConfig + // + // ManagerConfig.builder().clientStarter(...) is the single integration point. + // Swap in any BaseClientStarter (Docker, K8s, your own) without changing + // any sandbox code — infrastructure and application layers are decoupled. + // ===================================================================== + System.out.println("--- Step 2: Plug into ManagerConfig ---"); + ManagerConfig config = ManagerConfig.builder() + .clientStarter(customStarter) + .build(); + System.out.println(" ManagerConfig created with custom backend\n"); + + // ===================================================================== + // Step 3: Start SandboxService + // + // This triggers: CustomClientStarter.startClient() → CustomClient.connect() + // Watch the [CustomClient] logs below. + // ===================================================================== + System.out.println("--- Step 3: Start SandboxService (triggers connect()) ---"); + SandboxService sandboxService = new SandboxService(config); sandboxService.start(); + System.out.println(); + + // ===================================================================== + // Step 4: Create a CustomSandbox + // + // This triggers the container lifecycle: + // imageExists() → createContainer() → startContainer() + // Watch the [CustomClient] logs to see each method being called. + // ===================================================================== + System.out.println("--- Step 4: Create CustomSandbox (triggers container lifecycle) ---"); + CustomSandbox sandbox = new CustomSandbox(sandboxService, "user1", "session1"); + System.out.println(" Sandbox created for userId=user1, sessionId=session1\n"); + + // ===================================================================== + // Step 5: Run Python — callTool hooks fire automatically + // + // The call flow is: + // sandbox.runPython(code) + // → callTool("run_ipython_cell", {code: "..."}) + // → [HOOK:before] logged + // → fakeToolExecution() — in real app, this would be super.callTool() + // → [HOOK:after] logged with elapsed time + // ===================================================================== + System.out.println("--- Step 5: Run Python (hooks fire automatically) ---"); + String result1 = sandbox.runPython("x = 42\nprint(f'x = {x}')"); + System.out.println(" Result: " + result1 + "\n"); + + // ===================================================================== + // Step 6: Run Shell — same hook lifecycle + // ===================================================================== + System.out.println("--- Step 6: Run Shell Command ---"); + String result2 = sandbox.runShell("echo 'Hello from custom sandbox!' && uname -a"); + System.out.println(" Result: " + result2 + "\n"); - try (Sandbox sandbox = new CustomSandbox(sandboxService, "user1", "session1")) { - Gson gson = new Gson(); - String tools = gson.toJson(sandbox.listTools("")); - System.out.println("Available tools: "); - System.out.println(tools); + // ===================================================================== + // Step 7: Direct callTool — low-level API + // + // You can also call callTool() directly for tools that don't have + // convenience methods yet. + // ===================================================================== + System.out.println("--- Step 7: Direct callTool (low-level) ---"); + String result3 = sandbox.callTool("run_ipython_cell", + Map.of("code", "import sys; print(f'Python {sys.version}')")); + System.out.println(" Result: " + result3 + "\n"); + + // ===================================================================== + // Step 8: Session Reuse + // + // Creating a new sandbox with the SAME userId + sessionId reuses the + // existing container. In a real sandbox, Python variables would persist. + // ===================================================================== + System.out.println("--- Step 8: Session Reuse (same userId + sessionId) ---"); + CustomSandbox sandbox2 = new CustomSandbox(sandboxService, "user1", "session1"); + String result4 = sandbox2.runPython("print(f'x is still {x}')"); + System.out.println(" Result: " + result4); + System.out.println(" (In a real sandbox, variables would persist across calls)\n"); + + // ===================================================================== + // Step 9: Environment Variable Injection + // + // Pass custom environment variables when creating the sandbox. + // These are injected into the container/instance at creation time. + // ===================================================================== + System.out.println("--- Step 9: Environment Variable Injection ---"); + CustomSandbox envSandbox = new CustomSandbox( + sandboxService, "user2", "session2", + Map.of("MY_APP_NAME", "CustomSandboxDemo", "MY_APP_VERSION", "1.0.0")); + String result5 = envSandbox.runShell("echo \"App: $MY_APP_NAME v$MY_APP_VERSION\""); + System.out.println(" Result: " + result5 + "\n"); + + // ===================================================================== + // Step 10: Close sandbox — triggers cleanup lifecycle + // + // sandbox.close() calls: stopContainer() → removeContainer() + // In a real application, use try-with-resources for auto cleanup: + // try (CustomSandbox s = new CustomSandbox(service, "u", "s")) { ... } + // ===================================================================== + System.out.println("--- Step 10: Close sandbox (triggers cleanup lifecycle) ---"); + try { + sandbox.close(); } catch (Exception e) { - e.printStackTrace(); + // Expected: fake backend doesn't track real containers + System.out.println(" (Cleanup attempted — in a real backend, stopContainer + removeContainer would run)"); } + + System.out.println(); + System.out.println("╔══════════════════════════════════════════════════════════════╗"); + System.out.println("║ ✅ All steps completed successfully! ║"); + System.out.println("║ ║"); + System.out.println("║ To make this real, replace the fake/stub implementations: ║"); + System.out.println("║ • CustomClient: connect to your platform's API ║"); + System.out.println("║ • CustomSandbox.callTool: call super.callTool() instead ║"); + System.out.println("╚══════════════════════════════════════════════════════════════╝"); } -} \ No newline at end of file +}