mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 11:37:31 +08:00
feat(plans): per-step agent delegation + fix kanban pending column (issue #385)
This commit is contained in:
parent
1373b78b0a
commit
eca4229751
@ -157,6 +157,30 @@ public class AgentGraphBuilder {
|
|||||||
this.auditEventService = s;
|
this.auditEventService = s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional per-step delegation dependencies for the Plan-Execute graph.
|
||||||
|
* Setter injection (like {@link #auditEventService}) breaks the
|
||||||
|
* {@code AgentService ⇆ AgentGraphBuilder} construction cycle. Null when not
|
||||||
|
* wired (legacy / test) — per-step delegation is then simply disabled.
|
||||||
|
*/
|
||||||
|
private AgentService agentService;
|
||||||
|
|
||||||
|
// @Lazy on the injection point: inject a lazy-resolution proxy so the
|
||||||
|
// AgentService ⇆ AgentGraphBuilder cycle is broken at bean-creation time
|
||||||
|
// (the real bean is resolved on first use, when the graph is built).
|
||||||
|
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||||
|
public void setAgentService(@org.springframework.context.annotation.Lazy AgentService agentService) {
|
||||||
|
this.agentService = agentService;
|
||||||
|
}
|
||||||
|
|
||||||
|
private vip.mate.tool.builtin.DelegateAgentTool delegateAgentTool;
|
||||||
|
|
||||||
|
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||||
|
public void setDelegateAgentTool(
|
||||||
|
@org.springframework.context.annotation.Lazy vip.mate.tool.builtin.DelegateAgentTool delegateAgentTool) {
|
||||||
|
this.delegateAgentTool = delegateAgentTool;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据 AgentEntity 构建完整的 Agent 实例(沿用 Agent / 全局默认模型)。
|
* 根据 AgentEntity 构建完整的 Agent 实例(沿用 Agent / 全局默认模型)。
|
||||||
*/
|
*/
|
||||||
@ -554,8 +578,11 @@ public class AgentGraphBuilder {
|
|||||||
if (auditEventService != null) {
|
if (auditEventService != null) {
|
||||||
executor.setAuditEventService(auditEventService);
|
executor.setAuditEventService(auditEventService);
|
||||||
}
|
}
|
||||||
PlanGenerationNode planGenerationNode = new PlanGenerationNode(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet, goalService, goalProperties);
|
PlanGenerationNode planGenerationNode = new PlanGenerationNode(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet, goalService, goalProperties, agentService);
|
||||||
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
|
||||||
|
// through DelegateAgentTool (null when delegation deps aren't wired).
|
||||||
|
stepExecutionNode.setDelegateAgentTool(delegateAgentTool);
|
||||||
PlanSummaryNode planSummaryNode = new PlanSummaryNode(chatModel, planningService, streamingHelper);
|
PlanSummaryNode planSummaryNode = new PlanSummaryNode(chatModel, planningService, streamingHelper);
|
||||||
DirectAnswerNode directAnswerNode = new DirectAnswerNode();
|
DirectAnswerNode directAnswerNode = new DirectAnswerNode();
|
||||||
|
|
||||||
|
|||||||
@ -10,8 +10,11 @@ import org.springframework.ai.chat.messages.UserMessage;
|
|||||||
import org.springframework.ai.chat.model.ChatModel;
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
import org.springframework.ai.chat.prompt.Prompt;
|
import org.springframework.ai.chat.prompt.Prompt;
|
||||||
import org.springframework.ai.converter.BeanOutputConverter;
|
import org.springframework.ai.converter.BeanOutputConverter;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import vip.mate.agent.AgentService;
|
||||||
import vip.mate.agent.AgentToolSet;
|
import vip.mate.agent.AgentToolSet;
|
||||||
import vip.mate.agent.GraphEventPublisher;
|
import vip.mate.agent.GraphEventPublisher;
|
||||||
|
import vip.mate.agent.model.AgentEntity;
|
||||||
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
||||||
import vip.mate.agent.graph.plan.state.PlanStateAccessor;
|
import vip.mate.agent.graph.plan.state.PlanStateAccessor;
|
||||||
import vip.mate.agent.graph.plan.state.PlanStateKeys;
|
import vip.mate.agent.graph.plan.state.PlanStateKeys;
|
||||||
@ -27,6 +30,7 @@ import vip.mate.goal.service.GoalService;
|
|||||||
import vip.mate.planning.service.PlanningService;
|
import vip.mate.planning.service.PlanningService;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
@ -61,6 +65,9 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
/** Optional — auto-derive a goal from the plan. Null disables the feature (legacy/test). */
|
/** Optional — auto-derive a goal from the plan. Null disables the feature (legacy/test). */
|
||||||
private final GoalService goalService;
|
private final GoalService goalService;
|
||||||
private final GoalProperties goalProperties;
|
private final GoalProperties goalProperties;
|
||||||
|
/** Optional — advertise delegatable specialist agents to the planner and
|
||||||
|
* resolve per-step assignments. Null disables per-step delegation (legacy/test). */
|
||||||
|
private final AgentService agentService;
|
||||||
|
|
||||||
/** 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;
|
||||||
@ -75,7 +82,12 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
@JsonProperty("needs_planning") boolean needsPlanning,
|
@JsonProperty("needs_planning") boolean needsPlanning,
|
||||||
@JsonProperty("direct_answer") String directAnswer,
|
@JsonProperty("direct_answer") String directAnswer,
|
||||||
@JsonProperty("plan_type") String planType,
|
@JsonProperty("plan_type") String planType,
|
||||||
@JsonProperty("steps") List<String> steps
|
@JsonProperty("steps") List<String> steps,
|
||||||
|
// Optional per-step delegation: agent names parallel to steps (same
|
||||||
|
// 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
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private static final String PLANNING_PROMPT = """
|
private static final String PLANNING_PROMPT = """
|
||||||
@ -190,6 +202,16 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
ConversationWindowManager conversationWindowManager,
|
ConversationWindowManager conversationWindowManager,
|
||||||
AgentToolSet toolSet,
|
AgentToolSet toolSet,
|
||||||
GoalService goalService, GoalProperties goalProperties) {
|
GoalService goalService, GoalProperties goalProperties) {
|
||||||
|
this(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet,
|
||||||
|
goalService, goalProperties, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlanGenerationNode(ChatModel chatModel, PlanningService planningService,
|
||||||
|
NodeStreamingChatHelper streamingHelper,
|
||||||
|
ConversationWindowManager conversationWindowManager,
|
||||||
|
AgentToolSet toolSet,
|
||||||
|
GoalService goalService, GoalProperties goalProperties,
|
||||||
|
AgentService agentService) {
|
||||||
this.chatModel = chatModel;
|
this.chatModel = chatModel;
|
||||||
this.planningService = planningService;
|
this.planningService = planningService;
|
||||||
this.streamingHelper = streamingHelper;
|
this.streamingHelper = streamingHelper;
|
||||||
@ -197,6 +219,7 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
this.toolSet = toolSet;
|
this.toolSet = toolSet;
|
||||||
this.goalService = goalService;
|
this.goalService = goalService;
|
||||||
this.goalProperties = goalProperties;
|
this.goalProperties = goalProperties;
|
||||||
|
this.agentService = agentService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -265,6 +288,60 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enabled agents in the given workspace, excluding the parent (plan) agent
|
||||||
|
* itself — these are the agents a step can be delegated to. Empty when
|
||||||
|
* delegation is unavailable (no {@link AgentService}) or no peers exist.
|
||||||
|
*/
|
||||||
|
private List<AgentEntity> listDelegatableAgents(Long workspaceId, String parentAgentId) {
|
||||||
|
if (agentService == null || workspaceId == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return agentService.listAgentsByWorkspace(workspaceId, true).stream()
|
||||||
|
.filter(a -> a.getId() != null && !String.valueOf(a.getId()).equals(parentAgentId))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[PlanGeneration] Failed to list delegatable agents (non-fatal): {}", e.toString());
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map the planner's {@code step_agents} (agent names, parallel to steps) to
|
||||||
|
* agent ids. Returns {@code null} when nothing is delegated so {@code createPlan}
|
||||||
|
* stays on the legacy path. Names are matched case-insensitively against the
|
||||||
|
* delegatable agents; blank / unknown / parent-agent names resolve to {@code null}
|
||||||
|
* (that step runs with the parent agent).
|
||||||
|
*/
|
||||||
|
private List<Long> resolveStepAgents(List<String> steps, List<String> stepAgents,
|
||||||
|
Long workspaceId, String parentAgentId) {
|
||||||
|
if (stepAgents == null || stepAgents.isEmpty() || steps == null || steps.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<AgentEntity> delegatable = listDelegatableAgents(workspaceId, parentAgentId);
|
||||||
|
if (delegatable.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Map<String, Long> byName = new HashMap<>();
|
||||||
|
for (AgentEntity a : delegatable) {
|
||||||
|
if (a.getName() != null) {
|
||||||
|
byName.put(a.getName().trim().toLowerCase(), a.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<Long> ids = new ArrayList<>();
|
||||||
|
boolean any = false;
|
||||||
|
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) {
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
ids.add(id);
|
||||||
|
}
|
||||||
|
return any ? ids : null;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Map<String, Object> apply(OverAllState state) throws Exception {
|
public Map<String, Object> apply(OverAllState state) throws Exception {
|
||||||
PlanStateAccessor accessor = new PlanStateAccessor(state);
|
PlanStateAccessor accessor = new PlanStateAccessor(state);
|
||||||
@ -341,6 +418,24 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
+ "\n单次工具调用应归为单步(B),不要拆成多步。"));
|
+ "\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()
|
||||||
|
.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
|
||||||
// prior constraints without re-reading full history.
|
// prior constraints without re-reading full history.
|
||||||
String workingContext = accessor.workingContext();
|
String workingContext = accessor.workingContext();
|
||||||
@ -455,9 +550,15 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
steps = List.of(goal);
|
steps = List.of(goal);
|
||||||
}
|
}
|
||||||
|
|
||||||
var plan = planningService.createPlan(agentId, conversationId, goal, steps);
|
// Resolve any per-step agent delegation the planner asked for. Null
|
||||||
log.info("[PlanGeneration] Plan created: id={}, steps={} ({})",
|
// when nothing is delegated, keeping createPlan on the legacy path.
|
||||||
plan.getId(), steps.size(), steps.size() == 1 ? "single-step" : "multi-step");
|
List<Long> stepAgentIds = resolveStepAgents(steps,
|
||||||
|
triage != null ? triage.stepAgents() : null,
|
||||||
|
chatOrigin.workspaceId(), agentId);
|
||||||
|
var plan = planningService.createPlan(agentId, conversationId, goal, steps, stepAgentIds);
|
||||||
|
log.info("[PlanGeneration] Plan created: id={}, steps={} ({}){}",
|
||||||
|
plan.getId(), steps.size(), steps.size() == 1 ? "single-step" : "multi-step",
|
||||||
|
stepAgentIds != null ? ", per-step delegation=" + stepAgentIds : "");
|
||||||
|
|
||||||
events.add(GraphEventPublisher.planCreated(plan.getId(), steps));
|
events.add(GraphEventPublisher.planCreated(plan.getId(), steps));
|
||||||
|
|
||||||
|
|||||||
@ -27,7 +27,9 @@ import vip.mate.agent.context.RuntimeContextInjector;
|
|||||||
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
|
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
|
||||||
import vip.mate.channel.web.ChatStreamTracker;
|
import vip.mate.channel.web.ChatStreamTracker;
|
||||||
import vip.mate.planning.service.PlanningService;
|
import vip.mate.planning.service.PlanningService;
|
||||||
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
import vip.mate.skill.runtime.SkillCatalogRenderer;
|
import vip.mate.skill.runtime.SkillCatalogRenderer;
|
||||||
|
import vip.mate.tool.builtin.DelegateAgentTool;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -68,6 +70,17 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
*/
|
*/
|
||||||
private final SkillCatalogRenderer skillCatalogRenderer;
|
private final SkillCatalogRenderer skillCatalogRenderer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional per-step delegation executor. Set after construction (this node is
|
||||||
|
* built by AgentGraphBuilder, not Spring) so a plan step assigned to a
|
||||||
|
* specialist agent runs on that agent. Null disables per-step delegation.
|
||||||
|
*/
|
||||||
|
private DelegateAgentTool delegateAgentTool;
|
||||||
|
|
||||||
|
public void setDelegateAgentTool(DelegateAgentTool delegateAgentTool) {
|
||||||
|
this.delegateAgentTool = delegateAgentTool;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-step tool-call ceiling, aligned with {@code BaseAgent.MAX_ITERATIONS_HARD_CEILING}.
|
* Per-step tool-call ceiling, aligned with {@code BaseAgent.MAX_ITERATIONS_HARD_CEILING}.
|
||||||
* Matching the agent-level cap means this constant is never the bottleneck —
|
* Matching the agent-level cap means this constant is never the bottleneck —
|
||||||
@ -198,6 +211,19 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
// "react_step" / "first_turn" markers when both stream into the
|
// "react_step" / "first_turn" markers when both stream into the
|
||||||
// same SSE feed.
|
// same SSE feed.
|
||||||
boolean iterationEventsOn = streamTracker == null || streamTracker.isIterationEventsEnabled();
|
boolean iterationEventsOn = streamTracker == null || streamTracker.isIterationEventsEnabled();
|
||||||
|
|
||||||
|
// Per-step delegation: when this step is assigned to a different
|
||||||
|
// specialist agent, run it on that agent (as an isolated child) and use
|
||||||
|
// its reply as the step result, instead of executing locally with the
|
||||||
|
// parent agent's tools. assignedAgentId comes from the DB so it survives
|
||||||
|
// replay / approval-resume.
|
||||||
|
Long assignedAgentId = planningService.getStepAssignedAgent(planId, stepIndex);
|
||||||
|
if (delegateAgentTool != null && assignedAgentId != null
|
||||||
|
&& !assignedAgentId.equals(parseLongOrNull(agentId))) {
|
||||||
|
return executeDelegatedStep(accessor, stepIndex, step, planId, assignedAgentId,
|
||||||
|
chatOrigin, events, iterationEventsOn);
|
||||||
|
}
|
||||||
|
|
||||||
if (iterationEventsOn) {
|
if (iterationEventsOn) {
|
||||||
events.add(GraphEventPublisher.iterationStart(stepIndex, "plan_step", "parent", null));
|
events.add(GraphEventPublisher.iterationStart(stepIndex, "plan_step", "parent", null));
|
||||||
}
|
}
|
||||||
@ -622,6 +648,82 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a step by delegating it to its assigned specialist agent. The
|
||||||
|
* delegated agent runs the step description as a self-contained goal and its
|
||||||
|
* reply becomes the step result. Mirrors the success/failure bookkeeping of
|
||||||
|
* the local execution path (sub-plan status, completed-results accumulation,
|
||||||
|
* incremental working-context update) so the rest of the plan graph is
|
||||||
|
* unaffected by where the step ran.
|
||||||
|
*/
|
||||||
|
private Map<String, Object> executeDelegatedStep(
|
||||||
|
PlanStateAccessor accessor, int stepIndex, String step, Long planId,
|
||||||
|
Long assignedAgentId, ChatOrigin chatOrigin,
|
||||||
|
List<GraphEventPublisher.GraphEvent> events, boolean iterationEventsOn) {
|
||||||
|
|
||||||
|
if (iterationEventsOn) {
|
||||||
|
events.add(GraphEventPublisher.iterationStart(stepIndex, "plan_step", "parent", null));
|
||||||
|
}
|
||||||
|
events.add(GraphEventPublisher.stepStarted(stepIndex, step));
|
||||||
|
events.add(GraphEventPublisher.phase("executing", Map.of(
|
||||||
|
"stepIndex", stepIndex, "stepTitle", step,
|
||||||
|
"delegatedAgentId", String.valueOf(assignedAgentId))));
|
||||||
|
planningService.updateSubPlanStatus(planId, stepIndex, "running");
|
||||||
|
|
||||||
|
log.info("[StepExecution] Delegating step {} to agent {}", stepIndex + 1, assignedAgentId);
|
||||||
|
|
||||||
|
String result;
|
||||||
|
try {
|
||||||
|
result = delegateAgentTool.delegateByAgentId(assignedAgentId, step, chatOrigin);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[StepExecution] Delegated step {} threw: {}", stepIndex, e.getMessage(), e);
|
||||||
|
result = "[错误] 委派执行异常:" + e.getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
String finalResult = result != null ? result : "";
|
||||||
|
boolean failed = finalResult.isEmpty() || finalResult.startsWith("[错误]");
|
||||||
|
if (failed) {
|
||||||
|
planningService.updateSubPlanFailure(planId, stepIndex, finalResult);
|
||||||
|
} else {
|
||||||
|
planningService.updateSubPlanResult(planId, stepIndex, finalResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
events.add(GraphEventPublisher.stepCompleted(stepIndex, finalResult));
|
||||||
|
if (iterationEventsOn) {
|
||||||
|
events.add(GraphEventPublisher.iterationEnd(stepIndex, "parent", null, finalResult.length(), 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the rolling working-context in sync exactly like the local path
|
||||||
|
// so later steps see this delegated step's result.
|
||||||
|
String prevWorkingContext = accessor.workingContext();
|
||||||
|
String formattedNewStep = formatStepResult(stepIndex, finalResult);
|
||||||
|
String updatedWorkingContext = prevWorkingContext.isEmpty()
|
||||||
|
? rebuildWorkingContext(accessor, appendOne(accessor.completedResults(), formattedNewStep))
|
||||||
|
: appendStepIncremental(prevWorkingContext, formattedNewStep);
|
||||||
|
|
||||||
|
return PlanStateAccessor.output()
|
||||||
|
.currentStepResult(finalResult)
|
||||||
|
.completedResults(formattedNewStep)
|
||||||
|
.currentStepIndex(stepIndex + 1)
|
||||||
|
.workingContext(updatedWorkingContext)
|
||||||
|
.currentPhase("step_completed")
|
||||||
|
.contentStreamed(true)
|
||||||
|
.events(events)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a string id to Long, or null when blank / non-numeric. */
|
||||||
|
private static Long parseLongOrNull(String s) {
|
||||||
|
if (s == null || s.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Long.parseLong(s.trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RFC-052: assemble the final answer text from direct tool outputs in this
|
* RFC-052: assemble the final answer text from direct tool outputs in this
|
||||||
* step. Mirrors {@code FinalAnswerNode#assembleDirectAnswer} so the user
|
* step. Mirrors {@code FinalAnswerNode#assembleDirectAnswer} so the user
|
||||||
|
|||||||
@ -29,6 +29,14 @@ public class SubPlanEntity {
|
|||||||
/** 步骤状态:pending / running / completed / failed */
|
/** 步骤状态:pending / running / completed / failed */
|
||||||
private String status;
|
private String status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delegated agent id for this step. When non-null, the executor routes this
|
||||||
|
* step to that specialist agent instead of the parent (plan) agent. Null =
|
||||||
|
* run with the parent agent (original behavior). The frontend resolves the
|
||||||
|
* display name from this id via the agent store.
|
||||||
|
*/
|
||||||
|
private Long assignedAgentId;
|
||||||
|
|
||||||
/** 步骤执行结果 */
|
/** 步骤执行结果 */
|
||||||
@TableField(value = "result", updateStrategy = FieldStrategy.ALWAYS)
|
@TableField(value = "result", updateStrategy = FieldStrategy.ALWAYS)
|
||||||
private String result;
|
private String result;
|
||||||
|
|||||||
@ -43,11 +43,27 @@ public class PlanningService {
|
|||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public PlanEntity createPlan(String agentId, String conversationId, String goal, List<String> steps) {
|
public PlanEntity createPlan(String agentId, String conversationId, String goal, List<String> steps) {
|
||||||
|
return createPlan(agentId, conversationId, goal, steps, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建执行计划,并为每个步骤可选地指派专职子 agent。
|
||||||
|
* stepAgentIds 与 steps 等长同序(按位置对应);某位为 null 表示该步骤由父 agent 执行。
|
||||||
|
* stepAgentIds 整体可空(无委派的历史调用方)。
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public PlanEntity createPlan(String agentId, String conversationId, String goal,
|
||||||
|
List<String> steps, List<Long> stepAgentIds) {
|
||||||
PlanEntity plan = new PlanEntity();
|
PlanEntity plan = new PlanEntity();
|
||||||
plan.setAgentId(agentId);
|
plan.setAgentId(agentId);
|
||||||
plan.setConversationId(conversationId);
|
plan.setConversationId(conversationId);
|
||||||
plan.setGoal(goal);
|
plan.setGoal(goal);
|
||||||
plan.setStatus("running");
|
// A freshly generated plan is queued, not yet running: it sits in the
|
||||||
|
// board's "pending" column until its first step actually starts
|
||||||
|
// (updateSubPlanStatus promotes the plan to "running" then). This makes
|
||||||
|
// the board's pending column meaningful for queued / approval-gated
|
||||||
|
// plans instead of being perpetually empty.
|
||||||
|
plan.setStatus("pending");
|
||||||
plan.setTotalSteps(steps.size());
|
plan.setTotalSteps(steps.size());
|
||||||
plan.setCompletedSteps(0);
|
plan.setCompletedSteps(0);
|
||||||
planMapper.insert(plan);
|
planMapper.insert(plan);
|
||||||
@ -58,6 +74,11 @@ public class PlanningService {
|
|||||||
sub.setStepIndex(i);
|
sub.setStepIndex(i);
|
||||||
sub.setDescription(steps.get(i));
|
sub.setDescription(steps.get(i));
|
||||||
sub.setStatus("pending");
|
sub.setStatus("pending");
|
||||||
|
// Per-step delegation: only set when an assignment exists for this
|
||||||
|
// index; otherwise the step runs with the parent agent.
|
||||||
|
if (stepAgentIds != null && i < stepAgentIds.size()) {
|
||||||
|
sub.setAssignedAgentId(stepAgentIds.get(i));
|
||||||
|
}
|
||||||
subPlanMapper.insert(sub);
|
subPlanMapper.insert(sub);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -74,6 +95,13 @@ public class PlanningService {
|
|||||||
sub.setStatus(status);
|
sub.setStatus(status);
|
||||||
if ("running".equals(status)) {
|
if ("running".equals(status)) {
|
||||||
sub.setStartTime(LocalDateTime.now());
|
sub.setStartTime(LocalDateTime.now());
|
||||||
|
// Promote the parent plan out of the "pending" (queued) column
|
||||||
|
// the moment its first step actually starts executing.
|
||||||
|
PlanEntity plan = planMapper.selectById(planId);
|
||||||
|
if (plan != null && "pending".equals(plan.getStatus())) {
|
||||||
|
plan.setStatus("running");
|
||||||
|
planMapper.updateById(plan);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
subPlanMapper.updateById(sub);
|
subPlanMapper.updateById(sub);
|
||||||
}
|
}
|
||||||
@ -156,6 +184,17 @@ public class PlanningService {
|
|||||||
.orderByAsc(SubPlanEntity::getStepIndex));
|
.orderByAsc(SubPlanEntity::getStepIndex));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The agent delegated to run a given step, or {@code null} when the step
|
||||||
|
* runs with the parent (plan) agent. Read by the executor to route a step to
|
||||||
|
* its specialist agent. Sourced from the DB (not graph state) so it survives
|
||||||
|
* replay / approval-resume.
|
||||||
|
*/
|
||||||
|
public Long getStepAssignedAgent(Long planId, int stepIndex) {
|
||||||
|
SubPlanEntity sub = getSubPlan(planId, stepIndex);
|
||||||
|
return sub != null ? sub.getAssignedAgentId() : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 标记计划失败
|
* 标记计划失败
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -324,6 +324,26 @@ public class DelegateAgentTool {
|
|||||||
return result.toToolResponse(target.getName());
|
return result.toToolResponse(target.getName());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delegate a task to an agent by id — used by per-step plan delegation so a
|
||||||
|
* plan step can run on a dedicated specialist agent. Resolves the target by
|
||||||
|
* id, then reuses {@link #delegateToAgent}'s isolated-child execution
|
||||||
|
* (sub-agent registry, event relay, depth guard). The parent {@link ChatOrigin}
|
||||||
|
* is forwarded so the child inherits channel / workspace binding. Returns the
|
||||||
|
* child's reply text, or an error string when the agent is missing/disabled.
|
||||||
|
*/
|
||||||
|
public String delegateByAgentId(Long agentId, String task, ChatOrigin parentOrigin) {
|
||||||
|
if (agentId == null) {
|
||||||
|
return "[错误] 未指定委派 Agent。";
|
||||||
|
}
|
||||||
|
AgentEntity target = agentMapper.selectById(agentId);
|
||||||
|
if (target == null || !Boolean.TRUE.equals(target.getEnabled())) {
|
||||||
|
return "[错误] 未找到 id=" + agentId + " 的已启用 Agent。";
|
||||||
|
}
|
||||||
|
ToolContext ctx = (parentOrigin != null ? parentOrigin : ChatOrigin.EMPTY).toToolContext();
|
||||||
|
return delegateToAgent(target.getName(), task, false, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Parallel delegation ====================
|
// ==================== Parallel delegation ====================
|
||||||
|
|
||||||
@vip.mate.tool.ConcurrencyUnsafe("internally fans out to its own thread pool; outer executor must not double-parallelize")
|
@vip.mate.tool.ConcurrencyUnsafe("internally fans out to its own thread pool; outer executor must not double-parallelize")
|
||||||
|
|||||||
@ -0,0 +1,10 @@
|
|||||||
|
-- V156: Per-step agent delegation for plan-execute.
|
||||||
|
--
|
||||||
|
-- A plan step can now be delegated to a dedicated specialist agent (e.g. a test
|
||||||
|
-- step handed to a "QA agent", a UI step to a "frontend agent"). assigned_agent_id
|
||||||
|
-- records which agent should run the step; the executor routes that step to the
|
||||||
|
-- delegated agent instead of the parent agent.
|
||||||
|
--
|
||||||
|
-- Nullable: a NULL assigned_agent_id means "run with the parent (plan) agent",
|
||||||
|
-- which is the original behavior — legacy rows and unassigned steps are unaffected.
|
||||||
|
ALTER TABLE mate_sub_plan ADD COLUMN IF NOT EXISTS assigned_agent_id BIGINT;
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
-- V156: Per-step agent delegation for plan-execute (see H2 file for context).
|
||||||
|
-- KingbaseES (PostgreSQL-compatible) supports ADD COLUMN IF NOT EXISTS.
|
||||||
|
ALTER TABLE mate_sub_plan ADD COLUMN IF NOT EXISTS assigned_agent_id BIGINT;
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
-- V156: Per-step agent delegation for plan-execute (see H2 file for context).
|
||||||
|
-- MySQL 8.0 doesn't support `ADD COLUMN IF NOT EXISTS`, so the existence check
|
||||||
|
-- goes through INFORMATION_SCHEMA + a prepared statement.
|
||||||
|
SET @col_exists := (
|
||||||
|
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'mate_sub_plan'
|
||||||
|
AND COLUMN_NAME = 'assigned_agent_id'
|
||||||
|
);
|
||||||
|
SET @ddl := IF(@col_exists = 0,
|
||||||
|
'ALTER TABLE mate_sub_plan ADD COLUMN assigned_agent_id BIGINT NULL',
|
||||||
|
'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
@ -290,8 +290,8 @@ class SourceEvidenceLedgerTest {
|
|||||||
[1] MAST-Data数据集
|
[1] MAST-Data数据集
|
||||||
""");
|
""");
|
||||||
|
|
||||||
assertTrue("来源: header must be present: " + rendered,
|
assertTrue(rendered.contains("来源:"),
|
||||||
rendered.contains("来源:"));
|
"来源: header must be present: " + rendered);
|
||||||
assertTrue(rendered.contains("[1] MAST-Data数据集"),
|
assertTrue(rendered.contains("[1] MAST-Data数据集"),
|
||||||
"source line content must be preserved: " + rendered);
|
"source line content must be preserved: " + rendered);
|
||||||
assertTrue(ledger.validateAnswer(rendered).valid());
|
assertTrue(ledger.validateAnswer(rendered).valid());
|
||||||
|
|||||||
@ -83,6 +83,19 @@
|
|||||||
<span class="pb-card__steps">{{ group.latest.completedSteps }}/{{ group.latest.totalSteps }}</span>
|
<span class="pb-card__steps">{{ group.latest.completedSteps }}/{{ group.latest.totalSteps }}</span>
|
||||||
<span v-if="group.plans.length > 1" class="pb-card__runs" :title="t('plans.runs', { n: group.plans.length })">×{{ group.plans.length }}</span>
|
<span v-if="group.plans.length > 1" class="pb-card__runs" :title="t('plans.runs', { n: group.plans.length })">×{{ group.plans.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Sub-task breakdown: surfaces how many steps are still pending /
|
||||||
|
running, so an in-progress plan no longer hides its queued work. -->
|
||||||
|
<div v-if="showDist(group.latest)" class="pb-card__dist">
|
||||||
|
<span v-if="stepDist(group.latest).running" class="pb-distchip is-running">
|
||||||
|
{{ stepDist(group.latest).running }} {{ t('plans.col.running') }}
|
||||||
|
</span>
|
||||||
|
<span v-if="stepDist(group.latest).pending" class="pb-distchip is-pending">
|
||||||
|
{{ stepDist(group.latest).pending }} {{ t('plans.col.pending') }}
|
||||||
|
</span>
|
||||||
|
<span v-if="stepDist(group.latest).completed" class="pb-distchip is-completed">
|
||||||
|
{{ stepDist(group.latest).completed }} {{ t('plans.col.completed') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@ -258,6 +271,24 @@ function progressPct(plan: Plan): number {
|
|||||||
return Math.round((plan.completedSteps / plan.totalSteps) * 100)
|
return Math.round((plan.completedSteps / plan.totalSteps) * 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sub-task status breakdown for a plan card. Plan-execute runs steps
|
||||||
|
// sequentially, so at most one step is "running" at any time; the rest of the
|
||||||
|
// not-yet-done steps are pending. Derived from the plan summary fields so the
|
||||||
|
// list endpoint stays cheap (no per-plan step fetch).
|
||||||
|
function stepDist(plan: Plan): { pending: number; running: number; completed: number } {
|
||||||
|
const total = plan.totalSteps ?? 0
|
||||||
|
const completed = Math.min(plan.completedSteps ?? 0, total)
|
||||||
|
const running = plan.status === 'running' && completed < total ? 1 : 0
|
||||||
|
const pending = Math.max(0, total - completed - running)
|
||||||
|
return { pending, running, completed }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only worth showing for multi-step plans that are still active; a finished or
|
||||||
|
// single-step plan is already fully described by the progress bar.
|
||||||
|
function showDist(plan: Plan): boolean {
|
||||||
|
return (plan.totalSteps ?? 0) > 1 && (plan.status === 'running' || plan.status === 'pending')
|
||||||
|
}
|
||||||
|
|
||||||
function laneLetter(name: string): string {
|
function laneLetter(name: string): string {
|
||||||
return (name || '?').trim().charAt(0).toUpperCase()
|
return (name || '?').trim().charAt(0).toUpperCase()
|
||||||
}
|
}
|
||||||
@ -556,6 +587,38 @@ onMounted(reload)
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 9px;
|
margin-top: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sub-task breakdown chips */
|
||||||
|
.pb-card__dist {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.pb-distchip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: var(--mc-radius-full);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 16px;
|
||||||
|
background: var(--mc-bg-muted);
|
||||||
|
color: var(--mc-text-tertiary);
|
||||||
|
}
|
||||||
|
.pb-distchip.is-running {
|
||||||
|
background: var(--mc-primary-bg);
|
||||||
|
color: var(--mc-primary);
|
||||||
|
}
|
||||||
|
.pb-distchip.is-pending {
|
||||||
|
background: var(--mc-bg-muted);
|
||||||
|
color: var(--mc-text-secondary);
|
||||||
|
}
|
||||||
|
.pb-distchip.is-completed {
|
||||||
|
background: var(--mc-success-bg, var(--mc-bg-muted));
|
||||||
|
color: var(--mc-success);
|
||||||
|
}
|
||||||
.pb-card__steps {
|
.pb-card__steps {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--mc-text-tertiary);
|
color: var(--mc-text-tertiary);
|
||||||
|
|||||||
@ -68,6 +68,10 @@
|
|||||||
<span class="pd-step__dot" :class="`is-${step.status}`"></span>
|
<span class="pd-step__dot" :class="`is-${step.status}`"></span>
|
||||||
<div class="pd-step__body">
|
<div class="pd-step__body">
|
||||||
<div class="pd-step__title"><b>{{ step.stepIndex + 1 }}.</b> {{ step.description }}</div>
|
<div class="pd-step__title"><b>{{ step.stepIndex + 1 }}.</b> {{ step.description }}</div>
|
||||||
|
<div v-if="delegatedAgentName(step)" class="pd-step__agent">
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||||
|
<span>{{ t('plans.delegatedTo') }} {{ delegatedAgentName(step) }}</span>
|
||||||
|
</div>
|
||||||
<div v-if="step.result && expanded === String(step.id)" class="pd-step__result markdown-body" v-html="resultHtml"></div>
|
<div v-if="step.result && expanded === String(step.id)" class="pd-step__result markdown-body" v-html="resultHtml"></div>
|
||||||
<div v-else-if="step.result" class="pd-step__hint">{{ t('plans.viewResult') }}</div>
|
<div v-else-if="step.result" class="pd-step__hint">{{ t('plans.viewResult') }}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -85,6 +89,7 @@ import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import SkillIcon from '@/components/common/SkillIcon.vue'
|
import SkillIcon from '@/components/common/SkillIcon.vue'
|
||||||
import { useStreamingMarkdown } from '@/composables/useStreamingMarkdown'
|
import { useStreamingMarkdown } from '@/composables/useStreamingMarkdown'
|
||||||
|
import { useAgentStore } from '@/stores/useAgentStore'
|
||||||
import type { Plan, SubPlan } from '@/types'
|
import type { Plan, SubPlan } from '@/types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@ -98,8 +103,17 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{ close: [] }>()
|
const emit = defineEmits<{ close: [] }>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const agentStore = useAgentStore()
|
||||||
const expanded = ref<string>('')
|
const expanded = ref<string>('')
|
||||||
|
|
||||||
|
// Resolve the delegated agent's display name from its id via the agent store.
|
||||||
|
// Empty string when the step isn't delegated (runs with the parent agent).
|
||||||
|
function delegatedAgentName(step: SubPlan): string {
|
||||||
|
if (step.assignedAgentId == null || step.assignedAgentId === '') return ''
|
||||||
|
const a = agentStore.agents.find((x) => String(x.id) === String(step.assignedAgentId))
|
||||||
|
return a?.name || ''
|
||||||
|
}
|
||||||
|
|
||||||
// Render the plan output and the expanded step result as markdown, reusing the
|
// Render the plan output and the expanded step result as markdown, reusing the
|
||||||
// same renderer the chat uses. `streaming = false` → full-fidelity one-shot
|
// same renderer the chat uses. `streaming = false` → full-fidelity one-shot
|
||||||
// render (code highlight + cache), since this content is already complete.
|
// render (code highlight + cache), since this content is already complete.
|
||||||
@ -433,6 +447,18 @@ html.dark .pd-tile {
|
|||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
color: var(--mc-primary);
|
color: var(--mc-primary);
|
||||||
}
|
}
|
||||||
|
.pd-step__agent {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 5px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: var(--mc-radius-full);
|
||||||
|
background: var(--mc-primary-bg);
|
||||||
|
color: var(--mc-primary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.pd-panel {
|
.pd-panel {
|
||||||
|
|||||||
@ -441,6 +441,7 @@ export default {
|
|||||||
untitled: 'Untitled plan',
|
untitled: 'Untitled plan',
|
||||||
steps: 'steps',
|
steps: 'steps',
|
||||||
viewResult: 'Click to view result',
|
viewResult: 'Click to view result',
|
||||||
|
delegatedTo: 'Delegated to',
|
||||||
col: {
|
col: {
|
||||||
pending: 'To Do',
|
pending: 'To Do',
|
||||||
running: 'In Progress',
|
running: 'In Progress',
|
||||||
|
|||||||
@ -441,6 +441,7 @@ export default {
|
|||||||
untitled: '未命名计划',
|
untitled: '未命名计划',
|
||||||
steps: '步骤',
|
steps: '步骤',
|
||||||
viewResult: '点击查看结果',
|
viewResult: '点击查看结果',
|
||||||
|
delegatedTo: '委派给',
|
||||||
col: {
|
col: {
|
||||||
pending: '待执行',
|
pending: '待执行',
|
||||||
running: '执行中',
|
running: '执行中',
|
||||||
|
|||||||
@ -718,6 +718,9 @@ export interface SubPlan {
|
|||||||
result?: string
|
result?: string
|
||||||
startTime?: string
|
startTime?: string
|
||||||
endTime?: string
|
endTime?: string
|
||||||
|
/** Delegated specialist agent for this step (snowflake id — keep as string).
|
||||||
|
* Absent/null means the step runs with the parent (plan) agent. */
|
||||||
|
assignedAgentId?: string | number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Plan {
|
export interface Plan {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user