mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(team): plan-execute leads orchestrate through the task board — hand-off bridge, resume gate and step dependencies
This commit is contained in:
parent
c15e51b34b
commit
2cbe00a1e7
@ -61,6 +61,7 @@ import vip.mate.workspace.conversation.ConversationService;
|
|||||||
import vip.mate.approval.ApprovalWorkflowService;
|
import vip.mate.approval.ApprovalWorkflowService;
|
||||||
import vip.mate.channel.web.ChatStreamTracker;
|
import vip.mate.channel.web.ChatStreamTracker;
|
||||||
import vip.mate.team.service.TeamContextBuilder;
|
import vip.mate.team.service.TeamContextBuilder;
|
||||||
|
import vip.mate.team.service.TeamPlanBridge;
|
||||||
import vip.mate.wiki.service.WikiContextService;
|
import vip.mate.wiki.service.WikiContextService;
|
||||||
|
|
||||||
import java.lang.reflect.Field;
|
import java.lang.reflect.Field;
|
||||||
@ -103,6 +104,7 @@ public class AgentGraphBuilder {
|
|||||||
private boolean markdownNormalizeEnabled;
|
private boolean markdownNormalizeEnabled;
|
||||||
private final ConversationService conversationService;
|
private final ConversationService conversationService;
|
||||||
private final TeamContextBuilder teamContextBuilder;
|
private final TeamContextBuilder teamContextBuilder;
|
||||||
|
private final TeamPlanBridge teamPlanBridge;
|
||||||
private final ModelConfigService modelConfigService;
|
private final ModelConfigService modelConfigService;
|
||||||
private final ModelProviderService modelProviderService;
|
private final ModelProviderService modelProviderService;
|
||||||
private final ModelContextWindowResolver contextWindowResolver;
|
private final ModelContextWindowResolver contextWindowResolver;
|
||||||
@ -643,6 +645,9 @@ public class AgentGraphBuilder {
|
|||||||
executor.setAuditEventService(auditEventService);
|
executor.setAuditEventService(auditEventService);
|
||||||
}
|
}
|
||||||
PlanGenerationNode planGenerationNode = new PlanGenerationNode(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet, goalService, goalProperties, agentService);
|
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);
|
StepExecutionNode stepExecutionNode = new StepExecutionNode(chatModel, toolSet, executor, planningService, streamTracker, reasoningEffort, streamingHelper, conversationWindowManager, skillCatalogRenderer);
|
||||||
// Per-step delegation: route a step assigned to a specialist agent
|
// Per-step delegation: route a step assigned to a specialist agent
|
||||||
// through DelegateAgentTool (null when delegation deps aren't wired).
|
// through DelegateAgentTool (null when delegation deps aren't wired).
|
||||||
@ -787,7 +792,10 @@ public class AgentGraphBuilder {
|
|||||||
AsyncEdgeAction.edge_async(new PlanGenerationDispatcher()),
|
AsyncEdgeAction.edge_async(new PlanGenerationDispatcher()),
|
||||||
Map.of(
|
Map.of(
|
||||||
PlanStateKeys.STEP_EXECUTION_NODE, PlanStateKeys.STEP_EXECUTION_NODE,
|
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,
|
.addConditionalEdges(PlanStateKeys.STEP_EXECUTION_NODE,
|
||||||
AsyncEdgeAction.edge_async(new StepProgressDispatcher()),
|
AsyncEdgeAction.edge_async(new StepProgressDispatcher()),
|
||||||
Map.of(
|
Map.of(
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package vip.mate.agent.graph.plan.edge;
|
|||||||
import com.alibaba.cloud.ai.graph.OverAllState;
|
import com.alibaba.cloud.ai.graph.OverAllState;
|
||||||
import com.alibaba.cloud.ai.graph.action.EdgeAction;
|
import com.alibaba.cloud.ai.graph.action.EdgeAction;
|
||||||
import vip.mate.agent.graph.plan.state.PlanStateKeys;
|
import vip.mate.agent.graph.plan.state.PlanStateKeys;
|
||||||
|
import vip.mate.agent.graph.state.MateClawStateKeys;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Routes the graph after the triage node.
|
* Routes the graph after the triage node.
|
||||||
@ -22,6 +23,12 @@ public class PlanGenerationDispatcher implements EdgeAction {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String apply(OverAllState state) {
|
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);
|
boolean needsPlanning = state.value(PlanStateKeys.NEEDS_PLANNING, false);
|
||||||
if (!needsPlanning) {
|
if (!needsPlanning) {
|
||||||
return PlanStateKeys.DIRECT_ANSWER_NODE;
|
return PlanStateKeys.DIRECT_ANSWER_NODE;
|
||||||
|
|||||||
@ -28,6 +28,8 @@ import vip.mate.goal.model.GoalCriterion;
|
|||||||
import vip.mate.goal.model.GoalEntity;
|
import vip.mate.goal.model.GoalEntity;
|
||||||
import vip.mate.goal.service.GoalService;
|
import vip.mate.goal.service.GoalService;
|
||||||
import vip.mate.planning.service.PlanningService;
|
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.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
@ -70,6 +72,17 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
* resolve per-step assignments. Null disables per-step delegation (legacy/test). */
|
* resolve per-step assignments. Null disables per-step delegation (legacy/test). */
|
||||||
private final AgentService agentService;
|
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. */
|
/** Plan steps below this size are trivial tool tasks, not goal-worthy. */
|
||||||
private static final int MIN_STEPS_FOR_AUTO_GOAL = 2;
|
private static final int MIN_STEPS_FOR_AUTO_GOAL = 2;
|
||||||
/** Cap the auto-derived goal title; the full request rides in the description. */
|
/** 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
|
// order). An empty string / missing entry means "run with the parent
|
||||||
// agent". Only populated when delegatable specialist agents are
|
// agent". Only populated when delegatable specialist agents are
|
||||||
// advertised to the planner; absent for backward compatibility.
|
// 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 = """
|
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
|
* Enabled agents in the given workspace, excluding the parent (plan) agent
|
||||||
* itself — these are the agents a step can be delegated to. Empty when
|
* 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<>();
|
List<GraphEventPublisher.GraphEvent> events = new ArrayList<>();
|
||||||
events.add(GraphEventPublisher.phase("planning", Map.of("goal", persistGoal)));
|
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.
|
// Replay path: plan is already in state (injected by chatWithReplayStream); skip LLM.
|
||||||
Long existingPlanId = state.<Long>value(PlanStateKeys.PLAN_ID).orElse(null);
|
Long existingPlanId = state.<Long>value(PlanStateKeys.PLAN_ID).orElse(null);
|
||||||
if (existingPlanId != null) {
|
if (existingPlanId != null) {
|
||||||
@ -467,22 +579,42 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
+ "\n单次工具调用应归为单步(B),不要拆成多步。"));
|
+ "\n单次工具调用应归为单步(B),不要拆成多步。"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Advertise delegatable specialist agents so the planner can assign a
|
// Advertise delegatable agents. A team lead advertises its member
|
||||||
// multi-step plan's step to a dedicated agent (e.g. a test step to a
|
// roster with mandatory assignment (steps hand off to the team
|
||||||
// QA agent, a UI step to a frontend agent). Only fills the step's
|
// board and run in parallel there); everyone else advertises the
|
||||||
// step_agents slot; unassigned steps stay with the parent agent.
|
// workspace-wide specialist list with optional assignment.
|
||||||
// Skipped entirely when no peer agents exist in the workspace.
|
AgentTeamEntity leadTeam = null;
|
||||||
List<AgentEntity> delegatable = listDelegatableAgents(chatOrigin.workspaceId(), agentId);
|
if (teamPlanBridge != null) {
|
||||||
if (!delegatable.isEmpty()) {
|
Long numericAgentId = parseNumericAgentId(agentId);
|
||||||
String agentLines = delegatable.stream()
|
leadTeam = numericAgentId == null ? null
|
||||||
|
: teamPlanBridge.leadTeam(numericAgentId).orElse(null);
|
||||||
|
}
|
||||||
|
if (leadTeam != null) {
|
||||||
|
String memberLines = teamPlanBridge.roster(leadTeam).stream()
|
||||||
.map(a -> "- " + a.getName()
|
.map(a -> "- " + a.getName()
|
||||||
+ (StringUtils.hasText(a.getDescription()) ? ":" + a.getDescription() : ""))
|
+ (StringUtils.hasText(a.getDescription()) ? ":" + a.getDescription() : ""))
|
||||||
.collect(Collectors.joining("\n"));
|
.collect(Collectors.joining("\n"));
|
||||||
promptMessages.add(new UserMessage(
|
promptMessages.add(new UserMessage(
|
||||||
"可委派的专职 Agent(仅当某步骤明显属于其专长时才指派,否则该步骤留空、由你自己执行):\n"
|
"你是团队「" + leadTeam.getName() + "」的 lead。多步任务的每个步骤都将分派到团队任务板,"
|
||||||
+ agentLines
|
+ "由成员并行执行。团队成员:\n" + memberLines
|
||||||
+ "\n若要委派,在 step_agents 数组对应位置填写 Agent 名称(与 steps 同序、等长);"
|
+ "\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
|
// Inject working context (rolling conversation summary) so triage respects
|
||||||
@ -599,6 +731,39 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
steps = List.of(persistGoal);
|
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
|
// Resolve any per-step agent delegation the planner asked for. Null
|
||||||
// when nothing is delegated, keeping createPlan on the legacy path.
|
// when nothing is delegated, keeping createPlan on the legacy path.
|
||||||
List<Long> stepAgentIds = resolveStepAgents(steps,
|
List<Long> stepAgentIds = resolveStepAgents(steps,
|
||||||
|
|||||||
@ -74,7 +74,9 @@ public class PlanSummaryNode implements NodeAction {
|
|||||||
Prompt prompt = new Prompt(List.of(
|
Prompt prompt = new Prompt(List.of(
|
||||||
new SystemMessage("请根据以下各步骤的执行结果,给出一个简洁完整的总结回答。"
|
new SystemMessage("请根据以下各步骤的执行结果,给出一个简洁完整的总结回答。"
|
||||||
+ "直接回答用户的原始问题,不要罗列步骤。"
|
+ "直接回答用户的原始问题,不要罗列步骤。"
|
||||||
+ "如果对话上下文中包含用户的特殊要求(如风格、语言、格式等),请在总结中体现。"),
|
+ "如果对话上下文中包含用户的特殊要求(如风格、语言、格式等),请在总结中体现。"
|
||||||
|
+ "若执行结果中包含交付物下载链接,请在回答中原样列出这些链接。"
|
||||||
|
+ "若某些步骤未完成,如实说明未完成的部分及原因。"),
|
||||||
new UserMessage(userContent.toString())
|
new UserMessage(userContent.toString())
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|||||||
@ -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"));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 完成计划
|
* 完成计划
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -3,8 +3,10 @@ package vip.mate.team.service;
|
|||||||
import cn.hutool.core.util.IdUtil;
|
import cn.hutool.core.util.IdUtil;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import vip.mate.team.event.TeamTasksDelegatedEvent;
|
||||||
import vip.mate.agent.AgentService;
|
import vip.mate.agent.AgentService;
|
||||||
import vip.mate.channel.web.ChatStreamTracker;
|
import vip.mate.channel.web.ChatStreamTracker;
|
||||||
import vip.mate.team.model.AgentTeamEntity;
|
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). */
|
/** Members with a run currently in flight in this JVM (belt-and-braces on top of hasActiveTask). */
|
||||||
private final Set<Long> runningMembers = ConcurrentHashMap.newKeySet();
|
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. */
|
/** Asynchronously sweep the team's board and dispatch whatever is eligible. */
|
||||||
public void requestDispatch(Long teamId) {
|
public void requestDispatch(Long teamId) {
|
||||||
DISPATCH_EXECUTOR.submit(() -> {
|
DISPATCH_EXECUTOR.submit(() -> {
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -46,7 +46,6 @@ public class TeamService {
|
|||||||
public AgentTeamEntity createTeam(String name, String description, Long leadAgentId,
|
public AgentTeamEntity createTeam(String name, String description, Long leadAgentId,
|
||||||
List<Long> memberAgentIds, String createdBy) {
|
List<Long> memberAgentIds, String createdBy) {
|
||||||
requireAgentExists(leadAgentId, "lead");
|
requireAgentExists(leadAgentId, "lead");
|
||||||
requireReactLead(leadAgentId);
|
|
||||||
requireNotInAnyTeam(leadAgentId);
|
requireNotInAnyTeam(leadAgentId);
|
||||||
if (memberAgentIds != null) {
|
if (memberAgentIds != null) {
|
||||||
for (Long memberId : memberAgentIds) {
|
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) {
|
private void requireNotInAnyTeam(Long agentId) {
|
||||||
// Membership check ignores team status on purpose: an agent parked in a
|
// Membership check ignores team status on purpose: an agent parked in a
|
||||||
|
|||||||
@ -563,6 +563,19 @@ public class TeamTaskService {
|
|||||||
return taskMapper.selectById(taskId);
|
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) {
|
public List<TeamTaskEntity> listTasks(Long teamId, List<String> statuses) {
|
||||||
return taskMapper.selectList(Wrappers.<TeamTaskEntity>lambdaQuery()
|
return taskMapper.selectList(Wrappers.<TeamTaskEntity>lambdaQuery()
|
||||||
.eq(TeamTaskEntity::getTeamId, teamId)
|
.eq(TeamTaskEntity::getTeamId, teamId)
|
||||||
|
|||||||
@ -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("", "", "1,2"), 3);
|
||||||
|
assertEquals(List.of(0, 1), deps.get(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 plan→board hand-off contract: all-or-nothing member resolution,
|
||||||
|
* dependency→blockedBy 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -21,9 +21,9 @@ import static org.junit.jupiter.api.Assertions.*;
|
|||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pins team creation guards — most importantly that a plan-execute agent can
|
* Pins team creation guards. Any agent type may lead: a plan-execute lead's
|
||||||
* never become a lead: its own serial delegation pipeline bypasses the team
|
* multi-step plans hand off to the team board through the plan bridge, so the
|
||||||
* board, so the collaboration would silently degrade to solo delegation.
|
* former ReAct-only restriction no longer applies.
|
||||||
*/
|
*/
|
||||||
class TeamServiceTest {
|
class TeamServiceTest {
|
||||||
|
|
||||||
@ -62,22 +62,10 @@ class TeamServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("a plan-execute agent is rejected as lead with an actionable message")
|
@DisplayName("a plan-execute agent may lead — its plans hand off to the board via the bridge")
|
||||||
void planExecuteLeadRejected() {
|
void planExecuteLeadAllowed() {
|
||||||
when(agentMapper.selectById(LEAD_ID)).thenReturn(agent(LEAD_ID, "plan_execute"));
|
when(agentMapper.selectById(LEAD_ID)).thenReturn(agent(LEAD_ID, "plan_execute"));
|
||||||
|
when(agentMapper.selectById(MEMBER_ID)).thenReturn(agent(MEMBER_ID, "react"));
|
||||||
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(memberMapper.selectCount(any())).thenReturn(0L);
|
when(memberMapper.selectCount(any())).thenReturn(0L);
|
||||||
|
|
||||||
assertDoesNotThrow(() ->
|
assertDoesNotThrow(() ->
|
||||||
|
|||||||
@ -52,8 +52,6 @@ export default {
|
|||||||
deliverables: 'Deliverables',
|
deliverables: 'Deliverables',
|
||||||
viewRun: 'View execution',
|
viewRun: 'View execution',
|
||||||
timeline: 'Timeline',
|
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: {
|
eventType: {
|
||||||
created: 'Created',
|
created: 'Created',
|
||||||
dispatched: 'Dispatched',
|
dispatched: 'Dispatched',
|
||||||
|
|||||||
@ -52,8 +52,6 @@ export default {
|
|||||||
deliverables: '交付物',
|
deliverables: '交付物',
|
||||||
viewRun: '查看执行过程',
|
viewRun: '查看执行过程',
|
||||||
timeline: '时间线',
|
timeline: '时间线',
|
||||||
leadReactHint: 'Lead 需为 ReAct 型员工:计划执行型员工走自身的串行委派管线,不会使用团队任务板',
|
|
||||||
leadTypeWarning: 'Lead 是计划执行型,团队协同不会生效',
|
|
||||||
eventType: {
|
eventType: {
|
||||||
created: '创建',
|
created: '创建',
|
||||||
dispatched: '派发',
|
dispatched: '派发',
|
||||||
|
|||||||
@ -78,9 +78,6 @@
|
|||||||
</span>
|
</span>
|
||||||
{{ store.currentTeam.leadName }}
|
{{ store.currentTeam.leadName }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="leadIsPlanExecute" class="lead-warning" :title="t('teams.leadReactHint')">
|
|
||||||
⚠ {{ t('teams.leadTypeWarning') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-header__right">
|
<div class="detail-header__right">
|
||||||
<div class="view-switch">
|
<div class="view-switch">
|
||||||
@ -205,8 +202,6 @@
|
|||||||
:key="String(agent.id)"
|
:key="String(agent.id)"
|
||||||
class="agent-pill"
|
class="agent-pill"
|
||||||
:class="{ 'is-selected is-lead': createForm.leadAgentId === String(agent.id) }"
|
: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))"
|
@click="selectLead(String(agent.id))"
|
||||||
>
|
>
|
||||||
<span class="agent-pill__icon" :style="{ color: agentIconColor(agent.icon) }">
|
<span class="agent-pill__icon" :style="{ color: agentIconColor(agent.icon) }">
|
||||||
@ -215,7 +210,6 @@
|
|||||||
{{ agent.name }}
|
{{ agent.name }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="form-hint">{{ t('teams.leadReactHint') }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{{ t('teams.membersField') }} <i>*</i></label>
|
<label>{{ t('teams.membersField') }} <i>*</i></label>
|
||||||
@ -685,17 +679,6 @@ const memberCandidates = computed(() =>
|
|||||||
agentStore.agents.filter((a) => String(a.id) !== createForm.leadAgentId),
|
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() {
|
function openCreateDialog() {
|
||||||
createForm.name = ''
|
createForm.name = ''
|
||||||
@ -1617,18 +1600,6 @@ async function cancelTask() {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--mc-text-tertiary);
|
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 {
|
.activity-feed {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user