feat(plans): per-step agent delegation + fix kanban pending column (issue #385)

This commit is contained in:
matevip 2026-06-21 21:20:58 +08:00
parent 1373b78b0a
commit eca4229751
15 changed files with 427 additions and 8 deletions

View File

@ -157,6 +157,30 @@ public class AgentGraphBuilder {
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 / 全局默认模型
*/
@ -554,8 +578,11 @@ public class AgentGraphBuilder {
if (auditEventService != null) {
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);
// 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);
DirectAnswerNode directAnswerNode = new DirectAnswerNode();

View File

@ -10,8 +10,11 @@ import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
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.GraphEventPublisher;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.graph.plan.state.PlanStateAccessor;
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 java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
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). */
private final GoalService goalService;
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. */
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("direct_answer") String directAnswer,
@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 = """
@ -190,6 +202,16 @@ public class PlanGenerationNode implements NodeAction {
ConversationWindowManager conversationWindowManager,
AgentToolSet toolSet,
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.planningService = planningService;
this.streamingHelper = streamingHelper;
@ -197,6 +219,7 @@ public class PlanGenerationNode implements NodeAction {
this.toolSet = toolSet;
this.goalService = goalService;
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
public Map<String, Object> apply(OverAllState state) throws Exception {
PlanStateAccessor accessor = new PlanStateAccessor(state);
@ -341,6 +418,24 @@ public class PlanGenerationNode implements NodeAction {
+ "\n单次工具调用应归为单步B不要拆成多步。"));
}
// Advertise delegatable specialist agents so the planner can assign a
// multi-step plan's step to a dedicated agent (e.g. a test step to a
// QA agent, a UI step to a frontend agent). Only fills the step's
// step_agents slot; unassigned steps stay with the parent agent.
// Skipped entirely when no peer agents exist in the workspace.
List<AgentEntity> delegatable = listDelegatableAgents(chatOrigin.workspaceId(), agentId);
if (!delegatable.isEmpty()) {
String agentLines = delegatable.stream()
.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
// prior constraints without re-reading full history.
String workingContext = accessor.workingContext();
@ -455,9 +550,15 @@ public class PlanGenerationNode implements NodeAction {
steps = List.of(goal);
}
var plan = planningService.createPlan(agentId, conversationId, goal, steps);
log.info("[PlanGeneration] Plan created: id={}, steps={} ({})",
plan.getId(), steps.size(), steps.size() == 1 ? "single-step" : "multi-step");
// Resolve any per-step agent delegation the planner asked for. Null
// when nothing is delegated, keeping createPlan on the legacy path.
List<Long> stepAgentIds = resolveStepAgents(steps,
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));

View File

@ -27,7 +27,9 @@ import vip.mate.agent.context.RuntimeContextInjector;
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.planning.service.PlanningService;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.skill.runtime.SkillCatalogRenderer;
import vip.mate.tool.builtin.DelegateAgentTool;
import java.util.ArrayList;
import java.util.List;
@ -68,6 +70,17 @@ public class StepExecutionNode implements NodeAction {
*/
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}.
* 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
// same SSE feed.
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) {
events.add(GraphEventPublisher.iterationStart(stepIndex, "plan_step", "parent", null));
}
@ -622,6 +648,82 @@ public class StepExecutionNode implements NodeAction {
.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
* step. Mirrors {@code FinalAnswerNode#assembleDirectAnswer} so the user

View File

@ -29,6 +29,14 @@ public class SubPlanEntity {
/** 步骤状态pending / running / completed / failed */
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)
private String result;

View File

@ -43,11 +43,27 @@ public class PlanningService {
*/
@Transactional
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();
plan.setAgentId(agentId);
plan.setConversationId(conversationId);
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.setCompletedSteps(0);
planMapper.insert(plan);
@ -58,6 +74,11 @@ public class PlanningService {
sub.setStepIndex(i);
sub.setDescription(steps.get(i));
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);
});
@ -74,6 +95,13 @@ public class PlanningService {
sub.setStatus(status);
if ("running".equals(status)) {
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);
}
@ -156,6 +184,17 @@ public class PlanningService {
.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;
}
/**
* 标记计划失败
*/

View File

@ -324,6 +324,26 @@ public class DelegateAgentTool {
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 ====================
@vip.mate.tool.ConcurrencyUnsafe("internally fans out to its own thread pool; outer executor must not double-parallelize")

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -290,8 +290,8 @@ class SourceEvidenceLedgerTest {
[1] MAST-Data数据集
""");
assertTrue("来源: header must be present: " + rendered,
rendered.contains("来源:"));
assertTrue(rendered.contains("来源:"),
"来源: header must be present: " + rendered);
assertTrue(rendered.contains("[1] MAST-Data数据集"),
"source line content must be preserved: " + rendered);
assertTrue(ledger.validateAnswer(rendered).valid());

View File

@ -83,6 +83,19 @@
<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>
</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>
<button
@ -258,6 +271,24 @@ function progressPct(plan: Plan): number {
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 {
return (name || '?').trim().charAt(0).toUpperCase()
}
@ -556,6 +587,38 @@ onMounted(reload)
gap: 8px;
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 {
font-size: 11px;
color: var(--mc-text-tertiary);

View File

@ -68,6 +68,10 @@
<span class="pd-step__dot" :class="`is-${step.status}`"></span>
<div class="pd-step__body">
<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-else-if="step.result" class="pd-step__hint">{{ t('plans.viewResult') }}</div>
</div>
@ -85,6 +89,7 @@ import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import SkillIcon from '@/components/common/SkillIcon.vue'
import { useStreamingMarkdown } from '@/composables/useStreamingMarkdown'
import { useAgentStore } from '@/stores/useAgentStore'
import type { Plan, SubPlan } from '@/types'
const props = defineProps<{
@ -98,8 +103,17 @@ const props = defineProps<{
const emit = defineEmits<{ close: [] }>()
const { t } = useI18n()
const agentStore = useAgentStore()
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
// same renderer the chat uses. `streaming = false` full-fidelity one-shot
// render (code highlight + cache), since this content is already complete.
@ -433,6 +447,18 @@ html.dark .pd-tile {
font-size: 11.5px;
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) {
.pd-panel {

View File

@ -441,6 +441,7 @@ export default {
untitled: 'Untitled plan',
steps: 'steps',
viewResult: 'Click to view result',
delegatedTo: 'Delegated to',
col: {
pending: 'To Do',
running: 'In Progress',

View File

@ -441,6 +441,7 @@ export default {
untitled: '未命名计划',
steps: '步骤',
viewResult: '点击查看结果',
delegatedTo: '委派给',
col: {
pending: '待执行',
running: '执行中',

View File

@ -718,6 +718,9 @@ export interface SubPlan {
result?: string
startTime?: 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 {