Agent Scope Java 2.x 系列【31】Harness:技能生命周期之执行流程

文章目录

  • [1. 概述](#1. 概述)
  • [2. 系统提示词](#2. 系统提示词)
    • [2.1 多仓库合并](#2.1 多仓库合并)
    • [2.2 动态可见性过滤](#2.2 动态可见性过滤)
    • [2.3 市场资源本地化](#2.3 市场资源本地化)
    • [2.4 构造运行时技能条目](#2.4 构造运行时技能条目)
    • [2.5 装载目录并注册工具](#2.5 装载目录并注册工具)
    • [2.6 渲染并追加技能列表到提示词](#2.6 渲染并追加技能列表到提示词)
  • [3. 技能文件读取](#3. 技能文件读取)
    • [3.1 load_skill_through_path 工具](#3.1 load_skill_through_path 工具)
    • [3.2 四层资源寻址策略](#3.2 四层资源寻址策略)
  • [4. Shell 脚本调用](#4. Shell 脚本调用)
    • [4.1 自动生成执行路径](#4.1 自动生成执行路径)
    • [4.2 files-root 计算规则](#4.2 files-root 计算规则)
    • [4.3 变量注入](#4.3 变量注入)
    • [4.4 执行脚本工具](#4.4 执行脚本工具)

1. 概述

每次 Agent.call() 触发时,进入到运行时阶段,包括:

  • 加载技能描述到系统提示词
  • 读取技能
  • 执行脚本
  • 自学习闭环处理(默认关闭,本次不介绍)

2. 系统提示词

每一轮大模型思考前都会执行一次,负责刷新当前会话可用技能清单,并把技能列表自动追加到系统提示词。

执行流水线(串行阻塞步骤):

  1. 上下文兜底
  2. 多仓库技能聚合合并(同名高优先级覆盖低优先级)
  3. 会话级别可见性灰度过滤
  4. 远程市场技能资源本地落地(staging
  5. 构建运行时条目:绑定懒加载资源 + 脚本执行根目录
  6. 装载技能目录到运行时,自动注册工具
  7. 拼接可用技能文本块,追加进 system prompt

涉及源码:

java 复制代码
    @Override
    public Mono<String> onSystemPrompt(Agent agent, RuntimeContext ctx, String currentPrompt) {
        if (ctx == null) {
            ctx = RuntimeContext.empty();
        }

        Map<String, RepoBound> merged = mergeRepositories(ctx);
        if (merged.isEmpty()) {
            runtime.install(SkillCatalog.empty(), toolkit);
            return Mono.just(currentPrompt);
        }

        List<RepoBound> visible = applyVisibility(merged.values(), ctx);
        if (visible.isEmpty()) {
            runtime.install(SkillCatalog.empty(), toolkit);
            return Mono.just(currentPrompt);
        }

        Map<String, StageResult> staged =
                stager != null ? stager.stage(visible, sourceNamespaces) : Map.of();

        List<HarnessSkillEntry> entries = new ArrayList<>(visible.size());
        for (RepoBound bound : visible) {
            SkillResources lazy = null;
            if (bound.repo() instanceof LazyResourceCapable lrc) {
                try {
                    lazy = lrc.resourcesFor(bound.skill().getName(), ctx);
                } catch (Exception e) {
                    log.debug(
                            "resourcesFor({}) failed; continuing without lazy: {}",
                            bound.skill().getName(),
                            e.getMessage());
                }
            }
            StageResult stage = staged.getOrDefault(bound.skill().getName(), StageResult.NONE);
            String filesRoot = shellPathPolicy.resolve(bound.skill().getName(), stage);
            entries.add(new HarnessSkillEntry(bound.skill(), lazy, filesRoot));
        }

        SkillCatalog catalog = SkillCatalog.of(entries);
        runtime.install(catalog, toolkit);

        SkillFilter effective =
                builderFilter.overlay(ctx != null ? ctx.get(SkillFilter.class) : null);
        String append = runtime.renderPrompt(catalog, effective);
        if (append == null || append.isEmpty()) {
            return Mono.just(currentPrompt);
        }
        String base = currentPrompt != null ? currentPrompt : "";
        String separator = base.isEmpty() || base.endsWith("\n") ? "" : "\n";
        return Mono.just(base + separator + append);
    }

2.1 多仓库合并

执行流程:

  1. 遍历全量仓库列表,按仓库优先级(低→高)循环写入 Map
  2. 同名技能后写入直接覆盖前面记录,天然实现高优先级仓库覆盖低优先级;
  3. RepoBound 同时保存技能实例 + 来源仓库,保留归属信息;
  4. 合并为空时,向 runtime 装入空目录,清空全部可用技能。

运行上下文兜底,防止上层中间件传递空上下文,保证后续仓库读取、文件操作都有隔离环境。

java 复制代码
if (ctx == null) {
    ctx = RuntimeContext.empty();
}

按仓库优先级由低到高依次加载技能:

  • 当多个仓库存在同名技能时,后遍历仓库的记录直接覆盖前面记录,天然实现高优先级仓库覆盖低优先级仓库。
  • 同时使用 RepoBound 绑定「技能实例 + 来源仓库」,保留归属信息,供后续懒资源加载、市场缓存使用。
java 复制代码
    /**
     * 合并所有仓库中的技能数据,按照仓库组装顺序执行加载。
     * 规则:技能名称相同时,后遍历仓库的技能会覆盖前面的记录(高优先级覆盖低优先级)。
     * 同时为每一条最终保留的技能绑定来源仓库,供后续懒加载资源、市场文件缓存使用。
     *
     * @param ctx 运行上下文,用于多租户文件隔离读取
     * @return 有序Map:key为技能名称,value为绑定了技能实例与来源仓库的包装对象
     */
    private LinkedHashMap<String, RepoBound> mergeRepositories(RuntimeContext ctx) {
        // 使用LinkedHashMap保证技能插入顺序不变
        LinkedHashMap<String, RepoBound> merged = new LinkedHashMap<>();

        // 按优先级从低到高遍历整条仓库链路
        for (AgentSkillRepository repo : repositories) {
            List<AgentSkill> skills;
            try {
                // 读取当前仓库下全部技能元数据(仅加载SKILL.md配置,不读取脚本资源)
                skills = repo.getAllSkills();
            } catch (Exception e) {
                // 单个仓库读取异常仅打印警告,跳过当前仓库,不影响整体流程
                log.warn(
                        "Skill repository {} failed to load: {}",
                        repo.getClass().getSimpleName(),
                        e.getMessage());
                continue;
            }

            // 仓库返回null集合,直接跳过
            if (skills == null) {
                continue;
            }

            // 遍历本仓库所有技能
            for (AgentSkill skill : skills) {
                // 过滤空对象、技能名称为空的无效数据
                if (skill == null || skill.getName() == null) {
                    continue;
                }
                // 同名技能自动覆盖,并且绑定技能与所属仓库,保留来源信息
                merged.put(skill.getName(), new RepoBound(skill, repo));
            }
        }
        return merged;
    }

合并为空时处理:

java 复制代码
        if (merged.isEmpty()) {
            runtime.install(SkillCatalog.empty(), toolkit);
            return Mono.just(currentPrompt);
        }

2.2 动态可见性过滤

处理逻辑:

  1. 执行运行时灰度 / 租户白名单 / 会话级权限控制;
  2. 区分两层过滤:构建期静态过滤器 + 当前请求动态过滤器;
  3. 过滤后无技能,则同样重置为空目录

执行技能运行时可见性过滤:

java 复制代码
    /**
     * 执行技能运行时可见性过滤
     * 根据会话上下文对技能做灰度、租户白名单拦截,只保留当前会话允许使用的技能
     *
     * @param input 合并完成后的全量技能+仓库绑定集合
     * @param ctx   当前会话运行上下文,携带租户、会话ID等权限信息
     * @return 过滤之后保留的 RepoBound 列表
     */
    private List<RepoBound> applyVisibility(
            java.util.Collection<RepoBound> input, RuntimeContext ctx) {
        // 没有配置过滤器 或者 技能集合为空,直接原样返回副本
        if (visibilityFilter == null || input.isEmpty()) {
            return new ArrayList<>(input);
        }

        // 使用IdentityHashMap严格按对象地址绑定,避免equals相等造成匹配错乱
        Map<AgentSkill, RepoBound> bySkill = new IdentityHashMap<>();
        // 提取纯技能对象列表,传给过滤器做筛选
        List<AgentSkill> raw = new ArrayList<>(input.size());
        for (RepoBound rb : input) {
            bySkill.put(rb.skill(), rb);
            raw.add(rb.skill());
        }

        List<AgentSkill> filtered;
        try {
            // 执行会话级权限过滤逻辑
            filtered = visibilityFilter.filter(raw, ctx);
        } catch (Exception e) {
            // 过滤器执行异常时,降级为放行全部技能,保证Agent正常运行不中断
            log.warn(
                    "SkillVisibilityFilter {} failed; treating as pass-through: {}",
                    visibilityFilter.getClass().getSimpleName(),
                    e.getMessage());
            return new ArrayList<>(input);
        }

        // 过滤结果为空时,返回原始集合
        if (filtered == null) {
            return new ArrayList<>(input);
        }

        // 把过滤后的Skill对象还原回原始RepoBound(带上来源仓库信息)
        List<RepoBound> out = new ArrayList<>(filtered.size());
        for (AgentSkill s : filtered) {
            RepoBound rb = bySkill.get(s);
            if (rb != null) {
                out.add(rb);
            }
        }
        return out;
    }

为空时处理:

java 复制代码
        if (visible.isEmpty()) {
            runtime.install(SkillCatalog.empty(), toolkit);
            return Mono.just(currentPrompt);
        }

2.3 市场资源本地化

处理逻辑:

  1. 仅远程 Hub 技能需要把脚本、模板缓存到本地 .skills-cache
  2. 本地工作区技能跳过 staging
  3. StageResult 标记缓存状态,用来区分缓存目录路径。
java 复制代码
        // 将市场仓库的技能资源预缓存到本地工作区目录
        Map<String, StageResult> staged =
                stager != null ? stager.stage(visible, sourceNamespaces) : Map.of();

对符合条件的技能执行本地资源落地缓存,并返回技能名称对应缓存结果的映射:

java 复制代码
    /**
     * 对符合条件的技能执行本地资源落地缓存,并返回技能名称对应缓存结果的映射。
     * 规则:来源为本地工作区仓库 WorkspaceSkillRepository 的技能标记为 WorkspaceNative,无需缓存,文件已经在工作目录中。
     *
     * <p>每次调用都会重新生成需要保留的目录白名单;
     * .skills-cache/<source-ns>/ 下不在白名单内的旧目录会被自动清理,实现垃圾回收,避免已下线市场技能残留缓存文件。
     *
     * @param visible    经过可见性过滤后的技能+绑定仓库列表,同名技能已经在上游去重,只保留高优先级版本
     * @param sourceNs   仓库唯一标识 → 命名空间映射,用来解决不同仓库getSource()重名冲突,自动追加 _idx 后缀区分
     * @return 有序Map:key=技能名称,value=缓存状态(原生工作区/已落地缓存/无需缓存)
     */
    public Map<String, StageResult> stage(
            List<RepoBound> visible, Map<AgentSkillRepository, String> sourceNs) {
        Map<String, StageResult> roots = new HashMap<>(visible.size());
        // 没有工作区根目录(仅Classpath内置技能的特殊场景),直接跳过缓存落地
        if (workspaceRoot == null) {
            for (RepoBound bound : visible) {
                // 本地工作区技能标记为原生目录
                if (bound.repo() instanceof WorkspaceSkillRepository) {
                    roots.put(bound.skill().getName(), new StageResult.WorkspaceNative());
                } else {
                    // 非本地仓库,无法落地资源,标记为无缓存
                    roots.put(bound.skill().getName(), StageResult.NONE);
                }
            }
            return roots;
        }

        // 缓存根目录:<workspace>/.skills-cache
        Path cacheRoot = workspaceRoot.resolve(CACHE_DIR);
        // 保存本轮需要保留的缓存目录,用于后续清理垃圾目录
        Set<Path> retained = new HashSet<>();

        for (RepoBound bound : visible) {
            AgentSkill skill = bound.skill();
            String name = skill.getName();
            // 过滤无名称的无效技能
            if (name == null || name.isBlank()) {
                continue;
            }

            // 分支1:本地工作区技能,文件已就位,不需要复制到缓存目录
            if (bound.repo() instanceof WorkspaceSkillRepository) {
                roots.put(name, new StageResult.WorkspaceNative());
                continue;
            }

            // 分支2:远程市场技能,需要把资源落地到缓存文件夹
            // 优先获取预计算好的命名空间,兜底使用仓库source标识,冲突时使用全局默认命名空间
            String ns = sourceNs.get(bound.repo());
            if (ns == null || ns.isBlank()) {
                ns = bound.repo().getSource();
                if (ns == null || ns.isBlank()) {
                    ns = GLOBAL_NAMESPACE;
                }
            }

            // 拼接缓存路径:.skills-cache/{命名空间}/{技能名}
            Path stagedDir = cacheRoot.resolve(ns).resolve(name);
            try {
                // 对比文件变更,仅在资源发生变化时重新写入文件,避免重复IO
                materializeIfChanged(stagedDir, skill.getResources());
                // 加入保留白名单,防止被GC清理
                retained.add(stagedDir);
                // 标记为已缓存,并带上命名空间与技能名,用于后续拼接执行路径
                roots.put(name, new StageResult.Cached(ns, name));
            } catch (Exception e) {
                // 资源落地失败仅告警,降级为不挂载资源,保证技能元信息依然可用
                log.warn("Failed to stage skill '{}' (source-ns={}): {}", name, ns, e.getMessage());
                roots.put(name, StageResult.NONE);
            }
        }

        // 垃圾回收:删除缓存目录中不在白名单里的旧文件夹
        garbageCollectOrphans(cacheRoot, retained);
        return roots;
    }

2.4 构造运行时技能条目

懒加载资源设计:

  • 只有仓库实现 LazyResourceCapable 才会延迟加载 scripts/assets,不会在 Prompt 刷新阶段一次性读取所有文件,减少轮次 IO 压力。
  • 资源读取异常只打 debug 日志,降级为只加载元信息,不打断整轮会话。

执行根目录自适应机制,shellPathPolicy.resolve() 根据环境自动输出文件根路径:

  • 本地普通文件:工作区真实路径
  • 沙箱文件系统:容器内虚拟路径
  • 禁止 Shell 模式:空路径,阻止脚本执行

最后封装为 HarnessSkillEntry,统一交付给运行时。

java 复制代码
        // 批量构建技能运行时条目
        List<HarnessSkillEntry> entries = new ArrayList<>(visible.size());
        for (RepoBound bound : visible) {
            SkillResources lazy = null;
            // 如果当前仓库支持资源懒加载,则延迟读取脚本、模板、静态资源
            if (bound.repo() instanceof LazyResourceCapable lrc) {
                try {
                    // 按技能名称获取延迟资源加载器,不立刻把文件读入内存
                    lazy = lrc.resourcesFor(bound.skill().getName(), ctx);
                } catch (Exception e) {
                    // 资源加载失败仅打印调试日志,降级为只保留技能元信息继续运行
                    log.debug(
                            "resourcesFor({}) failed; continuing without lazy: {}",
                            bound.skill().getName(),
                            e.getMessage());
                }
            }

            // 读取当前技能的本地缓存状态:已缓存 / 无需缓存
            StageResult stage = staged.getOrDefault(bound.skill().getName(), StageResult.NONE);
            // 根据运行环境(本地/沙箱/禁用脚本)解析脚本执行的文件根目录
            String filesRoot = shellPathPolicy.resolve(bound.skill().getName(), stage);

            // 封装成运行时条目:技能元数据 + 懒加载资源 + 脚本根路径
            entries.add(new HarnessSkillEntry(bound.skill(), lazy, filesRoot));
        }

2.5 装载目录并注册工具

把所有条目打包为统一目录:

  • install 是幂等操作,自动向 Toolkit 注册 load_skill_through_path 文件加载工具;
  • 下一轮 Agent 调用时,可以通过该工具读取技能配套脚本。
java 复制代码
SkillCatalog catalog = SkillCatalog.of(entries);
runtime.install(catalog, toolkit);

2.6 渲染并追加技能列表到提示词

处理逻辑:

  • 合并两级过滤器:全局静态 Filter 、当前上下文临时 Filter
    Runtime 渲染标准化的 <available_skills> 文本片段;
    智能处理换行符,避免提示词出现多余空行;
    把技能清单拼接在原有系统提示词末尾,大模型实时感知本轮可用工具集合。
java 复制代码
SkillFilter effective =
        builderFilter.overlay(ctx != null ? ctx.get(SkillFilter.class) : null);
String append = runtime.renderPrompt(catalog, effective);

渲染 <available_skills> XML 块:

xml 复制代码
## Available Skills

<available_skills>
  <skill>
    <name>my-skill</name>
    <description>...</description>
    <source>workspace</source>
    <skill-id>my-skill_workspace</skill-id>
    <files-root>/workspace/skills/my-skill</files-root>  <!-- 只有 Shell 可用时存在 -->
  </skill>
</available_skills>

## Code Execution   <!-- 只有至少一个 Skill 可 Shell 达时出现 -->
<code_execution>
  You have access to execute_shell_command...
  Each skill includes <files-root>...
</code_execution>

3. 技能文件读取

3.1 load_skill_through_path 工具

当大模型需要调用某个技能时,会调用 load_skill_through_path 工具,这是 Agent 主动读取技能配套文件的核心工具,不仅读取 SKILL.md 元配置,还可以读取引用文档、脚本模板、资源文件。

无论技能来自内置包、远程市场、全局工作区、用户私有目录,调用格式完全一致:

java 复制代码
load_skill_through_path(skillId, 相对文件路径)

调用示例:

  • load_skill_through_path(skillId, path="SKILL.md") 返回 markdown 正文
  • load_skill_through_path(skillId, path="references/style-guide.md") 返回该 skill 目录下的任意文件

中间件在构建技能条目时预先绑定好了 skillId 与文件根目录,大模型只需要填相对路径,不需要关心物理存储位置。

SkillLoadToolload_skill_through_path 工具的完整实现类,由 SkillRuntime 持有并完成幂等注册。

源码:

java 复制代码
/**
 * 内置工具 load_skill_through_path 的实现类
 *
 * <p>非SKILL.md文件的三层查找顺序:
 * <ol>
 *   <li>内存Map:Layer1全局内置、Layer2市场、Layer3全局工作区,资源预加载常驻内存</li>
 *   <li>懒加载文件:Layer4用户私有工作区,通过 LazyResourceCapable 实时读取沙箱文件</li>
 *   <li>文件未找到:汇总所有可用文件清单返回给模型,不抛出异常打断推理</li>
 * </ol>
 *
 * <p>设计优化:
 * 工具实例持有 SkillCatalog 原子引用,每轮会话只更新目录快照,工具对象只注册一次,避免反复创建与注册。
 */
@SuppressWarnings("deprecation")
public final class SkillLoadTool implements AgentTool {

    // 对外暴露的工具名称
    public static final String TOOL_NAME = "load_skill_through_path";
    // 技能主配置文件名
    private static final String SKILL_FILE = "SKILL.md";

    private static final Logger log = LoggerFactory.getLogger(SkillLoadTool.class);

    /**
     * 原子引用:实时指向当前会话最新的技能目录快照
     * 由 SkillRuntime 注入,每一轮SystemPrompt都会更新引用指向的对象
     */
    private final AtomicReference<SkillCatalog> catalogRef;

    public SkillLoadTool(AtomicReference<SkillCatalog> catalogRef) {
        if (catalogRef == null) {
            throw new IllegalArgumentException("catalogRef must not be null");
        }
        this.catalogRef = catalogRef;
    }

    @Override
    public String getName() {
        return TOOL_NAME;
    }

    @Override
    public String getDescription() {
        return "根据技能ID与技能内部相对路径,读取技能资源文件\n\n"
                + "路径规范:\n"
                + "- path=\"SKILL.md\" 读取技能主配置文档\n"
                + "- 填写精确的文件相对路径,例如 references/guide.md、scripts/run.py\n"
                + "- 禁止使用 . / ./ 或者绝对目录路径";
    }

    /**
     * 动态生成入参JSON Schema
     * skillId 的枚举值自动取自当前会话可用技能列表,做到动态下拉候选
     */
    @Override
    public Map<String, Object> getParameters() {
        SkillCatalog snapshot = catalogRef.get();
        List<String> ids = snapshot == null ? List.of() : snapshot.ids();
        return Map.of(
                "type", "object",
                "properties",
                        Map.of(
                                "skillId",
                                        Map.of(
                                                "type", "string",
                                                "description", "技能唯一标识",
                                                "enum", ids),
                                "path",
                                        Map.of(
                                                "type",
                                                "string",
                                                "description",
                                                "技能内部文件相对路径,只能填写文件名,不能填目录或绝对路径")),
                "required", List.of("skillId", "path"));
    }

    /**
     * 工具异步执行入口
     */
    @Override
    public Mono<ToolResultBlock> callAsync(ToolCallParam param) {
        try {
            Map<String, Object> input = param.getInput();
            String skillId = stringOrNull(input.get("skillId"));
            String path = stringOrNull(input.get("path"));

            // 入参非空校验
            if (skillId == null || skillId.isEmpty()) {
                return Mono.just(ToolResultBlock.error("缺少必填参数:skillId"));
            }
            if (path == null || path.isEmpty()) {
                return Mono.just(ToolResultBlock.error("缺少必填参数:path"));
            }

            // 从当前最新目录快照中查找技能条目
            SkillCatalog catalog = catalogRef.get();
            HarnessSkillEntry entry = catalog == null ? null : catalog.get(skillId);
            if (entry == null) {
                return Mono.just(ToolResultBlock.error("找不到该技能,请核对skillId"));
            }

            // 执行文件读取逻辑
            return Mono.just(loadOne(entry, path));
        } catch (Exception e) {
            log.error("load_skill_through_path failed", e);
            return Mono.just(ToolResultBlock.error(e.getMessage()));
        }
    }

    /**
     * 核心文件读取三层路由逻辑
     */
    private ToolResultBlock loadOne(HarnessSkillEntry entry, String path) {
        AgentSkill skill = entry.skill();

        // 分支1:读取主配置文件 SKILL.md,直接拼接完整元数据+正文
        if (SKILL_FILE.equals(path)) {
            return ToolResultBlock.text(formatSkillMarkdown(entry));
        }

        // 分支2:优先读取内存预加载资源(Layer1/Layer2/Layer3)
        Map<String, String> mem = skill.getResources();
        if (mem != null && mem.containsKey(path)) {
            return ToolResultBlock.text(formatResource(entry, path, mem.get(path)));
        }

        // 分支3:内存未命中,走懒加载文件读取(仅Layer4用户私有仓库生效)
        if (entry.lazyResources() != null) {
            Optional<String> lazy = entry.lazyResources().read(path);
            if (lazy.isPresent()) {
                return ToolResultBlock.text(formatResource(entry, path, lazy.get()));
            }
        }

        // 分支4:文件不存在,返回可用文件列表,引导模型修正路径
        return ToolResultBlock.error(formatNotFound(entry, path));
    }

    // ---------------------------------------------------------------------
    // 响应文本格式化工具方法
    // ---------------------------------------------------------------------

    /**
     * 四重反引号围栏,避免文件内容内部嵌套代码块造成解析混乱
     */
    private static final String CONTENT_FENCE = "````";

    /**
     * 格式化SKILL.md完整内容,包含元信息头+原始YAML+正文
     * 保证patch修改时前后文本完全对齐,避免补丁匹配失败
     */
    private String formatSkillMarkdown(HarnessSkillEntry entry) {
        AgentSkill skill = entry.skill();
        StringBuilder sb = new StringBuilder();
        sb.append("成功加载技能:").append(skill.getSkillId()).append("\n\n");
        sb.append("技能名称:").append(skill.getName()).append("\n");
        sb.append("描述:").append(skill.getDescription()).append("\n");
        sb.append("来源仓库:").append(skill.getSource()).append("\n");
        if (entry.filesRoot() != null && !entry.filesRoot().isBlank()) {
            sb.append("脚本根目录:").append(entry.filesRoot()).append("\n");
        }

        // 还原完整带FrontMatter的原始文档
        String fullMarkdown =
                MarkdownSkillParser.generate(skill.getMetadata(), skill.getSkillContent());
        sb.append("\n文件内容(SKILL.md):\n");
        sb.append(CONTENT_FENCE).append("markdown\n");
        sb.append(fullMarkdown);
        if (!fullMarkdown.endsWith("\n")) {
            sb.append("\n");
        }
        sb.append(CONTENT_FENCE).append("\n");
        return sb.toString();
    }

    /**
     * 格式化普通资源文件(脚本、文档、模板)返回结果
     */
    private String formatResource(HarnessSkillEntry entry, String path, String content) {
        StringBuilder sb = new StringBuilder();
        sb.append("成功读取资源,所属技能:")
                .append(entry.skill().getSkillId())
                .append("\n");
        sb.append("文件路径:").append(path).append("\n");
        if (entry.filesRoot() != null && !entry.filesRoot().isBlank()) {
            sb.append("执行根目录:").append(entry.filesRoot()).append("\n");
        }
        sb.append("\n文件内容:\n");
        sb.append(CONTENT_FENCE).append("\n");
        sb.append(content);
        if (content == null || !content.endsWith("\n")) {
            sb.append("\n");
        }
        sb.append(CONTENT_FENCE).append("\n");
        return sb.toString();
    }

    /**
     * 文件未找到时,汇总内存+磁盘全部可用文件名,返回候选列表
     */
    private String formatNotFound(HarnessSkillEntry entry, String missingPath) {
        StringBuilder sb = new StringBuilder();
        sb.append("文件不存在:'")
                .append(missingPath)
                .append("',技能ID:'")
                .append(entry.skill().getSkillId())
                .append("'.\n\n");

        // 有序集合去重,优先展示SKILL.md
        LinkedHashSet<String> available = new LinkedHashSet<>();
        available.add(SKILL_FILE);

        // 加入内存中的资源列表
        Map<String, String> mem = entry.skill().getResources();
        if (mem != null) {
            available.addAll(mem.keySet());
        }

        // 加入磁盘懒加载目录下的文件列表
        SkillResources lazy = entry.lazyResources();
        if (lazy != null) {
            try {
                available.addAll(lazy.list());
            } catch (Exception e) {
                log.debug(
                        "lazyResources.list() failed for '{}': {}",
                        entry.skill().getSkillId(),
                        e.getMessage());
            }
        }

        // 打印候选清单
        sb.append("当前可用文件列表:\n");
        int i = 1;
        List<String> ordered = new ArrayList<>(available);
        for (String p : ordered) {
            sb.append(i++).append(". ").append(p).append("\n");
        }
        return sb.toString();
    }

    /**
     * 安全转换对象为字符串,null转为null值
     */
    private static String stringOrNull(Object o) {
        return o == null ? null : String.valueOf(o);
    }
}

3.2 四层资源寻址策略

具体怎么取文件,取决于 skill 来自哪里:

层级 技能来源 文件读取策略
Layer 1 项目全局技能 启动预加载至内存
Layer 2 市场远端(Git、MySQL、Nacos) 预加载常驻内存
Layer 3 全局工作区 workspace/skills 启动预加载至内存
Layer 4 用户私有 /skills SKILL.md常驻内存;其余文件按需走文件系统,自动处理用户隔离与沙箱路由

查找优先级:内存命中 → 磁盘文件读取 → 找不到则返回目录清单,避免直接报错

重点:读取文档全程只访问内存与宿主文件系统,不启动沙箱

4. Shell 脚本调用

4.1 自动生成执行路径

技能分两类(本地工作区技能 + 远程市场缓存技能),同时分三套运行环境(沙箱、本地可执行、禁用Shell),物理根目录完全不一样。如果让大模型自己拼接绝对路径,极易写错。

解决方案:在中间件构建技能条目阶段,预先计算好当前环境下该技能的根目录路径,存入字段 files-root

大模型只写固定模板命令:

复制代码
execute_shell_command("python3 {{files-root}}/scripts/foo.py")

框架自动把变量替换为真实绝对路径,Agent全程不需要关心文件来自哪里、部署在哪种环境。


4.2 files-root 计算规则

  1. Sandbox 沙箱环境(容器运行):
技能来源 files-root 最终值
本地工作区 skill(Layer3/Layer4) /workspace/skills/<skill-name>
远程市场 skill(Layer2,已缓存到本地) /workspace/.skills-cache/<source-namespace>/<skill-name>
  1. Local-with-shell 本机允许执行脚本:
技能来源 files-root 最终值
本地工作区 skill {工作区根目录}/skills/<skill-name>
远程市场缓存 skill {工作区根目录}/.skills-cache/<source-namespace>/<skill-name>
  1. 无Shell模式(未注册 execute_shell_command):直接不渲染 files-root 变量,禁止脚本执行,保障安全。

4.3 变量注入

HarnessSkillMiddleware 处理:

  1. 循环遍历每一条经过过滤的技能;
  2. 调用 ShellPathPolicy.resolve(skillName, StageResult) 自动算出当前环境对应的根路径;
  3. 把路径存入 HarnessSkillEntry.filesRoot

渲染系统提示词

  1. SkillRuntime 生成 <available_skills> 技能清单,把预计算好的 files-root 作为变量注入每一条技能描述中。

大模型输出统一模板,只拼接相对路径:

复制代码
{{files-root}}/scripts/run-checks.sh

框架自动完成变量替换,生成合法绝对路径,自动区分:

  • 私有工作区目录
  • 市场缓存目录
  • 沙箱容器路径

4.4 执行脚本工具

ShellCommandTool 提供了 execute_shell_command 工具,执行 Shell 脚本命令,和前面的技能运行链路配合,执行 {{files-root}}/scripts/xxx.py 这类脚本。自带严格安全管控、目录锁定、超时控制、多字符集输出解码,是整套技能执行链路的最后一环。

核心源码:

java 复制代码
/*
 * Copyright 2024-2026 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
 *
 *      http://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.core.tool.coding;

import io.agentscope.core.message.TextBlock;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.tool.AgentTool;
import io.agentscope.core.tool.ToolCallParam;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;

/**
 * 执行Shell命令的工具类,附带完整安全校验机制
 *
 * <p>核心能力:
 * 1. 命令白名单校验,限制可执行程序
 * 2. 执行前注入审批回调,非白名单命令需要人工放行
 * 3. 拦截多命令拼接字符(& | ;),防止命令注入攻击
 * 4. 执行超时控制,超时自动强杀进程,避免僵尸进程
 * 5. 自动区分Windows / Linux / macOS系统,适配不同shell解释器
 * 6. 自定义字符集,解决Windows中文GBK乱码问题
 * 7. 异步读取进程stdout/stderr,解决管道缓冲区满导致的进程死锁
 *
 * <p>安全说明:
 * 无参构造会放开全部命令执行权限,生产环境必须配置命令白名单或者审批回调。
 *
 * @see CommandValidator
 * @see UnixCommandValidator
 * @see WindowsCommandValidator
 */
public class ShellCommandTool implements AgentTool {

    private static final Logger logger = LoggerFactory.getLogger(ShellCommandTool.class);
    // 默认执行超时时间:300秒
    private static final int DEFAULT_TIMEOUT = 300;

    /**
     * 全局共享线程池,专门异步读取进程输出流
     * 采用缓存线程池动态扩容,线程为守护线程,不会阻止JVM正常退出
     * 解决Java Process管道阻塞死锁问题:子进程输出打满缓冲区时,后台线程持续消费流
     */
    private static final ExecutorService STREAM_READER_POOL =
            Executors.newCachedThreadPool(
                    new ThreadFactory() {
                        private final AtomicInteger counter = new AtomicInteger(0);

                        @Override
                        public Thread newThread(Runnable r) {
                            Thread t =
                                    new Thread(
                                            r,
                                            "ShellCommand-StreamReader-"
                                                    + counter.incrementAndGet());
                            // 守护线程,JVM退出时自动回收
                            t.setDaemon(true);
                            return t;
                        }
                    });

    // 命令白名单:仅允许名单内的可执行程序运行
    private final Set<String> allowedCommands;
    // 非白名单命令的人工审批回调函数
    private final Function<String, Boolean> approvalCallback;
    // 命令语法校验器,区分Unix与Windows安全规则
    private final CommandValidator commandValidator;
    // 命令执行的工作根目录,锁定脚本运行范围,实现目录沙箱隔离
    private final Path baseDir;

    /**
     * 解析控制台输出的字符集,默认UTF-8;支持运行时单次调用覆盖
     */
    private final Charset charset;

    public ShellCommandTool() {
        this(null, null, null, createDefaultValidator(), StandardCharsets.UTF_8);
    }

    public ShellCommandTool(Set<String> allowedCommands) {
        this(null, allowedCommands, null, createDefaultValidator(), StandardCharsets.UTF_8);
    }

    /**
     * 构造器:配置命令白名单 + 审批回调
     *
     * @param allowedCommands 允许运行的可执行命令集合
     * @param approvalCallback 非白名单命令的审批回调
     */
    public ShellCommandTool(
            Set<String> allowedCommands, Function<String, Boolean> approvalCallback) {
        this(
                null,
                allowedCommands,
                approvalCallback,
                createDefaultValidator(),
                StandardCharsets.UTF_8);
    }

    /**
     * 构造器:指定执行根目录 + 命令白名单 + 审批回调
     *
     * @param baseDir 执行根目录,锁定命令运行目录
     * @param allowedCommands 允许运行的可执行命令集合
     * @param approvalCallback 非白名单命令的审批回调
     */
    public ShellCommandTool(
            String baseDir,
            Set<String> allowedCommands,
            Function<String, Boolean> approvalCallback) {
        this(
                baseDir,
                allowedCommands,
                approvalCallback,
                createDefaultValidator(),
                StandardCharsets.UTF_8);
    }

    /**
     * 构造器:自定义校验规则
     *
     * @param allowedCommands 命令白名单
     * @param approvalCallback 审批回调
     * @param commandValidator 自定义命令安全校验器
     */
    public ShellCommandTool(
            Set<String> allowedCommands,
            Function<String, Boolean> approvalCallback,
            CommandValidator commandValidator) {
        this(null, allowedCommands, approvalCallback, commandValidator, StandardCharsets.UTF_8);
    }

    /**
     * 构造器:指定运行目录+校验器,字符集默认UTF-8
     *
     * @param baseDir 执行根目录
     * @param allowedCommands 命令白名单
     * @param approvalCallback 审批回调
     * @param commandValidator 命令校验器
     */
    public ShellCommandTool(
            String baseDir,
            Set<String> allowedCommands,
            Function<String, Boolean> approvalCallback,
            CommandValidator commandValidator) {
        this(baseDir, allowedCommands, approvalCallback, commandValidator, StandardCharsets.UTF_8);
    }

    /**
     * 全参数构造器(所有其他构造器最终都调用此方法)
     *
     * @param baseDir 命令执行的工作目录
     * @param allowedCommands 可执行命令白名单
     * @param approvalCallback 非白名单命令审批回调
     * @param commandValidator 命令语法校验器
     * @param charset 控制台输出解码字符集,null则使用UTF-8
     */
    public ShellCommandTool(
            String baseDir,
            Set<String> allowedCommands,
            Function<String, Boolean> approvalCallback,
            CommandValidator commandValidator,
            Charset charset) {
        // 初始化线程安全的命令白名单,并做防御性拷贝,防止外部集合被篡改
        if (allowedCommands != null && !allowedCommands.isEmpty()) {
            this.allowedCommands = ConcurrentHashMap.newKeySet(allowedCommands.size());
            this.allowedCommands.addAll(allowedCommands);
        } else {
            this.allowedCommands = ConcurrentHashMap.newKeySet();
        }
        this.approvalCallback = approvalCallback;
        this.commandValidator =
                commandValidator != null ? commandValidator : createDefaultValidator();
        // 规范化绝对路径,消除../相对跳转,防止目录穿越
        this.baseDir = baseDir != null ? Paths.get(baseDir).toAbsolutePath().normalize() : null;
        this.charset = charset != null ? charset : StandardCharsets.UTF_8;

        if (this.baseDir != null) {
            logger.info("ShellCommandTool initialized with base directory: {}", this.baseDir);
        }
        logger.debug("ShellCommandTool initialized with charset: {}", this.charset.name());
    }

    /**
     * 根据当前操作系统自动创建命令校验器
     * Windows使用CMD规则,Linux/macOS使用Unix规则
     */
    private static CommandValidator createDefaultValidator() {
        String os = System.getProperty("os.name").toLowerCase();
        if (os.contains("win")) {
            return new WindowsCommandValidator();
        } else {
            return new UnixCommandValidator();
        }
    }

    // =============================== 白名单维护方法 ===============================

    /**
     * 获取不可修改的白名单视图,仅用于读取
     */
    public Set<String> getAllowedCommands() {
        return Collections.unmodifiableSet(allowedCommands);
    }

    /**
     * 线程安全:新增一条允许执行的命令
     */
    public boolean addAllowedCommand(String command) {
        if (command == null || command.trim().isEmpty()) {
            throw new IllegalArgumentException("Command cannot be null or empty");
        }
        boolean added = allowedCommands.add(command);
        if (added) {
            logger.debug("Added command to whitelist: {}", command);
        }
        return added;
    }

    /**
     * 线程安全:移除白名单内的命令
     */
    public boolean removeAllowedCommand(String command) {
        boolean removed = allowedCommands.remove(command);
        if (removed) {
            logger.debug("Removed command from whitelist: {}", command);
        }
        return removed;
    }

    /**
     * 清空全部命令白名单
     */
    public void clearAllowedCommands() {
        allowedCommands.clear();
        logger.debug("Cleared all commands from whitelist");
    }

    /**
     * 判断某个可执行程序是否在白名单内
     */
    public boolean isCommandAllowed(String command) {
        return allowedCommands.contains(command);
    }

    public Function<String, Boolean> getApprovalCallback() {
        return approvalCallback;
    }

    public CommandValidator getCommandValidator() {
        return commandValidator;
    }

    public Path getBaseDir() {
        return baseDir;
    }

    public Charset getCharset() {
        return charset;
    }

    // ========================= 实现AgentTool工具接口 =========================

    @Override
    public String getName() {
        // 对外暴露的工具名称,与模型调用对齐
        return "execute_shell_command";
    }

    @Override
    public String getDescription() {
        StringBuilder desc = new StringBuilder();
        desc.append("Execute a shell command with security validation and return the result.");

        // 追加执行目录说明
        if (baseDir != null) {
            desc.append(" WORKING DIRECTORY: The command will be executed in the directory: ");
            desc.append(baseDir.toString());
            desc.append(
                    ". All relative paths in the command will be resolved from this directory.");
        }

        // 追加白名单说明
        if (!allowedCommands.isEmpty()) {
            desc.append(" ALLOWED COMMANDS WHITELIST: [");
            String commandList =
                    new ArrayList<>(allowedCommands).stream().collect(Collectors.joining(", "));
            desc.append(commandList);
            desc.append("]. Only these commands can be executed directly.");
        } else {
            desc.append(" No whitelist configured - all commands require approval.");
        }

        desc.append(" Commands are validated against the whitelist (if configured).");
        desc.append(" Non-whitelisted commands require user approval via callback.");
        desc.append(
                " Multiple command separators (&, |, ;) are detected and blocked for security.");
        desc.append(" Returns output in format:");
        desc.append(" <returncode>code</returncode><stdout>output</stdout><stderr>error</stderr>.");
        desc.append(" If command is rejected, returncode will be -1 with SecurityError in stderr.");

        return desc.toString();
    }

    /**
     * 工具入参JSON Schema定义
     * command:必选命令字符串
     * timeout:可选超时秒数
     * charset:可选字符集,临时覆盖默认配置
     */
    @Override
    public Map<String, Object> getParameters() {
        return Map.of(
                "type", "object",
                "properties",
                        Map.of(
                                "command",
                                        Map.of(
                                                "type",
                                                "string",
                                                "description",
                                                "The shell command to execute"),
                                "timeout",
                                        Map.of(
                                                "type",
                                                "integer",
                                                "description",
                                                "The maximum time (in seconds) allowed for the"
                                                        + " command to run (default: 300)"),
                                "charset",
                                        Map.of(
                                                "type",
                                                "string",
                                                "description",
                                                "The charset used to decode command output"
                                                        + " (default: "
                                                        + charset.name()
                                                        + "). Common values: UTF-8, GBK, GB2312,"
                                                        + " ISO-8859-1, etc.")),
                "required", List.of("command"));
    }

    /**
     * 工具异步入口,解析入参并调用执行逻辑
     */
    @Override
    public Mono<ToolResultBlock> callAsync(ToolCallParam param) {
        Map<String, Object> input = param.getInput();
        String command = (String) input.get("command");
        Integer timeout =
                input.containsKey("timeout") ? ((Number) input.get("timeout")).intValue() : null;
        String charsetName = (String) input.get("charset");
        Charset overrideCharset = null;
        // 解析单次调用指定的字符集,解析失败自动降级为默认字符集
        if (charsetName != null && !charsetName.trim().isEmpty()) {
            try {
                overrideCharset = Charset.forName(charsetName.trim());
            } catch (IllegalArgumentException e) {
                logger.warn(
                        "Invalid charset name '{}', using default charset: {}",
                        charsetName,
                        charset.name());
            }
        }

        return executeShellCommand(command, timeout, overrideCharset);
    }

    // =============================== 命令执行核心逻辑 ===============================

    /**
     * 重载方法:使用全局默认字符集执行命令
     */
    public Mono<ToolResultBlock> executeShellCommand(String command, Integer timeout) {
        return executeShellCommand(command, timeout, null);
    }

    /**
     * 命令执行主入口:安全校验 → 审批 → 异步执行进程
     *
     * @param command 待执行命令
     * @param timeout 超时秒数
     * @param overrideCharset 本次调用临时字符集
     * @return 结构化命令执行结果
     */
    public Mono<ToolResultBlock> executeShellCommand(
            String command, Integer timeout, Charset overrideCharset) {

        // 优先使用单次调用字符集,否则使用实例全局字符集
        Charset effectiveCharset = overrideCharset != null ? overrideCharset : charset;
        // 兜底超时时间
        int actualTimeout = timeout != null && timeout > 0 ? timeout : DEFAULT_TIMEOUT;
        logger.debug(
                "Executing shell command: '{}' with timeout: {} seconds, charset: {}",
                command,
                actualTimeout,
                effectiveCharset.name());

        // 第一步:执行安全校验(命令拼接拦截 + 白名单检测)
        CommandValidator.ValidationResult validationResult =
                commandValidator.validate(command, allowedCommands);

        if (!validationResult.isAllowed()) {
            logger.info(
                    "Command '{}' validation failed: {}", command, validationResult.getReason());

            // 第二步:触发人工审批
            if (!requestUserApproval(command)) {
                String errorMsg =
                        approvalCallback == null
                                ? "SecurityError: "
                                        + validationResult.getReason()
                                        + " and no approval callback is configured."
                                : "SecurityError: Command execution was rejected by user. Reason: "
                                        + validationResult.getReason();
                logger.warn("Command '{}' execution rejected: {}", command, errorMsg);
                return Mono.just(formatResult(-1, "", errorMsg));
            }

            logger.info("Command '{}' approved by user, proceeding with execution", command);
        }

        // 第三步:异步执行外部进程,切换到boundedElastic调度器,不阻塞主Reactor线程
        return Mono.fromCallable(() -> executeCommand(command, actualTimeout, effectiveCharset))
                .subscribeOn(Schedulers.boundedElastic())
                // 额外预留2秒超时,防止进程等待卡死
                .timeout(Duration.ofSeconds(actualTimeout + 2))
                // 异常兜底处理:超时、IO异常统一封装为结构化错误
                .onErrorResume(
                        e -> {
                            logger.error(
                                    "Error executing command '{}': {}", command, e.getMessage(), e);
                            if (e instanceof TimeoutException) {
                                return Mono.just(
                                        formatResult(
                                                -1,
                                                "",
                                                String.format(
                                                        "TimeoutError: The command execution"
                                                                + " exceeded the timeout of %d"
                                                                + " seconds.",
                                                        actualTimeout)));
                            }
                            return Mono.just(formatResult(-1, "", "Error: " + e.getMessage()));
                        });
    }

    /**
     * 启动Process进程,捕获stdout/stderr,控制超时,解决管道死锁
     */
    private ToolResultBlock executeCommand(
            String command, int timeoutSeconds, Charset effectiveCharset) {
        ProcessBuilder processBuilder;

        // 根据操作系统自动选择解释器
        String os = System.getProperty("os.name").toLowerCase();
        if (os.contains("win")) {
            processBuilder = new ProcessBuilder("cmd.exe", "/c", command);
        } else {
            processBuilder = new ProcessBuilder("sh", "-c", command);
        }

        // 锁定命令执行目录,实现目录沙箱,禁止跨目录访问
        if (baseDir != null) {
            processBuilder.directory(baseDir.toFile());
            logger.debug("Setting working directory to: {}", baseDir);
        }

        Process process = null;
        // 异步流读取任务句柄
        Future<String> stdoutFuture = null;
        Future<String> stderrFuture = null;

        try {
            long startTime = System.currentTimeMillis();
            logger.debug("Starting command execution: {}", command);

            // 启动子进程
            process = processBuilder.start();

            // 【核心优化】异步开启两个后台线程持续读取输出流
            // 防止子进程输出填满管道缓冲区导致进程阻塞死锁
            stdoutFuture =
                    STREAM_READER_POOL.submit(
                            new StreamReader(process.getInputStream(), "stdout", effectiveCharset));
            stderrFuture =
                    STREAM_READER_POOL.submit(
                            new StreamReader(process.getErrorStream(), "stderr", effectiveCharset));

            // 带超时等待进程结束
            logger.debug("Waiting for process with timeout: {} seconds", timeoutSeconds);
            boolean completed = process.waitFor(timeoutSeconds, TimeUnit.SECONDS);
            long waitElapsed = System.currentTimeMillis() - startTime;
            logger.debug(
                    "process.waitFor returned: completed={}, elapsed={}ms", completed, waitElapsed);

            // 分支1:执行超时,强制销毁进程
            if (!completed) {
                logger.warn(
                        "Command '{}' exceeded timeout of {} seconds (actual wait: {}ms)",
                        command,
                        timeoutSeconds,
                        waitElapsed);

                process.destroyForcibly();

                // 短时间等待,尽可能拿到已输出内容
                String stdout = getOutputWithTimeout(stdoutFuture, 1, TimeUnit.SECONDS);
                String stderr = getOutputWithTimeout(stderrFuture, 1, TimeUnit.SECONDS);

                String timeoutMessage =
                        String.format(
                                "TimeoutError: The command execution exceeded the timeout of %d"
                                        + " seconds.",
                                timeoutSeconds);

                if (stderr != null && !stderr.isEmpty()) {
                    stderr = stderr + "\n" + timeoutMessage;
                } else {
                    stderr = timeoutMessage;
                }

                return formatResult(-1, stdout != null ? stdout : "", stderr);
            }

            // 分支2:进程正常退出,获取返回码与完整输出
            int returnCode = process.exitValue();
            String stdout = getOutputWithTimeout(stdoutFuture, 5, TimeUnit.SECONDS);
            String stderr = getOutputWithTimeout(stderrFuture, 5, TimeUnit.SECONDS);

            logger.debug("Command '{}' completed with return code: {}", command, returnCode);

            return formatResult(
                    returnCode, stdout != null ? stdout : "", stderr != null ? stderr : "");

        } catch (InterruptedException e) {
            // 线程被中断,强制杀掉进程
            Thread.currentThread().interrupt();
            logger.error("Command execution was interrupted: {}", command, e);
            if (process != null && process.isAlive()) {
                process.destroyForcibly();
            }
            return formatResult(-1, "", "Error: Command execution was interrupted");

        } catch (IOException e) {
            logger.error(
                    "IOException while executing command '{}': {}", command, e.getMessage(), e);
            return formatResult(-1, "", "Error: " + e.getMessage());

        } finally {
            // 兜底清理:进程仍然存活则强制销毁,防止僵尸进程
            if (process != null && process.isAlive()) {
                process.destroyForcibly();
            }
        }
    }

    /**
     * 带超时获取异步流读取结果,避免无限阻塞
     */
    private String getOutputWithTimeout(Future<String> future, long timeout, TimeUnit unit) {
        try {
            return future.get(timeout, unit);
        } catch (TimeoutException e) {
            logger.warn("Timeout waiting for stream reader to complete");
            future.cancel(true);
            return "";
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            logger.warn("Interrupted while waiting for stream reader");
            future.cancel(true);
            return "";
        } catch (ExecutionException e) {
            logger.error("Error in stream reader: {}", e.getCause().getMessage(), e.getCause());
            return "";
        }
    }

    /**
     * 把执行结果封装成固定XML标签格式,便于大模型解析
     */
    private ToolResultBlock formatResult(int returnCode, String stdout, String stderr) {
        String formattedOutput =
                String.format(
                        "<returncode>%d</returncode><stdout>%s</stdout><stderr>%s</stderr>",
                        returnCode, stdout, stderr);

        return ToolResultBlock.of(TextBlock.builder().text(formattedOutput).build());
    }

    /**
     * 调用外部审批回调,确认是否放行非白名单命令
     */
    private boolean requestUserApproval(String command) {
        if (approvalCallback == null) {
            logger.warn("No approval callback configured, rejecting command: {}", command);
            return false;
        }

        try {
            Boolean approved = approvalCallback.apply(command);
            if (approved != null && approved) {
                logger.info("User approved command execution: {}", command);
                return true;
            } else {
                logger.info("User rejected command execution: {}", command);
                return false;
            }
        } catch (Exception e) {
            logger.error(
                    "Error during approval callback for command '{}': {}",
                    command,
                    e.getMessage(),
                    e);
            return false;
        }
    }

    /**
     * 后台异步读取进程输出流任务
     * 持续消费stdout/stderr,防止管道缓冲区塞满导致子进程挂起
     */
    private static class StreamReader implements Callable<String> {
        private final InputStream inputStream;
        private final String streamType;
        private final Charset charset;

        StreamReader(InputStream inputStream, String streamType, Charset charset) {
            this.inputStream = inputStream;
            this.streamType = streamType;
            this.charset = charset;
        }

        @Override
        public String call() throws Exception {
            if (inputStream == null) {
                return "";
            }

            logger.debug("StreamReader [{}] started with charset {}", streamType, charset.name());
            StringBuilder output = new StringBuilder();
            // 使用指定字符集解码控制台输出
            try (BufferedReader reader =
                    new BufferedReader(new InputStreamReader(inputStream, charset))) {
                String line;
                while ((line = reader.readLine()) != null) {
                    if (output.length() > 0) {
                        output.append("\n");
                    }
                    output.append(line);
                }
            } catch (IOException e) {
                logger.error("Error reading {} stream: {}", streamType, e.getMessage(), e);
                throw e;
            }
            logger.debug("StreamReader [{}] completed, read {} bytes", streamType, output.length());
            return output.toString();
        }
    }
}