mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +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. */
|
||||
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 ====================
|
||||
|
||||
/** Unknown frontmatter keys are stashed here so a future field
|
||||
@ -198,6 +210,45 @@ public class SkillManifest {
|
||||
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
|
||||
@Builder
|
||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||
|
||||
@ -40,6 +40,7 @@ public class SkillManifestParser {
|
||||
"dashboard", "self-evolution", "self_evolution",
|
||||
"knowledge",
|
||||
"acp",
|
||||
"scripts",
|
||||
// legacy / housekeeping fields that aren't manifest-relevant
|
||||
"metadata"
|
||||
);
|
||||
@ -102,6 +103,7 @@ public class SkillManifestParser {
|
||||
.selfEvolution(parseSelfEvolution(coalesce(fm, "self-evolution", "self_evolution")))
|
||||
.knowledge(parseKnowledge(fm.get("knowledge")))
|
||||
.acp(parseAcp(fm.get("acp")))
|
||||
.scripts(parseScripts(fm.get("scripts")))
|
||||
.extras(extractUnknown(fm));
|
||||
|
||||
return b.build();
|
||||
@ -261,6 +263,37 @@ public class SkillManifestParser {
|
||||
.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")
|
||||
private SkillManifest.AcpBinding parseAcp(Object raw) {
|
||||
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.
|
||||
*/
|
||||
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 SkillDependencyChecker}; without this we'd reach for the
|
||||
@ -109,6 +115,7 @@ public class SkillPackageResolver {
|
||||
SkillMapper skillMapper,
|
||||
@Lazy WikiSkillWrapperToolFactory wikiWrapperFactory,
|
||||
@Lazy AcpSkillWrapperToolFactory acpWrapperFactory,
|
||||
@Lazy ScriptSkillWrapperToolFactory scriptWrapperFactory,
|
||||
@Lazy ToolRegistry toolRegistry) {
|
||||
this.frontmatterParser = frontmatterParser;
|
||||
this.manifestParser = manifestParser;
|
||||
@ -120,6 +127,7 @@ public class SkillPackageResolver {
|
||||
this.skillMapper = skillMapper;
|
||||
this.wikiWrapperFactory = wikiWrapperFactory;
|
||||
this.acpWrapperFactory = acpWrapperFactory;
|
||||
this.scriptWrapperFactory = scriptWrapperFactory;
|
||||
this.toolRegistry = toolRegistry;
|
||||
}
|
||||
|
||||
@ -533,6 +541,11 @@ public class SkillPackageResolver {
|
||||
// registeredWrappers map so deregistration covers both.
|
||||
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.
|
||||
Map<String, SkillManifest.RequirementDef> reqByKey = new LinkedHashMap<>();
|
||||
for (SkillManifest.RequirementDef r : manifest.getRequires()) {
|
||||
@ -808,6 +821,58 @@ public class SkillPackageResolver {
|
||||
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:综合判定 ====================
|
||||
|
||||
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