mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +08:00
feat(skill): typed wrapper tools for declared script entrypoints
This commit is contained in:
parent
31d4d014ed
commit
3d643f909d
@ -91,6 +91,18 @@ public class SkillManifest {
|
|||||||
/** Set when {@code type=acp}. Resolves to a {@code mate_acp_endpoint} row. */
|
/** Set when {@code type=acp}. Resolves to a {@code mate_acp_endpoint} row. */
|
||||||
private AcpBinding acp;
|
private AcpBinding acp;
|
||||||
|
|
||||||
|
// ==================== type=code script entrypoints ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declared script entrypoints from the {@code scripts} frontmatter
|
||||||
|
* block. Each entry is exposed to the model as a typed wrapper tool —
|
||||||
|
* the model fills schema-described fields and the runtime serializes
|
||||||
|
* them into process arguments, so a script consuming a JSON payload
|
||||||
|
* never depends on the model hand-crafting a JSON string.
|
||||||
|
*/
|
||||||
|
@Builder.Default
|
||||||
|
private List<ScriptDef> scripts = List.of();
|
||||||
|
|
||||||
// ==================== Forward-compat catch-all ====================
|
// ==================== Forward-compat catch-all ====================
|
||||||
|
|
||||||
/** Unknown frontmatter keys are stashed here so a future field
|
/** Unknown frontmatter keys are stashed here so a future field
|
||||||
@ -198,6 +210,45 @@ public class SkillManifest {
|
|||||||
private Long resolvedEndpointId;
|
private Long resolvedEndpointId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One script entrypoint declared under the {@code scripts} block. The
|
||||||
|
* resolver turns each into a typed wrapper tool named
|
||||||
|
* {@code skill_<skill>_<id>}.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||||
|
public static class ScriptDef {
|
||||||
|
/** Stable id; forms the suffix of the generated wrapper tool name. */
|
||||||
|
private String id;
|
||||||
|
/** Human-readable label for the entrypoint. */
|
||||||
|
private String label;
|
||||||
|
/** Script path relative to the skill directory (e.g. {@code scripts/run.py}). */
|
||||||
|
private String path;
|
||||||
|
/** What the entrypoint does — surfaced as the wrapper tool description. */
|
||||||
|
private String description;
|
||||||
|
/**
|
||||||
|
* Literal arguments prepended before the typed arguments. Lets one
|
||||||
|
* dispatcher script back several entrypoints — e.g. a fixed method
|
||||||
|
* name as {@code argv[1]} with the typed JSON payload as {@code argv[2]}.
|
||||||
|
*/
|
||||||
|
@Builder.Default
|
||||||
|
private List<String> fixedArgs = List.of();
|
||||||
|
/**
|
||||||
|
* Raw JSON Schema object describing the entrypoint's parameters,
|
||||||
|
* forwarded verbatim as the wrapper tool's input schema.
|
||||||
|
*/
|
||||||
|
@Builder.Default
|
||||||
|
private Map<String, Object> parameters = Map.of();
|
||||||
|
/**
|
||||||
|
* How typed arguments reach the script process:
|
||||||
|
* {@code json} (default) forwards one compact JSON argument;
|
||||||
|
* {@code flags} forwards each property as a {@code --key value} pair.
|
||||||
|
*/
|
||||||
|
@Builder.Default
|
||||||
|
private String argStyle = "json";
|
||||||
|
}
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder
|
||||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||||
|
|||||||
@ -40,6 +40,7 @@ public class SkillManifestParser {
|
|||||||
"dashboard", "self-evolution", "self_evolution",
|
"dashboard", "self-evolution", "self_evolution",
|
||||||
"knowledge",
|
"knowledge",
|
||||||
"acp",
|
"acp",
|
||||||
|
"scripts",
|
||||||
// legacy / housekeeping fields that aren't manifest-relevant
|
// legacy / housekeeping fields that aren't manifest-relevant
|
||||||
"metadata"
|
"metadata"
|
||||||
);
|
);
|
||||||
@ -102,6 +103,7 @@ public class SkillManifestParser {
|
|||||||
.selfEvolution(parseSelfEvolution(coalesce(fm, "self-evolution", "self_evolution")))
|
.selfEvolution(parseSelfEvolution(coalesce(fm, "self-evolution", "self_evolution")))
|
||||||
.knowledge(parseKnowledge(fm.get("knowledge")))
|
.knowledge(parseKnowledge(fm.get("knowledge")))
|
||||||
.acp(parseAcp(fm.get("acp")))
|
.acp(parseAcp(fm.get("acp")))
|
||||||
|
.scripts(parseScripts(fm.get("scripts")))
|
||||||
.extras(extractUnknown(fm));
|
.extras(extractUnknown(fm));
|
||||||
|
|
||||||
return b.build();
|
return b.build();
|
||||||
@ -261,6 +263,37 @@ public class SkillManifestParser {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== scripts ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the {@code scripts} block — a list of script entrypoint maps.
|
||||||
|
* The per-entry {@code parameters} map is carried through as a raw
|
||||||
|
* JSON Schema object; nested maps / lists from the YAML parse stay
|
||||||
|
* intact so the wrapper factory can serialize them verbatim.
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private List<SkillManifest.ScriptDef> parseScripts(Object rawScripts) {
|
||||||
|
if (!(rawScripts instanceof List<?> list)) return List.of();
|
||||||
|
List<SkillManifest.ScriptDef> out = new ArrayList<>();
|
||||||
|
for (Object item : list) {
|
||||||
|
if (!(item instanceof Map<?, ?> map)) continue;
|
||||||
|
Map<String, Object> m = (Map<String, Object>) map;
|
||||||
|
Map<String, Object> parameters = m.get("parameters") instanceof Map<?, ?> p
|
||||||
|
? toStringObjectMap((Map<?, ?>) p) : Map.of();
|
||||||
|
out.add(SkillManifest.ScriptDef.builder()
|
||||||
|
.id(string(m, "id"))
|
||||||
|
.label(string(m, "label"))
|
||||||
|
.path(string(m, "path"))
|
||||||
|
.description(string(m, "description"))
|
||||||
|
.fixedArgs(stringList(coalesce(m, "fixed_args", "fixedArgs")))
|
||||||
|
.parameters(parameters)
|
||||||
|
.argStyle(stringOrDefault(m, "arg_style",
|
||||||
|
stringOrDefault(m, "argStyle", "json")))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private SkillManifest.AcpBinding parseAcp(Object raw) {
|
private SkillManifest.AcpBinding parseAcp(Object raw) {
|
||||||
if (!(raw instanceof Map<?, ?> map)) return null;
|
if (!(raw instanceof Map<?, ?> map)) return null;
|
||||||
|
|||||||
@ -0,0 +1,262 @@
|
|||||||
|
package vip.mate.skill.runtime;
|
||||||
|
|
||||||
|
import cn.hutool.json.JSONUtil;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.tool.ToolCallback;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.skill.knowledge.SkillScopedToolCallback;
|
||||||
|
import vip.mate.skill.manifest.SkillManifest;
|
||||||
|
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||||
|
import vip.mate.skill.secret.SkillSecretService;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper tool factory for skill script entrypoints declared in the
|
||||||
|
* {@code scripts} manifest block.
|
||||||
|
*
|
||||||
|
* <p>A directory-backed skill ships executable scripts under {@code scripts/}.
|
||||||
|
* The generic {@code runSkillScript} tool can run any of them, but it forces
|
||||||
|
* the model to hand-assemble the argument list — brittle whenever a script
|
||||||
|
* consumes a structured JSON payload. This factory turns each declared
|
||||||
|
* entrypoint into its own typed tool: the model fills schema-described
|
||||||
|
* fields, and the runtime serializes them into process arguments. The model
|
||||||
|
* never crafts a JSON string by hand.
|
||||||
|
*
|
||||||
|
* <p>One wrapper per entrypoint, named {@code skill_<skill>_<entrypoint>}.
|
||||||
|
* Each wrapper closes over the resolved skill directory and id, so a call
|
||||||
|
* always targets the declaring skill's own script and decrypted secrets and
|
||||||
|
* cannot be redirected elsewhere.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ScriptSkillWrapperToolFactory {
|
||||||
|
|
||||||
|
private final SkillScriptExecutionService executionService;
|
||||||
|
private final SkillFileAccessPolicy accessPolicy;
|
||||||
|
private final SkillSecretService skillSecretService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build one wrapper callback per declared script entrypoint. Returns an
|
||||||
|
* empty list when the skill declares no entrypoints or has no directory
|
||||||
|
* (a database-only skill cannot expose runnable scripts).
|
||||||
|
*/
|
||||||
|
public List<ToolCallback> buildWrappers(ResolvedSkill resolved, SkillManifest manifest) {
|
||||||
|
if (resolved == null || manifest == null
|
||||||
|
|| manifest.getScripts() == null || manifest.getScripts().isEmpty()
|
||||||
|
|| resolved.getSkillDir() == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
String skillSlug = sanitize(manifest.getName());
|
||||||
|
if (skillSlug.isBlank()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
Path skillDir = resolved.getSkillDir();
|
||||||
|
Long skillId = resolved.getId();
|
||||||
|
|
||||||
|
List<ToolCallback> out = new ArrayList<>();
|
||||||
|
Set<String> seen = new LinkedHashSet<>();
|
||||||
|
for (SkillManifest.ScriptDef def : manifest.getScripts()) {
|
||||||
|
if (!isUsable(def)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String name = "skill_" + skillSlug + "_" + sanitize(def.getId());
|
||||||
|
if (!seen.add(name)) {
|
||||||
|
log.warn("Skill '{}' script entrypoint id '{}' collides on tool name '{}'; skipping duplicate",
|
||||||
|
manifest.getName(), def.getId(), name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.add(new SkillScopedToolCallback(
|
||||||
|
name,
|
||||||
|
buildDescription(manifest, def),
|
||||||
|
buildInputSchema(def),
|
||||||
|
input -> invoke(skillDir, skillId, def, input)));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names the wrappers this manifest would produce, without building them.
|
||||||
|
* Used by the resolver to merge entrypoint names into
|
||||||
|
* {@code manifest.allowedTools} so {@code getEffectiveAllowedTools()}
|
||||||
|
* surfaces them like any other declared tool.
|
||||||
|
*/
|
||||||
|
public List<String> wrapperNames(SkillManifest manifest) {
|
||||||
|
if (manifest == null || manifest.getName() == null
|
||||||
|
|| manifest.getScripts() == null || manifest.getScripts().isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
String skillSlug = sanitize(manifest.getName());
|
||||||
|
if (skillSlug.isBlank()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<String> names = new ArrayList<>();
|
||||||
|
Set<String> seen = new LinkedHashSet<>();
|
||||||
|
for (SkillManifest.ScriptDef def : manifest.getScripts()) {
|
||||||
|
if (!isUsable(def)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String name = "skill_" + skillSlug + "_" + sanitize(def.getId());
|
||||||
|
if (seen.add(name)) {
|
||||||
|
names.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An entrypoint is usable only when it has both an id and a script path. */
|
||||||
|
private static boolean isUsable(SkillManifest.ScriptDef def) {
|
||||||
|
return def != null
|
||||||
|
&& def.getId() != null && !def.getId().isBlank()
|
||||||
|
&& def.getPath() != null && !def.getPath().isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== invocation ====================
|
||||||
|
|
||||||
|
private String invoke(Path skillDir, Long skillId, SkillManifest.ScriptDef def, String input) {
|
||||||
|
try {
|
||||||
|
JsonNode args = (input == null || input.isBlank())
|
||||||
|
? objectMapper.createObjectNode()
|
||||||
|
: objectMapper.readTree(input);
|
||||||
|
|
||||||
|
// Path traversal is blocked here — only scripts under the
|
||||||
|
// skill's own scripts/ directory can be reached.
|
||||||
|
Path scriptPath = accessPolicy.validateScriptPath(skillDir, def.getPath());
|
||||||
|
if (scriptPath == null) {
|
||||||
|
return errorJson("invalid or unsafe script path: " + def.getPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> argv = buildArgv(def.getFixedArgs(), def.getArgStyle(), args);
|
||||||
|
|
||||||
|
// Inject this skill's stored secrets as subprocess env vars,
|
||||||
|
// mirroring the generic runSkillScript path.
|
||||||
|
Map<String, String> envVars = skillId != null
|
||||||
|
? skillSecretService.getDecrypted(skillId)
|
||||||
|
: Map.of();
|
||||||
|
|
||||||
|
SkillScriptExecutionService.ScriptResult result =
|
||||||
|
executionService.execute(scriptPath, argv, envVars);
|
||||||
|
return JSONUtil.createObj()
|
||||||
|
.set("exitCode", result.getExitCode())
|
||||||
|
.set("stdout", result.getStdout())
|
||||||
|
.set("stderr", result.getStderr())
|
||||||
|
.toString();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("script wrapper for entrypoint '{}' failed: {}", def.getId(), e.getMessage());
|
||||||
|
return errorJson(e.getMessage() == null ? "script invocation failed" : e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate the typed argument object into a process argument list.
|
||||||
|
*
|
||||||
|
* <p>{@code fixedArgs} are emitted first, verbatim — they let one
|
||||||
|
* dispatcher script back several entrypoints (e.g. a fixed method name
|
||||||
|
* as {@code argv[1]}). The typed arguments follow, shaped by
|
||||||
|
* {@code argStyle}:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code json} (default) — append the whole object as one compact
|
||||||
|
* JSON argument, the shape a script reading its last argv with a
|
||||||
|
* JSON parser expects.</li>
|
||||||
|
* <li>{@code flags} — append each property as {@code --key value};
|
||||||
|
* a {@code true} boolean becomes a bare {@code --key}, while a
|
||||||
|
* {@code false} or null property is dropped.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Package-private and static for direct unit testing.
|
||||||
|
*
|
||||||
|
* @return the argument list, or {@code null} when it would be empty
|
||||||
|
*/
|
||||||
|
static List<String> buildArgv(List<String> fixedArgs, String argStyle, JsonNode args) {
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
if (fixedArgs != null) {
|
||||||
|
for (String fixed : fixedArgs) {
|
||||||
|
if (fixed != null) {
|
||||||
|
out.add(fixed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ("flags".equalsIgnoreCase(argStyle)) {
|
||||||
|
if (args != null && args.isObject()) {
|
||||||
|
Iterator<Map.Entry<String, JsonNode>> fields = args.fields();
|
||||||
|
while (fields.hasNext()) {
|
||||||
|
Map.Entry<String, JsonNode> field = fields.next();
|
||||||
|
JsonNode value = field.getValue();
|
||||||
|
if (value == null || value.isNull()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (value.isBoolean()) {
|
||||||
|
if (value.asBoolean()) {
|
||||||
|
out.add("--" + field.getKey());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.add("--" + field.getKey());
|
||||||
|
out.add(value.isValueNode() ? value.asText() : value.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Default: json — append one compact JSON argument, unless the
|
||||||
|
// object is empty / absent (an entrypoint with no typed input).
|
||||||
|
if (args != null && !args.isNull() && !args.isMissingNode()
|
||||||
|
&& !(args.isObject() && args.isEmpty())) {
|
||||||
|
out.add(args.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.isEmpty() ? null : out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== helpers ====================
|
||||||
|
|
||||||
|
private String buildDescription(SkillManifest manifest, SkillManifest.ScriptDef def) {
|
||||||
|
String base;
|
||||||
|
if (def.getDescription() != null && !def.getDescription().isBlank()) {
|
||||||
|
base = def.getDescription().trim();
|
||||||
|
} else if (def.getLabel() != null && !def.getLabel().isBlank()) {
|
||||||
|
base = def.getLabel().trim();
|
||||||
|
} else {
|
||||||
|
base = "Run the '" + def.getId() + "' script";
|
||||||
|
}
|
||||||
|
return base + " (skill: " + manifest.getName() + "). "
|
||||||
|
+ "Fill the described fields; the arguments are forwarded to the script for you.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildInputSchema(SkillManifest.ScriptDef def) {
|
||||||
|
Map<String, Object> params = def.getParameters();
|
||||||
|
if (params == null || params.isEmpty()) {
|
||||||
|
return "{\"type\":\"object\",\"properties\":{}}";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(params);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("script entrypoint '{}' has an unserializable parameter schema: {}",
|
||||||
|
def.getId(), e.getMessage());
|
||||||
|
return "{\"type\":\"object\",\"properties\":{}}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tool-name slug rule shared with the knowledge / acp wrapper factories. */
|
||||||
|
private static String sanitize(String raw) {
|
||||||
|
if (raw == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return raw.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_]", "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String errorJson(String message) {
|
||||||
|
return JSONUtil.createObj().set("error", message).toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -72,6 +72,12 @@ public class SkillPackageResolver {
|
|||||||
* Spring lifecycle.
|
* Spring lifecycle.
|
||||||
*/
|
*/
|
||||||
private final AcpSkillWrapperToolFactory acpWrapperFactory;
|
private final AcpSkillWrapperToolFactory acpWrapperFactory;
|
||||||
|
/**
|
||||||
|
* Script-entrypoint wrapper factory for skills that declare a
|
||||||
|
* {@code scripts} manifest block. {@code @Lazy} to match the sibling
|
||||||
|
* wrapper factories and stay clear of bean construction-order loops.
|
||||||
|
*/
|
||||||
|
private final ScriptSkillWrapperToolFactory scriptWrapperFactory;
|
||||||
/**
|
/**
|
||||||
* {@code @Lazy} on ToolRegistry — same lazy-resolution loop as
|
* {@code @Lazy} on ToolRegistry — same lazy-resolution loop as
|
||||||
* {@code SkillDependencyChecker}; without this we'd reach for the
|
* {@code SkillDependencyChecker}; without this we'd reach for the
|
||||||
@ -109,6 +115,7 @@ public class SkillPackageResolver {
|
|||||||
SkillMapper skillMapper,
|
SkillMapper skillMapper,
|
||||||
@Lazy WikiSkillWrapperToolFactory wikiWrapperFactory,
|
@Lazy WikiSkillWrapperToolFactory wikiWrapperFactory,
|
||||||
@Lazy AcpSkillWrapperToolFactory acpWrapperFactory,
|
@Lazy AcpSkillWrapperToolFactory acpWrapperFactory,
|
||||||
|
@Lazy ScriptSkillWrapperToolFactory scriptWrapperFactory,
|
||||||
@Lazy ToolRegistry toolRegistry) {
|
@Lazy ToolRegistry toolRegistry) {
|
||||||
this.frontmatterParser = frontmatterParser;
|
this.frontmatterParser = frontmatterParser;
|
||||||
this.manifestParser = manifestParser;
|
this.manifestParser = manifestParser;
|
||||||
@ -120,6 +127,7 @@ public class SkillPackageResolver {
|
|||||||
this.skillMapper = skillMapper;
|
this.skillMapper = skillMapper;
|
||||||
this.wikiWrapperFactory = wikiWrapperFactory;
|
this.wikiWrapperFactory = wikiWrapperFactory;
|
||||||
this.acpWrapperFactory = acpWrapperFactory;
|
this.acpWrapperFactory = acpWrapperFactory;
|
||||||
|
this.scriptWrapperFactory = scriptWrapperFactory;
|
||||||
this.toolRegistry = toolRegistry;
|
this.toolRegistry = toolRegistry;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -533,6 +541,11 @@ public class SkillPackageResolver {
|
|||||||
// registeredWrappers map so deregistration covers both.
|
// registeredWrappers map so deregistration covers both.
|
||||||
applyAcpWrappers(resolved, manifest);
|
applyAcpWrappers(resolved, manifest);
|
||||||
|
|
||||||
|
// Skills that declare a scripts[] block get one typed wrapper
|
||||||
|
// tool per entrypoint, so a script consuming structured input
|
||||||
|
// is driven by schema fields instead of a hand-built JSON arg.
|
||||||
|
applyScriptWrappers(resolved, manifest);
|
||||||
|
|
||||||
// Build requirement lookup for feature checks.
|
// Build requirement lookup for feature checks.
|
||||||
Map<String, SkillManifest.RequirementDef> reqByKey = new LinkedHashMap<>();
|
Map<String, SkillManifest.RequirementDef> reqByKey = new LinkedHashMap<>();
|
||||||
for (SkillManifest.RequirementDef r : manifest.getRequires()) {
|
for (SkillManifest.RequirementDef r : manifest.getRequires()) {
|
||||||
@ -808,6 +821,58 @@ public class SkillPackageResolver {
|
|||||||
manifest.setAllowedTools(mergedAllowed);
|
manifest.setAllowedTools(mergedAllowed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register typed wrapper tools for a skill's declared script entrypoints
|
||||||
|
* (the {@code scripts} manifest block). Parallel structure to
|
||||||
|
* {@link #applyKnowledgeWrappers} / {@link #applyAcpWrappers}.
|
||||||
|
*
|
||||||
|
* <p>Skipped for {@code knowledge} / {@code acp} skills: those types own
|
||||||
|
* the wrapper slot, and this method must never deregister wrappers a
|
||||||
|
* sibling branch just registered. Also skipped when the skill declares
|
||||||
|
* no entrypoints, is disabled, or has no directory.
|
||||||
|
*/
|
||||||
|
private void applyScriptWrappers(ResolvedSkill resolved, SkillManifest manifest) {
|
||||||
|
String type = manifest.getType();
|
||||||
|
if ("knowledge".equalsIgnoreCase(type) || "acp".equalsIgnoreCase(type)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean hasScripts = manifest.getScripts() != null && !manifest.getScripts().isEmpty();
|
||||||
|
if (!hasScripts || !resolved.isEnabled() || resolved.getSkillDir() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Fresh build so a re-resolve diff-updates the registry cleanly.
|
||||||
|
// applyKnowledgeWrappers already cleared any prior set for a
|
||||||
|
// non-knowledge skill; this is a no-op safety net.
|
||||||
|
deregisterSkillWrappers(resolved.getId());
|
||||||
|
|
||||||
|
java.util.List<ToolCallback> wrappers = scriptWrapperFactory.buildWrappers(resolved, manifest);
|
||||||
|
if (wrappers.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
java.util.Set<String> registered = new java.util.LinkedHashSet<>();
|
||||||
|
Long entityId = resolved.getId();
|
||||||
|
for (ToolCallback cb : wrappers) {
|
||||||
|
String name = cb.getToolDefinition().name();
|
||||||
|
registered.add(name);
|
||||||
|
toolRegistry.registerPluginTool(cb, () ->
|
||||||
|
entityId != null && resolved.isEnabled());
|
||||||
|
}
|
||||||
|
if (entityId != null) {
|
||||||
|
registeredWrappers.put(entityId, registered);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append wrapper names to allowedTools so getEffectiveAllowedTools
|
||||||
|
// surfaces them like any other manifest-declared tool.
|
||||||
|
java.util.List<String> mergedAllowed = new java.util.ArrayList<>(
|
||||||
|
manifest.getAllowedTools() == null ? java.util.List.of() : manifest.getAllowedTools());
|
||||||
|
for (String wrapperName : scriptWrapperFactory.wrapperNames(manifest)) {
|
||||||
|
if (!mergedAllowed.contains(wrapperName)) {
|
||||||
|
mergedAllowed.add(wrapperName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
manifest.setAllowedTools(mergedAllowed);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 阶段 4:综合判定 ====================
|
// ==================== 阶段 4:综合判定 ====================
|
||||||
|
|
||||||
private void resolveRuntimeAvailability(ResolvedSkill resolved) {
|
private void resolveRuntimeAvailability(ResolvedSkill resolved) {
|
||||||
|
|||||||
@ -0,0 +1,69 @@
|
|||||||
|
package vip.mate.skill.manifest;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for {@link SkillManifestParser}'s handling of the {@code scripts}
|
||||||
|
* frontmatter block — the typed script entrypoint declarations.
|
||||||
|
*/
|
||||||
|
class SkillManifestParserScriptsTest {
|
||||||
|
|
||||||
|
/** parseRawMap / parseFromFrontmatter never touch the frontmatter parser. */
|
||||||
|
private final SkillManifestParser parser = new SkillManifestParser(null);
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("a scripts block parses into typed ScriptDef entries")
|
||||||
|
void parsesScriptsBlock() {
|
||||||
|
Map<String, Object> entry = Map.of(
|
||||||
|
"id", "create_meeting",
|
||||||
|
"label", "Create Meeting",
|
||||||
|
"path", "scripts/dispatcher.py",
|
||||||
|
"description", "Schedule a meeting",
|
||||||
|
"arg_style", "json",
|
||||||
|
"fixed_args", List.of("schedule_meeting"),
|
||||||
|
"parameters", Map.of(
|
||||||
|
"type", "object",
|
||||||
|
"properties", Map.of("topic", Map.of("type", "string")),
|
||||||
|
"required", List.of("topic")));
|
||||||
|
Map<String, Object> fm = Map.of(
|
||||||
|
"name", "demo",
|
||||||
|
"type", "code",
|
||||||
|
"scripts", List.of(entry));
|
||||||
|
|
||||||
|
SkillManifest manifest = parser.parseRawMap(fm, null, null);
|
||||||
|
|
||||||
|
assertThat(manifest).isNotNull();
|
||||||
|
assertThat(manifest.getScripts()).hasSize(1);
|
||||||
|
SkillManifest.ScriptDef def = manifest.getScripts().get(0);
|
||||||
|
assertThat(def.getId()).isEqualTo("create_meeting");
|
||||||
|
assertThat(def.getPath()).isEqualTo("scripts/dispatcher.py");
|
||||||
|
assertThat(def.getArgStyle()).isEqualTo("json");
|
||||||
|
assertThat(def.getFixedArgs()).containsExactly("schedule_meeting");
|
||||||
|
assertThat(def.getParameters()).containsKey("properties");
|
||||||
|
// 'scripts' is a known key — it must not also leak into extras.
|
||||||
|
assertThat(manifest.getExtras()).doesNotContainKey("scripts");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("arg_style defaults to json when omitted")
|
||||||
|
void argStyleDefaults() {
|
||||||
|
Map<String, Object> entry = Map.of("id", "run", "path", "scripts/run.sh");
|
||||||
|
SkillManifest manifest = parser.parseRawMap(
|
||||||
|
Map.of("name", "demo", "scripts", List.of(entry)), null, null);
|
||||||
|
assertThat(manifest.getScripts()).hasSize(1);
|
||||||
|
assertThat(manifest.getScripts().get(0).getArgStyle()).isEqualTo("json");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("no scripts block yields an empty list, not null")
|
||||||
|
void noScriptsBlock() {
|
||||||
|
SkillManifest manifest = parser.parseRawMap(Map.of("name", "demo"), null, null);
|
||||||
|
assertThat(manifest.getScripts()).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,120 @@
|
|||||||
|
package vip.mate.skill.runtime;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import vip.mate.skill.manifest.SkillManifest;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for {@link ScriptSkillWrapperToolFactory} — the argument
|
||||||
|
* translation ({@code buildArgv}) and wrapper naming ({@code wrapperNames})
|
||||||
|
* that turn a declared script entrypoint into a typed tool. The model fills
|
||||||
|
* schema fields; these are the steps that carry that typed input into the
|
||||||
|
* script process without the model hand-crafting a JSON string.
|
||||||
|
*/
|
||||||
|
class ScriptSkillWrapperToolFactoryTest {
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
/** wrapperNames touches no collaborators — null deps are fine here. */
|
||||||
|
private final ScriptSkillWrapperToolFactory factory =
|
||||||
|
new ScriptSkillWrapperToolFactory(null, null, null, objectMapper);
|
||||||
|
|
||||||
|
private JsonNode json(String raw) {
|
||||||
|
try {
|
||||||
|
return objectMapper.readTree(raw);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("json style forwards the whole object as one compact JSON argument")
|
||||||
|
void jsonStyleSingleArg() {
|
||||||
|
List<String> argv = ScriptSkillWrapperToolFactory.buildArgv(
|
||||||
|
List.of(), "json", json("{\"date\":\"2026-05-19\",\"topic\":\"智能体\"}"));
|
||||||
|
assertThat(argv).hasSize(1);
|
||||||
|
JsonNode back = json(argv.get(0));
|
||||||
|
assertThat(back.get("date").asText()).isEqualTo("2026-05-19");
|
||||||
|
assertThat(back.get("topic").asText()).isEqualTo("智能体");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("json style with an empty / absent object yields no arguments")
|
||||||
|
void jsonStyleEmpty() {
|
||||||
|
assertThat(ScriptSkillWrapperToolFactory.buildArgv(List.of(), "json", json("{}"))).isNull();
|
||||||
|
assertThat(ScriptSkillWrapperToolFactory.buildArgv(List.of(), "json", null)).isNull();
|
||||||
|
assertThat(ScriptSkillWrapperToolFactory.buildArgv(List.of(), null, json("{}"))).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("flags style emits --key value pairs and drops false / null properties")
|
||||||
|
void flagsStyle() {
|
||||||
|
List<String> argv = ScriptSkillWrapperToolFactory.buildArgv(List.of(), "flags",
|
||||||
|
json("{\"verbose\":true,\"file\":\"in.txt\",\"count\":3,\"debug\":false,\"note\":null}"));
|
||||||
|
assertThat(argv).containsExactly("--verbose", "--file", "in.txt", "--count", "3");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("flags style with no usable properties yields no arguments")
|
||||||
|
void flagsStyleEmpty() {
|
||||||
|
assertThat(ScriptSkillWrapperToolFactory.buildArgv(List.of(), "flags", json("{}"))).isNull();
|
||||||
|
assertThat(ScriptSkillWrapperToolFactory.buildArgv(List.of(), "flags", json("{\"off\":false}"))).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("fixedArgs are emitted before the typed JSON argument")
|
||||||
|
void fixedArgsPrependDispatcherMethod() {
|
||||||
|
// A dispatcher script: argv[1] = method, argv[2] = JSON payload.
|
||||||
|
List<String> argv = ScriptSkillWrapperToolFactory.buildArgv(
|
||||||
|
List.of("schedule_meeting"), "json", json("{\"subject\":\"智能体\"}"));
|
||||||
|
assertThat(argv).hasSize(2);
|
||||||
|
assertThat(argv.get(0)).isEqualTo("schedule_meeting");
|
||||||
|
assertThat(json(argv.get(1)).get("subject").asText()).isEqualTo("智能体");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("fixedArgs survive even when the entrypoint takes no typed input")
|
||||||
|
void fixedArgsWithoutTypedArgs() {
|
||||||
|
assertThat(ScriptSkillWrapperToolFactory.buildArgv(
|
||||||
|
List.of("convert_timestamp"), "json", json("{}")))
|
||||||
|
.containsExactly("convert_timestamp");
|
||||||
|
assertThat(ScriptSkillWrapperToolFactory.buildArgv(
|
||||||
|
List.of("m"), "flags", json("{\"v\":true}")))
|
||||||
|
.containsExactly("m", "--v");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("wrapperNames builds skill_<slug>_<id> for each usable entrypoint")
|
||||||
|
void wrapperNames() {
|
||||||
|
SkillManifest manifest = SkillManifest.builder()
|
||||||
|
.name("Tencent Meeting")
|
||||||
|
.scripts(List.of(
|
||||||
|
SkillManifest.ScriptDef.builder()
|
||||||
|
.id("create_meeting").path("scripts/create.py").build(),
|
||||||
|
SkillManifest.ScriptDef.builder()
|
||||||
|
.id("cancel_meeting").path("scripts/cancel.py").build()))
|
||||||
|
.build();
|
||||||
|
assertThat(factory.wrapperNames(manifest))
|
||||||
|
.containsExactly("skill_tencent_meeting_create_meeting",
|
||||||
|
"skill_tencent_meeting_cancel_meeting");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("wrapperNames skips entrypoints missing an id or a script path")
|
||||||
|
void wrapperNamesSkipsIncomplete() {
|
||||||
|
SkillManifest manifest = SkillManifest.builder()
|
||||||
|
.name("demo")
|
||||||
|
.scripts(List.of(
|
||||||
|
SkillManifest.ScriptDef.builder().id("ok").path("scripts/ok.py").build(),
|
||||||
|
SkillManifest.ScriptDef.builder().id("no_path").build(),
|
||||||
|
SkillManifest.ScriptDef.builder().path("scripts/no_id.py").build()))
|
||||||
|
.build();
|
||||||
|
assertThat(factory.wrapperNames(manifest)).containsExactly("skill_demo_ok");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user