diff --git a/assistant-agent-autoconfigure/pom.xml b/assistant-agent-autoconfigure/pom.xml index 236b58eb..a4b817f5 100644 --- a/assistant-agent-autoconfigure/pom.xml +++ b/assistant-agent-autoconfigure/pom.xml @@ -6,7 +6,7 @@ com.alibaba.agent.assistant assistant-agent - 0.2.5 + 0.2.6 assistant-agent-autoconfigure @@ -88,4 +88,4 @@ - \ No newline at end of file + diff --git a/assistant-agent-autoconfigure/src/main/java/com/alibaba/assistant/agent/autoconfigure/tools/WriteCodeTool.java b/assistant-agent-autoconfigure/src/main/java/com/alibaba/assistant/agent/autoconfigure/tools/WriteCodeTool.java index 019a5fba..3200f752 100644 --- a/assistant-agent-autoconfigure/src/main/java/com/alibaba/assistant/agent/autoconfigure/tools/WriteCodeTool.java +++ b/assistant-agent-autoconfigure/src/main/java/com/alibaba/assistant/agent/autoconfigure/tools/WriteCodeTool.java @@ -232,12 +232,16 @@ private void saveToStore(ToolContext toolContext, GeneratedCode code) { private static final String WRITE_CODE_DESCRIPTION = """ 使用指定的名称和完整代码注册一个 Python 函数。 + ⚠️ 四个参数都是必需的,缺一不可。**尤其是 `code`**:必须在本次调用中直接传入完整函数体的 Python 源码字符串,不能只传 functionName/description/parameters 然后期望后续再补 code —— 缺少 `code` 会立即报错 "Error: code is required"。 + 必需参数: - functionName:函数名称(snake_case 格式,例如 'calculate_sum') - description:函数功能的简要描述 - parameters:函数参数名的字符串数组。只写参数名,不要包含类型或描述。无参数时传空数组 []。 正确示例:["a", "b"]、["query"]、[] - - code:完整的 Python 函数代码,包含 'def' 语句和完整实现 + - code:完整的 Python 函数代码,必须以 'def function_name(...):' 开头并包含完整函数体;不能是空字符串、不能只写签名、不能省略 + + ⚠️ 一次性编排:对于一个完整任务,应在单次 `write_code` 中写出**覆盖全流程**的函数(含所有工具调用、分支、汇总、最终回复),而不是拆成 step0/step1/... 多次 write_code + execute_code。 代码编写规范: 1. 函数结构: diff --git a/assistant-agent-common/pom.xml b/assistant-agent-common/pom.xml index df2cc5c2..d3adf286 100644 --- a/assistant-agent-common/pom.xml +++ b/assistant-agent-common/pom.xml @@ -6,7 +6,7 @@ com.alibaba.agent.assistant assistant-agent - 0.2.5 + 0.2.6 assistant-agent-common @@ -43,4 +43,4 @@ - \ No newline at end of file + diff --git a/assistant-agent-common/src/main/java/com/alibaba/assistant/agent/common/constant/CodeactStateKeys.java b/assistant-agent-common/src/main/java/com/alibaba/assistant/agent/common/constant/CodeactStateKeys.java index 0064933c..7be9f821 100644 --- a/assistant-agent-common/src/main/java/com/alibaba/assistant/agent/common/constant/CodeactStateKeys.java +++ b/assistant-agent-common/src/main/java/com/alibaba/assistant/agent/common/constant/CodeactStateKeys.java @@ -156,6 +156,14 @@ private CodeactStateKeys() { */ public static final String EXPERIENCE_ALLOWED_REACT_TOOL_NAMES = "experience_allowed_react_tool_names"; + /** + * 本 session 中已成功调用过 {@code read_exp} 的 experience id 集合; + * {@code read_exp_doc} 的 session gate 依赖该状态。 + * + *

类型:List<String> + */ + public static final String EXPERIENCE_READ_EXP_IDS = "experience_read_exp_ids"; + // ==================== Session级别代码存储 ==================== /** diff --git a/assistant-agent-core/pom.xml b/assistant-agent-core/pom.xml index 12c89a11..f650f713 100644 --- a/assistant-agent-core/pom.xml +++ b/assistant-agent-core/pom.xml @@ -6,7 +6,7 @@ com.alibaba.agent.assistant assistant-agent - 0.2.5 + 0.2.6 assistant-agent-core diff --git a/assistant-agent-core/src/main/java/com/alibaba/assistant/agent/core/executor/GraalCodeExecutor.java b/assistant-agent-core/src/main/java/com/alibaba/assistant/agent/core/executor/GraalCodeExecutor.java index c0bf5f8d..dd078583 100644 --- a/assistant-agent-core/src/main/java/com/alibaba/assistant/agent/core/executor/GraalCodeExecutor.java +++ b/assistant-agent-core/src/main/java/com/alibaba/assistant/agent/core/executor/GraalCodeExecutor.java @@ -471,15 +471,7 @@ private String toPythonLiteral(Object value) { return "None"; } if (value instanceof String) { - // 转义特殊字符并使用三引号处理多行字符串 - String str = (String) value; - if (str.contains("\n") || str.contains("\"") || str.contains("'")) { - // 使用三引号,转义其中的三引号 - String escaped = str.replace("\\", "\\\\").replace("\"\"\"", "\\\"\\\"\\\""); - return "\"\"\"" + escaped + "\"\"\""; - } else { - return "\"" + str.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; - } + return toPythonStringLiteral((String) value); } if (value instanceof Number) { return value.toString(); @@ -515,6 +507,22 @@ private String toPythonLiteral(Object value) { return convertComplexObjectToPythonLiteral(value); } + static String toPythonStringLiteral(String value) { + if (value == null) { + return "None"; + } + try { + return JSON_MAPPER.writeValueAsString(value); + } catch (JsonProcessingException e) { + logger.warn("GraalCodeExecutor#toPythonStringLiteral - reason=JSON字符串序列化失败, error={}", e.getMessage()); + return "\"" + value.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\r", "\\r") + .replace("\n", "\\n") + .replace("\t", "\\t") + "\""; + } + } + /** * 将复杂对象转换为 Python 字面量 * diff --git a/assistant-agent-core/src/test/java/com/alibaba/assistant/agent/core/executor/GraalCodeExecutorStringLiteralTest.java b/assistant-agent-core/src/test/java/com/alibaba/assistant/agent/core/executor/GraalCodeExecutorStringLiteralTest.java new file mode 100644 index 00000000..c582ea7a --- /dev/null +++ b/assistant-agent-core/src/test/java/com/alibaba/assistant/agent/core/executor/GraalCodeExecutorStringLiteralTest.java @@ -0,0 +1,23 @@ +package com.alibaba.assistant.agent.core.executor; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class GraalCodeExecutorStringLiteralTest { + + @Test + void shouldEscapeTrailingDoubleQuoteSafely() { + String literal = GraalCodeExecutor.toPythonStringLiteral( + "帮我查询我参与的2026-03-06至2026-03-13期间进行中的发布计划\""); + + assertEquals("\"帮我查询我参与的2026-03-06至2026-03-13期间进行中的发布计划\\\"\"", literal); + } + + @Test + void shouldEscapeMultilineStringsSafely() { + String literal = GraalCodeExecutor.toPythonStringLiteral("用户原始需求: 第一行\n第二行\"结尾"); + + assertEquals("\"用户原始需求: 第一行\\n第二行\\\"结尾\"", literal); + } +} diff --git a/assistant-agent-evaluation/pom.xml b/assistant-agent-evaluation/pom.xml index 69d67670..670dc282 100644 --- a/assistant-agent-evaluation/pom.xml +++ b/assistant-agent-evaluation/pom.xml @@ -6,7 +6,7 @@ com.alibaba.agent.assistant assistant-agent - 0.2.5 + 0.2.6 assistant-agent-evaluation @@ -73,4 +73,4 @@ - \ No newline at end of file + diff --git a/assistant-agent-extensions/pom.xml b/assistant-agent-extensions/pom.xml index f288a2b2..54e43525 100644 --- a/assistant-agent-extensions/pom.xml +++ b/assistant-agent-extensions/pom.xml @@ -6,7 +6,7 @@ com.alibaba.agent.assistant assistant-agent - 0.2.5 + 0.2.6 assistant-agent-extensions @@ -93,4 +93,4 @@ - \ No newline at end of file + diff --git a/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/config/ExperienceDisclosureAutoConfiguration.java b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/config/ExperienceDisclosureAutoConfiguration.java index 7a549900..63bf1c26 100644 --- a/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/config/ExperienceDisclosureAutoConfiguration.java +++ b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/config/ExperienceDisclosureAutoConfiguration.java @@ -85,8 +85,10 @@ public ExperiencePrefetchHook experiencePrefetchHook(ExperienceDisclosureService @ConditionalOnMissingBean public ExperienceRuntimeModelInterceptor experienceRuntimeModelInterceptor(ExperienceDisclosureService service, ExperienceDisclosureContextResolver contextResolver, - ExperienceToolInvocationClassifier classifier) { - return new ExperienceRuntimeModelInterceptor(service, contextResolver, classifier); + ExperienceToolInvocationClassifier classifier, + ExperienceExtensionProperties properties) { + return new ExperienceRuntimeModelInterceptor(service, contextResolver, classifier, + properties.getReadExpDocMaxPaths()); } /** diff --git a/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/config/ExperienceExtensionProperties.java b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/config/ExperienceExtensionProperties.java index b6ad79c8..9ea2a035 100644 --- a/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/config/ExperienceExtensionProperties.java +++ b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/config/ExperienceExtensionProperties.java @@ -74,6 +74,11 @@ public class ExperienceExtensionProperties { */ private int maxContentLength = 2000; + /** + * {@code read_exp_doc} 单次允许读取的路径数量上限。 + */ + private int readExpDocMaxPaths = 6; + /** * 内存实现相关配置 */ @@ -270,6 +275,14 @@ public void setMaxContentLength(int maxContentLength) { this.maxContentLength = maxContentLength; } + public int getReadExpDocMaxPaths() { + return readExpDocMaxPaths; + } + + public void setReadExpDocMaxPaths(int readExpDocMaxPaths) { + this.readExpDocMaxPaths = readExpDocMaxPaths; + } + public InMemoryConfig getInMemory() { return inMemory; } diff --git a/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/disclosure/ExperienceDisclosurePayloads.java b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/disclosure/ExperienceDisclosurePayloads.java index e76e9130..178c0241 100644 --- a/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/disclosure/ExperienceDisclosurePayloads.java +++ b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/disclosure/ExperienceDisclosurePayloads.java @@ -353,6 +353,121 @@ public void setId(String id) { } } + public static class ReferenceManifestEntry implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private String path; + private String mediaType; + private String description; + private Long size; + + public String getPath() { return path; } + public void setPath(String path) { this.path = path; } + public String getMediaType() { return mediaType; } + public void setMediaType(String mediaType) { this.mediaType = mediaType; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + public Long getSize() { return size; } + public void setSize(Long size) { this.size = size; } + } + + public static class AssetManifestEntry implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private String path; + private String mediaType; + private String role; + private String description; + private Long size; + + public String getPath() { return path; } + public void setPath(String path) { this.path = path; } + public String getMediaType() { return mediaType; } + public void setMediaType(String mediaType) { this.mediaType = mediaType; } + public String getRole() { return role; } + public void setRole(String role) { this.role = role; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + public Long getSize() { return size; } + public void setSize(Long size) { this.size = size; } + } + + public static class ReadExpDocRequest implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private String id; + private List paths = new ArrayList<>(); + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public List getPaths() { return paths; } + public void setPaths(List paths) { this.paths = paths != null ? paths : new ArrayList<>(); } + } + + public static class ReadExpDocResponse implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private String id; + private List documents = new ArrayList<>(); + /** Non-fatal per-path errors (e.g., "path is an asset"); empty on success. */ + private List errors = new ArrayList<>(); + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public List getDocuments() { return documents; } + public void setDocuments(List documents) { + this.documents = documents != null ? documents : new ArrayList<>(); + } + public List getErrors() { return errors; } + public void setErrors(List errors) { + this.errors = errors != null ? errors : new ArrayList<>(); + } + } + + public static class ReadExpDocument implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private String path; + private String mediaType; + private String content; + + public String getPath() { return path; } + public void setPath(String path) { this.path = path; } + public String getMediaType() { return mediaType; } + public void setMediaType(String mediaType) { this.mediaType = mediaType; } + public String getContent() { return content; } + public void setContent(String content) { this.content = content; } + } + + public static class ReadExpDocError implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private String path; + private String reason; + + public ReadExpDocError() {} + public ReadExpDocError(String path, String reason) { + this.path = path; + this.reason = reason; + } + public String getPath() { return path; } + public void setPath(String path) { this.path = path; } + public String getReason() { return reason; } + public void setReason(String reason) { this.reason = reason; } + } + public static class ReadExpResponse implements Serializable { @Serial @@ -371,6 +486,24 @@ public static class ReadExpResponse implements Serializable { private ToolInvocationPath toolInvocationPath; private String callableToolName; private ExperienceArtifact artifact; + private List referenceManifest = new ArrayList<>(); + private List assetManifest = new ArrayList<>(); + + public List getReferenceManifest() { + return referenceManifest; + } + + public void setReferenceManifest(List referenceManifest) { + this.referenceManifest = referenceManifest != null ? referenceManifest : new ArrayList<>(); + } + + public List getAssetManifest() { + return assetManifest; + } + + public void setAssetManifest(List assetManifest) { + this.assetManifest = assetManifest != null ? assetManifest : new ArrayList<>(); + } public boolean isFound() { return found; diff --git a/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/disclosure/ExperienceDisclosurePromptContributor.java b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/disclosure/ExperienceDisclosurePromptContributor.java index 5a40717c..c5eac2e2 100644 --- a/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/disclosure/ExperienceDisclosurePromptContributor.java +++ b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/disclosure/ExperienceDisclosurePromptContributor.java @@ -126,6 +126,9 @@ protected String buildPrompt(GroupedExperienceCandidates candidates, protected void appendGuidanceText(StringBuilder sb) { sb.append("经验分为三类:COMMON 用于解释概念/产品术语,REACT 用于提供流程与策略参考,TOOL 用于能力判断、工具选择与调用路径判断。\n"); sb.append("披露方式分为两种:`DIRECT` 表示内容已满足高置信短文本条件,可直接利用;`PROGRESSIVE` 表示当前只给候选卡,需要完整正文时调用 `read_exp`。\n"); + sb.append("渐进披露分三层:L1 候选卡(本块内容) → L2 `read_exp(id)` 返回 content + referenceManifest + assetManifest → L3 `read_exp_doc(id, paths)` 仅按需读取 referenceManifest 中的某些参考文档完整内容。\n"); + sb.append("调用 `read_exp_doc` 前必须先 `read_exp(id)`,否则会返回错误:\"请先 read_exp(),再 read_exp_doc\";单次最多读取若干个路径,超出会被截断。\n"); + sb.append("`read_exp_doc` 仅能读取 referenceManifest 列出的文档;assetManifest 列出的脚本/资源文件不可通过 `read_exp_doc` 访问 —— 它们已被预先物化到沙箱 `/workspace/` 下,仅在沙箱内通过工具执行(cat/python/bash 等)读取。\n"); sb.append("优先复用已披露的 DIRECT 内容,再从候选中选择 id;只有当前候选明显不足或缺少方向时,再调用 `search_exp`。\n"); sb.append("TOOL 候选如果标记为 `REACT_DIRECT`,表示它具备被直接作为 React function call 调用的资格;"); sb.append("但只有在该单个工具即可直接完成任务时,才优先 direct call。"); diff --git a/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/disclosure/ExperienceDisclosureService.java b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/disclosure/ExperienceDisclosureService.java index 0ecc5a10..65cedefa 100644 --- a/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/disclosure/ExperienceDisclosureService.java +++ b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/disclosure/ExperienceDisclosureService.java @@ -1,24 +1,33 @@ package com.alibaba.assistant.agent.extension.experience.disclosure; import com.alibaba.assistant.agent.extension.experience.config.ExperienceExtensionProperties; -import com.alibaba.assistant.agent.extension.experience.disclosure.ExperienceDisclosurePayloads.ExperienceCandidateCard; +import com.alibaba.assistant.agent.extension.experience.disclosure.ExperienceDisclosurePayloads.AssetManifestEntry; import com.alibaba.assistant.agent.extension.experience.disclosure.ExperienceDisclosurePayloads.DirectExperienceGrounding; +import com.alibaba.assistant.agent.extension.experience.disclosure.ExperienceDisclosurePayloads.ExperienceCandidateCard; import com.alibaba.assistant.agent.extension.experience.disclosure.ExperienceDisclosurePayloads.GroupedExperienceCandidates; import com.alibaba.assistant.agent.extension.experience.disclosure.ExperienceDisclosurePayloads.PrefetchedExperienceSnapshot; +import com.alibaba.assistant.agent.extension.experience.disclosure.ExperienceDisclosurePayloads.ReadExpDocError; +import com.alibaba.assistant.agent.extension.experience.disclosure.ExperienceDisclosurePayloads.ReadExpDocResponse; +import com.alibaba.assistant.agent.extension.experience.disclosure.ExperienceDisclosurePayloads.ReadExpDocument; import com.alibaba.assistant.agent.extension.experience.disclosure.ExperienceDisclosurePayloads.ReadExpResponse; +import com.alibaba.assistant.agent.extension.experience.disclosure.ExperienceDisclosurePayloads.ReferenceManifestEntry; import com.alibaba.assistant.agent.extension.experience.disclosure.ExperienceDisclosurePayloads.SearchExpResponse; +import com.alibaba.assistant.agent.extension.experience.model.AssetEntry; import com.alibaba.assistant.agent.extension.experience.model.DisclosureStrategy; import com.alibaba.assistant.agent.extension.experience.model.Experience; import com.alibaba.assistant.agent.extension.experience.model.ExperienceQuery; import com.alibaba.assistant.agent.extension.experience.model.ExperienceQueryContext; import com.alibaba.assistant.agent.extension.experience.model.ExperienceType; +import com.alibaba.assistant.agent.extension.experience.model.ReferenceEntry; import com.alibaba.assistant.agent.extension.experience.spi.ExperienceProvider; import com.alibaba.assistant.agent.extension.experience.spi.ExperienceRepository; import org.springframework.util.StringUtils; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; import java.util.Optional; +import java.util.Set; import static com.alibaba.assistant.agent.extension.experience.disclosure.ExperienceDisclosurePayloads.PrefetchStatus; @@ -107,6 +116,8 @@ public ReadExpResponse read(String id) { response.setAssociatedTools(new ArrayList<>(experience.getAssociatedTools())); response.setRelatedExperiences(new ArrayList<>(experience.getRelatedExperiences())); response.setArtifact(experience.getArtifact()); + response.setReferenceManifest(buildReferenceManifest(experience)); + response.setAssetManifest(buildAssetManifest(experience)); if (experience.getType() == ExperienceType.TOOL) { response.setToolInvocationPath(toolInvocationClassifier.classify(experience)); @@ -115,6 +126,114 @@ public ReadExpResponse read(String id) { return response; } + /** + * L3 渐进披露:为 {@code read_exp_doc} 工具提供 reference 内容读取。 + * + *

仅接受 {@link Experience#getReferences()} 中声明的路径;asset 路径会被拒绝, + * 提示调用方在沙箱内以 {@code /workspace/} 读取。 + * + * @param id 经验 ID + * @param paths 本次希望读取的路径列表(最多 {@code maxPaths} 个) + * @return 包含已命中文档及错误列表的响应 + */ + public ReadExpDocResponse readDocs(String id, List paths, int maxPaths) { + ReadExpDocResponse response = new ReadExpDocResponse(); + response.setId(id); + if (!StringUtils.hasText(id) || paths == null || paths.isEmpty()) { + response.getErrors().add(new ReadExpDocError(null, "id 和 paths 均为必填")); + return response; + } + if (maxPaths > 0 && paths.size() > maxPaths) { + response.getErrors().add(new ReadExpDocError(null, + "paths 数量超过上限 " + maxPaths + ";请分多次调用")); + return response; + } + Optional opt = experienceRepository.findById(id); + if (opt.isEmpty()) { + response.getErrors().add(new ReadExpDocError(null, "Experience not found: " + id)); + return response; + } + Experience experience = opt.get(); + Set refPaths = new LinkedHashSet<>(); + if (experience.getReferences() != null) { + for (ReferenceEntry r : experience.getReferences()) { + if (r.getPath() != null) { + refPaths.add(r.getPath()); + } + } + } + Set assetPaths = new LinkedHashSet<>(); + if (experience.getAssets() != null) { + for (AssetEntry a : experience.getAssets()) { + if (a.getPath() != null) { + assetPaths.add(a.getPath()); + } + } + } + for (String requested : paths) { + if (requested == null || requested.isBlank()) { + response.getErrors().add(new ReadExpDocError(requested, "path 为空")); + continue; + } + if (assetPaths.contains(requested)) { + response.getErrors().add(new ReadExpDocError(requested, + "path is an asset; access it inside the sandbox workspace instead")); + continue; + } + if (!refPaths.contains(requested)) { + response.getErrors().add(new ReadExpDocError(requested, + "unknown path; available references: " + String.join(", ", refPaths))); + continue; + } + ReferenceEntry ref = experience.getReferences().stream() + .filter(r -> requested.equals(r.getPath())) + .findFirst() + .orElse(null); + if (ref == null) { + continue; + } + ReadExpDocument doc = new ReadExpDocument(); + doc.setPath(ref.getPath()); + doc.setMediaType(ref.getMediaType()); + doc.setContent(ref.getContent()); + response.getDocuments().add(doc); + } + return response; + } + + private List buildReferenceManifest(Experience experience) { + List out = new ArrayList<>(); + if (experience.getReferences() == null) { + return out; + } + for (ReferenceEntry r : experience.getReferences()) { + ReferenceManifestEntry m = new ReferenceManifestEntry(); + m.setPath(r.getPath()); + m.setMediaType(r.getMediaType()); + m.setDescription(r.getDescription()); + m.setSize(r.getSize()); + out.add(m); + } + return out; + } + + private List buildAssetManifest(Experience experience) { + List out = new ArrayList<>(); + if (experience.getAssets() == null) { + return out; + } + for (AssetEntry a : experience.getAssets()) { + AssetManifestEntry m = new AssetManifestEntry(); + m.setPath(a.getPath()); + m.setMediaType(a.getMediaType()); + m.setRole(a.getRole()); + m.setDescription(a.getDescription()); + m.setSize(a.getSize()); + out.add(m); + } + return out; + } + private GroupedExperienceCandidates searchGrouped(List commonExperiences, List reactExperiences, List toolExperiences) { diff --git a/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/disclosure/ExperienceRuntimeModelInterceptor.java b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/disclosure/ExperienceRuntimeModelInterceptor.java index 047ead0d..71cdad24 100644 --- a/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/disclosure/ExperienceRuntimeModelInterceptor.java +++ b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/disclosure/ExperienceRuntimeModelInterceptor.java @@ -1,6 +1,8 @@ package com.alibaba.assistant.agent.extension.experience.disclosure; import com.alibaba.assistant.agent.common.constant.CodeactStateKeys; +import com.alibaba.assistant.agent.extension.experience.disclosure.ExperienceDisclosurePayloads.ReadExpDocRequest; +import com.alibaba.assistant.agent.extension.experience.disclosure.ExperienceDisclosurePayloads.ReadExpDocResponse; import com.alibaba.assistant.agent.extension.experience.disclosure.ExperienceDisclosurePayloads.ReadExpRequest; import com.alibaba.assistant.agent.extension.experience.disclosure.ExperienceDisclosurePayloads.ReadExpResponse; import com.alibaba.assistant.agent.extension.experience.disclosure.ExperienceDisclosurePayloads.SearchExpRequest; @@ -40,19 +42,29 @@ public class ExperienceRuntimeModelInterceptor extends ModelInterceptor { public static final String SEARCH_EXP_TOOL_NAME = "search_exp"; public static final String READ_EXP_TOOL_NAME = "read_exp"; + public static final String READ_EXP_DOC_TOOL_NAME = "read_exp_doc"; private final ExperienceDisclosureService experienceDisclosureService; private final ExperienceDisclosureContextResolver contextResolver; private final ExperienceToolInvocationClassifier toolInvocationClassifier; + private final int readExpDocMaxPaths; private final List tools; public ExperienceRuntimeModelInterceptor(ExperienceDisclosureService experienceDisclosureService, ExperienceDisclosureContextResolver contextResolver, ExperienceToolInvocationClassifier toolInvocationClassifier) { + this(experienceDisclosureService, contextResolver, toolInvocationClassifier, 3); + } + + public ExperienceRuntimeModelInterceptor(ExperienceDisclosureService experienceDisclosureService, + ExperienceDisclosureContextResolver contextResolver, + ExperienceToolInvocationClassifier toolInvocationClassifier, + int readExpDocMaxPaths) { this.experienceDisclosureService = experienceDisclosureService; this.contextResolver = contextResolver; this.toolInvocationClassifier = toolInvocationClassifier; - this.tools = List.of(buildSearchTool(), buildReadTool()); + this.readExpDocMaxPaths = readExpDocMaxPaths > 0 ? readExpDocMaxPaths : 6; + this.tools = List.of(buildSearchTool(), buildReadTool(), buildReadDocTool()); } @Override @@ -118,6 +130,45 @@ private ToolCallback buildReadTool() { .build(); } + private ToolCallback buildReadDocTool() { + BiFunction function = (request, toolContext) -> { + String id = request != null ? request.getId() : null; + List paths = request != null ? request.getPaths() : List.of(); + OverAllState state = extractState(toolContext); + if (!hasPriorReadExp(state, id)) { + ReadExpDocResponse err = new ReadExpDocResponse(); + err.setId(id); + err.getErrors().add(new ExperienceDisclosurePayloads.ReadExpDocError(null, + "请先 read_exp(" + id + "),再 read_exp_doc")); + return JSON.toJSONString(err); + } + ReadExpDocResponse response = experienceDisclosureService.readDocs(id, paths, readExpDocMaxPaths); + return JSON.toJSONString(response); + }; + return FunctionToolCallback.builder(READ_EXP_DOC_TOOL_NAME, function) + .description("读取指定经验 references 列表中某些文档的完整内容(L3 渐进披露)。需要先 read_exp(id)。" + + "单次最多读取 " + readExpDocMaxPaths + " 个路径,超过会整次失败;" + + "请尽量把本轮需要的参考文档合并到一次调用中,避免反复多次调用。" + + "不能用于 asset 路径;asset 仅在沙箱 /workspace 下可访问。") + .inputType(ReadExpDocRequest.class) + .build(); + } + + private boolean hasPriorReadExp(OverAllState state, String id) { + if (state == null || id == null || id.isBlank()) { + return false; + } + Object value = state.value(CodeactStateKeys.EXPERIENCE_READ_EXP_IDS).orElse(null); + if (value instanceof List list) { + for (Object item : list) { + if (id.equals(String.valueOf(item))) { + return true; + } + } + } + return false; + } + private Set extractAllowedDirectToolNames(Map context) { if (context == null) { return Set.of(); diff --git a/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/disclosure/ExperienceRuntimeToolStateInterceptor.java b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/disclosure/ExperienceRuntimeToolStateInterceptor.java index b11b4262..706b0144 100644 --- a/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/disclosure/ExperienceRuntimeToolStateInterceptor.java +++ b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/disclosure/ExperienceRuntimeToolStateInterceptor.java @@ -55,6 +55,23 @@ public ToolCallResponse interceptToolCall(ToolCallRequest request, ToolCallHandl return response; } + private void recordReadExpId(OverAllState state, String id) { + if (state == null || id == null || id.isBlank()) { + return; + } + LinkedHashSet ids = new LinkedHashSet<>(); + Object existing = state.value(CodeactStateKeys.EXPERIENCE_READ_EXP_IDS).orElse(null); + if (existing instanceof List list) { + for (Object item : list) { + if (item != null) { + ids.add(String.valueOf(item)); + } + } + } + ids.add(id); + state.updateState(Map.of(CodeactStateKeys.EXPERIENCE_READ_EXP_IDS, new ArrayList<>(ids))); + } + private void mergeDirectToolNames(OverAllState state, String json, String toolName) { LinkedHashSet merged = new LinkedHashSet<>(); Object existing = state.value(CodeactStateKeys.EXPERIENCE_ALLOWED_REACT_TOOL_NAMES).orElse(null); @@ -120,6 +137,7 @@ private void maybeCacheReadDetail(OverAllState state, String json, String toolNa } cache.put(response.getId(), response); state.updateState(Map.of(CodeactStateKeys.EXPERIENCE_DETAIL_CACHE, cache)); + recordReadExpId(state, response.getId()); } private void mergeDirectGroundings(OverAllState state, String json, String toolName) { diff --git a/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/model/AssetEntry.java b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/model/AssetEntry.java new file mode 100644 index 00000000..8d56fe92 --- /dev/null +++ b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/model/AssetEntry.java @@ -0,0 +1,128 @@ +package com.alibaba.assistant.agent.extension.experience.model; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 经验中仅在沙箱内被使用的附件条目(脚本、静态资源、评估数据等)。 + * + *

{@link Experience#getAssets()} 中的条目 不会 被 {@code read_exp_doc} + * 披露给模型;CLI 执行路径会把它们写入沙箱 {@code /workspace/},由脚本/命令 + * 自行消费。{@code read_exp} 只返回 manifest(不含 {@code content})。 + * + *

典型来源:{@code scripts/**}、{@code assets/**}、{@code evals/**}、 + * {@code package.json} 等。 + */ +public class AssetEntry implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * skill 包内的相对路径,如 {@code scripts/analyze.py}。 + */ + private String path; + + /** + * MIME 类型。 + */ + private String mediaType; + + /** + * 角色分类:{@code script} / {@code asset} / {@code eval} / {@code metadata}。 + */ + private String role; + + /** + * 简要描述(由 H1 / LLM 总结 / fallback 产生)。 + */ + private String description; + + /** + * 原始字节数。 + */ + private Long size; + + /** + * 内容;文本类以 UTF-8 字符串存储,二进制类以 Base64 编码。null 表示需要通过 + * {@link #contentRef} 从外部存储拉取。 + */ + private String content; + + /** + * 外部存储(如 OSS)引用;目前预留字段,未启用。 + */ + private String contentRef; + + /** + * 内容 SHA-256 摘要,便于缓存 description。 + */ + private String contentHash; + + public AssetEntry() { + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public String getMediaType() { + return mediaType; + } + + public void setMediaType(String mediaType) { + this.mediaType = mediaType; + } + + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Long getSize() { + return size; + } + + public void setSize(Long size) { + this.size = size; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public String getContentRef() { + return contentRef; + } + + public void setContentRef(String contentRef) { + this.contentRef = contentRef; + } + + public String getContentHash() { + return contentHash; + } + + public void setContentHash(String contentHash) { + this.contentHash = contentHash; + } +} diff --git a/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/model/CliRuntimeConstants.java b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/model/CliRuntimeConstants.java new file mode 100644 index 00000000..9293fd4f --- /dev/null +++ b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/model/CliRuntimeConstants.java @@ -0,0 +1,46 @@ +package com.alibaba.assistant.agent.extension.experience.model; + +import java.util.List; + +public final class CliRuntimeConstants { + + public static final String SOURCE_CLI = "cli"; + public static final String EXECUTION_MODE_SANDBOX = "SANDBOX"; + public static final String AUTH_PROFILE_USER_TOKEN_BROKER = "USER_TOKEN_BROKER"; + public static final String AUTH_PROFILE_SERVER_STATIC_ENV = "SERVER_STATIC_ENV"; + public static final String SANDBOX_TEMPLATE_CODE_INTERPRETER = "code-interpreter"; + public static final String OUTPUT_FORMAT_TEXT = "text"; + + /** 每个 CLI provider 对应的唯一 TOOL 名称前缀/后缀。结果形如 {@code cli__tool}。 */ + public static final String TOOL_NAME_PREFIX = "cli_"; + public static final String TOOL_NAME_SUFFIX = "_tool"; + + /** 通用 TOOL experience id 前缀:{@code tool-cli-}。 */ + public static final String TOOL_ID_PREFIX = "tool-cli-"; + + /** 归类 CLI 工具的 targetClassName(供 CodeactTool 使用)。 */ + public static final String TOOL_GROUP_CLASS_NAME = "cli_tools"; + + /** 工具的唯一入参名,承载模型侧提交的 shell 命令。 */ + public static final String COMMAND_PARAM = "command"; + + /** 空闲沙箱淘汰时间(秒)的配置键。 */ + public static final String IDLE_TIMEOUT_PROPERTY = "aone.tool.cli.sandbox.idle-timeout-seconds"; + public static final long DEFAULT_IDLE_TIMEOUT_SECONDS = 300L; + + /** 默认允许作为管道下游的可执行文件白名单。 */ + public static final List DEFAULT_PIPE_ALLOWLIST = List.of( + "jq", "grep", "head", "tail", "sed", "awk", "sort", "uniq", "wc", "tr", "cut" + ); + + public static String toolName(String providerId) { + return TOOL_NAME_PREFIX + providerId + TOOL_NAME_SUFFIX; + } + + public static String toolExperienceId(String providerId) { + return TOOL_ID_PREFIX + providerId; + } + + private CliRuntimeConstants() { + } +} diff --git a/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/model/Experience.java b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/model/Experience.java index a1d7a73b..41f2827b 100644 --- a/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/model/Experience.java +++ b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/model/Experience.java @@ -84,6 +84,16 @@ public class Experience { */ private ExperienceMetadata metadata; + /** + * 渐进披露第三层(L3)面向 LLM 的参考文档。仅通过 {@code read_exp_doc} 按路径读取。 + */ + private List references = new ArrayList<>(); + + /** + * 仅在沙箱内使用的附件(脚本/静态资源/评估数据)。不会通过 {@code read_exp_doc} 披露。 + */ + private List assets = new ArrayList<>(); + public Experience() { this.id = UUID.randomUUID().toString(); this.createdAt = Instant.now(); @@ -239,6 +249,22 @@ public void setMetadata(ExperienceMetadata metadata) { this.metadata = metadata != null ? metadata : new ExperienceMetadata(); } + public List getReferences() { + return references; + } + + public void setReferences(List references) { + this.references = references != null ? references : new ArrayList<>(); + } + + public List getAssets() { + return assets; + } + + public void setAssets(List assets) { + this.assets = assets != null ? assets : new ArrayList<>(); + } + /** * 更新经验时自动更新时间戳 */ diff --git a/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/model/ExperienceArtifact.java b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/model/ExperienceArtifact.java index 74589d5a..88db4b00 100644 --- a/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/model/ExperienceArtifact.java +++ b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/model/ExperienceArtifact.java @@ -14,6 +14,9 @@ *

  • REACT:可直接构造 AssistantMessage(toolCalls + 可选文本) 进入 tool 执行
  • *
  • TOOL:提供工具连接信息和Schema,用于创建 CodeactTool
  • * + * + *

    注意:skill package 的文件资源由顶层 {@link Experience#getReferences()} 与 + * {@link Experience#getAssets()} 承载,artifact 不再保有 packageBundle 字段。 */ public class ExperienceArtifact implements Serializable { @@ -124,7 +127,7 @@ public static class ToolArtifact implements Serializable { private static final long serialVersionUID = 1L; /** - * 工具来源类型:mcp / a2a / http + * 工具来源类型:mcp / a2a / http / cli */ private String source; @@ -143,6 +146,33 @@ public static class ToolArtifact implements Serializable { private String httpUrl; private String httpBodyTemplate; + // --- CLI 运行时信息 --- + /** + * 该 TOOL 绑定的 CLI provider id + * 一个 provider 对应唯一的 TOOL experience({@code cli__tool})。 + */ + private String providerId; + + /** + * 允许执行的命令前缀正则(必须以 ^ 锚定,例如 "^dbs($|\\s)")。 + * 模型提交的 {@code command} 参数在分段后,首个 token 必须匹配该正则才会被执行。 + */ + private String commandAllowPattern; + + /** + * 允许作为管道下游的外部可执行文件白名单(如 jq/grep 等)。 + * 若为 {@code null} 或空,则仅允许没有管道的命令。 + */ + private java.util.List pipeAllowlist; + + private String runnerImage; + private String sandboxTemplate; + private String executionMode; + private String authProfile; + private String authProvider; + private String loginCommandTemplate; + private String outputFormat; + /** * 工具输入 Schema(JSON Schema 格式字符串) */ @@ -243,6 +273,86 @@ public void setHttpBodyTemplate(String httpBodyTemplate) { this.httpBodyTemplate = httpBodyTemplate; } + public String getRunnerImage() { + return runnerImage; + } + + public void setRunnerImage(String runnerImage) { + this.runnerImage = runnerImage; + } + + public String getSandboxTemplate() { + return sandboxTemplate; + } + + public void setSandboxTemplate(String sandboxTemplate) { + this.sandboxTemplate = sandboxTemplate; + } + + public String getExecutionMode() { + return executionMode; + } + + public void setExecutionMode(String executionMode) { + this.executionMode = executionMode; + } + + public String getAuthProfile() { + return authProfile; + } + + public void setAuthProfile(String authProfile) { + this.authProfile = authProfile; + } + + public String getAuthProvider() { + return authProvider; + } + + public void setAuthProvider(String authProvider) { + this.authProvider = authProvider; + } + + public String getLoginCommandTemplate() { + return loginCommandTemplate; + } + + public void setLoginCommandTemplate(String loginCommandTemplate) { + this.loginCommandTemplate = loginCommandTemplate; + } + + public String getProviderId() { + return providerId; + } + + public void setProviderId(String providerId) { + this.providerId = providerId; + } + + public String getCommandAllowPattern() { + return commandAllowPattern; + } + + public void setCommandAllowPattern(String commandAllowPattern) { + this.commandAllowPattern = commandAllowPattern; + } + + public java.util.List getPipeAllowlist() { + return pipeAllowlist; + } + + public void setPipeAllowlist(java.util.List pipeAllowlist) { + this.pipeAllowlist = pipeAllowlist; + } + + public String getOutputFormat() { + return outputFormat; + } + + public void setOutputFormat(String outputFormat) { + this.outputFormat = outputFormat; + } + public String getInputSchema() { return inputSchema; } @@ -275,5 +385,5 @@ public void setCodeactToolName(String codeactToolName) { this.codeactToolName = codeactToolName; } } -} +} diff --git a/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/model/ReferenceEntry.java b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/model/ReferenceEntry.java new file mode 100644 index 00000000..8e0367c6 --- /dev/null +++ b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/model/ReferenceEntry.java @@ -0,0 +1,101 @@ +package com.alibaba.assistant.agent.extension.experience.model; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 经验中渐进披露第三层(L3)可供模型读取的参考文档条目。 + * + *

    {@link Experience#getReferences()} 中的条目在 {@code read_exp} 响应里仅以 + * {@code path/mediaType/description/size} 这些字段暴露为 manifest;完整内容 + * 仅能通过 {@code read_exp_doc} 按需读取。 + * + *

    典型来源:{@code references/**}、根目录的 {@code *.md}/{@code *.yaml}、 + * agents 目录下的 markdown 等面向 LLM 的文档。 + */ +public class ReferenceEntry implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * skill 包内的相对路径,如 {@code references/domains/session.md}。 + */ + private String path; + + /** + * MIME 类型,如 {@code text/markdown}、{@code application/yaml}。 + */ + private String mediaType; + + /** + * 文档描述(由 H1 标题 / frontmatter / LLM 总结 / 回退文案生成)。 + */ + private String description; + + /** + * 文档正文(UTF-8 文本)。 + */ + private String content; + + /** + * 正文的 SHA-256 摘要,用于复用 description 缓存。 + */ + private String contentHash; + + /** + * 原始字节数。 + */ + private Long size; + + public ReferenceEntry() { + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public String getMediaType() { + return mediaType; + } + + public void setMediaType(String mediaType) { + this.mediaType = mediaType; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public String getContentHash() { + return contentHash; + } + + public void setContentHash(String contentHash) { + this.contentHash = contentHash; + } + + public Long getSize() { + return size; + } + + public void setSize(Long size) { + this.size = size; + } +} diff --git a/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/reply/tools/BaseReplyCodeactTool.java b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/reply/tools/BaseReplyCodeactTool.java index c80030ed..11fc38c2 100644 --- a/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/reply/tools/BaseReplyCodeactTool.java +++ b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/reply/tools/BaseReplyCodeactTool.java @@ -315,7 +315,9 @@ private CodeactToolMetadata buildCodeactMetadata() { */ private String buildInputSchemaFromParameterSchema() { if (parameterSchema == null || parameterSchema.getParameters().isEmpty()) { - return "{}"; + // 必须返回合法 JSON Schema 对象, 否则部分模型提供方(如 idealab Anthropic 兼容层)会 + // 拒 400: tools.N.custom.input_schema.type: Field required + return "{\"type\":\"object\",\"properties\":{}}"; } Map schema = new LinkedHashMap<>(); diff --git a/assistant-agent-extensions/src/test/java/com/alibaba/assistant/agent/extension/experience/disclosure/ExperienceDisclosurePromptContributorTest.java b/assistant-agent-extensions/src/test/java/com/alibaba/assistant/agent/extension/experience/disclosure/ExperienceDisclosurePromptContributorTest.java index 125fa17c..57225803 100644 --- a/assistant-agent-extensions/src/test/java/com/alibaba/assistant/agent/extension/experience/disclosure/ExperienceDisclosurePromptContributorTest.java +++ b/assistant-agent-extensions/src/test/java/com/alibaba/assistant/agent/extension/experience/disclosure/ExperienceDisclosurePromptContributorTest.java @@ -129,6 +129,11 @@ void contributeBuildsPromptWithCandidatesAndGroundings() { // Verify guidance text (Chinese body with tool references) assertTrue(text.contains("search_exp")); assertTrue(text.contains("read_exp")); + assertTrue(text.contains("read_exp_doc"), "prompt must describe read_exp_doc"); + assertTrue(text.contains("referenceManifest") || text.contains("manifest"), + "prompt must mention manifest"); + assertTrue(text.contains("/workspace/") || text.contains("沙箱"), + "prompt must describe sandbox workspace for assets"); assertTrue(text.contains("PROGRESSIVE")); assertTrue(text.contains("DIRECT")); } diff --git a/assistant-agent-management/pom.xml b/assistant-agent-management/pom.xml index b1da0e69..cc6099c8 100644 --- a/assistant-agent-management/pom.xml +++ b/assistant-agent-management/pom.xml @@ -6,7 +6,7 @@ com.alibaba.agent.assistant assistant-agent - 0.2.5 + 0.2.6 assistant-agent-management @@ -45,5 +45,11 @@ com.fasterxml.jackson.core jackson-databind + + + org.junit.jupiter + junit-jupiter + test + diff --git a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/config/ExperienceConsoleAutoConfiguration.java b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/config/ExperienceConsoleAutoConfiguration.java index 45970cee..67c6d958 100644 --- a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/config/ExperienceConsoleAutoConfiguration.java +++ b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/config/ExperienceConsoleAutoConfiguration.java @@ -9,13 +9,18 @@ import com.alibaba.assistant.agent.management.controller.ToolSourceController; import com.alibaba.assistant.agent.management.internal.InMemorySkillExchangeService; import com.alibaba.assistant.agent.management.internal.InMemoryToolSourceBrowser; +import com.alibaba.assistant.agent.management.internal.LlmReferenceSummarizer; +import com.alibaba.assistant.agent.management.internal.NoopReferenceSummarizer; import com.alibaba.assistant.agent.management.internal.RepositoryBackedExperienceManagementService; import com.alibaba.assistant.agent.management.internal.SkillPackageParser; import com.alibaba.assistant.agent.management.spi.ExperienceManagementService; +import com.alibaba.assistant.agent.management.spi.ReferenceSummarizer; import com.alibaba.assistant.agent.management.spi.SkillExchangeService; import com.alibaba.assistant.agent.management.spi.TenantListProvider; import com.alibaba.assistant.agent.management.spi.ToolSourceBrowser; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -42,8 +47,9 @@ public class ExperienceConsoleAutoConfiguration { @ConditionalOnMissingBean(ExperienceManagementService.class) public ExperienceManagementService repositoryBackedExperienceManagementService( ExperienceRepository repository, - @Autowired(required = false) ExperienceToolInvocationClassifier toolInvocationClassifier) { - return new RepositoryBackedExperienceManagementService(repository, toolInvocationClassifier); + @Autowired(required = false) ExperienceToolInvocationClassifier toolInvocationClassifier, + ReferenceSummarizer referenceSummarizer) { + return new RepositoryBackedExperienceManagementService(repository, toolInvocationClassifier, referenceSummarizer); } @Bean @@ -54,8 +60,22 @@ public ToolSourceBrowser inMemoryToolSourceBrowser() { @Bean @ConditionalOnMissingBean(SkillExchangeService.class) - public SkillExchangeService inMemorySkillExchangeService(ExperienceRepository repository) { - return new InMemorySkillExchangeService(repository); + public SkillExchangeService inMemorySkillExchangeService(ExperienceRepository repository, + ReferenceSummarizer referenceSummarizer) { + return new InMemorySkillExchangeService(repository, referenceSummarizer); + } + + @Bean + @ConditionalOnBean(ChatModel.class) + @ConditionalOnMissingBean(ReferenceSummarizer.class) + public ReferenceSummarizer llmReferenceSummarizer(ChatModel chatModel) { + return new LlmReferenceSummarizer(chatModel); + } + + @Bean + @ConditionalOnMissingBean(ReferenceSummarizer.class) + public ReferenceSummarizer noopReferenceSummarizer() { + return new NoopReferenceSummarizer(); } @Bean diff --git a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/controller/ExperienceManagementController.java b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/controller/ExperienceManagementController.java index a2c7dc05..026ad5a2 100644 --- a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/controller/ExperienceManagementController.java +++ b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/controller/ExperienceManagementController.java @@ -111,6 +111,45 @@ public ResponseEntity getById(@PathVariable("id") String id) { return ResponseEntity.ok(vo); } + /** + * 返回指定 asset 的原始内容(文本或 base64),供管理后台 UI 懒加载。 + * 路径支持多级,如 /{id}/assets/scripts/cmd.sh。 + */ + @GetMapping("/{id}/assets/**") + public ResponseEntity> getAssetContent( + @PathVariable("id") String id, + jakarta.servlet.http.HttpServletRequest request) { + String uri = request.getRequestURI(); + int p = uri.indexOf("/assets/"); + if (p < 0) { + return ResponseEntity.badRequest().body(Map.of("error", "invalid path")); + } + String assetPath = uri.substring(p + "/assets/".length()); + com.alibaba.assistant.agent.extension.experience.model.AssetEntry match = + service.loadAsset(id, assetPath); + if (match == null) { + return ResponseEntity.notFound().build(); + } + Map body = new LinkedHashMap<>(); + body.put("path", match.getPath()); + body.put("mediaType", match.getMediaType()); + body.put("role", match.getRole()); + body.put("size", match.getSize()); + body.put("content", match.getContent()); + body.put("contentRef", match.getContentRef()); + return ResponseEntity.ok(body); + } + + /** + * 重新生成指定经验下所有 reference 的 description(通常在 LLM summarizer 不可用时先导入、 + * 事后配置好 ChatModel 之后再批量补齐)。 + */ + @PostMapping("/{id}/resummarize") + public ResponseEntity> resummarize(@PathVariable("id") String id) { + int updated = service.resummarizeReferences(id); + return ResponseEntity.ok(Map.of("updated", updated)); + } + @PostMapping public ResponseEntity> create(@RequestBody ExperienceCreateRequest request) { String id = service.create(request); diff --git a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/controller/SkillExchangeController.java b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/controller/SkillExchangeController.java index ecbcc33f..cb926340 100644 --- a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/controller/SkillExchangeController.java +++ b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/controller/SkillExchangeController.java @@ -7,7 +7,9 @@ import com.alibaba.assistant.agent.management.model.SkillPackageImportResult; import com.alibaba.assistant.agent.management.spi.SkillExchangeService; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -19,6 +21,7 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.Map; @RestController @@ -67,6 +70,28 @@ public ResponseEntity> exportSkill(@PathVariable("id") Strin return ResponseEntity.ok(Map.of("content", content)); } + @GetMapping("/export-package/{id}") + public ResponseEntity exportSkillPackage(@PathVariable("id") String id) { + byte[] zipBytes = service.exportSkillPackage(id); + String filename = sanitizeFilename(id) + ".zip"; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType("application/zip")); + headers.setContentDisposition( + org.springframework.http.ContentDisposition.attachment() + .filename(filename, StandardCharsets.UTF_8) + .build()); + headers.setContentLength(zipBytes.length); + return ResponseEntity.ok().headers(headers).body(zipBytes); + } + + private static String sanitizeFilename(String value) { + if (value == null || value.isBlank()) { + return "skill"; + } + String trimmed = value.trim().replaceAll("[\\\\/:*?\"<>|]+", "_"); + return trimmed.length() > 80 ? trimmed.substring(0, 80) : trimmed; + } + @GetMapping("/export") public ResponseEntity> exportAll(@RequestParam(name = "type") ExperienceType type) { String content = service.exportAllSkills(type); diff --git a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/internal/DescriptionResolver.java b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/internal/DescriptionResolver.java new file mode 100644 index 00000000..5eb76179 --- /dev/null +++ b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/internal/DescriptionResolver.java @@ -0,0 +1,103 @@ +package com.alibaba.assistant.agent.management.internal; + +import com.alibaba.assistant.agent.management.spi.ReferenceSummarizer; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +/** + * 为 reference/asset 条目生成 description 的工具: + *

      + *
    1. .md 首个非空 H1 {@code # 标题}
    2. + *
    3. YAML frontmatter 中的 {@code description:} 字段(仅 .md 起始为 {@code ---} 时)
    4. + *
    5. 注入的 {@link ReferenceSummarizer} — 典型为 LLM 总结
    6. + *
    7. 回退:{@code "(no description) " + path}
    8. + *
    + * + *

    同时负责计算 SHA-256 {@code contentHash},供导入器复用已有 description 缓存。 + */ +public final class DescriptionResolver { + + private DescriptionResolver() { + } + + public static String resolve(String path, String content, ReferenceSummarizer summarizer) { + if (content != null && path != null && path.toLowerCase().endsWith(".md")) { + String h1 = extractH1(content); + if (h1 != null && !h1.isBlank()) { + return h1.trim(); + } + String fm = extractFrontmatterDescription(content); + if (fm != null && !fm.isBlank()) { + return fm.trim(); + } + } + if (summarizer != null && content != null && !content.isBlank()) { + try { + String summary = summarizer.summarize(path, content); + if (summary != null && !summary.isBlank()) { + return summary.trim(); + } + } catch (RuntimeException ignored) { + // 总结失败时走回退文案 + } + } + return "(no description) " + (path != null ? path : ""); + } + + public static String sha256Hex(byte[] bytes) { + if (bytes == null) { + return null; + } + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(md.digest(bytes)); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + } + + public static String sha256Hex(String text) { + return text != null ? sha256Hex(text.getBytes(StandardCharsets.UTF_8)) : null; + } + + private static String extractH1(String markdown) { + for (String line : markdown.split("\n", -1)) { + String trimmed = line.trim(); + if (trimmed.startsWith("# ")) { + String title = trimmed.substring(2).trim(); + if (!title.isEmpty()) { + return title; + } + } + // 跳过 frontmatter 之前的空行;遇到非空非 H1 且非 frontmatter 时继续扫描 + } + return null; + } + + private static String extractFrontmatterDescription(String markdown) { + if (!markdown.startsWith("---")) { + return null; + } + int end = markdown.indexOf("---", 3); + if (end < 0) { + return null; + } + String frontmatter = markdown.substring(3, end); + for (String line : frontmatter.split("\n", -1)) { + String trimmed = line.trim(); + if (trimmed.startsWith("description:")) { + String value = trimmed.substring("description:".length()).trim(); + if (value.startsWith("\"") && value.endsWith("\"") && value.length() >= 2) { + value = value.substring(1, value.length() - 1); + } else if (value.startsWith("'") && value.endsWith("'") && value.length() >= 2) { + value = value.substring(1, value.length() - 1); + } + return value; + } + } + return null; + } +} diff --git a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/internal/InMemorySkillExchangeService.java b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/internal/InMemorySkillExchangeService.java index 3e206e09..877b0306 100644 --- a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/internal/InMemorySkillExchangeService.java +++ b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/internal/InMemorySkillExchangeService.java @@ -1,28 +1,57 @@ package com.alibaba.assistant.agent.management.internal; +import com.alibaba.assistant.agent.extension.experience.model.AssetEntry; +import com.alibaba.assistant.agent.extension.experience.model.CliRuntimeConstants; import com.alibaba.assistant.agent.extension.experience.model.DisclosureStrategy; import com.alibaba.assistant.agent.extension.experience.model.Experience; +import com.alibaba.assistant.agent.extension.experience.model.ExperienceArtifact; import com.alibaba.assistant.agent.extension.experience.model.ExperienceType; +import com.alibaba.assistant.agent.extension.experience.model.ReferenceEntry; import com.alibaba.assistant.agent.extension.experience.spi.ExperienceRepository; import com.alibaba.assistant.agent.management.model.ExperienceVO; import com.alibaba.assistant.agent.management.model.SkillPackage; import com.alibaba.assistant.agent.management.model.SkillPackageImportResult; +import com.alibaba.assistant.agent.management.spi.ReferenceSummarizer; import com.alibaba.assistant.agent.management.spi.SkillExchangeService; - +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Comparator; +import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.StringJoiner; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; public class InMemorySkillExchangeService implements SkillExchangeService { private final ExperienceRepository repository; + private final ReferenceSummarizer referenceSummarizer; + private final ObjectMapper objectMapper = new ObjectMapper(); public InMemorySkillExchangeService(ExperienceRepository repository) { + this(repository, new NoopReferenceSummarizer()); + } + + public InMemorySkillExchangeService(ExperienceRepository repository, + ReferenceSummarizer referenceSummarizer) { this.repository = repository; + this.referenceSummarizer = referenceSummarizer != null ? referenceSummarizer : new NoopReferenceSummarizer(); } @Override @@ -47,6 +76,161 @@ public String exportAllSkills(ExperienceType type) { .collect(Collectors.joining("\n\n---\n\n")); } + @Override + public byte[] exportSkillPackage(String experienceId) { + Experience exp = repository.findById(experienceId) + .orElseThrow(() -> new IllegalArgumentException("Experience not found: " + experienceId)); + + Experience reactExp = exp; + Experience toolExp = null; + if (exp.getType() == ExperienceType.TOOL) { + toolExp = exp; + reactExp = findCounterpart(exp, ExperienceType.REACT).orElse(exp); + } else { + toolExp = findCounterpart(exp, ExperienceType.TOOL).orElse(null); + } + + String folder = slug(reactExp.getName()) + "/"; + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ZipOutputStream zos = new ZipOutputStream(baos)) { + + Set writtenPaths = new LinkedHashSet<>(); + + // 1) SKILL.md - rebuild from frontmatter + content (lossless w.r.t. current Experience state) + putZipEntry(zos, folder + "SKILL.md", + formatSkillMarkdown(reactExp).getBytes(StandardCharsets.UTF_8)); + writtenPaths.add("SKILL.md"); + + // 2) references (skip the auto-added SKILL.md duplicate; use text content as bytes) + if (reactExp.getReferences() != null) { + for (ReferenceEntry r : reactExp.getReferences()) { + if (r.getPath() == null || writtenPaths.contains(r.getPath())) { + continue; + } + String content = r.getContent() != null ? r.getContent() : ""; + putZipEntry(zos, folder + r.getPath(), + content.getBytes(StandardCharsets.UTF_8)); + writtenPaths.add(r.getPath()); + } + } + + // 3) assets (text → utf8 bytes; non-text → base64 decode) + if (reactExp.getAssets() != null) { + for (AssetEntry a : reactExp.getAssets()) { + if (a.getPath() == null || writtenPaths.contains(a.getPath())) { + continue; + } + putZipEntry(zos, folder + a.getPath(), decodeAssetBytes(a)); + writtenPaths.add(a.getPath()); + } + } + + // 4) package.json - reconstruct from CLI TOOL artifact if present + if (toolExp != null && toolExp.getArtifact() != null + && toolExp.getArtifact().getTool() != null + && !writtenPaths.contains("package.json")) { + byte[] pkgJson = buildPackageJsonBytes(reactExp, toolExp); + if (pkgJson != null) { + putZipEntry(zos, folder + "package.json", pkgJson); + } + } + + zos.finish(); + return baos.toByteArray(); + } catch (IOException e) { + throw new UncheckedIOException("Failed to build skill package zip", e); + } + } + + private Optional findCounterpart(Experience exp, ExperienceType wantedType) { + List related = exp.getRelatedExperiences(); + if (related == null) { + return Optional.empty(); + } + for (String relatedId : related) { + Optional opt = repository.findById(relatedId); + if (opt.isPresent() && opt.get().getType() == wantedType) { + return opt; + } + } + return Optional.empty(); + } + + private static void putZipEntry(ZipOutputStream zos, String path, byte[] data) throws IOException { + ZipEntry entry = new ZipEntry(path); + zos.putNextEntry(entry); + if (data != null && data.length > 0) { + zos.write(data); + } + zos.closeEntry(); + } + + private byte[] decodeAssetBytes(AssetEntry a) { + String content = a.getContent(); + if (content == null || content.isEmpty()) { + return new byte[0]; + } + if (isTextFile(a.getPath())) { + return content.getBytes(StandardCharsets.UTF_8); + } + try { + return Base64.getDecoder().decode(content); + } catch (IllegalArgumentException ex) { + // Defensive: fall back to UTF-8 bytes when content was unexpectedly plain text. + return content.getBytes(StandardCharsets.UTF_8); + } + } + + private byte[] buildPackageJsonBytes(Experience reactExp, Experience toolExp) { + ExperienceArtifact.ToolArtifact tool = toolExp.getArtifact().getTool(); + Map pkg = new LinkedHashMap<>(); + pkg.put("name", reactExp.getName() != null ? reactExp.getName() : toolExp.getName()); + if (reactExp.getMetadata() != null && reactExp.getMetadata().getVersion() != null) { + pkg.put("version", reactExp.getMetadata().getVersion()); + } + if (reactExp.getDescription() != null && !reactExp.getDescription().isBlank()) { + pkg.put("description", reactExp.getDescription()); + } + + Map cli = new LinkedHashMap<>(); + putIfNotBlank(cli, "provider", tool.getProviderId()); + putIfNotBlank(cli, "runnerImage", tool.getRunnerImage()); + putIfNotBlank(cli, "sandboxTemplate", tool.getSandboxTemplate()); + putIfNotBlank(cli, "executionMode", tool.getExecutionMode()); + putIfNotBlank(cli, "authProfile", tool.getAuthProfile()); + putIfNotBlank(cli, "authProvider", tool.getAuthProvider()); + putIfNotBlank(cli, "loginCommandTemplate", tool.getLoginCommandTemplate()); + putIfNotBlank(cli, "commandAllowPattern", tool.getCommandAllowPattern()); + if (tool.getPipeAllowlist() != null && !tool.getPipeAllowlist().isEmpty()) { + cli.put("pipeAllowlist", tool.getPipeAllowlist()); + } + putIfNotBlank(cli, "outputFormat", tool.getOutputFormat()); + putIfNotBlank(cli, "returnDescription", tool.getReturnDescription()); + if (tool.isReturnDirect()) { + cli.put("returnDirect", true); + } + if (toolExp.getDescription() != null && !toolExp.getDescription().isBlank()) { + cli.put("description", toolExp.getDescription()); + } + if (cli.isEmpty()) { + return null; + } + pkg.put("cli", cli); + + try { + return objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsBytes(pkg); + } catch (Exception e) { + return null; + } + } + + private static void putIfNotBlank(Map map, String key, String value) { + if (value != null && !value.isBlank()) { + map.put(key, value); + } + } + @Override public ExperienceVO previewSkillImport(String skillMarkdown) { Experience exp = parseSkillMarkdown(skillMarkdown); @@ -55,18 +239,123 @@ public ExperienceVO previewSkillImport(String skillMarkdown) { @Override public SkillPackageImportResult importSkillPackage(SkillPackage skillPackage) { - SkillPackageImportResult result = new SkillPackageImportResult(); + PreparedSkillImport prepared = prepareSkillPackageImport(skillPackage); + if (prepared.reactExperience == null) { + return prepared.result; + } + + Experience savedReact = repository.save(prepared.reactExperience); + prepared.result.setImportedId(savedReact.getId()); + prepared.result.setExperience(ExperienceVO.fromExperience(savedReact)); + if (prepared.toolExperience != null) { + Experience savedTool = repository.save(prepared.toolExperience); + prepared.result.setImportedToolId(savedTool.getId()); + prepared.result.setToolExperience(ExperienceVO.fromExperience(savedTool)); + } + + return prepared.result; + } + + @Override + public SkillPackageImportResult previewSkillPackageImport(SkillPackage skillPackage) { + PreparedSkillImport prepared = prepareSkillPackageImport(skillPackage); + if (prepared.reactExperience == null) { + return prepared.result; + } + + prepared.result.setExperience(ExperienceVO.fromExperience(prepared.reactExperience)); + if (prepared.toolExperience != null) { + prepared.result.setToolExperience(ExperienceVO.fromExperience(prepared.toolExperience)); + } + return prepared.result; + } + + private PreparedSkillImport prepareSkillPackageImport(SkillPackage skillPackage) { + SkillPackageImportResult result = new SkillPackageImportResult(); if (!skillPackage.hasSkillMd()) { result.addWarning("No SKILL.md found in package"); - return result; + return new PreparedSkillImport(result, null, null); + } + + Experience existingReact = findExistingReactExperienceByPackage(skillPackage).orElse(null); + Map existingDescriptionsByHash = buildDescriptionCache(existingReact); + + Experience reactExperience = parseSkillMarkdown(skillPackage.getSkillMdContent()); + enrichExperienceFromPackage(reactExperience, skillPackage); + + List references = new ArrayList<>(); + List assets = new ArrayList<>(); + buildReferencesAndAssets(skillPackage, references, assets, existingDescriptionsByHash); + reactExperience.setReferences(references); + reactExperience.setAssets(assets); + + result.setReferences(references); + result.setAssets(assets.stream().map(InMemorySkillExchangeService::assetWithoutContent).toList()); + collectProcessedFiles(skillPackage, result); + + Experience toolExperience = null; + CliBinding cliBinding = extractCliBinding(skillPackage.getPackageMetadata()); + if (cliBinding != null) { + toolExperience = buildCliToolExperience(skillPackage, reactExperience, cliBinding); + String cliToolName = CliRuntimeConstants.toolName(cliBinding.provider()); + reactExperience.setAssociatedTools(new ArrayList<>(List.of(cliToolName))); + List related = new ArrayList<>(); + if (toolExperience.getId() != null) { + related.add(toolExperience.getId()); + } + reactExperience.setRelatedExperiences(related); } - // 解析 SKILL.md(复用已有逻辑) - Experience exp = parseSkillMarkdown(skillPackage.getSkillMdContent()); + return new PreparedSkillImport(result, reactExperience, toolExperience); + } + + private Optional findExistingReactExperienceByPackage(SkillPackage skillPackage) { + if (skillPackage.getName() == null || skillPackage.getName().isBlank()) { + return Optional.empty(); + } + return repository.findAllByType(ExperienceType.REACT).stream() + .filter(e -> skillPackage.getName().equalsIgnoreCase(e.getName())) + .findFirst(); + } + + private Map buildDescriptionCache(Experience existing) { + Map cache = new HashMap<>(); + if (existing == null) { + return cache; + } + if (existing.getReferences() != null) { + for (ReferenceEntry ref : existing.getReferences()) { + if (ref.getContentHash() != null && ref.getDescription() != null) { + cache.put(ref.getContentHash(), ref.getDescription()); + } + } + } + if (existing.getAssets() != null) { + for (AssetEntry asset : existing.getAssets()) { + if (asset.getContentHash() != null && asset.getDescription() != null) { + cache.put(asset.getContentHash(), asset.getDescription()); + } + } + } + return cache; + } + + private void collectProcessedFiles(SkillPackage skillPackage, SkillPackageImportResult result) { result.addProcessedFile("SKILL.md"); + if (skillPackage.hasScripts()) { + for (String path : skillPackage.getScripts().keySet()) { + result.addProcessedFile(path); + } + } + if (skillPackage.hasOtherFiles()) { + for (String path : skillPackage.getOtherFiles().keySet()) { + result.addProcessedFile(path); + } + } + } - // 用 package.json 元数据补充(如果 frontmatter 中未提供) + private void enrichExperienceFromPackage(Experience exp, SkillPackage skillPackage) { if ((exp.getName() == null || exp.getName().isBlank()) && skillPackage.getName() != null) { exp.setName(skillPackage.getName()); } @@ -76,74 +365,439 @@ public SkillPackageImportResult importSkillPackage(SkillPackage skillPackage) { if (skillPackage.getVersion() != null) { exp.getMetadata().setVersion(skillPackage.getVersion()); } + } - // 处理 scripts/ - if (skillPackage.hasScripts()) { - for (Map.Entry entry : skillPackage.getScripts().entrySet()) { - result.addProcessedFile(entry.getKey()); - } + /** + * 遍历 skill 包中的脚本/附件,根据路径规则分类为 references 或 assets, + * 并通过 {@link #batchResolveDescriptions} 并行生成缺失的 description。 + */ + private void buildReferencesAndAssets(SkillPackage skillPackage, + List references, + List assets, + Map descriptionCache) { + // SKILL.md 同时保留为 reference(role=skill-md),以便 read_exp_doc 可按 path 检索。 + if (skillPackage.hasSkillMd()) { + String content = skillPackage.getSkillMdContent(); + ReferenceEntry skillRef = new ReferenceEntry(); + skillRef.setPath("SKILL.md"); + skillRef.setMediaType("text/markdown"); + skillRef.setContent(content); + skillRef.setContentHash(DescriptionResolver.sha256Hex(content)); + skillRef.setSize((long) content.getBytes(StandardCharsets.UTF_8).length); + references.add(skillRef); } - // 记录其他文件为跳过 + List pending = new ArrayList<>(); + + if (skillPackage.hasScripts()) { + skillPackage.getScripts().forEach((path, content) -> pending.add( + new PendingEntry(path, content.getBytes(StandardCharsets.UTF_8), true, content))); + } if (skillPackage.hasOtherFiles()) { - for (String path : skillPackage.getOtherFiles().keySet()) { - if ("package.json".equals(path)) { - result.addProcessedFile(path); + skillPackage.getOtherFiles().forEach((path, bytes) -> { + boolean text = isTextFile(path); + String textContent = text && bytes != null ? new String(bytes, StandardCharsets.UTF_8) : null; + pending.add(new PendingEntry(path, bytes != null ? bytes : new byte[0], text, textContent)); + }); + } + + batchResolveDescriptions(pending, descriptionCache); + + for (PendingEntry pe : pending) { + SkillContentClassifier.Bucket bucket = SkillContentClassifier.classify(pe.path); + if (bucket == SkillContentClassifier.Bucket.REFERENCE) { + ReferenceEntry ref = new ReferenceEntry(); + ref.setPath(pe.path); + ref.setMediaType(detectMediaType(pe.path)); + ref.setContent(pe.textContent != null + ? pe.textContent + : new String(pe.bytes, StandardCharsets.UTF_8)); + ref.setContentHash(pe.contentHash); + ref.setSize((long) pe.bytes.length); + ref.setDescription(pe.description); + references.add(ref); + } else { + AssetEntry asset = new AssetEntry(); + asset.setPath(pe.path); + asset.setMediaType(detectMediaType(pe.path)); + asset.setRole(SkillContentClassifier.assetRoleFor(pe.path)); + asset.setContentHash(pe.contentHash); + asset.setSize((long) pe.bytes.length); + asset.setDescription(pe.description); + if (pe.isText && pe.textContent != null) { + asset.setContent(pe.textContent); } else { - result.addSkippedFile(path, "File type not yet supported"); + asset.setContent(pe.bytes.length > 0 ? Base64.getEncoder().encodeToString(pe.bytes) : ""); } + assets.add(asset); } } - Experience saved = repository.save(exp); - result.setImportedId(saved.getId()); - result.setExperience(ExperienceVO.fromExperience(saved)); + references.sort(Comparator.comparing(ReferenceEntry::getPath)); + assets.sort(Comparator.comparing(AssetEntry::getPath)); + } + + /** + * 并行(最多 4 个线程)为每个 pending entry 生成 description;命中 {@code descriptionCache} + * 时跳过 summarizer 调用。 + */ + private void batchResolveDescriptions(List pending, Map cache) { + if (pending.isEmpty()) { + return; + } + for (PendingEntry pe : pending) { + pe.contentHash = DescriptionResolver.sha256Hex(pe.bytes); + } - return result; + ExecutorService executor = Executors.newFixedThreadPool(Math.min(4, pending.size())); + long start = System.currentTimeMillis(); + int ok = 0; + int fail = 0; + try { + List> futures = new ArrayList<>(); + for (PendingEntry pe : pending) { + String cached = pe.contentHash != null ? cache.get(pe.contentHash) : null; + if (cached != null) { + pe.description = cached; + continue; + } + futures.add(executor.submit(() -> { + pe.description = DescriptionResolver.resolve(pe.path, pe.textContent, referenceSummarizer); + })); + } + for (Future f : futures) { + try { + f.get(); + ok++; + } catch (Exception e) { + fail++; + } + } + } finally { + executor.shutdown(); + } + long elapsed = System.currentTimeMillis() - start; + // 控制台可见的单行概要;正式日志由上层记录。 + System.out.println("[skill-import] summarize cost=" + elapsed + "ms ok=" + ok + " fail=" + fail + + " total=" + pending.size()); } - @Override - public SkillPackageImportResult previewSkillPackageImport(SkillPackage skillPackage) { - SkillPackageImportResult result = new SkillPackageImportResult(); + private static AssetEntry assetWithoutContent(AssetEntry src) { + AssetEntry copy = new AssetEntry(); + copy.setPath(src.getPath()); + copy.setMediaType(src.getMediaType()); + copy.setRole(src.getRole()); + copy.setDescription(src.getDescription()); + copy.setSize(src.getSize()); + copy.setContentHash(src.getContentHash()); + copy.setContentRef(src.getContentRef()); + return copy; + } - if (!skillPackage.hasSkillMd()) { - result.addWarning("No SKILL.md found in package"); - return result; + private Experience buildCliToolExperience(SkillPackage skillPackage, + Experience reactExperience, + CliBinding cliBinding) { + String toolExperienceId = CliRuntimeConstants.toolExperienceId(cliBinding.provider()); + String toolName = CliRuntimeConstants.toolName(cliBinding.provider()); + + Experience existing = repository.findById(toolExperienceId).orElse(null); + Experience toolExperience = existing != null ? existing : new Experience(); + toolExperience.setId(toolExperienceId); + toolExperience.setType(ExperienceType.TOOL); + toolExperience.setName(toolName); + toolExperience.setDescription(firstNonBlank(cliBinding.description(), + "CLI tool for provider: " + cliBinding.provider())); + toolExperience.setContent(buildCliToolContent(cliBinding)); + toolExperience.setDisclosureStrategy(DisclosureStrategy.PROGRESSIVE); + toolExperience.setAssociatedTools(new ArrayList<>(List.of(toolName))); + + List relatedExperiences = new ArrayList<>(); + if (existing != null && existing.getRelatedExperiences() != null) { + relatedExperiences.addAll(existing.getRelatedExperiences()); } + if (reactExperience.getId() != null && !relatedExperiences.contains(reactExperience.getId())) { + relatedExperiences.add(reactExperience.getId()); + } + toolExperience.setRelatedExperiences(relatedExperiences); - Experience exp = parseSkillMarkdown(skillPackage.getSkillMdContent()); - result.addProcessedFile("SKILL.md"); + toolExperience.getMetadata().setSource("cli:" + cliBinding.provider()); + toolExperience.getMetadata().putProperty("cliProvider", cliBinding.provider()); + toolExperience.getMetadata().putProperty("cliExecutionMode", cliBinding.executionMode()); + toolExperience.getMetadata().putProperty("cliAuthProfile", cliBinding.authProfile()); + toolExperience.getMetadata().setVersion(skillPackage.getVersion()); - // 用 package.json 元数据补充 - if ((exp.getName() == null || exp.getName().isBlank()) && skillPackage.getName() != null) { - exp.setName(skillPackage.getName()); + Set tags = new HashSet<>(); + if (toolExperience.getTags() != null) { + tags.addAll(toolExperience.getTags()); } - if ((exp.getDescription() == null || exp.getDescription().isBlank()) && skillPackage.getDescription() != null) { - exp.setDescription(skillPackage.getDescription()); + tags.add("source:cli"); + tags.add("provider:" + cliBinding.provider()); + toolExperience.setTags(tags); + + ExperienceArtifact artifact = new ExperienceArtifact(); + + ExperienceArtifact.ToolArtifact toolArtifact = new ExperienceArtifact.ToolArtifact(); + toolArtifact.setSource(CliRuntimeConstants.SOURCE_CLI); + toolArtifact.setProviderId(cliBinding.provider()); + toolArtifact.setRunnerImage(cliBinding.runnerImage()); + toolArtifact.setSandboxTemplate(cliBinding.sandboxTemplate()); + toolArtifact.setExecutionMode(cliBinding.executionMode()); + toolArtifact.setAuthProfile(cliBinding.authProfile()); + toolArtifact.setAuthProvider(cliBinding.authProvider()); + toolArtifact.setLoginCommandTemplate(cliBinding.loginCommandTemplate()); + toolArtifact.setCommandAllowPattern(cliBinding.commandAllowPattern()); + toolArtifact.setPipeAllowlist(cliBinding.pipeAllowlist()); + toolArtifact.setOutputFormat(cliBinding.outputFormat()); + toolArtifact.setInputSchema(buildCommandInputSchema(cliBinding)); + toolArtifact.setReturnDescription(cliBinding.returnDescription()); + toolArtifact.setReturnDirect(cliBinding.returnDirect()); + toolArtifact.setCodeactToolName(toolName); + artifact.setTool(toolArtifact); + + toolExperience.setArtifact(artifact); + // CLI 执行器拿到的是 TOOL 经验,需要把 reference/asset 一并挂上,以便 materializer 能将 + // 文件写入沙箱 workspace。这里做深拷贝避免后续持久化时两个经验互相引用同一对象。 + toolExperience.setReferences(copyReferences(reactExperience.getReferences())); + toolExperience.setAssets(copyAssets(reactExperience.getAssets())); + return toolExperience; + } + + private static List copyReferences(List src) { + if (src == null) { + return new ArrayList<>(); } - if (skillPackage.getVersion() != null) { - exp.getMetadata().setVersion(skillPackage.getVersion()); + List out = new ArrayList<>(src.size()); + for (ReferenceEntry r : src) { + ReferenceEntry c = new ReferenceEntry(); + c.setPath(r.getPath()); + c.setMediaType(r.getMediaType()); + c.setDescription(r.getDescription()); + c.setContent(r.getContent()); + c.setContentHash(r.getContentHash()); + c.setSize(r.getSize()); + out.add(c); } + return out; + } - result.setExperience(ExperienceVO.fromExperience(exp)); + private static List copyAssets(List src) { + if (src == null) { + return new ArrayList<>(); + } + List out = new ArrayList<>(src.size()); + for (AssetEntry a : src) { + AssetEntry c = new AssetEntry(); + c.setPath(a.getPath()); + c.setMediaType(a.getMediaType()); + c.setRole(a.getRole()); + c.setDescription(a.getDescription()); + c.setSize(a.getSize()); + c.setContent(a.getContent()); + c.setContentRef(a.getContentRef()); + c.setContentHash(a.getContentHash()); + out.add(c); + } + return out; + } - // 分类列出所有文件 - if (skillPackage.hasScripts()) { - for (String path : skillPackage.getScripts().keySet()) { - result.addProcessedFile(path); + private String buildCommandInputSchema(CliBinding cliBinding) { + Map commandSchema = new LinkedHashMap<>(); + commandSchema.put("type", "string"); + String allow = cliBinding.commandAllowPattern(); + StringBuilder desc = new StringBuilder("Bash command to run inside the ") + .append(cliBinding.provider()) + .append(" CLI sandbox."); + if (allow != null && !allow.isBlank()) { + desc.append(" Command MUST match regex: ").append(allow).append('.'); + } + if (cliBinding.pipeAllowlist() != null && !cliBinding.pipeAllowlist().isEmpty()) { + desc.append(" Allowed pipe utilities: ") + .append(String.join(",", cliBinding.pipeAllowlist())).append('.'); + } + commandSchema.put("description", desc.toString()); + + Map properties = new LinkedHashMap<>(); + properties.put(CliRuntimeConstants.COMMAND_PARAM, commandSchema); + + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("properties", properties); + schema.put("required", List.of(CliRuntimeConstants.COMMAND_PARAM)); + return toJson(schema); + } + + private String buildCliToolContent(CliBinding cliBinding) { + StringBuilder sb = new StringBuilder(); + sb.append("## CLI Tool (").append(cliBinding.provider()).append(")\n\n"); + sb.append("**Provider**: ").append(cliBinding.provider()).append("\n\n"); + sb.append("**Execution**: ").append(cliBinding.executionMode()).append("\n\n"); + sb.append("**Auth**: ").append(cliBinding.authProfile()).append("\n\n"); + if (cliBinding.commandAllowPattern() != null && !cliBinding.commandAllowPattern().isBlank()) { + sb.append("**Allowed command pattern**: `") + .append(cliBinding.commandAllowPattern()).append("`\n\n"); + } + if (cliBinding.description() != null && !cliBinding.description().isBlank()) { + sb.append(cliBinding.description()).append("\n\n"); + } + sb.append("This tool accepts a freeform `command` argument. Supply the exact bash command you want to run; "); + sb.append("the server will validate it against the allow-pattern, materialise the skill package inside a "); + sb.append("reusable sandbox, perform the login flow on first invocation, and execute the command.\n"); + return sb.toString(); + } + + @SuppressWarnings("unchecked") + private CliBinding extractCliBinding(Map packageMetadata) { + if (packageMetadata == null || packageMetadata.isEmpty()) { + return null; + } + + Object cliSection = packageMetadata.get("cli"); + if (!(cliSection instanceof Map)) { + for (String namespace : List.of("meow", "meowAgent", "x-meow-agent", "xMeowAgent", "a1")) { + Object namespaceValue = packageMetadata.get(namespace); + if (namespaceValue instanceof Map namespaceMap && namespaceMap.get("cli") instanceof Map nestedCli) { + cliSection = nestedCli; + break; + } } } - if (skillPackage.hasOtherFiles()) { - for (String path : skillPackage.getOtherFiles().keySet()) { - if ("package.json".equals(path)) { - result.addProcessedFile(path); - } else { - result.addSkippedFile(path, "File type not yet supported"); + + if (!(cliSection instanceof Map rawCliMap)) { + return null; + } + + Map cliMap = new LinkedHashMap<>(); + rawCliMap.forEach((key, value) -> cliMap.put(String.valueOf(key), value)); + + String provider = asText(cliMap.get("provider")); + if (provider == null || provider.isBlank()) { + return null; + } + + List pipeAllowlist = extractPipeAllowlist(cliMap.get("pipeAllowlist")); + return new CliBinding( + provider, + asText(cliMap.get("description")), + firstNonBlank(asText(cliMap.get("runnerImage")), provider + "-cli:latest"), + firstNonBlank(asText(cliMap.get("sandboxTemplate")), CliRuntimeConstants.SANDBOX_TEMPLATE_CODE_INTERPRETER), + firstNonBlank(asText(cliMap.get("executionMode")), CliRuntimeConstants.EXECUTION_MODE_SANDBOX), + firstNonBlank(asText(cliMap.get("authProfile")), CliRuntimeConstants.AUTH_PROFILE_USER_TOKEN_BROKER), + firstNonBlank(asText(cliMap.get("authProvider")), provider), + firstNonBlank(asText(cliMap.get("loginCommandTemplate")), defaultLoginTemplate(provider)), + asText(cliMap.get("commandAllowPattern")), + pipeAllowlist, + firstNonBlank(asText(cliMap.get("outputFormat")), CliRuntimeConstants.OUTPUT_FORMAT_TEXT), + asText(cliMap.get("returnDescription")), + Boolean.parseBoolean(String.valueOf(cliMap.getOrDefault("returnDirect", false))) + ); + } + + private List extractPipeAllowlist(Object raw) { + if (raw instanceof List list) { + List result = new ArrayList<>(); + for (Object item : list) { + if (item != null) { + String s = String.valueOf(item).trim(); + if (!s.isBlank()) { + result.add(s); + } } } + return result.isEmpty() ? null : result; + } + return null; + } + + private String defaultLoginTemplate(String provider) { + if ("a1".equalsIgnoreCase(provider)) { + return "echo \"{{token}}\" | a1 auth login --platform code --with-token"; } + return null; + } + + private String toJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception e) { + return "{}"; + } + } + + private String asText(Object value) { + return value != null ? String.valueOf(value) : null; + } - return result; + private boolean isTextFile(String path) { + String lowerPath = path.toLowerCase(); + return lowerPath.endsWith(".md") + || lowerPath.endsWith(".txt") + || lowerPath.endsWith(".json") + || lowerPath.endsWith(".yaml") + || lowerPath.endsWith(".yml") + || lowerPath.endsWith(".sh") + || lowerPath.endsWith(".py") + || lowerPath.endsWith(".xml") + || lowerPath.endsWith(".properties") + || lowerPath.endsWith(".js") + || lowerPath.endsWith(".ts") + || lowerPath.endsWith(".sql"); + } + + private String detectMediaType(String path) { + String lowerPath = path.toLowerCase(); + if (lowerPath.endsWith(".md")) { + return "text/markdown"; + } + if (lowerPath.endsWith(".json")) { + return "application/json"; + } + if (lowerPath.endsWith(".yaml") || lowerPath.endsWith(".yml")) { + return "application/yaml"; + } + if (lowerPath.endsWith(".sh")) { + return "application/x-sh"; + } + if (lowerPath.endsWith(".py")) { + return "text/x-python"; + } + if (lowerPath.endsWith(".txt")) { + return "text/plain"; + } + if (lowerPath.endsWith(".png")) { + return "image/png"; + } + if (lowerPath.endsWith(".jpg") || lowerPath.endsWith(".jpeg")) { + return "image/jpeg"; + } + return isTextFile(path) ? "text/plain" : "application/octet-stream"; + } + + private String slug(String value) { + if (value == null || value.isBlank()) { + return "unknown"; + } + return value.toLowerCase() + .replaceAll("[^a-z0-9]+", "_") + .replaceAll("^_+|_+$", ""); + } + + /** + * 导入过程中暂存的文件条目:同时持有字节、文本视图、哈希与(异步)描述。 + */ + private static final class PendingEntry { + final String path; + final byte[] bytes; + final boolean isText; + final String textContent; + volatile String contentHash; + volatile String description; + + PendingEntry(String path, byte[] bytes, boolean isText, String textContent) { + this.path = path; + this.bytes = bytes; + this.isText = isText; + this.textContent = textContent; + } } private String formatSkillMarkdown(Experience exp) { @@ -193,24 +847,21 @@ private Experience parseSkillMarkdown(String skillMarkdown) { case "description" -> exp.setDescription(value); case "tags" -> exp.setTags(parseTags(value)); case "version" -> exp.getMetadata().setVersion(value); - default -> { } + default -> { + } } } - // Skill 导入默认类型为 REACT if (exp.getType() == null) { exp.setType(ExperienceType.REACT); } - // REACT 类型默认使用渐进式披露 if (exp.getDisclosureStrategy() == null && exp.getType() == ExperienceType.REACT) { exp.setDisclosureStrategy(DisclosureStrategy.PROGRESSIVE); } - // 标记来源 exp.getMetadata().setSource("skill-import"); - // Strip leading "# title\n" from body if present if (body.startsWith("# ")) { int newlineIdx = body.indexOf('\n'); if (newlineIdx > 0) { @@ -224,7 +875,6 @@ private Experience parseSkillMarkdown(String skillMarkdown) { private Set parseTags(String value) { Set tags = new LinkedHashSet<>(); - // Remove surrounding brackets value = value.trim(); if (value.startsWith("[")) { value = value.substring(1); @@ -252,7 +902,34 @@ private String formatTags(Set tags) { return joiner.toString(); } + private String firstNonBlank(String first, String second) { + if (first != null && !first.isBlank()) { + return first; + } + return second; + } + private String nullSafe(String value) { return value != null ? value : ""; } + + private record PreparedSkillImport(SkillPackageImportResult result, + Experience reactExperience, + Experience toolExperience) { + } + + private record CliBinding(String provider, + String description, + String runnerImage, + String sandboxTemplate, + String executionMode, + String authProfile, + String authProvider, + String loginCommandTemplate, + String commandAllowPattern, + List pipeAllowlist, + String outputFormat, + String returnDescription, + boolean returnDirect) { + } } diff --git a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/internal/LlmReferenceSummarizer.java b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/internal/LlmReferenceSummarizer.java new file mode 100644 index 00000000..e1ce9d84 --- /dev/null +++ b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/internal/LlmReferenceSummarizer.java @@ -0,0 +1,95 @@ +package com.alibaba.assistant.agent.management.internal; + +import com.alibaba.assistant.agent.management.spi.ReferenceSummarizer; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; + +import java.util.List; + +/** + * 基于 Spring AI {@link ChatModel} 的参考文档摘要生成器。 + * + *

    当 {@link DescriptionResolver} 无法从 .md H1 / frontmatter 提取标题时调用; + * 仅处理纯文本/代码/标记类文件(其它二进制资源不会传入,非 UTF-8 文本由调用方过滤)。 + * 任何异常都会被调用方捕获并回退到 {@code "(no description) path"} 文案。 + */ +public class LlmReferenceSummarizer implements ReferenceSummarizer { + + private static final Logger log = LoggerFactory.getLogger(LlmReferenceSummarizer.class); + + /** + * 只向 LLM 传前 N 个字符,避免超长文件导致的上下文浪费。 + */ + private static final int MAX_CONTENT_CHARS = 4000; + + private static final int MAX_SUMMARY_CHARS = 200; + + private static final String SYSTEM_PROMPT = "" + + "You summarize skill reference documents (markdown/yaml/text/code).\n" + + "Return a single-line Chinese description, <= 80 characters,\n" + + "that tells an AI agent what this file explains or provides so it can decide whether to read it.\n" + + "Do NOT start with quotes, labels, or boilerplate like 'This file'.\n" + + "Do NOT invent information absent from the content. No trailing punctuation."; + + private final ChatModel chatModel; + + public LlmReferenceSummarizer(ChatModel chatModel) { + if (chatModel == null) { + throw new IllegalArgumentException("ChatModel is required for LlmReferenceSummarizer"); + } + this.chatModel = chatModel; + } + + @Override + public String summarize(String path, String content) { + if (content == null || content.isBlank()) { + return null; + } + String trimmed = content.length() > MAX_CONTENT_CHARS + ? content.substring(0, MAX_CONTENT_CHARS) + "\n...[truncated]" + : content; + String userPrompt = "path: " + (path != null ? path : "(unknown)") + "\n\n---\n" + trimmed; + try { + Prompt prompt = new Prompt(List.of( + new SystemMessage(SYSTEM_PROMPT), + new UserMessage(userPrompt))); + ChatResponse response = chatModel.call(prompt); + if (response == null || response.getResult() == null || response.getResult().getOutput() == null) { + return null; + } + String text = response.getResult().getOutput().getText(); + return clean(text); + } catch (Exception e) { + log.warn("LlmReferenceSummarizer#summarize - path={}, error={}", path, e.toString()); + return null; + } + } + + private static String clean(String raw) { + if (raw == null) { + return null; + } + String s = raw.trim(); + // Collapse to first non-empty line + int nl = s.indexOf('\n'); + if (nl >= 0) { + s = s.substring(0, nl).trim(); + } + // Strip wrapping quotes + if ((s.startsWith("\"") && s.endsWith("\"")) || (s.startsWith("'") && s.endsWith("'"))) { + if (s.length() >= 2) { + s = s.substring(1, s.length() - 1).trim(); + } + } + if (s.length() > MAX_SUMMARY_CHARS) { + s = s.substring(0, MAX_SUMMARY_CHARS).trim(); + } + return s.isEmpty() ? null : s; + } +} diff --git a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/internal/NoopReferenceSummarizer.java b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/internal/NoopReferenceSummarizer.java new file mode 100644 index 00000000..fb4ad8ff --- /dev/null +++ b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/internal/NoopReferenceSummarizer.java @@ -0,0 +1,14 @@ +package com.alibaba.assistant.agent.management.internal; + +import com.alibaba.assistant.agent.management.spi.ReferenceSummarizer; + +/** + * 默认实现:不调用 LLM,始终返回 null,让调用方走回退文案。 + */ +public class NoopReferenceSummarizer implements ReferenceSummarizer { + + @Override + public String summarize(String path, String content) { + return null; + } +} diff --git a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/internal/RepositoryBackedExperienceManagementService.java b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/internal/RepositoryBackedExperienceManagementService.java index 7bd8fe56..da1dbc81 100644 --- a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/internal/RepositoryBackedExperienceManagementService.java +++ b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/internal/RepositoryBackedExperienceManagementService.java @@ -11,6 +11,7 @@ import com.alibaba.assistant.agent.management.model.ExperienceVO; import com.alibaba.assistant.agent.management.model.PageResult; import com.alibaba.assistant.agent.management.spi.ExperienceManagementService; +import com.alibaba.assistant.agent.management.spi.ReferenceSummarizer; import java.util.ArrayList; import java.util.EnumMap; @@ -23,15 +24,23 @@ public class RepositoryBackedExperienceManagementService implements ExperienceMa private final ExperienceRepository repository; private final ExperienceToolInvocationClassifier toolInvocationClassifier; + private final ReferenceSummarizer referenceSummarizer; public RepositoryBackedExperienceManagementService(ExperienceRepository repository) { - this(repository, null); + this(repository, null, null); } public RepositoryBackedExperienceManagementService(ExperienceRepository repository, ExperienceToolInvocationClassifier toolInvocationClassifier) { + this(repository, toolInvocationClassifier, null); + } + + public RepositoryBackedExperienceManagementService(ExperienceRepository repository, + ExperienceToolInvocationClassifier toolInvocationClassifier, + ReferenceSummarizer referenceSummarizer) { this.repository = repository; this.toolInvocationClassifier = toolInvocationClassifier; + this.referenceSummarizer = referenceSummarizer != null ? referenceSummarizer : new NoopReferenceSummarizer(); } @Override @@ -137,6 +146,52 @@ public Map countByType() { return counts; } + @Override + public com.alibaba.assistant.agent.extension.experience.model.AssetEntry loadAsset(String id, String path) { + if (id == null || path == null) { + return null; + } + Experience exp = repository.findById(id).orElse(null); + if (exp == null || exp.getAssets() == null) { + return null; + } + for (var a : exp.getAssets()) { + if (path.equals(a.getPath())) { + return a; + } + } + return null; + } + + @Override + public int resummarizeReferences(String id) { + if (id == null) { + return 0; + } + Experience exp = repository.findById(id).orElse(null); + if (exp == null || exp.getReferences() == null || exp.getReferences().isEmpty()) { + return 0; + } + int updated = 0; + for (var ref : exp.getReferences()) { + String content = ref.getContent(); + if (content == null || content.isBlank()) { + continue; + } + String oldDesc = ref.getDescription(); + String newDesc = DescriptionResolver.resolve(ref.getPath(), content, referenceSummarizer); + if (newDesc != null && !newDesc.equals(oldDesc)) { + ref.setDescription(newDesc); + updated++; + } + } + if (updated > 0) { + exp.touch(); + repository.save(exp); + } + return updated; + } + private List collectExperiences(ExperienceType type) { if (type != null) { return repository.findAllByType(type); diff --git a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/internal/SkillContentClassifier.java b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/internal/SkillContentClassifier.java new file mode 100644 index 00000000..dfa70c91 --- /dev/null +++ b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/internal/SkillContentClassifier.java @@ -0,0 +1,94 @@ +package com.alibaba.assistant.agent.management.internal; + +/** + * 将 skill 包中的文件路径分类为 {@link com.alibaba.assistant.agent.extension.experience.model.ReferenceEntry + * reference}(面向 LLM 的文档)或 {@link com.alibaba.assistant.agent.extension.experience.model.AssetEntry + * asset}(仅在沙箱内使用的附件),并推导 asset 的角色标签。 + * + *

    规则对齐 {@code openspec/changes/progressive-experience-disclosure/design.md} 中的 + * Decision 2: + *

    + */ +public final class SkillContentClassifier { + + private SkillContentClassifier() { + } + + public enum Bucket { + REFERENCE, + ASSET + } + + public static Bucket classify(String path) { + String normalized = normalize(path); + if (normalized.isEmpty()) { + return Bucket.ASSET; + } + + if (normalized.startsWith("references/")) { + return Bucket.REFERENCE; + } + if (normalized.startsWith("scripts/") + || normalized.startsWith("assets/") + || normalized.startsWith("evals/")) { + return Bucket.ASSET; + } + if (normalized.equals("package.json")) { + return Bucket.ASSET; + } + if (normalized.startsWith("agents/") && normalized.endsWith(".md")) { + return Bucket.REFERENCE; + } + // 根目录其他 md / yaml 文件视为 reference + if (!normalized.contains("/")) { + String lower = normalized.toLowerCase(); + if (lower.endsWith(".md") || lower.endsWith(".yaml") || lower.endsWith(".yml")) { + return Bucket.REFERENCE; + } + } + return Bucket.ASSET; + } + + public static String assetRoleFor(String path) { + String normalized = normalize(path); + if (normalized.equals("package.json")) { + return "metadata"; + } + if (normalized.startsWith("scripts/")) { + return "script"; + } + if (normalized.startsWith("evals/")) { + return "eval"; + } + if (normalized.startsWith("assets/")) { + return "asset"; + } + return "asset"; + } + + private static String normalize(String path) { + if (path == null) { + return ""; + } + String p = path.replace('\\', '/'); + if (p.startsWith("./")) { + p = p.substring(2); + } + while (p.startsWith("/")) { + p = p.substring(1); + } + return p; + } +} diff --git a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/internal/SkillPackageParser.java b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/internal/SkillPackageParser.java index cb8946df..75e1df91 100644 --- a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/internal/SkillPackageParser.java +++ b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/internal/SkillPackageParser.java @@ -1,6 +1,8 @@ package com.alibaba.assistant.agent.management.internal; import com.alibaba.assistant.agent.management.model.SkillPackage; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -36,6 +38,7 @@ public class SkillPackageParser { private static final int TAR_BLOCK_SIZE = 512; private static final long MAX_ENTRY_SIZE = 10 * 1024 * 1024; // 10MB per file + private final ObjectMapper objectMapper = new ObjectMapper(); /** * 从 tgz(gzipped tar)输入流解析 skill 包 @@ -262,6 +265,13 @@ private void enrichFromPackageJson(SkillPackage pkg) { } String json = new String(pkgJsonData, StandardCharsets.UTF_8); + try { + Map packageMetadata = objectMapper.readValue(json, new TypeReference<>() { + }); + pkg.setPackageMetadata(packageMetadata); + } catch (Exception e) { + log.warn("SkillPackageParser#enrichFromPackageJson - failed to parse package.json as structured metadata", e); + } // 简单的 JSON 字段提取(避免引入额外 JSON 库依赖) String name = extractJsonString(json, "name"); String version = extractJsonString(json, "version"); diff --git a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/model/ExperienceVO.java b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/model/ExperienceVO.java index f6939716..767b928b 100644 --- a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/model/ExperienceVO.java +++ b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/model/ExperienceVO.java @@ -6,6 +6,8 @@ import com.alibaba.assistant.agent.extension.experience.model.ExperienceMetadata; import com.alibaba.assistant.agent.extension.experience.model.ExperienceType; import com.alibaba.assistant.agent.extension.experience.model.FastIntentConfig; +import com.alibaba.assistant.agent.extension.experience.model.ReferenceEntry; +import com.alibaba.assistant.agent.extension.experience.model.AssetEntry; import java.time.Instant; import java.util.ArrayList; @@ -36,6 +38,8 @@ public class ExperienceVO { private Instant updatedAt; private Map properties = new HashMap<>(); private String toolInvocationPath; + private List references = new ArrayList<>(); + private List assets = new ArrayList<>(); public static ExperienceVO fromExperience(Experience exp) { ExperienceVO vo = new ExperienceVO(); @@ -52,6 +56,16 @@ public static ExperienceVO fromExperience(Experience exp) { vo.setFastIntentConfig(exp.getFastIntentConfig()); vo.setCreatedAt(exp.getCreatedAt()); vo.setUpdatedAt(exp.getUpdatedAt()); + if (exp.getReferences() != null) { + vo.setReferences(new ArrayList<>(exp.getReferences())); + } + if (exp.getAssets() != null) { + List assetViews = new ArrayList<>(); + for (AssetEntry asset : exp.getAssets()) { + assetViews.add(AssetView.fromAsset(asset)); + } + vo.setAssets(assetViews); + } ExperienceMetadata metadata = exp.getMetadata(); if (metadata != null) { @@ -278,4 +292,55 @@ public String getToolInvocationPath() { public void setToolInvocationPath(String toolInvocationPath) { this.toolInvocationPath = toolInvocationPath; } + + public List getReferences() { + return references; + } + + public void setReferences(List references) { + this.references = references; + } + + public List getAssets() { + return assets; + } + + public void setAssets(List assets) { + this.assets = assets; + } + + /** Public asset projection that hides raw content by default. */ + public static class AssetView { + private String path; + private String mediaType; + private String role; + private String description; + private Long size; + private boolean contentAvailable; + + public static AssetView fromAsset(AssetEntry entry) { + AssetView v = new AssetView(); + v.path = entry.getPath(); + v.mediaType = entry.getMediaType(); + v.role = entry.getRole(); + v.description = entry.getDescription(); + v.size = entry.getSize(); + v.contentAvailable = (entry.getContent() != null && !entry.getContent().isEmpty()) + || (entry.getContentRef() != null && !entry.getContentRef().isEmpty()); + return v; + } + + public String getPath() { return path; } + public void setPath(String path) { this.path = path; } + public String getMediaType() { return mediaType; } + public void setMediaType(String mediaType) { this.mediaType = mediaType; } + public String getRole() { return role; } + public void setRole(String role) { this.role = role; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + public Long getSize() { return size; } + public void setSize(Long size) { this.size = size; } + public boolean isContentAvailable() { return contentAvailable; } + public void setContentAvailable(boolean contentAvailable) { this.contentAvailable = contentAvailable; } + } } diff --git a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/model/SkillPackageImportResult.java b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/model/SkillPackageImportResult.java index 80e5fb2d..100b370f 100644 --- a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/model/SkillPackageImportResult.java +++ b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/model/SkillPackageImportResult.java @@ -1,5 +1,8 @@ package com.alibaba.assistant.agent.management.model; +import com.alibaba.assistant.agent.extension.experience.model.AssetEntry; +import com.alibaba.assistant.agent.extension.experience.model.ReferenceEntry; + import java.util.ArrayList; import java.util.List; @@ -21,6 +24,26 @@ public class SkillPackageImportResult { */ private ExperienceVO experience; + /** + * 导入后生成的 TOOL 类型经验 ID(CLI-bound package) + */ + private String importedToolId; + + /** + * 导入后的 TOOL 类型经验预览 + */ + private ExperienceVO toolExperience; + + /** + * 本次导入生成的 reference 条目列表(供管理页预览) + */ + private List references = new ArrayList<>(); + + /** + * 本次导入生成的 asset 条目列表(供管理页预览;content 可能已置空以避免载荷过大) + */ + private List assets = new ArrayList<>(); + /** * 成功处理的文件路径列表 */ @@ -89,6 +112,38 @@ public void setExperience(ExperienceVO experience) { this.experience = experience; } + public String getImportedToolId() { + return importedToolId; + } + + public void setImportedToolId(String importedToolId) { + this.importedToolId = importedToolId; + } + + public ExperienceVO getToolExperience() { + return toolExperience; + } + + public void setToolExperience(ExperienceVO toolExperience) { + this.toolExperience = toolExperience; + } + + public List getReferences() { + return references; + } + + public void setReferences(List references) { + this.references = references != null ? references : new ArrayList<>(); + } + + public List getAssets() { + return assets; + } + + public void setAssets(List assets) { + this.assets = assets != null ? assets : new ArrayList<>(); + } + public List getProcessedFiles() { return processedFiles; } diff --git a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/spi/ExperienceManagementService.java b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/spi/ExperienceManagementService.java index 5ecb0bfa..4820f0ef 100644 --- a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/spi/ExperienceManagementService.java +++ b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/spi/ExperienceManagementService.java @@ -25,4 +25,24 @@ public interface ExperienceManagementService { void delete(String id); Map countByType(); + + /** + * 加载指定经验下某个 asset 的完整条目(包含 content/contentRef)。 + * 用于管理后台按需查看 asset 内容。 + * + * @param id experience id + * @param path asset 相对路径 + * @return 匹配的 asset,未找到时返回 null + */ + com.alibaba.assistant.agent.extension.experience.model.AssetEntry loadAsset(String id, String path); + + /** + * 重新生成指定经验下所有 reference 的 {@code description}(走 H1 / frontmatter / + * LLM summarizer / 回退文案优先级)。适用于导入时 summarizer 尚未配置、 + * 事后补齐的场景。 + * + * @param id experience id + * @return 更新成功的 reference 数量;id 不存在或没有 reference 时返回 0 + */ + int resummarizeReferences(String id); } diff --git a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/spi/ReferenceSummarizer.java b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/spi/ReferenceSummarizer.java new file mode 100644 index 00000000..90f5bc6a --- /dev/null +++ b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/spi/ReferenceSummarizer.java @@ -0,0 +1,22 @@ +package com.alibaba.assistant.agent.management.spi; + +/** + * 为 skill 包导入阶段提供 reference 文档描述生成能力的 SPI。 + * + *

    调用方按以下优先级选择 description: + *

      + *
    1. .md 首个非空 H1 标题
    2. + *
    3. YAML frontmatter 中的 {@code description:} 字段
    4. + *
    5. 本 SPI:调用 LLM/默认实现对正文做总结
    6. + *
    7. 回退文案:{@code "(no description) " + path}
    8. + *
    + */ +public interface ReferenceSummarizer { + + /** + * @param path skill 包内的相对路径,仅用于日志/提示 + * @param content 文档正文(UTF-8) + * @return 一段简短描述;返回 null/空串表示无法总结,调用方应使用回退文案 + */ + String summarize(String path, String content); +} diff --git a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/spi/SkillExchangeService.java b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/spi/SkillExchangeService.java index e9683bcf..b92bebf7 100644 --- a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/spi/SkillExchangeService.java +++ b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/spi/SkillExchangeService.java @@ -13,6 +13,21 @@ public interface SkillExchangeService { String exportAllSkills(ExperienceType type); + /** + * 将 experience 还原为与导入互逆的 skill 包(zip)。 + * + *

    导出内容包括: + *

      + *
    • SKILL.md:根据当前 experience 的 frontmatter 与 content 重建
    • + *
    • references/、scripts/、assets/ 等原始文件,按原相对路径写回
    • + *
    • package.json:若 experience 关联了 CLI TOOL 经验,则根据 artifact 还原 cli 字段
    • + *
    + * + * @param experienceId 目标 experience id(接受 REACT 或 TOOL 任一侧) + * @return zip 字节内容 + */ + byte[] exportSkillPackage(String experienceId); + ExperienceVO previewSkillImport(String skillMarkdown); /** diff --git a/assistant-agent-management/src/main/resources/static/exp-console/app.js b/assistant-agent-management/src/main/resources/static/exp-console/app.js index b9b191d9..15a5ca45 100644 --- a/assistant-agent-management/src/main/resources/static/exp-console/app.js +++ b/assistant-agent-management/src/main/resources/static/exp-console/app.js @@ -97,6 +97,11 @@ const ExpConsole = (() => { fallback: 'Fallback', fastIntentMatchJson: 'Fast Intent Match JSON', artifactJson: 'Artifact JSON', + referencesLabel: 'References', + assetsLabel: 'Assets', + resummarizeBtn: 'Re-summarize', + resummarizeOk: 'Updated {n} references', + resummarizeFail: 'Re-summarize failed', propertiesJson: 'Metadata Properties JSON', importSkillTitle: 'Import SKILL', importExperienceModal: 'Import Experience (from SKILL)', @@ -126,6 +131,7 @@ const ExpConsole = (() => { toastImportFailed: 'Import failed: {message}', toastPreviewFailed: 'Preview failed: {message}', toastExportFailed: 'Export failed: {message}', + toastExportDownloaded: 'Downloaded {filename}.', toastLoadToolsFailed: 'Failed to load tools: {message}', toastRequestFailed: 'Request failed ({status})', toastUpdated: 'Experience updated.', @@ -225,6 +231,11 @@ const ExpConsole = (() => { fallback: '回退策略', fastIntentMatchJson: '快速意图匹配 JSON', artifactJson: 'Artifact JSON', + referencesLabel: '参考文档 References', + assetsLabel: '资源文件 Assets', + resummarizeBtn: '重新总结', + resummarizeOk: '已更新 {n} 个参考文档描述', + resummarizeFail: '重新总结失败', propertiesJson: '元数据属性 JSON', importSkillTitle: '导入 SKILL', importExperienceModal: '导入经验(来自 SKILL)', @@ -254,6 +265,7 @@ const ExpConsole = (() => { toastImportFailed: '导入失败: {message}', toastPreviewFailed: '预览失败: {message}', toastExportFailed: '导出失败: {message}', + toastExportDownloaded: '已下载 {filename}。', toastLoadToolsFailed: '加载工具失败: {message}', toastRequestFailed: '请求失败 ({status})', toastUpdated: '经验已更新。', @@ -347,6 +359,10 @@ const ExpConsole = (() => { dom.formFastIntentMatch = $('#form-fastintent-match'); dom.formArtifactJson = $('#form-artifact-json'); dom.formPropertiesJson = $('#form-properties-json'); + dom.formGroupReferences = $('#form-group-references'); + dom.formReferencesList = $('#form-references-list'); + dom.formGroupAssets = $('#form-group-assets'); + dom.formAssetsList = $('#form-assets-list'); dom.skillImportModal = $('#skill-import-modal'); dom.skillImportContent = $('#skill-import-content'); dom.skillPreviewArea = $('#skill-preview-area'); @@ -598,6 +614,8 @@ const ExpConsole = (() => { setText('form-label-fastintent-fallback', t('fallback')); setText('form-label-fastintent-match', t('fastIntentMatchJson')); setText('form-label-artifact-json', t('artifactJson')); + setText('form-label-references', t('referencesLabel')); + setText('form-label-assets', t('assetsLabel')); setText('form-label-properties-json', t('propertiesJson')); setText('modal-cancel', t('cancel')); @@ -1337,6 +1355,8 @@ const ExpConsole = (() => { dom.formFastIntentMatch.value = stringifyJson(exp.fastIntentConfig ? exp.fastIntentConfig.match : null); dom.formArtifactJson.value = stringifyJson(exp.artifact); dom.formPropertiesJson.value = stringifyJson(exp.properties); + renderReferencesAndAssets(exp); + wireResummarizeButton(exp.id); openModal(t('editExperienceModal'), true); } catch (err) { @@ -1454,6 +1474,99 @@ const ExpConsole = (() => { return TOOL_INVOCATION_PATH_DEFAULT; } + function wireResummarizeButton(experienceId) { + const btn = document.getElementById('btn-resummarize'); + if (!btn || !experienceId) { + return; + } + btn.textContent = t('resummarizeBtn'); + btn.onclick = async () => { + btn.disabled = true; + const original = btn.textContent; + btn.textContent = '…'; + try { + const resp = await fetch(`${API_BASE}/experiences/${encodeURIComponent(experienceId)}/resummarize`, { + method: 'POST', + }); + if (!resp.ok) { + throw new Error('HTTP ' + resp.status); + } + const payload = await resp.json(); + const n = payload.updated ?? 0; + showToast(t('resummarizeOk', { n }), 'success'); + // Reload experience to reflect new descriptions + const detailResp = await fetch(`${API_BASE}/experiences/${encodeURIComponent(experienceId)}`); + if (detailResp.ok) { + const exp = await detailResp.json(); + renderReferencesAndAssets(exp); + } + } catch (err) { + showToast(t('resummarizeFail') + ': ' + err.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = original; + } + }; + } + + function renderReferencesAndAssets(exp) { + const refs = (exp && Array.isArray(exp.references)) ? exp.references : []; + const assets = (exp && Array.isArray(exp.assets)) ? exp.assets : []; + if (dom.formGroupReferences) { + dom.formGroupReferences.hidden = refs.length === 0; + if (refs.length > 0) { + dom.formReferencesList.innerHTML = refs.map(r => { + const size = r.size != null ? ` · ${r.size}B` : ''; + const desc = r.description ? ` — ${escapeHtml(r.description)}` : ''; + const preview = r.content ? `
    content
    ${escapeHtml(r.content)}
    ` : ''; + return `
    ${escapeHtml(r.path || '')}${size}${desc}${preview}
    `; + }).join(''); + } + } + if (dom.formGroupAssets) { + dom.formGroupAssets.hidden = assets.length === 0; + if (assets.length > 0) { + dom.formAssetsList.innerHTML = assets.map(a => { + const role = a.role ? ` [${escapeHtml(a.role)}]` : ''; + const size = a.size != null ? ` · ${a.size}B` : ''; + const desc = a.description ? ` — ${escapeHtml(a.description)}` : ''; + const avail = a.contentAvailable + ? ` [load content]` + : ''; + return `
    ${escapeHtml(a.path || '')}${role}${size}${desc}${avail}
    `; + }).join(''); + dom.formAssetsList.querySelectorAll('.asset-fetch-link').forEach(link => { + link.addEventListener('click', async (ev) => { + ev.preventDefault(); + const path = link.getAttribute('data-asset-path'); + try { + const encoded = path.split('/').map(encodeURIComponent).join('/'); + const resp = await fetch(`${API_BASE}/experiences/${encodeURIComponent(exp.id)}/assets/${encoded}`); + if (!resp.ok) { + throw new Error('HTTP ' + resp.status); + } + const payload = await resp.json(); + const row = dom.formAssetsList.querySelector(`[data-asset-row="${CSS.escape(path)}"]`); + if (row) { + const pre = document.createElement('pre'); + pre.style.whiteSpace = 'pre-wrap'; + pre.style.margin = '4px 0'; + pre.style.background = '#f6f8fa'; + pre.style.padding = '4px'; + const content = payload.content || ''; + pre.textContent = content.length > 2000 ? content.slice(0, 2000) + '…(truncated)' : content; + row.appendChild(pre); + link.remove(); + } + } catch (err) { + showToast('Load asset failed: ' + err.message, 'error'); + } + }); + }); + } + } + } + // ═══════════════════════════════════════════════════════ // Delete @@ -1564,7 +1677,7 @@ const ExpConsole = (() => { function renderImportResult(result) { let html = ''; - // Preview experience info + // React experience summary if (result.experience) { const exp = result.experience; dom.skillPreviewArea.hidden = false; @@ -1576,6 +1689,39 @@ const ExpConsole = (() => { `; } + // Tool experience (CLI binding) + if (result.toolExperience) { + const tool = result.toolExperience; + html += `
    Tool Experience (CLI)
    +
    Name: ${escapeHtml(tool.name || '')}
    +
    ID: ${escapeHtml(tool.id || '')}
    +
    ${escapeHtml(t('previewDescription'))}: ${escapeHtml(tool.description || '')}
    +
    `; + } + + // References + if (result.references && result.references.length) { + html += `
    ${escapeHtml(t('referencesLabel'))} (${result.references.length})
      `; + for (const r of result.references) { + const desc = r.description ? ` — ${escapeHtml(r.description)}` : ''; + const size = r.size != null ? ` · ${r.size}B` : ''; + html += `
    • ${escapeHtml(r.path || '')}${size}${desc}
    • `; + } + html += `
    `; + } + + // Assets + if (result.assets && result.assets.length) { + html += `
    ${escapeHtml(t('assetsLabel'))} (${result.assets.length})
      `; + for (const a of result.assets) { + const role = a.role ? ` [${escapeHtml(a.role)}]` : ''; + const size = a.size != null ? ` · ${a.size}B` : ''; + const desc = a.description ? ` — ${escapeHtml(a.description)}` : ''; + html += `
    • ${escapeHtml(a.path || '')}${role}${size}${desc}
    • `; + } + html += `
    `; + } + // Processed files if (result.processedFiles && result.processedFiles.length) { html += `
    ${escapeHtml(t('processedFiles'))}
      `; @@ -1682,19 +1828,19 @@ const ExpConsole = (() => { return; } try { - const result = await api.previewSkillPackage(selectedFile); - if (result.experience) { - openImportForm(result.experience); - // Show info toast about skipped files / warnings - if (result.skippedFiles && result.skippedFiles.length) { - const skippedNames = result.skippedFiles.map(sf => sf.path).join(', '); - showToast(t('skippedFiles') + ': ' + skippedNames, 'info'); - } - if (result.warnings && result.warnings.length) { - showToast(result.warnings.join('; '), 'error'); - } - } else { - showToast(t('toastImportFailed', { message: 'No experience data found in package' }), 'error'); + // Directly call /import-package — preserves references/assets and creates + // tool experience alongside react experience. + const result = await api.importSkillPackage(selectedFile); + renderImportResult(result); + if (result.warnings && result.warnings.length) { + showToast(result.warnings.join('; '), 'error'); + } + showToast(t('toastImportSuccess') || 'Import successful', 'success'); + closeSkillImport(); + // Refresh list + open detail drawer on the newly created react experience + if (result.importedId) { + await loadExperiences(); + setTimeout(() => { openEditModal(result.importedId); }, 200); } } catch (err) { showToast(t('toastImportFailed', { message: err.message }), 'error'); @@ -1721,14 +1867,45 @@ const ExpConsole = (() => { async function exportAsSkill(id) { try { - const result = await api.exportSkill(id); - dom.skillExportContent.value = result.content || result; - dom.skillExportModal.hidden = false; + // 触发 zip 下载(无损还原 SKILL.md / references / assets / package.json) + const url = API_BASE + '/skills/export-package/' + encodeURIComponent(id); + const resp = await fetch(url); + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + throw new Error(text || `HTTP ${resp.status}`); + } + const blob = await resp.blob(); + const filename = parseFilenameFromContentDisposition(resp.headers.get('content-disposition')) + || (id + '.zip'); + triggerDownload(blob, filename); + showToast(t('toastExportDownloaded', { filename }), 'success'); } catch (err) { showToast(t('toastExportFailed', { message: err.message }), 'error'); } } + function parseFilenameFromContentDisposition(header) { + if (!header) return null; + // RFC 5987: filename*=UTF-8''xxx + const starMatch = header.match(/filename\*=UTF-8''([^;]+)/i); + if (starMatch) { + try { return decodeURIComponent(starMatch[1]); } catch (_) { /* fallthrough */ } + } + const plainMatch = header.match(/filename="?([^";]+)"?/i); + return plainMatch ? plainMatch[1] : null; + } + + function triggerDownload(blob, filename) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } + function closeSkillExport() { dom.skillExportModal.hidden = true; } diff --git a/assistant-agent-management/src/main/resources/static/exp-console/index.html b/assistant-agent-management/src/main/resources/static/exp-console/index.html index 684d91f6..011852e9 100644 --- a/assistant-agent-management/src/main/resources/static/exp-console/index.html +++ b/assistant-agent-management/src/main/resources/static/exp-console/index.html @@ -244,6 +244,14 @@
    + +
    diff --git a/assistant-agent-management/src/test/java/com/alibaba/assistant/agent/management/internal/InMemorySkillExchangeServiceTest.java b/assistant-agent-management/src/test/java/com/alibaba/assistant/agent/management/internal/InMemorySkillExchangeServiceTest.java new file mode 100644 index 00000000..f93de53b --- /dev/null +++ b/assistant-agent-management/src/test/java/com/alibaba/assistant/agent/management/internal/InMemorySkillExchangeServiceTest.java @@ -0,0 +1,116 @@ +package com.alibaba.assistant.agent.management.internal; + +import com.alibaba.assistant.agent.extension.experience.internal.InMemoryExperienceRepository; +import com.alibaba.assistant.agent.extension.experience.model.Experience; +import com.alibaba.assistant.agent.extension.experience.model.ExperienceType; +import com.alibaba.assistant.agent.management.model.SkillPackage; +import com.alibaba.assistant.agent.management.model.SkillPackageImportResult; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InMemorySkillExchangeServiceTest { + + @Test + void shouldPreviewCliBoundSkillPackageWithPackageTree() { + InMemorySkillExchangeService service = new InMemorySkillExchangeService(new InMemoryExperienceRepository()); + + SkillPackage skillPackage = new SkillPackage(); + skillPackage.setSkillMdContent(""" + --- + name: A1 Skill + description: Use A1 for coding tasks + --- + + # A1 Skill + + Follow the workflow. + """); + skillPackage.setName("a1-skill"); + skillPackage.setVersion("0.0.1"); + skillPackage.setScripts(Map.of("scripts/bootstrap.sh", "echo bootstrap")); + skillPackage.setOtherFiles(Map.of( + "package.json", """ + { + "name": "a1-skill", + "version": "0.0.1" + } + """.getBytes(), + "references/guides/setup.md", "guide".getBytes(), + "assets/templates/config.yaml", "name: demo".getBytes() + )); + skillPackage.setPackageMetadata(Map.of( + "meow", Map.of( + "cli", Map.of( + "provider", "a1", + "toolName", "a1_run", + "runnerImage", "registry/a1-cli:1.0.0", + "commandAllowPattern", "^a1(\\s|$)", + "inputSchema", Map.of( + "type", "object", + "properties", Map.of("command", Map.of("type", "string")) + ) + ) + ) + )); + + SkillPackageImportResult result = service.previewSkillPackageImport(skillPackage); + + assertNotNull(result.getExperience()); + assertNotNull(result.getToolExperience()); + assertNotNull(result.getReferences()); + assertNotNull(result.getAssets()); + assertEquals("cli", result.getToolExperience().getArtifact().getTool().getSource()); + assertEquals("registry/a1-cli:1.0.0", result.getToolExperience().getArtifact().getTool().getRunnerImage()); + assertTrue(result.getProcessedFiles().contains("scripts/bootstrap.sh")); + assertTrue(result.getAssets().stream() + .anyMatch(asset -> "assets/templates/config.yaml".equals(asset.getPath()) && "asset".equals(asset.getRole()))); + } + + @Test + void shouldImportReactAndToolExperiencesForCliBoundSkill() { + InMemoryExperienceRepository repository = new InMemoryExperienceRepository(); + InMemorySkillExchangeService service = new InMemorySkillExchangeService(repository); + + SkillPackage skillPackage = new SkillPackage(); + skillPackage.setSkillMdContent(""" + --- + name: A1 Skill + description: Use A1 for coding tasks + --- + + # A1 Skill + + Follow the workflow. + """); + skillPackage.setName("a1-skill"); + skillPackage.setVersion("0.0.1"); + skillPackage.setPackageMetadata(Map.of( + "cli", Map.of( + "provider", "a1", + "toolName", "a1_run", + "commandAllowPattern", "^a1(\\s|$)" + ) + )); + + SkillPackageImportResult result = service.importSkillPackage(skillPackage); + + assertNotNull(result.getImportedId()); + assertNotNull(result.getImportedToolId()); + + List reactExperiences = repository.findAllByType(ExperienceType.REACT); + List toolExperiences = repository.findAllByType(ExperienceType.TOOL); + assertEquals(1, reactExperiences.size()); + assertEquals(1, toolExperiences.size()); + assertEquals(List.of(result.getImportedToolId()), reactExperiences.get(0).getRelatedExperiences()); + assertEquals(List.of(result.getImportedId()), toolExperiences.get(0).getRelatedExperiences()); + assertEquals("cli", toolExperiences.get(0).getArtifact().getTool().getSource()); + assertNotNull(toolExperiences.get(0).getReferences()); + assertNotNull(toolExperiences.get(0).getAssets()); + } +} diff --git a/assistant-agent-management/src/test/java/com/alibaba/assistant/agent/management/internal/SkillPackageParserTest.java b/assistant-agent-management/src/test/java/com/alibaba/assistant/agent/management/internal/SkillPackageParserTest.java new file mode 100644 index 00000000..3fa3ae18 --- /dev/null +++ b/assistant-agent-management/src/test/java/com/alibaba/assistant/agent/management/internal/SkillPackageParserTest.java @@ -0,0 +1,74 @@ +package com.alibaba.assistant.agent.management.internal; + +import com.alibaba.assistant.agent.management.model.SkillPackage; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SkillPackageParserTest { + + @Test + void shouldParseStructuredPackageMetadataFromPackageJson() throws Exception { + SkillPackageParser parser = new SkillPackageParser(); + + byte[] zipData = buildZip(Map.of( + "demo-skill/SKILL.md", """ + --- + name: demo + description: demo skill + --- + + # Demo + + hello + """, + "demo-skill/package.json", """ + { + "name": "demo-from-package", + "version": "1.0.1", + "description": "cli skill", + "meow": { + "cli": { + "provider": "a1", + "toolName": "a1_demo", + "commandAllowPattern": "^a1(\\\\s|$)" + } + } + } + """ + )); + + SkillPackage skillPackage = parser.parseZip(new ByteArrayInputStream(zipData)); + + assertEquals("demo-from-package", skillPackage.getName()); + assertEquals("1.0.1", skillPackage.getVersion()); + assertEquals("cli skill", skillPackage.getDescription()); + assertTrue(skillPackage.getPackageMetadata().containsKey("meow")); + Object meow = skillPackage.getPackageMetadata().get("meow"); + assertInstanceOf(Map.class, meow); + Object cli = ((Map) meow).get("cli"); + assertInstanceOf(Map.class, cli); + assertEquals("a1", ((Map) cli).get("provider")); + } + + private byte[] buildZip(Map files) throws Exception { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + try (ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream, StandardCharsets.UTF_8)) { + for (Map.Entry entry : files.entrySet()) { + zipOutputStream.putNextEntry(new ZipEntry(entry.getKey())); + zipOutputStream.write(entry.getValue().getBytes(StandardCharsets.UTF_8)); + zipOutputStream.closeEntry(); + } + } + return outputStream.toByteArray(); + } +} diff --git a/assistant-agent-prompt-builder/pom.xml b/assistant-agent-prompt-builder/pom.xml index e07db64b..3e02067f 100644 --- a/assistant-agent-prompt-builder/pom.xml +++ b/assistant-agent-prompt-builder/pom.xml @@ -6,7 +6,7 @@ com.alibaba.agent.assistant assistant-agent - 0.2.5 + 0.2.6 assistant-agent-prompt-builder @@ -36,4 +36,4 @@ test - \ No newline at end of file + diff --git a/assistant-agent-start/pom.xml b/assistant-agent-start/pom.xml index 1e0ea2af..277f7b16 100644 --- a/assistant-agent-start/pom.xml +++ b/assistant-agent-start/pom.xml @@ -6,7 +6,7 @@ com.alibaba.agent.assistant assistant-agent - 0.2.5 + 0.2.6 assistant-agent-start diff --git a/pom.xml b/pom.xml index 665acbe3..7fbd30e5 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ 4.0.0 com.alibaba.agent.assistant assistant-agent - 0.2.5 + 0.2.6 pom