mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(agent): digital-employee builder skill to auto-create agents and chain them into a workflow (#165)
This commit is contained in:
parent
892bc652f2
commit
a910004b3b
@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<String> requestedSkills = parseNameList(skillNames);
|
||||
List<String> requestedTools = parseNameList(toolNames);
|
||||
|
||||
List<String> boundSkills = bindSkills(created, workspaceId, requestedSkills);
|
||||
List<String> boundTools = bindTools(created, requestedTools);
|
||||
|
||||
Map<String, Object> 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<SkillEntity> skills = skillMapper.selectList(new LambdaQueryWrapper<SkillEntity>()
|
||||
.eq(SkillEntity::getEnabled, true)
|
||||
.eq(SkillEntity::getDeleted, 0)
|
||||
.orderByAsc(SkillEntity::getName));
|
||||
long effectiveWs = workspaceId == null ? 1L : workspaceId;
|
||||
List<Map<String, String>> 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<String, String> 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<Map<String, String>> toolCatalog = new ArrayList<>();
|
||||
try {
|
||||
for (AvailableToolDTO t : availableToolService.listAvailable()) {
|
||||
if (t == null || !t.isAvailable() || t.getName() == null || t.getName().isBlank()) continue;
|
||||
Map<String, String> 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<String, Object> 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<String> bindSkills(AgentEntity agent, long workspaceId, List<String> requestedSkills) {
|
||||
if (requestedSkills.isEmpty()) return List.of();
|
||||
List<Long> ids = new ArrayList<>();
|
||||
List<String> boundNames = new ArrayList<>();
|
||||
for (String raw : requestedSkills) {
|
||||
String skillName = raw.trim();
|
||||
if (skillName.isEmpty()) continue;
|
||||
List<SkillEntity> matches = skillMapper.selectList(new LambdaQueryWrapper<SkillEntity>()
|
||||
.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<String> bindTools(AgentEntity agent, List<String> requestedTools) {
|
||||
if (requestedTools.isEmpty()) return List.of();
|
||||
Set<String> 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<String> 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<String> parseNameList(String raw) {
|
||||
if (raw == null || raw.isBlank()) return List.of();
|
||||
String trimmed = raw.trim();
|
||||
if (trimmed.startsWith("[")) {
|
||||
try {
|
||||
List<String> parsed = objectMapper.readValue(trimmed, new TypeReference<List<String>>() {});
|
||||
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<String> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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. */
|
||||
|
||||
@ -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 就复用,避免制造一堆同质化角色。
|
||||
- 不自动发布工作流,不自动启用触发器。
|
||||
@ -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<Long> 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");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user