文章目录
- [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. 系统提示词
每一轮大模型思考前都会执行一次,负责刷新当前会话可用技能清单,并把技能列表自动追加到系统提示词。
执行流水线(串行阻塞步骤):
- 上下文兜底
- 多仓库技能聚合合并(同名高优先级覆盖低优先级)
- 会话级别可见性灰度过滤
- 远程市场技能资源本地落地(
staging) - 构建运行时条目:绑定懒加载资源 + 脚本执行根目录
- 装载技能目录到运行时,自动注册工具
- 拼接可用技能文本块,追加进
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 多仓库合并
执行流程:
- 遍历全量仓库列表,按仓库优先级(低→高)循环写入
Map; - 同名技能后写入直接覆盖前面记录,天然实现高优先级仓库覆盖低优先级;
RepoBound同时保存技能实例 + 来源仓库,保留归属信息;- 合并为空时,向
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 动态可见性过滤
处理逻辑:
- 执行运行时灰度 / 租户白名单 / 会话级权限控制;
- 区分两层过滤:构建期静态过滤器 + 当前请求动态过滤器;
- 过滤后无技能,则同样重置为空目录
执行技能运行时可见性过滤:
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 市场资源本地化
处理逻辑:
- 仅远程
Hub技能需要把脚本、模板缓存到本地.skills-cache; - 本地工作区技能跳过
staging; 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 与文件根目录,大模型只需要填相对路径,不需要关心物理存储位置。
SkillLoadTool 是 load_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 计算规则
Sandbox沙箱环境(容器运行):
| 技能来源 | files-root 最终值 |
|---|---|
| 本地工作区 skill(Layer3/Layer4) | /workspace/skills/<skill-name> |
| 远程市场 skill(Layer2,已缓存到本地) | /workspace/.skills-cache/<source-namespace>/<skill-name> |
Local-with-shell本机允许执行脚本:
| 技能来源 | files-root 最终值 |
|---|---|
| 本地工作区 skill | {工作区根目录}/skills/<skill-name> |
| 远程市场缓存 skill | {工作区根目录}/.skills-cache/<source-namespace>/<skill-name> |
- 无Shell模式(未注册
execute_shell_command):直接不渲染files-root变量,禁止脚本执行,保障安全。
4.3 变量注入
HarnessSkillMiddleware 处理:
- 循环遍历每一条经过过滤的技能;
- 调用
ShellPathPolicy.resolve(skillName, StageResult)自动算出当前环境对应的根路径; - 把路径存入
HarnessSkillEntry.filesRoot。
渲染系统提示词:
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();
}
}
}