From a910004b3b14a7965501bbe23963c033ad335573 Mon Sep 17 00:00:00 2001 From: matevip Date: Thu, 21 May 2026 16:26:04 +0800 Subject: [PATCH] feat(agent): digital-employee builder skill to auto-create agents and chain them into a workflow (#165) --- .../vip/mate/agent/AgentAuthoringTool.java | 313 ++++++++++++++++++ .../mate/tool/builtin/DelegateAgentTool.java | 7 +- .../skills/digital_employee/SKILL.md | 103 ++++++ .../mate/agent/AgentAuthoringToolTest.java | 134 ++++++++ 4 files changed, 556 insertions(+), 1 deletion(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/AgentAuthoringTool.java create mode 100644 mateclaw-server/src/main/resources/skills/digital_employee/SKILL.md create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/AgentAuthoringToolTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentAuthoringTool.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentAuthoringTool.java new file mode 100644 index 00000000..003b455c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentAuthoringTool.java @@ -0,0 +1,313 @@ +package vip.mate.agent; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import vip.mate.agent.binding.service.AgentBindingService; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.agent.model.AgentEntity; +import vip.mate.exception.MateClawException; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.repository.SkillMapper; +import vip.mate.tool.model.AvailableToolDTO; +import vip.mate.tool.service.AvailableToolService; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Agent-callable employee authoring tool. + * + *

Lets an agent design and persist a new specialized employee (Agent) + * from a plain-language role spec, then bind a focused capability set to + * it. Pairs with the workflow drafting tool so a single chat turn can plan + * a team of employees and chain them into a workflow: + * design roles → {@link #create_employee} for each → workflow drafting tool + * referencing the just-created employees. + * + *

Workspace is taken from {@link ChatOrigin} on the active + * {@link ToolContext}; the LLM can never write into a foreign workspace + * even if its prompt tried to forge one. Mirrors the create-then-bind + * sequence used when applying an agent template. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AgentAuthoringTool { + + private final AgentService agentService; + private final AgentBindingService agentBindingService; + private final SkillMapper skillMapper; + private final AvailableToolService availableToolService; + private final ObjectMapper objectMapper; + + /** Cap on names listed per catalog section so the tool result stays small. */ + private static final int CATALOG_MAX_PER_SECTION = 200; + + @Tool(description = """ + Create a new specialized employee (Agent) in the current workspace from a role spec, \ + and optionally bind a focused set of skills and tools to it. \ + Use this when a task needs a role that does not exist yet — design the role, then create it. \ + Returns the new agentId (string) and a short summary. \ + Leave skillNames/toolNames empty to make a generalist that inherits all globally-enabled capabilities. \ + Call list_capability_catalog first to learn the exact skill and tool names you can assign. \ + The created employee is enabled immediately and can be referenced by the workflow drafting tool.""") + public String create_employee( + @ToolParam(description = "Employee name, unique within the workspace, e.g. \"market-research-analyst\".") + String name, + @ToolParam(description = "One-line description of the employee's role and responsibility. Shown in pickers and used by the workflow planner to route work.") + String description, + @ToolParam(description = "System prompt that defines the employee's persona, expertise, and working style. Be specific about its specialty.") + String systemPrompt, + @ToolParam(description = "Agent type: \"react\" (single-loop reasoning, default) or \"plan_execute\" (decompose then execute). Leave empty for react.", required = false) + String agentType, + @ToolParam(description = "Optional model name override (must match an enabled model). Leave empty to use the workspace default model.", required = false) + String modelName, + @ToolParam(description = "Skills to bind, as a JSON array of skill names or a comma-separated list, e.g. [\"sql_query\",\"make_plan\"]. Empty = inherit all globally-enabled skills. Names must come from list_capability_catalog.", required = false) + String skillNames, + @ToolParam(description = "Tools to bind, as a JSON array of tool names or a comma-separated list, e.g. [\"web_search\",\"read_file\"]. Empty = inherit all globally-enabled tools. Names must come from list_capability_catalog.", required = false) + String toolNames, + @Nullable ToolContext ctx) { + + ChatOrigin origin = ChatOrigin.from(ctx); + Long workspaceId = origin.workspaceId(); + if (workspaceId == null || workspaceId <= 0) { + return "[error] Cannot determine the current workspace; invoke this tool within a workspace context."; + } + if (name == null || name.isBlank()) { + return "[error] Employee name is required."; + } + + AgentEntity agent = new AgentEntity(); + agent.setName(name.trim()); + agent.setDescription(blankToNull(description)); + if (systemPrompt != null && !systemPrompt.isBlank()) { + agent.setSystemPrompt(systemPrompt); + } + agent.setAgentType(normalizeAgentType(agentType)); + agent.setModelName(blankToNull(modelName)); + agent.setWorkspaceId(workspaceId); + agent.setCreatorUserId(parseUserId(origin.requesterId())); + + AgentEntity created; + try { + created = agentService.createAgent(agent); + } catch (MateClawException e) { + // Duplicate name / blank name surface here as a friendly message + // so the planner can rename and retry instead of aborting. + return "[error] Failed to create employee: " + e.getMessage(); + } + + List requestedSkills = parseNameList(skillNames); + List requestedTools = parseNameList(toolNames); + + List boundSkills = bindSkills(created, workspaceId, requestedSkills); + List boundTools = bindTools(created, requestedTools); + + Map result = new LinkedHashMap<>(); + result.put("agentId", String.valueOf(created.getId())); + result.put("name", created.getName()); + result.put("agentType", created.getAgentType()); + result.put("skillsBound", boundSkills.isEmpty() ? "(inherits global defaults)" : boundSkills); + result.put("toolsBound", boundTools.isEmpty() ? "(inherits global defaults)" : boundTools); + result.put("note", "Employee created and enabled. Reference it by name in the workflow drafting tool to chain it into a workflow."); + try { + return objectMapper.writeValueAsString(result); + } catch (Exception e) { + return "Employee created: id=" + created.getId() + " name=" + created.getName(); + } + } + + @Tool(description = """ + List the capabilities you can assign when creating an employee: the enabled skill names \ + and the bindable tool names in the current workspace. \ + Call this before create_employee so you assign real, resolvable names rather than guessing.""") + public String list_capability_catalog(@Nullable ToolContext ctx) { + ChatOrigin origin = ChatOrigin.from(ctx); + Long workspaceId = origin.workspaceId(); + + // Skills: builtin (global) + skills owned by this workspace, enabled only. + List skills = skillMapper.selectList(new LambdaQueryWrapper() + .eq(SkillEntity::getEnabled, true) + .eq(SkillEntity::getDeleted, 0) + .orderByAsc(SkillEntity::getName)); + long effectiveWs = workspaceId == null ? 1L : workspaceId; + List> skillCatalog = new ArrayList<>(); + for (SkillEntity s : skills) { + if (s.getName() == null || s.getName().isBlank()) continue; + boolean builtin = Boolean.TRUE.equals(s.getBuiltin()); + long skillWs = s.getWorkspaceId() == null ? 1L : s.getWorkspaceId(); + if (!builtin && skillWs != effectiveWs) continue; + Map m = new LinkedHashMap<>(); + m.put("name", s.getName()); + m.put("description", s.getDescription() == null ? "" : s.getDescription()); + skillCatalog.add(m); + if (skillCatalog.size() >= CATALOG_MAX_PER_SECTION) break; + } + + // Tools: only those the binding service would accept (available == true). + List> toolCatalog = new ArrayList<>(); + try { + for (AvailableToolDTO t : availableToolService.listAvailable()) { + if (t == null || !t.isAvailable() || t.getName() == null || t.getName().isBlank()) continue; + Map m = new LinkedHashMap<>(); + m.put("name", t.getName()); + m.put("description", t.getDescription() == null ? "" : t.getDescription()); + toolCatalog.add(m); + if (toolCatalog.size() >= CATALOG_MAX_PER_SECTION) break; + } + } catch (Exception e) { + log.warn("[AgentAuthoringTool] tool catalog lookup failed: {}", e.getMessage()); + } + + Map result = new LinkedHashMap<>(); + result.put("skills", skillCatalog); + result.put("tools", toolCatalog); + try { + return objectMapper.writeValueAsString(result); + } catch (Exception e) { + return "{\"skills\":[],\"tools\":[]}"; + } + } + + // ==================== helpers ==================== + + /** + * Resolve requested skill names to ids within reach of this agent + * (builtin skills are global; otherwise the skill must belong to the + * agent's workspace) and bind them. Returns the names actually bound; + * unresolved names are skipped with a warning so a single typo does not + * abort the whole hire. + */ + private List bindSkills(AgentEntity agent, long workspaceId, List requestedSkills) { + if (requestedSkills.isEmpty()) return List.of(); + List ids = new ArrayList<>(); + List boundNames = new ArrayList<>(); + for (String raw : requestedSkills) { + String skillName = raw.trim(); + if (skillName.isEmpty()) continue; + List matches = skillMapper.selectList(new LambdaQueryWrapper() + .eq(SkillEntity::getName, skillName) + .eq(SkillEntity::getDeleted, 0)); + SkillEntity chosen = matches.stream() + .filter(s -> { + if (Boolean.TRUE.equals(s.getBuiltin())) return true; + long ws = s.getWorkspaceId() == null ? 1L : s.getWorkspaceId(); + return ws == workspaceId; + }) + .findFirst() + .orElse(null); + if (chosen == null) { + log.warn("[AgentAuthoringTool] skill '{}' not resolvable for workspace {}; skipping", skillName, workspaceId); + continue; + } + ids.add(chosen.getId()); + boundNames.add(chosen.getName()); + } + if (ids.isEmpty()) return List.of(); + try { + // Best-effort: the employee is already persisted, so a late + // binding failure (e.g. a skill row deleted between resolve and + // bind) must not throw out of the tool and strand the caller with + // an error on top of an already-created agent. The agent simply + // keeps the default capability set instead. + agentBindingService.setSkillBindings(agent.getId(), ids); + } catch (Exception e) { + log.warn("[AgentAuthoringTool] skill binding failed for agent {}; left on global defaults: {}", + agent.getId(), e.getMessage()); + return List.of(); + } + return boundNames; + } + + /** + * Filter requested tool names through the picker (only available == true + * names are bindable) and bind them. Returns the names actually bound. + */ + private List bindTools(AgentEntity agent, List requestedTools) { + if (requestedTools.isEmpty()) return List.of(); + Set bindable; + try { + bindable = availableToolService.listAvailable().stream() + .filter(AvailableToolDTO::isAvailable) + .map(AvailableToolDTO::getName) + .collect(Collectors.toSet()); + } catch (Exception e) { + log.warn("[AgentAuthoringTool] tool picker unavailable; skipping tool bind: {}", e.getMessage()); + return List.of(); + } + List filtered = new ArrayList<>(); + for (String raw : requestedTools) { + String toolName = raw == null ? "" : raw.trim(); + if (toolName.isEmpty()) continue; + if (bindable.contains(toolName)) { + filtered.add(toolName); + } else { + log.warn("[AgentAuthoringTool] tool '{}' not bindable; skipping", toolName); + } + } + if (filtered.isEmpty()) return List.of(); + try { + // Best-effort, same rationale as bindSkills: never throw after the + // employee has been created. + agentBindingService.setToolBindings(agent.getId(), filtered); + } catch (Exception e) { + log.warn("[AgentAuthoringTool] tool binding failed for agent {}; left on global defaults: {}", + agent.getId(), e.getMessage()); + return List.of(); + } + return filtered; + } + + /** Parse a JSON array of strings or a comma-separated list into a name list. */ + private List parseNameList(String raw) { + if (raw == null || raw.isBlank()) return List.of(); + String trimmed = raw.trim(); + if (trimmed.startsWith("[")) { + try { + List parsed = objectMapper.readValue(trimmed, new TypeReference>() {}); + return parsed == null ? List.of() : parsed; + } catch (Exception ignored) { + // Fall through to comma split — the model occasionally emits a + // malformed array; a comma split still recovers most names. + } + } + List out = new ArrayList<>(); + for (String part : trimmed.replace("[", "").replace("]", "").split(",")) { + String p = part.trim().replaceAll("^[\"']|[\"']$", ""); + if (!p.isEmpty()) out.add(p); + } + return out; + } + + private static String normalizeAgentType(String agentType) { + if (agentType == null || agentType.isBlank()) return "react"; + String t = agentType.trim().toLowerCase(); + return "plan_execute".equals(t) ? "plan_execute" : "react"; + } + + private static String blankToNull(String s) { + return (s == null || s.isBlank()) ? null : s; + } + + /** Best-effort numeric parse of the requester id for creator attribution. */ + private static Long parseUserId(String requesterId) { + if (requesterId == null || requesterId.isBlank()) return null; + try { + return Long.parseLong(requesterId.trim()); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java index 0adb1ad9..46124bd2 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java @@ -125,7 +125,12 @@ public class DelegateAgentTool { "setGoal", "addGoalCriterion", "completeGoal", - "getGoalStatus" + "getGoalStatus", + // Employee authoring spawns persistent agents; a delegated child + // doing so risks recursive team creation and privilege creep, so + // it stays with the parent (same stance as delegate* recursion + // guards above). The read-only capability catalog is fine to keep. + "create_employee" ); /** Executor for parallel delegation — one JDK 21 virtual thread per child agent. */ diff --git a/mateclaw-server/src/main/resources/skills/digital_employee/SKILL.md b/mateclaw-server/src/main/resources/skills/digital_employee/SKILL.md new file mode 100644 index 00000000..750d4221 --- /dev/null +++ b/mateclaw-server/src/main/resources/skills/digital_employee/SKILL.md @@ -0,0 +1,103 @@ +--- +name: digital_employee +nameZh: 数字员工组建 +nameEn: Digital Employee Builder +version: "1.0.0" +icon: "🧑‍💼" +description: "根据一句业务需求,自动规划并创建一组分工明确的数字员工(Agent),再把他们编排成一条工作流,无需逐个手工创建。" +tags: digital_employee,agent,workflow,team +dependencies: + tools: + - listAvailableAgents + - list_capability_catalog + - create_employee + - workflow_draft_generate +--- + +# 数字员工组建 + +把一句业务需求,变成「一支分工明确的数字员工团队 + 一条把他们串起来的工作流」。用户不必逐个设计、逐个手工创建 Agent。 + +## 何时使用 + +- 用户描述了一个**需要多个角色协作**的目标("帮我搭一个做竞品分析的团队"、"我要一套从线索到成交的销售流程")。 +- 用户说"建几个 Agent / 数字员工帮我做 X"、"把 X 这件事自动化成一支团队"。 +- 现有 Agent 不足以覆盖需求,需要新建专才角色。 + +## 不应使用 + +- 一个 Agent 就能完成 → 直接用 `chat_with_agent` 或现有 Agent。 +- 只是想编排**已存在**的 Agent 协作 → 用 `multi_agent_collaboration`。 +- 只是想把流程做成工作流,且员工都已存在 → 直接用 `workflow_draft_generate`。 + +## 工作流程 + +### 第一步:理解需求,盘点现状 + +1. 读懂用户的业务目标、产出物、是否有触发条件(定时 / 来消息时)、要不要审批、结果发到哪个渠道。 +2. 调用 `listAvailableAgents()` 看现有员工——**能复用就复用**,不要重复造同名角色。 +3. 调用 `list_capability_catalog()` 拿到可分配的**技能名**和**工具名**清单。后续 `create_employee` 只能用清单里的真实名字,不要臆造。 + +如果需求关键信息缺失(产出物、角色边界),先向用户澄清一句再继续,不要凭空假设。 + +### 第二步:设计团队(2–6 个角色) + +把目标拆成**互补的角色**,每个角色给出: + +- `name`:工作区内唯一,用英文 kebab-case(如 `market-research-analyst`)。 +- `description`:一句话职责,工作流编排器会据此分配任务。 +- `systemPrompt`:定义这个员工的专长、视角、工作方式——要具体,别写空话。 +- `skillNames` / `toolNames`:从 `list_capability_catalog()` 里挑这个角色真正需要的;**留空则继承全局默认能力**(通才)。专才角色建议显式收窄。 +- `agentType`:默认 `react`;只有当角色需要"先规划再分步执行"时才用 `plan_execute`。 + +设计原则: + +- 角色数量 2–6 个,宁少勿滥;每个角色职责单一、边界清晰。 +- 避免两个角色职责重叠。 +- 一般不指定 `modelName`,留空用工作区默认模型;用户明确要求某模型时才填。 + +### 第三步:逐个创建员工 + +对每个设计好的角色调用一次 `create_employee(...)`: + +``` +create_employee( + name="market-research-analyst", + description="负责竞品功能、定价、市场动态的检索与结构化整理", + systemPrompt="你是资深市场研究分析师……(写清专长与产出格式)", + skillNames=["news", "web_search"], // 来自 list_capability_catalog,可留空 + toolNames=["web_search"] // 来自 list_capability_catalog,可留空 +) +``` + +- 工具会返回 `agentId` 和实际绑定的技能/工具;**记下每个员工的 name**,下一步要用。 +- 若返回 `[error]`(如重名),换个名字重试,不要中断整个流程。 +- 创建即启用——员工立刻可被工作流引用。 + +### 第四步:编排工作流 + +所有员工创建完成后,调用一次 `workflow_draft_generate(description=...)`。在 `description` 里: + +- **点名第三步创建的真实员工**(用它们的 name),说明执行顺序、依赖关系、并行还是串行。 +- 写清触发条件、是否需要审批、产出发往哪个渠道。 + +由于员工已经落库,`workflow_draft_generate` 会读到它们作为可用数字员工,直接引用真实 agent,而不是填 `TODO_*_AGENT` 占位。 + +> 工作流只会保存为**草稿**,不会自动发布、不会自动启用触发器。这是安全约定——让用户在工作流编辑器里 review 后再 publish。 + +### 第五步:汇报 + +给用户一份清晰小结: + +- 创建了哪些员工(name + 职责 + 绑定的关键能力)。 +- 生成的工作流草稿名称、id、各步骤如何串联。 +- 草稿编译预校验是否通过、有无缺失字段需要补。 +- 明确告知:工作流是草稿,请到工作流编辑器确认后再发布。 + +## 关键规则 + +- 员工 name 用英文 kebab-case 且工作区内唯一;工作流 step.name / outputVar 的命名约束由 `workflow_draft_generate` 负责,本技能不必操心。 +- 技能名 / 工具名必须来自 `list_capability_catalog()`,臆造的名字会被静默跳过。 +- 先创建员工,**再**生成工作流——顺序不能反,否则工作流只能拿到占位符。 +- 能复用现有 Agent 就复用,避免制造一堆同质化角色。 +- 不自动发布工作流,不自动启用触发器。 diff --git a/mateclaw-server/src/test/java/vip/mate/agent/AgentAuthoringToolTest.java b/mateclaw-server/src/test/java/vip/mate/agent/AgentAuthoringToolTest.java new file mode 100644 index 00000000..efafc53e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/AgentAuthoringToolTest.java @@ -0,0 +1,134 @@ +package vip.mate.agent; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; +import vip.mate.MateClawApplication; +import vip.mate.agent.binding.service.AgentBindingService; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.agent.model.AgentEntity; + +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Exercises the agent authoring tool against a real Spring context so the + * create-then-bind sequence runs through {@link AgentService} and + * {@link AgentBindingService} exactly as it would at chat time. Builtin + * skills are seeded on startup, so skill-name resolution hits real rows. + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:agent_authoring_test_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", + "spring.ai.dashscope.api-key=test-key", + "spring.main.web-application-type=none" +}) +class AgentAuthoringToolTest { + + private static final AtomicLong WS_SEQ = new AtomicLong(70_000L); + + @Autowired + private AgentAuthoringTool tool; + @Autowired + private AgentService agentService; + @Autowired + private AgentBindingService bindingService; + + private final ObjectMapper mapper = new ObjectMapper(); + + private long workspaceId; + + @BeforeEach + void setUp() { + workspaceId = WS_SEQ.getAndIncrement(); + } + + private ToolContext ctxFor(long ws) { + return ChatOrigin.web("conv-" + ws, "123", ws, null).toToolContext(); + } + + @Test + @DisplayName("create_employee 在 ChatOrigin 的 workspace 内创建 Agent,无能力名时继承全局默认") + void createsGeneralistInOriginWorkspace() throws Exception { + String json = tool.create_employee( + "generalist-helper", "general assistant", "You help with anything.", + null, null, null, null, ctxFor(workspaceId)); + + JsonNode node = mapper.readTree(json); + long agentId = Long.parseLong(node.get("agentId").asText()); + + AgentEntity created = agentService.getAgent(agentId); + assertEquals("generalist-helper", created.getName()); + assertEquals(workspaceId, created.getWorkspaceId()); + assertEquals("react", created.getAgentType()); + // Creator attribution parsed from the numeric requesterId. + assertEquals(123L, created.getCreatorUserId()); + // No bindings declared → inherits global defaults (null sentinel). + assertNull(bindingService.getBoundSkillIds(agentId)); + assertNull(bindingService.getBoundToolNames(agentId)); + } + + @Test + @DisplayName("create_employee 绑定指定的内置技能(按名解析为 id)") + void bindsRequestedBuiltinSkill() throws Exception { + String json = tool.create_employee( + "planner-employee", "planning specialist", "You break goals into plans.", + "react", null, "[\"make_plan\"]", null, ctxFor(workspaceId)); + + JsonNode node = mapper.readTree(json); + long agentId = Long.parseLong(node.get("agentId").asText()); + + Set boundSkills = bindingService.getBoundSkillIds(agentId); + assertNotNull(boundSkills, "declaring a skill must create a binding set"); + assertEquals(1, boundSkills.size()); + // The summary echoes the bound skill name. + assertTrue(node.get("skillsBound").toString().contains("make_plan")); + } + + @Test + @DisplayName("create_employee 缺少 workspace 上下文时拒绝执行") + void rejectsWithoutWorkspace() { + String result = tool.create_employee( + "no-ws", "x", "y", null, null, null, null, ChatOrigin.EMPTY.toToolContext()); + assertTrue(result.startsWith("[error]")); + } + + @Test + @DisplayName("create_employee 重名返回友好错误而非抛出") + void duplicateNameReturnsFriendlyError() { + ToolContext ctx = ctxFor(workspaceId); + tool.create_employee("dup-employee", "first", "p", null, null, null, null, ctx); + String second = tool.create_employee("dup-employee", "second", "p", null, null, null, null, ctx); + assertTrue(second.startsWith("[error]"), "duplicate name should surface as a friendly error"); + } + + @Test + @DisplayName("list_capability_catalog 返回技能与工具清单") + void catalogReturnsSkillsAndTools() throws Exception { + String json = tool.list_capability_catalog(ctxFor(workspaceId)); + JsonNode node = mapper.readTree(json); + assertTrue(node.has("skills")); + assertTrue(node.has("tools")); + assertTrue(node.get("skills").isArray()); + // make_plan is a seeded builtin skill, so the catalog must surface it. + boolean hasMakePlan = false; + for (JsonNode s : node.get("skills")) { + if ("make_plan".equals(s.path("name").asText())) { hasMakePlan = true; break; } + } + assertTrue(hasMakePlan, "seeded builtin skill make_plan should appear in the catalog"); + } +}