diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index a68a6ea9..88ecc762 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -60,6 +60,7 @@ import vip.mate.tool.guard.service.ToolGuardService; import vip.mate.workspace.conversation.ConversationService; import vip.mate.approval.ApprovalWorkflowService; import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.team.service.TeamContextBuilder; import vip.mate.wiki.service.WikiContextService; import java.lang.reflect.Field; @@ -101,6 +102,7 @@ public class AgentGraphBuilder { "${mate.agent.markdown-normalize-enabled:true}") private boolean markdownNormalizeEnabled; private final ConversationService conversationService; + private final TeamContextBuilder teamContextBuilder; private final ModelConfigService modelConfigService; private final ModelProviderService modelProviderService; private final ModelContextWindowResolver contextWindowResolver; @@ -1751,7 +1753,12 @@ public class AgentGraphBuilder { Integer wikiBudgetTokens = memoryBudgetTokens == Integer.MAX_VALUE ? null : memoryBudgetTokens; String wikiContext = wikiContextService.buildWikiContext(entity.getId(), wikiBudgetTokens); - return basePrompt + ABOUT_YOU_BLOCK + toolGuidance + searchGuidance + wikiContext; + // Team context (role-specific board playbook, or a negative notice for + // agents outside any team). Baked here so it shares the prompt-cache + // prefix; TeamChangedEvent evicts the cached agent on composition changes. + String teamContext = teamContextBuilder.buildTeamContext(entity.getId()); + + return basePrompt + ABOUT_YOU_BLOCK + toolGuidance + searchGuidance + wikiContext + teamContext; } /** diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java index 5671523a..e1c3c4f8 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java @@ -21,6 +21,7 @@ import vip.mate.memory.MemoryProperties; import vip.mate.memory.lifecycle.MemoryLifecycleMediator; import vip.mate.memory.lifecycle.TurnContext; import vip.mate.memory.service.MemoryRecallTracker; +import vip.mate.team.event.TeamChangedEvent; import vip.mate.workspace.conversation.model.ConversationEntity; import vip.mate.workspace.conversation.repository.ConversationMapper; @@ -239,6 +240,18 @@ public class AgentService { } } + /** + * Invalidate cached agents whenever their team's composition or settings + * change. The team context block is baked into the system prompt at build + * time, so membership edits would otherwise stay invisible until restart. + */ + @EventListener + public void onTeamChanged(TeamChangedEvent event) { + if (event.agentIds() != null) { + event.agentIds().forEach(agentInstances::remove); + } + } + // ==================== 运行时入口 ==================== public String chat(Long agentId, String message, String conversationId) { diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamContextBuilder.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamContextBuilder.java new file mode 100644 index 00000000..3f73e3c9 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamContextBuilder.java @@ -0,0 +1,110 @@ +package vip.mate.team.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.repository.AgentMapper; +import vip.mate.team.model.AgentTeamEntity; +import vip.mate.team.model.AgentTeamMemberEntity; + +import java.util.List; +import java.util.Optional; + +/** + * Renders the team context block ("TEAM.md") appended to an agent's system + * prompt. The lead receives the full orchestration playbook; members receive + * execution-focused instructions; agents outside any team receive a one-line + * negative notice so the model never probes the team_tasks tool speculatively. + * + * The block is baked into the cached agent instance; {@code TeamChangedEvent} + * evicts affected agents so composition changes surface on the next turn. + * + * @author MateClaw Team + */ +@Component +@RequiredArgsConstructor +public class TeamContextBuilder { + + static final String NO_TEAM_NOTICE = """ + + ## Team + You are not part of any agent team. Do NOT call the team_tasks tool. + """; + + private final TeamService teamService; + private final AgentMapper agentMapper; + + /** Build the team context block for the given agent; never returns null. */ + public String buildTeamContext(Long agentId) { + Optional teamOpt = teamService.getTeamForAgent(agentId); + if (teamOpt.isEmpty()) { + return NO_TEAM_NOTICE; + } + AgentTeamEntity team = teamOpt.get(); + List members = teamService.listMembers(team.getId()); + boolean isLead = teamService.isLead(team, agentId); + + StringBuilder sb = new StringBuilder(2048); + sb.append("\n\n## Team: ").append(team.getName()).append('\n'); + if (team.getDescription() != null && !team.getDescription().isBlank()) { + sb.append(team.getDescription()).append('\n'); + } + sb.append("Your role: ").append(isLead ? "LEAD — you orchestrate this team." : "MEMBER.") + .append('\n'); + + sb.append(""" + + ### Members + This is the complete and authoritative list of your team. Do NOT use tools to verify it. + """); + for (AgentTeamMemberEntity member : members) { + AgentEntity agent = agentMapper.selectById(member.getAgentId()); + String name = agent != null && agent.getName() != null ? agent.getName() + : String.valueOf(member.getAgentId()); + sb.append("- **").append(name).append("** (agentId: ").append(member.getAgentId()) + .append(", ").append(member.getRole()).append(')'); + if (member.getAgentId().equals(agentId)) { + sb.append(" — you"); + } else if (agent != null && agent.getDescription() != null + && !agent.getDescription().isBlank()) { + sb.append(": ").append(agent.getDescription().strip()); + } + sb.append('\n'); + } + + sb.append(isLead ? leadPlaybook() : memberPlaybook()); + return sb.toString(); + } + + private static String leadPlaybook() { + return """ + + ### Delegation workflow (mandatory) + - Delegate work by creating tasks on the team board: `team_tasks(action="create", subject=..., description=..., assigneeAgentId=...)`. Every delegation MUST go through the board — never pretend a teammate did something without a task backing it. + - Check the board FIRST: call `team_tasks(action="list")` before creating tasks so you never create duplicates. + - Create ALL tasks for the request up front in one batch. Order dependent work with `blockedBy` (ids of prerequisite tasks). Then announce the assignments to the user and STOP — do not keep reasoning while members work. + - Delegation is NOT completion. After creating tasks, never say the work is "done" or "finished"; say it has been assigned and results will follow. + - Never assign a task to yourself — the lead orchestrates, members execute. + - Task sizing: one task = one specific action producing one output. Split a task if it needs two different skills; do not over-split mechanical steps. + - If a prerequisite task is already completed, pass its result inside the new task's description instead of blocking on it. + + ### When results arrive + Member results are delivered to you as system messages in this conversation. Review them, cross-check against the original request, then synthesize ONE coherent reply for the user. Do not forward raw member output unedited. + + ### Handling blockers + When a member reports a blocker the task auto-fails and you are notified with the reason. Resolve the missing input (provide context, adjust the description), then re-dispatch it with `team_tasks(action="retry", taskId=...)`, or cancel it with `action="cancel"` if it is no longer needed. + """; + } + + private static String memberPlaybook() { + return """ + + ### Working on assigned tasks + - When a task is dispatched to you, focus entirely on executing it. Your final reply becomes the task result and is reported back to the lead automatically. + - Report meaningful milestones with `team_tasks(action="progress", taskId=..., percent=..., step=...)`. The taskId is included in the dispatch message. + - Leave findings other teammates may need as comments: `team_tasks(action="comment", taskId=..., text=...)`. + - If you cannot proceed (missing input, unclear scope, failed dependency), report it with `team_tasks(action="comment", taskId=..., type="blocker", text="what you need")`. This fails the task and notifies the lead — do NOT silently improvise around a blocker. + - You may inspect the board with `action="list"` or `action="get"` for context, but do not create or cancel tasks. + """; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java b/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java new file mode 100644 index 00000000..8e827f6e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java @@ -0,0 +1,320 @@ +package vip.mate.team.tool; + +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.model.AgentEntity; +import vip.mate.agent.repository.AgentMapper; +import vip.mate.team.model.AgentTeamEntity; +import vip.mate.team.model.TeamTaskCommentEntity; +import vip.mate.team.model.TeamTaskCreateCommand; +import vip.mate.team.model.TeamTaskEntity; +import vip.mate.team.model.TeamTaskStatus; +import vip.mate.team.service.TeamDispatchService; +import vip.mate.team.service.TeamService; +import vip.mate.team.service.TeamTaskService; +import vip.mate.tool.builtin.ToolExecutionContext; +import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.ConversationEntity; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Shared team task board exposed to the LLM. One multi-action tool (rather + * than one tool per action) keeps the schema compact and mirrors how the + * model already phrases board operations as an action verb plus fields. + * + * Role gating: only the lead creates/cancels/retries tasks; members complete, + * report progress, and comment; everyone reads. All errors return structured + * strings written for LLM self-correction, never exceptions. + * + * @author MateClaw Team + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class TeamTasksTool { + + private final TeamService teamService; + private final TeamTaskService taskService; + private final TeamDispatchService dispatchService; + private final ConversationService conversationService; + private final AgentMapper agentMapper; + + @Tool(description = "Operate your team's shared task board. Actions: " + + "'list' all tasks; 'get' one task with comments (taskId); " + + "'create' a task (lead only; subject, description, assigneeAgentId required, " + + "optional blockedBy comma-separated prerequisite task ids, priority, higher first); " + + "'complete' a task with its result summary (taskId, result); " + + "'progress' to report execution progress (taskId, percent 0-100, step); " + + "'comment' to leave a note, or type='blocker' when you are stuck and need the lead " + + "(taskId, text); 'cancel' (lead only; taskId, text as reason); " + + "'retry' a failed/stale task back to pending (lead only; taskId). " + + "Only usable when you belong to an agent team.") + public String team_tasks( + @ToolParam(description = "One of: list, get, create, complete, progress, comment, cancel, retry") + String action, + @ToolParam(description = "Task id (string form is fine) — required by every action except list/create", required = false) + String taskId, + @ToolParam(description = "create: short task title", required = false) + String subject, + @ToolParam(description = "create: full task instructions; include every input the member needs — members do not see this conversation", required = false) + String description, + @ToolParam(description = "create: agentId of the member who should execute the task", required = false) + String assigneeAgentId, + @ToolParam(description = "create: comma-separated ids of tasks that must finish first", required = false) + String blockedBy, + @ToolParam(description = "create: priority, higher dispatches first (default 0)", required = false) + Integer priority, + @ToolParam(description = "complete: result summary reported back to the lead", required = false) + String result, + @ToolParam(description = "progress: completion percent 0-100", required = false) + Integer percent, + @ToolParam(description = "progress: one-line description of the current step", required = false) + String step, + @ToolParam(description = "comment/cancel: comment text or cancellation reason", required = false) + String text, + @ToolParam(description = "comment: 'note' (default) or 'blocker' to escalate to the lead", required = false) + String type, + @Nullable ToolContext ctx) { + + String conversationId = ToolExecutionContext.conversationId(ctx); + if (conversationId == null || conversationId.isBlank()) { + return "Error: no conversation context bound to this call."; + } + ConversationEntity conversation = conversationService.findByConversationId(conversationId); + if (conversation == null || conversation.getAgentId() == null) { + return "Error: cannot resolve the calling agent for this conversation."; + } + Long agentId = conversation.getAgentId(); + Optional teamOpt = teamService.getTeamForAgent(agentId); + if (teamOpt.isEmpty()) { + return "Error: you are not part of any agent team; team_tasks is unavailable."; + } + AgentTeamEntity team = teamOpt.get(); + boolean isLead = teamService.isLead(team, agentId); + + try { + return switch (action == null ? "" : action) { + case "list" -> renderBoard(team); + case "get" -> renderDetail(team, parseId(taskId, "taskId")); + case "create" -> createTask(team, agentId, isLead, subject, description, + assigneeAgentId, blockedBy, priority, conversationId); + case "complete" -> completeTask(team, agentId, parseId(taskId, "taskId"), result); + case "progress" -> progress(team, agentId, parseId(taskId, "taskId"), percent, step); + case "comment" -> comment(team, agentId, parseId(taskId, "taskId"), type, text); + case "cancel" -> cancel(team, isLead, parseId(taskId, "taskId"), text); + case "retry" -> retry(team, isLead, parseId(taskId, "taskId")); + default -> "Error: unknown action '" + action + + "'. Use one of: list, get, create, complete, progress, comment, cancel, retry."; + }; + } catch (IllegalArgumentException | IllegalStateException e) { + return "Error: " + e.getMessage(); + } catch (Exception e) { + log.warn("team_tasks {} failed for team={} agent={}: {}", + action, team.getId(), agentId, e.getMessage()); + return "Error: team_tasks failed — " + e.getMessage(); + } + } + + // ==================== actions ==================== + + private String createTask(AgentTeamEntity team, Long agentId, boolean isLead, + String subject, String description, String assigneeAgentId, + String blockedBy, Integer priority, String conversationId) { + if (!isLead) { + return "Error: only the team lead can create tasks. Report blockers or ask the " + + "lead via a comment on your current task instead."; + } + TeamTaskEntity task = taskService.createTask(TeamTaskCreateCommand.builder() + .teamId(team.getId()) + .subject(subject) + .description(description) + .assigneeAgentId(parseId(assigneeAgentId, "assigneeAgentId")) + .createdByAgentId(agentId) + .priority(priority) + .blockedBy(parseIdList(blockedBy)) + .leadConversationId(conversationId) + .build()); + if (TeamTaskStatus.PENDING.equals(task.getStatus())) { + dispatchService.requestDispatch(team.getId()); + } + return "✓ Created task #" + task.getTaskNumber() + " (id: " + task.getId() + + ") \"" + task.getSubject() + "\" assigned to " + agentName(task.getAssigneeAgentId()) + + ". Status: " + task.getStatus() + + (TeamTaskStatus.BLOCKED.equals(task.getStatus()) + ? " (starts automatically once its prerequisites finish)." : ".") + + " Members are dispatched automatically — do not wait in this turn."; + } + + private String completeTask(AgentTeamEntity team, Long agentId, Long taskId, String result) { + requireTaskInTeam(team, taskId); + if (result == null || result.isBlank()) { + return "Error: result is required — summarize what was produced."; + } + List released = taskService.completeTask(taskId, agentId, result); + if (!released.isEmpty()) { + dispatchService.requestDispatch(team.getId()); + } + TeamTaskEntity task = taskService.getTask(taskId); + StringBuilder sb = new StringBuilder("✓ Task #" + task.getTaskNumber() + " " + + task.getStatus() + "."); + if (TeamTaskStatus.IN_REVIEW.equals(task.getStatus())) { + sb.append(" It awaits human approval before counting as done."); + } + if (!released.isEmpty()) { + sb.append(" Released ").append(released.size()).append(" dependent task(s)."); + } + return sb.toString(); + } + + private String progress(AgentTeamEntity team, Long agentId, Long taskId, + Integer percent, String step) { + requireTaskInTeam(team, taskId); + if (percent != null && (percent < 0 || percent > 100)) { + return "Error: percent must be between 0 and 100."; + } + boolean ok = taskService.updateProgress(taskId, agentId, percent, step); + return ok ? "✓ Progress recorded." + : "Error: task is not in progress under your ownership; progress not recorded."; + } + + private String comment(AgentTeamEntity team, Long agentId, Long taskId, + String type, String text) { + requireTaskInTeam(team, taskId); + if (text == null || text.isBlank()) { + return "Error: text is required for a comment."; + } + boolean escalated = taskService.addComment(taskId, TeamTaskService.AUTHOR_AGENT, + String.valueOf(agentId), type, text); + return escalated + ? "✓ Blocker recorded. The task is now failed and the lead has been notified — stop working on it." + : "✓ Comment added."; + } + + private String cancel(AgentTeamEntity team, boolean isLead, Long taskId, String reason) { + if (!isLead) { + return "Error: only the team lead can cancel tasks."; + } + requireTaskInTeam(team, taskId); + taskService.cancelTask(taskId, reason); + return "✓ Task cancelled."; + } + + private String retry(AgentTeamEntity team, boolean isLead, Long taskId) { + if (!isLead) { + return "Error: only the team lead can retry tasks."; + } + requireTaskInTeam(team, taskId); + if (!taskService.retryTask(taskId)) { + return "Error: only failed or stale tasks can be retried."; + } + dispatchService.requestDispatch(team.getId()); + return "✓ Task reset to pending; it will be re-dispatched."; + } + + // ==================== rendering ==================== + + private String renderBoard(AgentTeamEntity team) { + List tasks = taskService.listTasks(team.getId(), null); + if (tasks.isEmpty()) { + return "The task board is empty."; + } + StringBuilder sb = new StringBuilder("Task board for team \"") + .append(team.getName()).append("\" (").append(tasks.size()).append(" tasks):\n"); + for (TeamTaskEntity task : tasks) { + sb.append("- #").append(task.getTaskNumber()) + .append(" [").append(task.getStatus()).append("] ") + .append(task.getSubject()) + .append(" (id: ").append(task.getId()) + .append(", assignee: ").append(agentName(task.getAssigneeAgentId())); + if (task.getProgressPercent() != null + && TeamTaskStatus.IN_PROGRESS.equals(task.getStatus())) { + sb.append(", ").append(task.getProgressPercent()).append('%'); + } + sb.append(")\n"); + } + return sb.toString(); + } + + private String renderDetail(AgentTeamEntity team, Long taskId) { + TeamTaskEntity task = requireTaskInTeam(team, taskId); + StringBuilder sb = new StringBuilder(512); + sb.append("Task #").append(task.getTaskNumber()) + .append(" (id: ").append(task.getId()).append(")\n") + .append("Subject: ").append(task.getSubject()).append('\n') + .append("Status: ").append(task.getStatus()).append('\n') + .append("Assignee: ").append(agentName(task.getAssigneeAgentId())).append('\n'); + if (task.getDescription() != null && !task.getDescription().isBlank()) { + sb.append("Description: ").append(task.getDescription()).append('\n'); + } + if (task.getProgressStep() != null) { + sb.append("Progress: ").append(task.getProgressPercent() == null ? "?" + : task.getProgressPercent()).append("% — ").append(task.getProgressStep()).append('\n'); + } + if (task.getResult() != null && !task.getResult().isBlank()) { + sb.append("Result: ").append(task.getResult()).append('\n'); + } + if (task.getReason() != null && !task.getReason().isBlank()) { + sb.append("Reason: ").append(task.getReason()).append('\n'); + } + List comments = taskService.listComments(taskId); + if (!comments.isEmpty()) { + sb.append("Comments:\n"); + for (TeamTaskCommentEntity comment : comments) { + sb.append("- [").append(comment.getCommentType()).append("] ") + .append(comment.getAuthorType()).append(' ').append(comment.getAuthorId()) + .append(": ").append(comment.getContent()).append('\n'); + } + } + return sb.toString(); + } + + // ==================== helpers ==================== + + private TeamTaskEntity requireTaskInTeam(AgentTeamEntity team, Long taskId) { + TeamTaskEntity task = taskService.getTask(taskId); + if (task == null || !task.getTeamId().equals(team.getId())) { + throw new IllegalArgumentException("task " + taskId + " not found on this team's board"); + } + return task; + } + + private String agentName(Long agentId) { + if (agentId == null) { + return "-"; + } + AgentEntity agent = agentMapper.selectById(agentId); + return agent != null && agent.getName() != null ? agent.getName() : String.valueOf(agentId); + } + + private static Long parseId(String raw, String field) { + if (raw == null || raw.isBlank()) { + throw new IllegalArgumentException(field + " is required"); + } + try { + return Long.valueOf(raw.trim()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException(field + " must be a numeric id, got: " + raw); + } + } + + private static List parseIdList(String raw) { + if (raw == null || raw.isBlank()) { + return List.of(); + } + List ids = new ArrayList<>(); + for (String part : raw.split(",")) { + if (!part.isBlank()) { + ids.add(parseId(part, "blockedBy entry")); + } + } + return ids; + } +} diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V173__register_team_tasks_tool.sql b/mateclaw-server/src/main/resources/db/migration/h2/V173__register_team_tasks_tool.sql new file mode 100644 index 00000000..4d998e37 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V173__register_team_tasks_tool.sql @@ -0,0 +1,6 @@ +-- V173: Register the team_tasks built-in tool (shared team task board). +-- (H2 dialect) + +MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +KEY (id) +VALUES (1000000904, 'TeamTasksTool', '团队任务板', '团队共享任务板:lead 用 create 建任务并指派成员(支持 blockedBy 依赖与优先级);成员用 progress 汇报进度、comment 留言(type=blocker 时自动失败并升级给 lead)、complete 提交结果;list/get 查看看板。仅团队成员可用。', 'builtin', 'teamTasksTool', '📋', TRUE, TRUE, NOW(), NOW(), 0); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V173__register_team_tasks_tool.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V173__register_team_tasks_tool.sql new file mode 100644 index 00000000..f81f4ffc --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V173__register_team_tasks_tool.sql @@ -0,0 +1,6 @@ +-- V173: Register the team_tasks built-in tool (shared team task board). +-- (KingbaseES / PostgreSQL dialect) + +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000904, 'TeamTasksTool', '团队任务板', '团队共享任务板:lead 用 create 建任务并指派成员(支持 blockedBy 依赖与优先级);成员用 progress 汇报进度、comment 留言(type=blocker 时自动失败并升级给 lead)、complete 提交结果;list/get 查看看板。仅团队成员可用。', 'builtin', 'teamTasksTool', '📋', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V173__register_team_tasks_tool.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V173__register_team_tasks_tool.sql new file mode 100644 index 00000000..2003f548 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V173__register_team_tasks_tool.sql @@ -0,0 +1,6 @@ +-- V173: Register the team_tasks built-in tool (shared team task board). +-- (MySQL dialect) + +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000904, 'TeamTasksTool', '团队任务板', '团队共享任务板:lead 用 create 建任务并指派成员(支持 blockedBy 依赖与优先级);成员用 progress 汇报进度、comment 留言(type=blocker 时自动失败并升级给 lead)、complete 提交结果;list/get 查看看板。仅团队成员可用。', 'builtin', 'teamTasksTool', '📋', TRUE, TRUE, NOW(), NOW(), 0) +ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted); diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamContextBuilderTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamContextBuilderTest.java new file mode 100644 index 00000000..ec9819af --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamContextBuilderTest.java @@ -0,0 +1,113 @@ +package vip.mate.team.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.repository.AgentMapper; +import vip.mate.team.model.AgentTeamEntity; +import vip.mate.team.model.AgentTeamMemberEntity; +import vip.mate.team.model.TeamRole; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Pins the role split of the team prompt block: lead gets the delegation + * playbook, members get execution instructions, non-team agents get the + * negative notice that keeps the model away from team_tasks. + */ +class TeamContextBuilderTest { + + private static final Long TEAM_ID = 10L; + private static final Long LEAD_ID = 1L; + private static final Long MEMBER_ID = 2L; + + private TeamService teamService; + private AgentMapper agentMapper; + private TeamContextBuilder builder; + private AgentTeamEntity team; + + @BeforeEach + void setUp() { + teamService = mock(TeamService.class); + agentMapper = mock(AgentMapper.class); + builder = new TeamContextBuilder(teamService, agentMapper); + + team = new AgentTeamEntity(); + team.setId(TEAM_ID); + team.setName("内容组"); + team.setDescription("负责内容生产"); + team.setLeadAgentId(LEAD_ID); + + AgentTeamMemberEntity lead = member(LEAD_ID, TeamRole.LEAD); + AgentTeamMemberEntity writer = member(MEMBER_ID, TeamRole.MEMBER); + when(teamService.listMembers(TEAM_ID)).thenReturn(List.of(lead, writer)); + + AgentEntity leadAgent = agent("主管", "orchestrates"); + AgentEntity writerAgent = agent("写手", "writes articles"); + when(agentMapper.selectById(LEAD_ID)).thenReturn(leadAgent); + when(agentMapper.selectById(MEMBER_ID)).thenReturn(writerAgent); + } + + private static AgentTeamMemberEntity member(Long agentId, String role) { + AgentTeamMemberEntity m = new AgentTeamMemberEntity(); + m.setTeamId(TEAM_ID); + m.setAgentId(agentId); + m.setRole(role); + return m; + } + + private static AgentEntity agent(String name, String description) { + AgentEntity a = new AgentEntity(); + a.setName(name); + a.setDescription(description); + return a; + } + + @Test + @DisplayName("agents outside any team get the negative notice only") + void noTeamNegativeNotice() { + when(teamService.getTeamForAgent(99L)).thenReturn(Optional.empty()); + String ctx = builder.buildTeamContext(99L); + assertTrue(ctx.contains("not part of any agent team")); + assertFalse(ctx.contains("Delegation workflow")); + } + + @Test + @DisplayName("the lead gets the orchestration playbook and the member roster") + void leadGetsPlaybook() { + when(teamService.getTeamForAgent(LEAD_ID)).thenReturn(Optional.of(team)); + when(teamService.isLead(team, LEAD_ID)).thenReturn(true); + + String ctx = builder.buildTeamContext(LEAD_ID); + + assertTrue(ctx.contains("## Team: 内容组")); + assertTrue(ctx.contains("LEAD — you orchestrate")); + assertTrue(ctx.contains("Delegation workflow (mandatory)")); + assertTrue(ctx.contains("Delegation is NOT completion")); + assertTrue(ctx.contains("写手")); + assertTrue(ctx.contains("agentId: " + MEMBER_ID)); + // The lead must not receive member execution instructions. + assertFalse(ctx.contains("Working on assigned tasks")); + } + + @Test + @DisplayName("a member gets execution instructions, not the delegation playbook") + void memberGetsExecutionRules() { + when(teamService.getTeamForAgent(MEMBER_ID)).thenReturn(Optional.of(team)); + when(teamService.isLead(team, MEMBER_ID)).thenReturn(false); + + String ctx = builder.buildTeamContext(MEMBER_ID); + + assertTrue(ctx.contains("Your role: MEMBER.")); + assertTrue(ctx.contains("Working on assigned tasks")); + assertTrue(ctx.contains("type=\"blocker\"")); + assertFalse(ctx.contains("Delegation workflow")); + // Self is marked in the roster instead of repeating its description. + assertTrue(ctx.contains("— you")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/team/tool/TeamTasksToolTest.java b/mateclaw-server/src/test/java/vip/mate/team/tool/TeamTasksToolTest.java new file mode 100644 index 00000000..b17550d6 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/team/tool/TeamTasksToolTest.java @@ -0,0 +1,241 @@ +package vip.mate.team.tool; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.repository.AgentMapper; +import vip.mate.team.model.AgentTeamEntity; +import vip.mate.team.model.TeamTaskCreateCommand; +import vip.mate.team.model.TeamTaskEntity; +import vip.mate.team.model.TeamTaskStatus; +import vip.mate.team.service.TeamDispatchService; +import vip.mate.team.service.TeamService; +import vip.mate.team.service.TeamTaskService; +import vip.mate.tool.builtin.ToolExecutionContext; +import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.ConversationEntity; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * Pins the tool facade's contracts: caller resolution via the conversation, + * team membership gating, lead-only actions, LLM-friendly error strings, and + * the pass-through into TeamTaskService. + */ +class TeamTasksToolTest { + + private static final String CONV = "conv-1"; + private static final Long TEAM_ID = 10L; + private static final Long LEAD_ID = 1L; + private static final Long MEMBER_ID = 2L; + + private TeamService teamService; + private TeamTaskService taskService; + private TeamDispatchService dispatchService; + private ConversationService conversationService; + private AgentMapper agentMapper; + private TeamTasksTool tool; + private AgentTeamEntity team; + + @BeforeEach + void setUp() { + teamService = mock(TeamService.class); + taskService = mock(TeamTaskService.class); + dispatchService = mock(TeamDispatchService.class); + conversationService = mock(ConversationService.class); + agentMapper = mock(AgentMapper.class); + tool = new TeamTasksTool(teamService, taskService, dispatchService, + conversationService, agentMapper); + + team = new AgentTeamEntity(); + team.setId(TEAM_ID); + team.setName("研发组"); + team.setLeadAgentId(LEAD_ID); + + ToolExecutionContext.set(CONV, "admin"); + } + + @AfterEach + void tearDown() { + ToolExecutionContext.clear(); + } + + private void callerIs(Long agentId) { + ConversationEntity conv = new ConversationEntity(); + conv.setConversationId(CONV); + conv.setAgentId(agentId); + when(conversationService.findByConversationId(CONV)).thenReturn(conv); + when(teamService.getTeamForAgent(agentId)).thenReturn(Optional.of(team)); + when(teamService.isLead(team, agentId)).thenReturn(agentId.equals(LEAD_ID)); + } + + private TeamTaskEntity task(Long id, String status) { + TeamTaskEntity t = new TeamTaskEntity(); + t.setId(id); + t.setTeamId(TEAM_ID); + t.setTaskNumber(3); + t.setSubject("collect data"); + t.setStatus(status); + t.setAssigneeAgentId(MEMBER_ID); + return t; + } + + private String invoke(String action, String taskId) { + return tool.team_tasks(action, taskId, null, null, null, null, null, + null, null, null, null, null, null); + } + + // ==================== context & membership gating ==================== + + @Test + @DisplayName("no conversation context yields a structured error") + void noContextError() { + ToolExecutionContext.clear(); + assertTrue(invoke("list", null).startsWith("Error: no conversation context")); + } + + @Test + @DisplayName("an agent outside any team is refused") + void nonTeamAgentRefused() { + ConversationEntity conv = new ConversationEntity(); + conv.setConversationId(CONV); + conv.setAgentId(99L); + when(conversationService.findByConversationId(CONV)).thenReturn(conv); + when(teamService.getTeamForAgent(99L)).thenReturn(Optional.empty()); + + assertTrue(invoke("list", null).contains("not part of any agent team")); + } + + @Test + @DisplayName("unknown action lists the valid ones") + void unknownAction() { + callerIs(LEAD_ID); + assertTrue(invoke("destroy", null).contains("unknown action")); + } + + // ==================== role gating ==================== + + @Test + @DisplayName("member create is refused — only the lead delegates") + void memberCannotCreate() { + callerIs(MEMBER_ID); + String out = tool.team_tasks("create", null, "subj", "desc", + String.valueOf(MEMBER_ID), null, null, null, null, null, null, null, null); + assertTrue(out.contains("only the team lead can create")); + verify(taskService, never()).createTask(any()); + } + + @Test + @DisplayName("member cancel and retry are refused") + void memberCannotCancelOrRetry() { + callerIs(MEMBER_ID); + when(taskService.getTask(5L)).thenReturn(task(5L, TeamTaskStatus.PENDING)); + assertTrue(invoke("cancel", "5").contains("only the team lead")); + assertTrue(invoke("retry", "5").contains("only the team lead")); + verify(taskService, never()).cancelTask(any(), any()); + verify(taskService, never()).retryTask(any()); + } + + // ==================== create pass-through ==================== + + @Test + @DisplayName("lead create parses ids, wires the lead conversation and reports the assignee") + void leadCreatePassesThrough() { + callerIs(LEAD_ID); + TeamTaskEntity created = task(50L, TeamTaskStatus.PENDING); + when(taskService.createTask(any())).thenReturn(created); + AgentEntity member = new AgentEntity(); + member.setName("写手"); + when(agentMapper.selectById(MEMBER_ID)).thenReturn(member); + + String out = tool.team_tasks("create", null, "collect data", "step details", + String.valueOf(MEMBER_ID), "11,12", 5, null, null, null, null, null, null); + + assertTrue(out.startsWith("✓ Created task #3")); + assertTrue(out.contains("写手")); + ArgumentCaptor captor = + ArgumentCaptor.forClass(TeamTaskCreateCommand.class); + verify(taskService).createTask(captor.capture()); + TeamTaskCreateCommand cmd = captor.getValue(); + assertEquals(MEMBER_ID, cmd.getAssigneeAgentId()); + assertEquals(List.of(11L, 12L), cmd.getBlockedBy()); + assertEquals(LEAD_ID, cmd.getCreatedByAgentId()); + assertEquals(CONV, cmd.getLeadConversationId()); + verify(dispatchService).requestDispatch(TEAM_ID); + } + + @Test + @DisplayName("creating a blocked task does not trigger a dispatch sweep") + void blockedCreateDoesNotDispatch() { + callerIs(LEAD_ID); + TeamTaskEntity blocked = task(51L, TeamTaskStatus.BLOCKED); + when(taskService.createTask(any())).thenReturn(blocked); + + tool.team_tasks("create", null, "later step", null, + String.valueOf(MEMBER_ID), "50", null, null, null, null, null, null, null); + + verify(dispatchService, never()).requestDispatch(any()); + } + + @Test + @DisplayName("service validation errors surface as Error: strings, not exceptions") + void serviceErrorsBecomeStrings() { + callerIs(LEAD_ID); + when(taskService.createTask(any())) + .thenThrow(new IllegalArgumentException("assignee is required")); + String out = tool.team_tasks("create", null, "s", null, + String.valueOf(MEMBER_ID), null, null, null, null, null, null, null, null); + assertEquals("Error: assignee is required", out); + } + + // ==================== member execution actions ==================== + + @Test + @DisplayName("complete requires a result and reports released dependents") + void completeReportsRelease() { + callerIs(MEMBER_ID); + when(taskService.getTask(5L)).thenReturn(task(5L, TeamTaskStatus.COMPLETED)); + when(taskService.completeTask(5L, MEMBER_ID, "done, see report")) + .thenReturn(List.of(6L)); + + assertTrue(invoke("complete", "5").startsWith("Error: result is required")); + + String ok = tool.team_tasks("complete", "5", null, null, null, null, null, + "done, see report", null, null, null, null, null); + assertTrue(ok.contains("Released 1 dependent task(s)")); + } + + @Test + @DisplayName("a blocker comment tells the member to stop working") + void blockerCommentStops() { + callerIs(MEMBER_ID); + when(taskService.getTask(5L)).thenReturn(task(5L, TeamTaskStatus.IN_PROGRESS)); + when(taskService.addComment(eq(5L), eq(TeamTaskService.AUTHOR_AGENT), + anyString(), eq("blocker"), anyString())).thenReturn(true); + + String out = tool.team_tasks("comment", "5", null, null, null, null, null, + null, null, null, "missing credentials", "blocker", null); + assertTrue(out.contains("stop working")); + } + + @Test + @DisplayName("a task from another team is invisible") + void foreignTaskRejected() { + callerIs(MEMBER_ID); + TeamTaskEntity foreign = task(5L, TeamTaskStatus.PENDING); + foreign.setTeamId(999L); + when(taskService.getTask(5L)).thenReturn(foreign); + + assertTrue(invoke("get", "5").contains("not found on this team's board")); + } +}