feat(team): plan-execute leads orchestrate through the task board — hand-off bridge, resume gate and step dependencies

This commit is contained in:
mateaix 2026-07-25 22:22:38 +08:00
parent c15e51b34b
commit 2cbe00a1e7
16 changed files with 862 additions and 82 deletions

View File

@ -61,6 +61,7 @@ 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.team.service.TeamPlanBridge;
import vip.mate.wiki.service.WikiContextService;
import java.lang.reflect.Field;
@ -103,6 +104,7 @@ public class AgentGraphBuilder {
private boolean markdownNormalizeEnabled;
private final ConversationService conversationService;
private final TeamContextBuilder teamContextBuilder;
private final TeamPlanBridge teamPlanBridge;
private final ModelConfigService modelConfigService;
private final ModelProviderService modelProviderService;
private final ModelContextWindowResolver contextWindowResolver;
@ -643,6 +645,9 @@ public class AgentGraphBuilder {
executor.setAuditEventService(auditEventService);
}
PlanGenerationNode planGenerationNode = new PlanGenerationNode(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet, goalService, goalProperties, agentService);
// Team hand-off: a lead-of-team plan agent parks multi-step plans on
// the team task board instead of the serial delegation pipeline.
planGenerationNode.setTeamPlanBridge(teamPlanBridge);
StepExecutionNode stepExecutionNode = new StepExecutionNode(chatModel, toolSet, executor, planningService, streamTracker, reasoningEffort, streamingHelper, conversationWindowManager, skillCatalogRenderer);
// Per-step delegation: route a step assigned to a specialist agent
// through DelegateAgentTool (null when delegation deps aren't wired).
@ -787,7 +792,10 @@ public class AgentGraphBuilder {
AsyncEdgeAction.edge_async(new PlanGenerationDispatcher()),
Map.of(
PlanStateKeys.STEP_EXECUTION_NODE, PlanStateKeys.STEP_EXECUTION_NODE,
PlanStateKeys.DIRECT_ANSWER_NODE, PlanStateKeys.DIRECT_ANSWER_NODE))
PlanStateKeys.DIRECT_ANSWER_NODE, PlanStateKeys.DIRECT_ANSWER_NODE,
// Board-delegated plan settled: step results were
// rebuilt from team tasks summarize directly.
PlanStateKeys.PLAN_SUMMARY_NODE, PlanStateKeys.PLAN_SUMMARY_NODE))
.addConditionalEdges(PlanStateKeys.STEP_EXECUTION_NODE,
AsyncEdgeAction.edge_async(new StepProgressDispatcher()),
Map.of(

View File

@ -3,6 +3,7 @@ package vip.mate.agent.graph.plan.edge;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.EdgeAction;
import vip.mate.agent.graph.plan.state.PlanStateKeys;
import vip.mate.agent.graph.state.MateClawStateKeys;
/**
* Routes the graph after the triage node.
@ -22,6 +23,12 @@ public class PlanGenerationDispatcher implements EdgeAction {
@Override
public String apply(OverAllState state) {
// A board-delegated plan whose tasks all settled resumes straight into
// the summary its step results were rebuilt from the team task board
// by the resume gate; the step loop has nothing left to execute.
if ("plan_delegated_settled".equals(state.value(MateClawStateKeys.CURRENT_PHASE, ""))) {
return PlanStateKeys.PLAN_SUMMARY_NODE;
}
boolean needsPlanning = state.value(PlanStateKeys.NEEDS_PLANNING, false);
if (!needsPlanning) {
return PlanStateKeys.DIRECT_ANSWER_NODE;

View File

@ -28,6 +28,8 @@ import vip.mate.goal.model.GoalCriterion;
import vip.mate.goal.model.GoalEntity;
import vip.mate.goal.service.GoalService;
import vip.mate.planning.service.PlanningService;
import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.service.TeamPlanBridge;
import java.util.ArrayList;
import java.util.HashMap;
@ -70,6 +72,17 @@ public class PlanGenerationNode implements NodeAction {
* resolve per-step assignments. Null disables per-step delegation (legacy/test). */
private final AgentService agentService;
/**
* Optional hands a team lead's plan off to the team task board and
* resumes parked plans on later messages. Null keeps the legacy serial
* pipeline (non-team deployments / tests).
*/
private TeamPlanBridge teamPlanBridge;
public void setTeamPlanBridge(TeamPlanBridge teamPlanBridge) {
this.teamPlanBridge = teamPlanBridge;
}
/** Plan steps below this size are trivial tool tasks, not goal-worthy. */
private static final int MIN_STEPS_FOR_AUTO_GOAL = 2;
/** Cap the auto-derived goal title; the full request rides in the description. */
@ -88,7 +101,13 @@ public class PlanGenerationNode implements NodeAction {
// order). An empty string / missing entry means "run with the parent
// agent". Only populated when delegatable specialist agents are
// advertised to the planner; absent for backward compatibility.
@JsonProperty("step_agents") List<String> stepAgents
@JsonProperty("step_agents") List<String> stepAgents,
// Optional per-step prerequisites, parallel to steps: each entry is
// a comma-separated list of earlier 1-based step numbers ("" = no
// prerequisite, may start immediately). Only requested when steps
// hand off to a team board, where independent steps run in
// parallel; anything invalid falls back to a sequential chain.
@JsonProperty("step_deps") List<String> stepDeps
) {}
private static final String PLANNING_PROMPT = """
@ -331,6 +350,60 @@ public class PlanGenerationNode implements NodeAction {
}
}
/**
* Parse the planner's step_deps (1-based step numbers, comma separated)
* into 0-based prerequisite indices. Any irregularity missing field,
* length mismatch, unparseable entry, self/forward reference falls back
* to a sequential chain (step i depends on step i-1), which is exactly
* the serial semantics the plan pipeline has today: dependency info is a
* parallelism bonus, never a correctness requirement. Package-private for
* direct unit testing.
*/
static List<List<Integer>> parseStepDeps(List<String> stepDeps, int stepCount) {
if (stepDeps == null || stepDeps.size() != stepCount) {
return sequentialChain(stepCount);
}
List<List<Integer>> parsed = new ArrayList<>();
for (int i = 0; i < stepCount; i++) {
List<Integer> deps = new ArrayList<>();
String raw = stepDeps.get(i);
if (raw != null && !raw.isBlank()) {
for (String part : raw.split("[,]")) {
if (part.isBlank()) {
continue;
}
try {
int depIndex = Integer.parseInt(part.trim()) - 1;
if (depIndex < 0 || depIndex >= i) {
return sequentialChain(stepCount);
}
deps.add(depIndex);
} catch (NumberFormatException e) {
return sequentialChain(stepCount);
}
}
}
parsed.add(deps);
}
return parsed;
}
private static List<List<Integer>> sequentialChain(int stepCount) {
List<List<Integer>> chain = new ArrayList<>();
for (int i = 0; i < stepCount; i++) {
chain.add(i == 0 ? List.of() : List.of(i - 1));
}
return chain;
}
private static Long parseNumericAgentId(String agentId) {
try {
return Long.valueOf(agentId);
} catch (Exception e) {
return null;
}
}
/**
* Enabled agents in the given workspace, excluding the parent (plan) agent
* itself these are the agents a step can be delegated to. Empty when
@ -422,6 +495,45 @@ public class PlanGenerationNode implements NodeAction {
List<GraphEventPublisher.GraphEvent> events = new ArrayList<>();
events.add(GraphEventPublisher.phase("planning", Map.of("goal", persistGoal)));
// Delegated-plan resume gate: a plan parked on the team board resumes
// here on ANY inbound message (settle announcement or a user asking
// for status) deterministic routing that never depends on how the
// triage LLM classifies the wake-up text. Mirrors the approval-replay
// pattern: park in the DB, resume from the DB.
if (teamPlanBridge != null) {
TeamPlanBridge.ParkedPlanState parked = teamPlanBridge.checkParkedPlan(conversationId);
if (parked instanceof TeamPlanBridge.Settled settled) {
log.info("[PlanGeneration] Delegated plan {} settled ({} results) — routing to summary",
settled.planId(), settled.completedResults().size());
return PlanStateAccessor.output()
.needsPlanning(true)
.planId(settled.planId())
.planSteps(settled.steps())
.planValid(true)
.currentStepIndex(settled.steps().size())
// Summarize against the original plan goal, not the
// wake-up message that happened to trigger the resume.
.goal(settled.goal())
.put(PlanStateKeys.COMPLETED_RESULTS, settled.completedResults())
.currentPhase("plan_delegated_settled")
.events(events)
.build();
}
if (parked instanceof TeamPlanBridge.InFlight inFlight) {
log.info("[PlanGeneration] Delegated plan still in flight — answering with progress");
if (streamingHelper != null) {
streamingHelper.broadcastContent(conversationId, inFlight.progressText());
}
return PlanStateAccessor.output()
.needsPlanning(false)
.directAnswer(inFlight.progressText())
.currentPhase("direct_answer")
.contentStreamed(true)
.events(events)
.build();
}
}
// Replay path: plan is already in state (injected by chatWithReplayStream); skip LLM.
Long existingPlanId = state.<Long>value(PlanStateKeys.PLAN_ID).orElse(null);
if (existingPlanId != null) {
@ -467,22 +579,42 @@ public class PlanGenerationNode implements NodeAction {
+ "\n单次工具调用应归为单步B不要拆成多步。"));
}
// Advertise delegatable specialist agents so the planner can assign a
// multi-step plan's step to a dedicated agent (e.g. a test step to a
// QA agent, a UI step to a frontend agent). Only fills the step's
// step_agents slot; unassigned steps stay with the parent agent.
// Skipped entirely when no peer agents exist in the workspace.
List<AgentEntity> delegatable = listDelegatableAgents(chatOrigin.workspaceId(), agentId);
if (!delegatable.isEmpty()) {
String agentLines = delegatable.stream()
// Advertise delegatable agents. A team lead advertises its member
// roster with mandatory assignment (steps hand off to the team
// board and run in parallel there); everyone else advertises the
// workspace-wide specialist list with optional assignment.
AgentTeamEntity leadTeam = null;
if (teamPlanBridge != null) {
Long numericAgentId = parseNumericAgentId(agentId);
leadTeam = numericAgentId == null ? null
: teamPlanBridge.leadTeam(numericAgentId).orElse(null);
}
if (leadTeam != null) {
String memberLines = teamPlanBridge.roster(leadTeam).stream()
.map(a -> "- " + a.getName()
+ (StringUtils.hasText(a.getDescription()) ? "" + a.getDescription() : ""))
.collect(Collectors.joining("\n"));
promptMessages.add(new UserMessage(
"可委派的专职 Agent仅当某步骤明显属于其专长时才指派否则该步骤留空、由你自己执行\n"
+ agentLines
+ "\n若要委派在 step_agents 数组对应位置填写 Agent 名称(与 steps 同序、等长);"
+ "不委派的步骤填空字符串。多数步骤通常不需要委派。"));
"你是团队「" + leadTeam.getName() + "」的 lead。多步任务的每个步骤都将分派到团队任务板"
+ "由成员并行执行。团队成员:\n" + memberLines
+ "\n要求\n"
+ "1. 在 step_agents 数组为每个步骤填写一名成员名称(与 steps 同序、等长,不允许留空)。\n"
+ "2. 在 step_deps 数组标注每个步骤的前置步骤序号1 起始,逗号分隔;无前置填空字符串)。"
+ "相互独立的步骤请不要标注前置,以便并行执行。\n"
+ "3. 每个步骤描述必须自包含——执行成员看不到本对话,把所需的输入与要求写进步骤里。"));
} else {
List<AgentEntity> delegatable = listDelegatableAgents(chatOrigin.workspaceId(), agentId);
if (!delegatable.isEmpty()) {
String agentLines = delegatable.stream()
.map(a -> "- " + a.getName()
+ (StringUtils.hasText(a.getDescription()) ? "" + a.getDescription() : ""))
.collect(Collectors.joining("\n"));
promptMessages.add(new UserMessage(
"可委派的专职 Agent仅当某步骤明显属于其专长时才指派否则该步骤留空、由你自己执行\n"
+ agentLines
+ "\n若要委派在 step_agents 数组对应位置填写 Agent 名称(与 steps 同序、等长);"
+ "不委派的步骤填空字符串。多数步骤通常不需要委派。"));
}
}
// Inject working context (rolling conversation summary) so triage respects
@ -599,6 +731,39 @@ public class PlanGenerationNode implements NodeAction {
steps = List.of(persistGoal);
}
// Team hand-off: a lead whose every step resolved to a member
// parks the plan on the task board and ends the turn execution
// continues through the board's dispatch/announce machinery, and
// any later message resumes via the delegated-plan gate above.
if (leadTeam != null) {
List<Long> memberIds = teamPlanBridge.resolveMembers(leadTeam, steps,
triage != null ? triage.stepAgents() : null);
if (memberIds != null) {
List<List<Integer>> stepDeps = parseStepDeps(
triage != null ? triage.stepDeps() : null, steps.size());
var delegatedPlan = planningService.createPlan(
agentId, conversationId, persistGoal, steps, memberIds);
events.add(GraphEventPublisher.planCreated(delegatedPlan.getId(), steps));
String announcement = teamPlanBridge.delegatePlan(leadTeam,
delegatedPlan.getId(), persistGoal, steps, stepDeps,
memberIds, conversationId);
streamingHelper.broadcastContent(conversationId, announcement);
log.info("[PlanGeneration] Plan {} handed off to team {} board ({} steps)",
delegatedPlan.getId(), leadTeam.getId(), steps.size());
return PlanStateAccessor.output()
.needsPlanning(false)
.directAnswer(announcement)
.currentPhase("direct_answer")
.contentStreamed(true)
.thinkingStreamed(!result.thinking().isEmpty())
.mergeUsage(state, result)
.events(events)
.build();
}
log.info("[PlanGeneration] Lead plan not fully assigned to members; "
+ "falling back to the serial pipeline");
}
// Resolve any per-step agent delegation the planner asked for. Null
// when nothing is delegated, keeping createPlan on the legacy path.
List<Long> stepAgentIds = resolveStepAgents(steps,

View File

@ -74,7 +74,9 @@ public class PlanSummaryNode implements NodeAction {
Prompt prompt = new Prompt(List.of(
new SystemMessage("请根据以下各步骤的执行结果,给出一个简洁完整的总结回答。"
+ "直接回答用户的原始问题,不要罗列步骤。"
+ "如果对话上下文中包含用户的特殊要求(如风格、语言、格式等),请在总结中体现。"),
+ "如果对话上下文中包含用户的特殊要求(如风格、语言、格式等),请在总结中体现。"
+ "若执行结果中包含交付物下载链接,请在回答中原样列出这些链接。"
+ "若某些步骤未完成,如实说明未完成的部分及原因。"),
new UserMessage(userContent.toString())
));

View File

@ -127,6 +127,35 @@ public class PlanningService {
}
}
/**
* Park a plan whose steps were handed off to a team task board. The plan
* stays in this status while board tasks execute; any later inbound
* message resumes it through the delegated-plan gate.
*/
public void markPlanDelegated(Long planId) {
PlanEntity plan = planMapper.selectById(planId);
if (plan != null) {
plan.setStatus("delegated");
planMapper.updateById(plan);
}
}
/**
* Latest board-delegated plan parked on this conversation, or null. The
* counterpart of {@link #findAwaitingApprovalContext} for the team
* hand-off flow: park in the DB, resume from the DB.
*/
public PlanEntity findDelegatedPlan(String conversationId) {
if (conversationId == null || conversationId.isBlank()) {
return null;
}
return planMapper.selectOne(new LambdaQueryWrapper<PlanEntity>()
.eq(PlanEntity::getConversationId, conversationId)
.eq(PlanEntity::getStatus, "delegated")
.orderByDesc(PlanEntity::getCreateTime)
.last("LIMIT 1"));
}
/**
* 完成计划
*/

View File

@ -0,0 +1,13 @@
package vip.mate.team.event;
/**
* Published after a plan's steps were handed off to a team's task board, so
* the dispatch layer sweeps immediately instead of waiting for the scheduled
* pass. An event (rather than a direct call) keeps the hand-off bridge free
* of the dispatch service a direct dependency would close a bean cycle
* through the agent graph builder.
*
* @author MateClaw Team
*/
public record TeamTasksDelegatedEvent(Long teamId) {
}

View File

@ -3,8 +3,10 @@ package vip.mate.team.service;
import cn.hutool.core.util.IdUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import vip.mate.team.event.TeamTasksDelegatedEvent;
import vip.mate.agent.AgentService;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.team.model.AgentTeamEntity;
@ -77,6 +79,16 @@ public class TeamDispatchService {
/** Members with a run currently in flight in this JVM (belt-and-braces on top of hasActiveTask). */
private final Set<Long> runningMembers = ConcurrentHashMap.newKeySet();
/**
* Plan hand-off notification: sweep the board as soon as a delegated
* plan's tasks land. Event-driven because the hand-off bridge cannot
* depend on this service directly (bean cycle through the graph builder).
*/
@EventListener
public void onTeamTasksDelegated(TeamTasksDelegatedEvent event) {
requestDispatch(event.teamId());
}
/** Asynchronously sweep the team's board and dispatch whatever is eligible. */
public void requestDispatch(Long teamId) {
DISPATCH_EXECUTOR.submit(() -> {

View File

@ -0,0 +1,304 @@
package vip.mate.team.service;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.planning.model.PlanEntity;
import vip.mate.planning.service.PlanningService;
import vip.mate.team.event.TeamTasksDelegatedEvent;
import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.model.AgentTeamMemberEntity;
import vip.mate.team.model.TeamRole;
import vip.mate.team.model.TeamTaskCreateCommand;
import vip.mate.team.model.TeamTaskEntity;
import vip.mate.team.model.TeamTaskStatus;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Bridges the Plan-Execute graph onto the team task board. When a plan's
* lead-of-team owner assigns every step to a team member, the steps become
* board tasks (dependencies mapped to blockedBy), the plan parks in the
* "delegated" status and the lead's turn ends execution then runs through
* the board's dispatch/announce machinery instead of the serial per-step
* delegation pipeline. Any later inbound message resumes through
* {@link #checkParkedPlan}: settled boards feed the plan summary, in-flight
* boards produce a progress answer.
*
* Deliberately does NOT depend on the dispatch service (bean cycle through
* the agent graph builder) a {@link TeamTasksDelegatedEvent} triggers the
* immediate sweep instead.
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class TeamPlanBridge {
/** Task subject cap; the full step text rides in the description. */
static final int SUBJECT_MAX_CHARS = 120;
private final TeamService teamService;
private final TeamTaskService taskService;
private final PlanningService planningService;
private final AgentMapper agentMapper;
private final ApplicationEventPublisher eventPublisher;
// ==================== triage support ====================
/** The team this agent leads, if any. */
public Optional<AgentTeamEntity> leadTeam(Long agentId) {
if (agentId == null) {
return Optional.empty();
}
return teamService.getTeamForAgent(agentId)
.filter(team -> teamService.isLead(team, agentId));
}
/** Assignable members (lead excluded), for the planner's roster message. */
public List<AgentEntity> roster(AgentTeamEntity team) {
List<AgentEntity> members = new ArrayList<>();
for (AgentTeamMemberEntity member : teamService.listMembers(team.getId())) {
if (TeamRole.LEAD.equals(member.getRole())) {
continue;
}
AgentEntity agent = agentMapper.selectById(member.getAgentId());
if (agent != null) {
members.add(agent);
}
}
return members;
}
/**
* Map the planner's step_agents names onto team member ids. Returns null
* unless EVERY step resolves to a member the hand-off is all-or-nothing
* (mixed local/board plans are out of scope), and a null keeps the plan
* on the legacy serial pipeline.
*/
public List<Long> resolveMembers(AgentTeamEntity team, List<String> steps,
List<String> stepAgents) {
if (steps == null || steps.isEmpty() || stepAgents == null) {
return null;
}
Map<String, Long> byName = new HashMap<>();
for (AgentEntity member : roster(team)) {
if (member.getName() != null) {
byName.put(member.getName().trim().toLowerCase(), member.getId());
}
}
List<Long> ids = new ArrayList<>();
for (int i = 0; i < steps.size(); i++) {
String name = i < stepAgents.size() ? stepAgents.get(i) : null;
Long id = (name == null || name.isBlank()) ? null
: byName.get(name.trim().toLowerCase());
if (id == null) {
return null;
}
ids.add(id);
}
return ids;
}
// ==================== hand-off ====================
/**
* Create one board task per step (dependencies blockedBy), park the plan
* as "delegated" and nudge the dispatcher. Returns the announcement text
* the lead streams to the user before ending its turn.
*
* @param stepDeps per-step prerequisite step indices (0-based, each
* referencing an earlier step); the caller guarantees
* validity via its sequential-chain fallback
*/
public String delegatePlan(AgentTeamEntity team, Long planId, String goal,
List<String> steps, List<List<Integer>> stepDeps,
List<Long> memberIds, String leadConversationId) {
List<TeamTaskEntity> created = new ArrayList<>();
for (int i = 0; i < steps.size(); i++) {
String step = steps.get(i);
List<Long> blockedBy = new ArrayList<>();
for (Integer depIndex : stepDeps.get(i)) {
blockedBy.add(created.get(depIndex).getId());
}
TeamTaskEntity task = taskService.createTask(TeamTaskCreateCommand.builder()
.teamId(team.getId())
.subject(subjectOf(step))
.description(step + "\n\n[Plan context]\nOverall request: " + goal)
.assigneeAgentId(memberIds.get(i))
.createdByAgentId(team.getLeadAgentId())
.blockedBy(blockedBy.isEmpty() ? null : blockedBy)
.leadConversationId(leadConversationId)
.channel("plan")
.metadata(new JSONObject()
.set("planId", String.valueOf(planId))
.set("stepIndex", i)
.toString())
.build());
created.add(task);
}
planningService.markPlanDelegated(planId);
eventPublisher.publishEvent(new TeamTasksDelegatedEvent(team.getId()));
log.info("Plan {} delegated to team {} board as {} task(s)", planId, team.getId(),
created.size());
return buildAnnouncement(created, stepDeps);
}
// ==================== resume gate ====================
/** Outcome of the parked-plan check on an inbound message. */
public sealed interface ParkedPlanState permits None, Settled, InFlight {
}
public record None() implements ParkedPlanState {
}
/** All board tasks terminal — resume into the plan summary. */
public record Settled(Long planId, String goal, List<String> steps,
List<String> completedResults) implements ParkedPlanState {
}
/** Board still working — answer with live progress, stay parked. */
public record InFlight(String progressText) implements ParkedPlanState {
}
/**
* Inspect the conversation's parked plan, if any. Settled boards sync the
* sub-plan mirror and return the step results (with deliverable links) in
* the summary node's expected format; in-flight boards return a rendered
* progress snapshot.
*/
public ParkedPlanState checkParkedPlan(String conversationId) {
PlanEntity plan = planningService.findDelegatedPlan(conversationId);
if (plan == null) {
return new None();
}
Optional<AgentTeamEntity> teamOpt = leadTeam(parseAgentId(plan.getAgentId()));
if (teamOpt.isEmpty()) {
// Team dissolved or lead reassigned while parked nothing to wait
// for; fail the plan so the conversation is not wedged forever.
planningService.markPlanFailed(plan.getId(), "team no longer available");
return new None();
}
List<TeamTaskEntity> tasks = taskService.listTasksByPlan(teamOpt.get().getId(), plan.getId());
if (tasks.isEmpty()) {
planningService.markPlanFailed(plan.getId(), "board tasks vanished");
return new None();
}
boolean allTerminal = tasks.stream()
.allMatch(task -> TeamTaskStatus.isTerminal(task.getStatus()));
List<String> steps = planningService.getSubPlans(plan.getId()).stream()
.map(sub -> sub.getDescription())
.toList();
if (!allTerminal) {
return new InFlight(buildProgressText(tasks));
}
return new Settled(plan.getId(), plan.getGoal(), steps, settle(plan.getId(), tasks));
}
/** Sync the sub-plan mirror from terminal tasks and render step results. */
private List<String> settle(Long planId, List<TeamTaskEntity> tasks) {
List<String> results = new ArrayList<>();
for (TeamTaskEntity task : tasks) {
int stepIndex = stepIndexOf(task);
StringBuilder line = new StringBuilder();
if (TeamTaskStatus.COMPLETED.equals(task.getStatus())) {
planningService.updateSubPlanResult(planId, stepIndex,
task.getResult() == null ? "" : task.getResult());
line.append(String.format("步骤%d结果%s", stepIndex + 1,
task.getResult() == null ? "(无输出)" : task.getResult()));
} else {
String reason = task.getReason() == null ? task.getStatus() : task.getReason();
planningService.updateSubPlanFailure(planId, stepIndex, reason);
line.append(String.format("步骤%d未完成%s%s", stepIndex + 1,
task.getStatus(), reason));
}
for (TeamTaskService.Deliverable file : taskService.listDeliverables(task)) {
line.append("\n交付物").append(file.name()).append("").append(file.url());
}
results.add(line.toString());
}
return results;
}
// ==================== rendering ====================
private String buildAnnouncement(List<TeamTaskEntity> tasks, List<List<Integer>> stepDeps) {
StringBuilder sb = new StringBuilder("已将计划分派到团队任务板并行执行:\n");
for (int i = 0; i < tasks.size(); i++) {
TeamTaskEntity task = tasks.get(i);
sb.append("- #").append(task.getTaskNumber()).append(' ')
.append(task.getSubject())
.append("").append(agentName(task.getAssigneeAgentId())).append("");
if (!stepDeps.get(i).isEmpty()) {
sb.append(" — 前置:");
for (Integer depIndex : stepDeps.get(i)) {
sb.append('#').append(tasks.get(depIndex).getTaskNumber()).append(' ');
}
}
sb.append('\n');
}
sb.append("成员完成后我会汇总结果给你。");
return sb.toString();
}
private String buildProgressText(List<TeamTaskEntity> tasks) {
StringBuilder sb = new StringBuilder("计划仍在团队任务板上执行中:\n");
for (TeamTaskEntity task : tasks) {
sb.append("- #").append(task.getTaskNumber()).append(' ')
.append(task.getSubject())
.append("").append(task.getStatus());
if (task.getProgressPercent() != null
&& TeamTaskStatus.IN_PROGRESS.equals(task.getStatus())) {
sb.append("").append(task.getProgressPercent()).append('%');
if (task.getProgressStep() != null) {
sb.append("").append(task.getProgressStep());
}
sb.append('');
}
sb.append('\n');
}
sb.append("全部完成后我会汇总;如需调整可在团队任务板上操作。");
return sb.toString();
}
// ==================== helpers ====================
private static String subjectOf(String step) {
String firstLine = step.strip().lines().findFirst().orElse(step.strip());
return firstLine.length() <= SUBJECT_MAX_CHARS ? firstLine
: firstLine.substring(0, SUBJECT_MAX_CHARS);
}
private static int stepIndexOf(TeamTaskEntity task) {
try {
return JSONUtil.parseObj(task.getMetadata()).getInt("stepIndex", 0);
} catch (Exception e) {
return 0;
}
}
private static Long parseAgentId(String agentId) {
try {
return Long.valueOf(agentId);
} catch (Exception e) {
return null;
}
}
private String agentName(Long agentId) {
AgentEntity agent = agentId == null ? null : agentMapper.selectById(agentId);
return agent != null && agent.getName() != null ? agent.getName()
: String.valueOf(agentId);
}
}

View File

@ -46,7 +46,6 @@ public class TeamService {
public AgentTeamEntity createTeam(String name, String description, Long leadAgentId,
List<Long> memberAgentIds, String createdBy) {
requireAgentExists(leadAgentId, "lead");
requireReactLead(leadAgentId);
requireNotInAnyTeam(leadAgentId);
if (memberAgentIds != null) {
for (Long memberId : memberAgentIds) {
@ -225,21 +224,6 @@ public class TeamService {
}
}
/**
* The lead must run the ReAct graph. A plan-execute lead orchestrates
* through its own serial per-step delegation pipeline, which bypasses the
* team board entirely tasks are never created, members never run in
* parallel, and the collaboration silently degrades to solo delegation.
*/
private void requireReactLead(Long agentId) {
AgentEntity agent = agentMapper.selectById(agentId);
if (agent != null && "plan_execute".equals(agent.getAgentType())) {
throw new IllegalArgumentException(
"lead agent must be a ReAct agent: a plan-execute lead plans serial steps "
+ "through its own delegation pipeline and never uses the team board. "
+ "Switch the agent's type to ReAct, or pick a different lead.");
}
}
private void requireNotInAnyTeam(Long agentId) {
// Membership check ignores team status on purpose: an agent parked in a

View File

@ -563,6 +563,19 @@ public class TeamTaskService {
return taskMapper.selectById(taskId);
}
/**
* Tasks created from a delegated plan's steps, ordered by creation. The
* plan linkage lives in the task metadata JSON ({@code "planId"} written
* as a string), matched with a LIKE team boards are small and the
* pattern includes the quoted key, so false positives are not a concern.
*/
public List<TeamTaskEntity> listTasksByPlan(Long teamId, Long planId) {
return taskMapper.selectList(Wrappers.<TeamTaskEntity>lambdaQuery()
.eq(TeamTaskEntity::getTeamId, teamId)
.like(TeamTaskEntity::getMetadata, "\"planId\":\"" + planId + "\"")
.orderByAsc(TeamTaskEntity::getCreateTime));
}
public List<TeamTaskEntity> listTasks(Long teamId, List<String> statuses) {
return taskMapper.selectList(Wrappers.<TeamTaskEntity>lambdaQuery()
.eq(TeamTaskEntity::getTeamId, teamId)

View File

@ -0,0 +1,60 @@
package vip.mate.agent.graph.plan.node;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* Pins the step_deps parsing contract: valid annotations become 0-based
* prerequisite indices enabling parallel board dispatch; ANY irregularity
* falls back to the sequential chain that matches today's serial semantics.
*/
class PlanGenerationStepDepsTest {
@Test
@DisplayName("valid deps parse to 0-based indices; empty entries mean no prerequisite")
void validDepsParse() {
List<List<Integer>> deps = PlanGenerationNode.parseStepDeps(
List.of("", "", "1,2"), 3);
assertEquals(List.of(), deps.get(0));
assertEquals(List.of(), deps.get(1));
assertEquals(List.of(0, 1), deps.get(2));
}
@Test
@DisplayName("missing field or length mismatch falls back to the sequential chain")
void missingFallsBackToChain() {
List<List<Integer>> absent = PlanGenerationNode.parseStepDeps(null, 3);
assertEquals(List.of(), absent.get(0));
assertEquals(List.of(0), absent.get(1));
assertEquals(List.of(1), absent.get(2));
List<List<Integer>> mismatch = PlanGenerationNode.parseStepDeps(List.of(""), 3);
assertEquals(List.of(1), mismatch.get(2));
}
@Test
@DisplayName("self references, forward references and junk all fall back to the chain")
void invalidFallsBackToChain() {
// Self reference (step 2 depends on step 2).
assertEquals(List.of(0),
PlanGenerationNode.parseStepDeps(List.of("", "2"), 2).get(1));
// Forward reference (step 1 depends on step 2).
assertEquals(List.of(),
PlanGenerationNode.parseStepDeps(List.of("2", ""), 2).get(0));
// Unparseable entry.
assertEquals(List.of(0),
PlanGenerationNode.parseStepDeps(List.of("", "abc"), 2).get(1));
}
@Test
@DisplayName("full-width comma separators are accepted")
void fullWidthCommaAccepted() {
List<List<Integer>> deps = PlanGenerationNode.parseStepDeps(
List.of("", "", "12"), 3);
assertEquals(List.of(0, 1), deps.get(2));
}
}

View File

@ -0,0 +1,228 @@
package vip.mate.team.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.context.ApplicationEventPublisher;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.planning.model.PlanEntity;
import vip.mate.planning.model.SubPlanEntity;
import vip.mate.planning.service.PlanningService;
import vip.mate.team.event.TeamTasksDelegatedEvent;
import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.model.AgentTeamMemberEntity;
import vip.mate.team.model.TeamRole;
import vip.mate.team.model.TeamTaskCreateCommand;
import vip.mate.team.model.TeamTaskEntity;
import vip.mate.team.model.TeamTaskStatus;
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 planboard hand-off contract: all-or-nothing member resolution,
* dependencyblockedBy mapping with plan linkage in task metadata, parking
* via the delegated status + dispatch event, and the three-way resume gate.
*/
class TeamPlanBridgeTest {
private static final Long TEAM_ID = 10L;
private static final Long LEAD_ID = 1L;
private static final Long WRITER_ID = 2L;
private static final Long ANALYST_ID = 3L;
private static final Long PLAN_ID = 77L;
private static final String CONV = "lead-conv";
private TeamService teamService;
private TeamTaskService taskService;
private PlanningService planningService;
private AgentMapper agentMapper;
private ApplicationEventPublisher eventPublisher;
private TeamPlanBridge bridge;
private AgentTeamEntity team;
@BeforeEach
void setUp() {
teamService = mock(TeamService.class);
taskService = mock(TeamTaskService.class);
planningService = mock(PlanningService.class);
agentMapper = mock(AgentMapper.class);
eventPublisher = mock(ApplicationEventPublisher.class);
bridge = new TeamPlanBridge(teamService, taskService, planningService,
agentMapper, eventPublisher);
team = new AgentTeamEntity();
team.setId(TEAM_ID);
team.setName("编队");
team.setLeadAgentId(LEAD_ID);
when(teamService.listMembers(TEAM_ID)).thenReturn(List.of(
member(LEAD_ID, TeamRole.LEAD),
member(WRITER_ID, TeamRole.MEMBER),
member(ANALYST_ID, TeamRole.MEMBER)));
when(agentMapper.selectById(WRITER_ID)).thenReturn(agent(WRITER_ID, "写手"));
when(agentMapper.selectById(ANALYST_ID)).thenReturn(agent(ANALYST_ID, "分析师"));
}
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(Long id, String name) {
AgentEntity a = new AgentEntity();
a.setId(id);
a.setName(name);
return a;
}
private static TeamTaskEntity task(Long id, int number, int stepIndex, String status) {
TeamTaskEntity t = new TeamTaskEntity();
t.setId(id);
t.setTeamId(TEAM_ID);
t.setTaskNumber(number);
t.setSubject("task " + number);
t.setStatus(status);
t.setAssigneeAgentId(WRITER_ID);
t.setMetadata("{\"planId\":\"" + PLAN_ID + "\",\"stepIndex\":" + stepIndex + "}");
return t;
}
// ==================== member resolution ====================
@Test
@DisplayName("resolution is all-or-nothing: one unknown name keeps the legacy pipeline")
void resolutionAllOrNothing() {
assertEquals(List.of(WRITER_ID, ANALYST_ID),
bridge.resolveMembers(team, List.of("s1", "s2"), List.of("写手", "分析师")));
assertNull(bridge.resolveMembers(team, List.of("s1", "s2"), List.of("写手", "路人")));
assertNull(bridge.resolveMembers(team, List.of("s1", "s2"), List.of("写手", "")));
assertNull(bridge.resolveMembers(team, List.of("s1", "s2"), null));
}
// ==================== hand-off ====================
@Test
@DisplayName("delegatePlan maps deps to blockedBy, stamps plan linkage, parks and nudges dispatch")
void delegatePlanCreatesLinkedTasks() {
when(taskService.createTask(any())).thenAnswer(inv -> {
TeamTaskCreateCommand cmd = inv.getArgument(0);
TeamTaskEntity created = new TeamTaskEntity();
created.setId((long) (100 + cmd.getMetadata().hashCode() % 1000));
created.setId(cmd.getMetadata().contains("\"stepIndex\":0") ? 101L : 102L);
created.setTaskNumber(created.getId().intValue() - 100);
created.setSubject(cmd.getSubject());
created.setAssigneeAgentId(cmd.getAssigneeAgentId());
return created;
});
String announcement = bridge.delegatePlan(team, PLAN_ID, "整体请求",
List.of("第一步", "第二步"), List.of(List.of(), List.of(0)),
List.of(WRITER_ID, ANALYST_ID), CONV);
ArgumentCaptor<TeamTaskCreateCommand> captor =
ArgumentCaptor.forClass(TeamTaskCreateCommand.class);
verify(taskService, times(2)).createTask(captor.capture());
TeamTaskCreateCommand first = captor.getAllValues().get(0);
TeamTaskCreateCommand second = captor.getAllValues().get(1);
assertNull(first.getBlockedBy());
assertEquals(List.of(101L), second.getBlockedBy());
assertTrue(first.getMetadata().contains("\"planId\":\"" + PLAN_ID + "\""));
assertTrue(second.getMetadata().contains("\"stepIndex\":1"));
assertEquals(CONV, first.getLeadConversationId());
assertEquals(LEAD_ID, first.getCreatedByAgentId());
assertTrue(first.getDescription().contains("整体请求"));
verify(planningService).markPlanDelegated(PLAN_ID);
verify(eventPublisher).publishEvent(new TeamTasksDelegatedEvent(TEAM_ID));
assertTrue(announcement.contains("并行"));
assertTrue(announcement.contains("前置"));
}
// ==================== resume gate ====================
private void parkedPlan() {
PlanEntity plan = new PlanEntity();
plan.setId(PLAN_ID);
plan.setAgentId(String.valueOf(LEAD_ID));
plan.setConversationId(CONV);
plan.setStatus("delegated");
plan.setGoal("整体请求");
when(planningService.findDelegatedPlan(CONV)).thenReturn(plan);
when(teamService.getTeamForAgent(LEAD_ID)).thenReturn(Optional.of(team));
when(teamService.isLead(team, LEAD_ID)).thenReturn(true);
SubPlanEntity sub0 = new SubPlanEntity();
sub0.setStepIndex(0);
sub0.setDescription("第一步");
SubPlanEntity sub1 = new SubPlanEntity();
sub1.setStepIndex(1);
sub1.setDescription("第二步");
when(planningService.getSubPlans(PLAN_ID)).thenReturn(List.of(sub0, sub1));
}
@Test
@DisplayName("no parked plan yields None; in-flight boards yield a progress answer")
void gateNoneAndInFlight() {
when(planningService.findDelegatedPlan(CONV)).thenReturn(null);
assertInstanceOf(TeamPlanBridge.None.class, bridge.checkParkedPlan(CONV));
parkedPlan();
when(taskService.listTasksByPlan(TEAM_ID, PLAN_ID)).thenReturn(List.of(
task(101L, 1, 0, TeamTaskStatus.COMPLETED),
task(102L, 2, 1, TeamTaskStatus.IN_PROGRESS)));
TeamPlanBridge.ParkedPlanState state = bridge.checkParkedPlan(CONV);
TeamPlanBridge.InFlight inFlight = assertInstanceOf(TeamPlanBridge.InFlight.class, state);
assertTrue(inFlight.progressText().contains("in_progress"));
verify(planningService, never()).updateSubPlanResult(any(), anyInt(), anyString());
}
@Test
@DisplayName("a settled board syncs the sub-plan mirror and returns summary-ready results")
void gateSettled() {
parkedPlan();
TeamTaskEntity done = task(101L, 1, 0, TeamTaskStatus.COMPLETED);
done.setResult("卖点已产出");
done.setMetadata("{\"planId\":\"" + PLAN_ID + "\",\"stepIndex\":0,"
+ "\"deliverables\":[{\"name\":\"a.docx\",\"url\":\"/api/v1/files/generated/x\"}]}");
TeamTaskEntity failed = task(102L, 2, 1, TeamTaskStatus.FAILED);
failed.setReason("blocked: 缺输入");
when(taskService.listTasksByPlan(TEAM_ID, PLAN_ID)).thenReturn(List.of(done, failed));
when(taskService.listDeliverables(done)).thenReturn(List.of(
new TeamTaskService.Deliverable("a.docx", "/api/v1/files/generated/x", null)));
TeamPlanBridge.ParkedPlanState state = bridge.checkParkedPlan(CONV);
TeamPlanBridge.Settled settled = assertInstanceOf(TeamPlanBridge.Settled.class, state);
assertEquals(PLAN_ID, settled.planId());
assertEquals("整体请求", settled.goal());
assertEquals(List.of("第一步", "第二步"), settled.steps());
assertTrue(settled.completedResults().get(0).contains("步骤1结果卖点已产出"));
assertTrue(settled.completedResults().get(0).contains("a.docx → /api/v1/files/generated/x"));
assertTrue(settled.completedResults().get(1).contains("步骤2未完成"));
verify(planningService).updateSubPlanResult(PLAN_ID, 0, "卖点已产出");
verify(planningService).updateSubPlanFailure(eq(PLAN_ID), eq(1), anyString());
}
@Test
@DisplayName("a vanished board fails the plan instead of wedging the conversation")
void gateVanishedBoardFailsPlan() {
parkedPlan();
when(taskService.listTasksByPlan(TEAM_ID, PLAN_ID)).thenReturn(List.of());
assertInstanceOf(TeamPlanBridge.None.class, bridge.checkParkedPlan(CONV));
verify(planningService).markPlanFailed(eq(PLAN_ID), anyString());
}
}

View File

@ -21,9 +21,9 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* Pins team creation guards most importantly that a plan-execute agent can
* never become a lead: its own serial delegation pipeline bypasses the team
* board, so the collaboration would silently degrade to solo delegation.
* Pins team creation guards. Any agent type may lead: a plan-execute lead's
* multi-step plans hand off to the team board through the plan bridge, so the
* former ReAct-only restriction no longer applies.
*/
class TeamServiceTest {
@ -62,22 +62,10 @@ class TeamServiceTest {
}
@Test
@DisplayName("a plan-execute agent is rejected as lead with an actionable message")
void planExecuteLeadRejected() {
@DisplayName("a plan-execute agent may lead — its plans hand off to the board via the bridge")
void planExecuteLeadAllowed() {
when(agentMapper.selectById(LEAD_ID)).thenReturn(agent(LEAD_ID, "plan_execute"));
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
() -> service.createTeam("", null, LEAD_ID, List.of(MEMBER_ID), "admin"));
assertTrue(e.getMessage().contains("ReAct"));
verify(teamMapper, never()).insert(any(AgentTeamEntity.class));
}
@Test
@DisplayName("members may be plan-execute — only the lead role is restricted")
void planExecuteMemberAllowedByTypeGuard() {
when(agentMapper.selectById(LEAD_ID)).thenReturn(agent(LEAD_ID, "react"));
when(agentMapper.selectById(MEMBER_ID)).thenReturn(agent(MEMBER_ID, "plan_execute"));
when(agentMapper.selectById(MEMBER_ID)).thenReturn(agent(MEMBER_ID, "react"));
when(memberMapper.selectCount(any())).thenReturn(0L);
assertDoesNotThrow(() ->

View File

@ -52,8 +52,6 @@ export default {
deliverables: 'Deliverables',
viewRun: 'View execution',
timeline: 'Timeline',
leadReactHint: 'The lead must be a ReAct agent: plan-execute agents orchestrate through their own serial delegation pipeline and never use the team board',
leadTypeWarning: 'Lead is plan-execute — team collaboration will not engage',
eventType: {
created: 'Created',
dispatched: 'Dispatched',

View File

@ -52,8 +52,6 @@ export default {
deliverables: '交付物',
viewRun: '查看执行过程',
timeline: '时间线',
leadReactHint: 'Lead 需为 ReAct 型员工:计划执行型员工走自身的串行委派管线,不会使用团队任务板',
leadTypeWarning: 'Lead 是计划执行型,团队协同不会生效',
eventType: {
created: '创建',
dispatched: '派发',

View File

@ -78,9 +78,6 @@
</span>
{{ store.currentTeam.leadName }}
</span>
<span v-if="leadIsPlanExecute" class="lead-warning" :title="t('teams.leadReactHint')">
{{ t('teams.leadTypeWarning') }}
</span>
</div>
<div class="detail-header__right">
<div class="view-switch">
@ -205,8 +202,6 @@
:key="String(agent.id)"
class="agent-pill"
:class="{ 'is-selected is-lead': createForm.leadAgentId === String(agent.id) }"
:disabled="agent.agentType === 'plan_execute'"
:title="agent.agentType === 'plan_execute' ? t('teams.leadReactHint') : undefined"
@click="selectLead(String(agent.id))"
>
<span class="agent-pill__icon" :style="{ color: agentIconColor(agent.icon) }">
@ -215,7 +210,6 @@
{{ agent.name }}
</button>
</div>
<p class="form-hint">{{ t('teams.leadReactHint') }}</p>
</div>
<div class="form-group">
<label>{{ t('teams.membersField') }} <i>*</i></label>
@ -685,17 +679,6 @@ const memberCandidates = computed(() =>
agentStore.agents.filter((a) => String(a.id) !== createForm.leadAgentId),
)
/**
* A plan-execute lead orchestrates through its own serial delegation pipeline
* and never touches the team board surface a standing warning so a lead
* whose type was switched after team creation doesn't fail silently.
*/
const leadIsPlanExecute = computed(() => {
const leadId = store.currentTeam?.team.leadAgentId
if (!leadId) return false
const agent = agentStore.agents.find((a) => String(a.id) === String(leadId))
return agent?.agentType === 'plan_execute'
})
function openCreateDialog() {
createForm.name = ''
@ -1617,18 +1600,6 @@ async function cancelTask() {
font-size: 12px;
color: var(--mc-text-tertiary);
}
.agent-pill:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.lead-warning {
font-size: 12px;
color: var(--mc-danger, #d9534f);
background: rgba(217, 83, 79, 0.08);
border: 1px solid rgba(217, 83, 79, 0.25);
border-radius: 8px;
padding: 2px 8px;
}
.activity-feed {
display: flex;
flex-direction: column;