mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 11:37:31 +08:00
feat(delegation): 子 Agent 成本透出 + 单任务/计划步骤委派结构化
- 子执行改走 chatWithUsage,捕获并透出每个子 Agent 的 prompt/completion token (单任务回复、并行机读头+逐行、delegation_end/child_complete/单路 broadcastEnd 事件) - 新增 delegateByAgentIdStructured 返回结构化 ChildResult;计划步骤委派改按 success()/isBlank() 判定成败,替掉脆弱的错误前缀匹配 - 前端委派段与嵌套节点显示紧凑成本后缀/徽标 - 测试:子执行 stub 迁移到 chatWithUsage + 新增 token 回归用例
This commit is contained in:
parent
db11433883
commit
d390935763
@ -30,6 +30,7 @@ import vip.mate.planning.service.PlanningService;
|
|||||||
import vip.mate.agent.context.ChatOrigin;
|
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 vip.mate.tool.builtin.DelegateAgentTool;
|
||||||
|
import vip.mate.tool.builtin.DelegateAgentTool.ChildResult;
|
||||||
import vip.mate.tool.builtin.DelegationContext;
|
import vip.mate.tool.builtin.DelegationContext;
|
||||||
import vip.mate.tool.builtin.ToolExecutionContext;
|
import vip.mate.tool.builtin.ToolExecutionContext;
|
||||||
|
|
||||||
@ -678,7 +679,7 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
// Seed the delegation context with the plan's REAL conversation id (from
|
// Seed the delegation context with the plan's REAL conversation id (from
|
||||||
// graph state) so the delegated child conversation is parented to it and
|
// graph state) so the delegated child conversation is parented to it and
|
||||||
// stays hidden from the user's conversation list. The ChatOrigin in the
|
// stays hidden from the user's conversation list. The ChatOrigin in the
|
||||||
// plan-execute path carries no conversationId, so delegateByAgentId can't
|
// plan-execute path carries no conversationId, so the delegation can't
|
||||||
// derive the parent on its own — we provide it here.
|
// derive the parent on its own — we provide it here.
|
||||||
boolean seeded = false;
|
boolean seeded = false;
|
||||||
if (conversationId != null && !conversationId.isBlank()
|
if (conversationId != null && !conversationId.isBlank()
|
||||||
@ -687,20 +688,30 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
DelegationContext.enter(conversationId, Set.of(), conversationId, null, 0);
|
DelegationContext.enter(conversationId, Set.of(), conversationId, null, 0);
|
||||||
seeded = true;
|
seeded = true;
|
||||||
}
|
}
|
||||||
String result;
|
ChildResult childResult = null;
|
||||||
|
String delegateError = null;
|
||||||
try {
|
try {
|
||||||
result = delegateAgentTool.delegateByAgentId(assignedAgentId, step, chatOrigin);
|
childResult = delegateAgentTool.delegateByAgentIdStructured(assignedAgentId, step, chatOrigin);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[StepExecution] Delegated step {} threw: {}", stepIndex, e.getMessage(), e);
|
log.error("[StepExecution] Delegated step {} threw: {}", stepIndex, e.getMessage(), e);
|
||||||
result = "[错误] 委派执行异常:" + e.getMessage();
|
delegateError = e.getMessage();
|
||||||
} finally {
|
} finally {
|
||||||
if (seeded) {
|
if (seeded) {
|
||||||
DelegationContext.exit();
|
DelegationContext.exit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String finalResult = result != null ? result : "";
|
// Branch on the structured outcome instead of pattern-matching an error
|
||||||
boolean failed = finalResult.isEmpty() || finalResult.startsWith("[错误]");
|
// prefix out of the reply text: a successful child with non-empty content
|
||||||
|
// is the only "ok" case; blank / error / missing all count as failure.
|
||||||
|
boolean ok = childResult != null && childResult.success() && !childResult.isBlank();
|
||||||
|
String finalResult = ok
|
||||||
|
? (childResult.result() != null ? childResult.result() : "")
|
||||||
|
: "[错误] 委派执行失败:" + (delegateError != null ? delegateError
|
||||||
|
: childResult != null && childResult.error() != null ? childResult.error()
|
||||||
|
: childResult != null && childResult.isBlank() ? "子 Agent 返回内容为空"
|
||||||
|
: "未知错误");
|
||||||
|
boolean failed = !ok;
|
||||||
if (failed) {
|
if (failed) {
|
||||||
planningService.updateSubPlanFailure(planId, stepIndex, finalResult);
|
planningService.updateSubPlanFailure(planId, stepIndex, finalResult);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import org.springframework.lang.Nullable;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import vip.mate.agent.AgentService;
|
import vip.mate.agent.AgentService;
|
||||||
|
import vip.mate.agent.AgentService.ChatResult;
|
||||||
import vip.mate.agent.context.ChatOrigin;
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
import vip.mate.agent.delegation.SubagentRegistry;
|
import vip.mate.agent.delegation.SubagentRegistry;
|
||||||
import vip.mate.agent.model.AgentEntity;
|
import vip.mate.agent.model.AgentEntity;
|
||||||
@ -242,6 +243,38 @@ public class DelegateAgentTool {
|
|||||||
return "[错误] 未找到名为「" + agentName + "」的已启用 Agent。" + availableAgentsHint();
|
return "[错误] 未找到名为「" + agentName + "」的已启用 Agent。" + availableAgentsHint();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Execute via the shared single-task core (registry, relay, broadcast,
|
||||||
|
// child run). Returns the structured ChildResult plus the child's
|
||||||
|
// session handle so the parent can follow up on this exact sub-agent.
|
||||||
|
SingleDelegation sd = executeSingleDelegation(target, task, inheritParentContext, ctx);
|
||||||
|
ChildResult result = sd.result();
|
||||||
|
|
||||||
|
String response = result.toToolResponse(target.getName());
|
||||||
|
// Surface the child's session handle so the parent can follow up on this
|
||||||
|
// exact sub-agent (its conversation persists past this call) via
|
||||||
|
// send_to_subagent, instead of re-spawning a fresh, context-less child.
|
||||||
|
if (result.success() && sd.childConversationId() != null) {
|
||||||
|
response += "\n\n[session_id: " + sd.childConversationId()
|
||||||
|
+ " — to follow up with this sub-agent, call send_to_subagent(session_id, message)]";
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Carrier for a single-task delegation: the structured child result plus
|
||||||
|
* the child conversation handle (null when spawning was short-circuited). */
|
||||||
|
private record SingleDelegation(ChildResult result, String childConversationId) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared execution core for single-task delegation, used by both the
|
||||||
|
* LLM-facing {@link #delegateToAgent} tool and the id-based
|
||||||
|
* {@link #delegateByAgentIdStructured} (per-step plan delegation). Handles
|
||||||
|
* spawn-pause, child conversation creation, optional parent-context
|
||||||
|
* inheritance, sub-agent registry, event relay/broadcast, and the child
|
||||||
|
* run — returning the structured {@link ChildResult} so callers decide how
|
||||||
|
* to format it (tool string vs. plan step bookkeeping).
|
||||||
|
*/
|
||||||
|
private SingleDelegation executeSingleDelegation(AgentEntity target, String task,
|
||||||
|
Boolean inheritParentContext, ToolContext ctx) {
|
||||||
String parentConversationId = resolveParentConversationId();
|
String parentConversationId = resolveParentConversationId();
|
||||||
// Root (human-facing) conversation at the top of the delegation tree.
|
// Root (human-facing) conversation at the top of the delegation tree.
|
||||||
// At depth 0 the immediate parent IS the root; deeper layers carry it
|
// At depth 0 the immediate parent IS the root; deeper layers carry it
|
||||||
@ -249,14 +282,15 @@ public class DelegateAgentTool {
|
|||||||
String rootConversationId = DelegationContext.rootConversationId();
|
String rootConversationId = DelegationContext.rootConversationId();
|
||||||
if (rootConversationId == null) rootConversationId = parentConversationId;
|
if (rootConversationId == null) rootConversationId = parentConversationId;
|
||||||
String parentSubagentId = DelegationContext.currentSubagentId();
|
String parentSubagentId = DelegationContext.currentSubagentId();
|
||||||
int childDepth = depth + 1;
|
int childDepth = DelegationContext.currentDepth() + 1;
|
||||||
|
|
||||||
// Spawn-pause: short-circuit before creating child state when either the
|
// Spawn-pause: short-circuit before creating child state when either the
|
||||||
// immediate parent or the root tree is paused, so no conversation rows /
|
// immediate parent or the root tree is paused, so no conversation rows /
|
||||||
// relays / registry entries leak.
|
// relays / registry entries leak.
|
||||||
if ((parentConversationId != null && subagentRegistry.isSpawnPaused(parentConversationId))
|
if ((parentConversationId != null && subagentRegistry.isSpawnPaused(parentConversationId))
|
||||||
|| (rootConversationId != null && subagentRegistry.isSpawnPaused(rootConversationId))) {
|
|| (rootConversationId != null && subagentRegistry.isSpawnPaused(rootConversationId))) {
|
||||||
return "[错误] Spawning paused for this conversation; resume via /api/v1/subagents/spawn-pause";
|
return new SingleDelegation(ChildResult.ofError(0, target.getName(),
|
||||||
|
"Spawning paused for this conversation; resume via /api/v1/subagents/spawn-pause"), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
String childConversationId = createChildConv(target, parentConversationId);
|
String childConversationId = createChildConv(target, parentConversationId);
|
||||||
@ -275,12 +309,12 @@ public class DelegateAgentTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.info("Agent delegation: depth={}, target={}({}), childConv={}, parentConv={}",
|
log.info("Agent delegation: depth={}, target={}({}), childConv={}, parentConv={}",
|
||||||
depth + 1, target.getName(), target.getId(), childConversationId, parentConversationId);
|
childDepth, target.getName(), target.getId(), childConversationId, parentConversationId);
|
||||||
|
|
||||||
// Register the live sub-agent first so its stable id rides on every
|
// Register the live sub-agent first so its stable id rides on every
|
||||||
// event. Disposable is null in the synchronous single-task path because
|
// event. Disposable is null in the synchronous single-task path because
|
||||||
// the executor blocks on AgentService#chat directly — there is no Flux
|
// the executor blocks on AgentService#chatWithUsage directly — there is
|
||||||
// subscription to dispose. Interrupts here are best-effort (status flip).
|
// no Flux subscription to dispose. Interrupts here are best-effort (status flip).
|
||||||
String subagentId = parentConversationId != null
|
String subagentId = parentConversationId != null
|
||||||
? subagentRegistry.register(parentConversationId, childConversationId,
|
? subagentRegistry.register(parentConversationId, childConversationId,
|
||||||
target.getId(), task, null, parentSubagentId, childDepth, rootConversationId)
|
target.getId(), task, null, parentSubagentId, childDepth, rootConversationId)
|
||||||
@ -326,16 +360,7 @@ public class DelegateAgentTool {
|
|||||||
broadcastEnd(rootConversationId, childConversationId, target.getName(), result,
|
broadcastEnd(rootConversationId, childConversationId, target.getName(), result,
|
||||||
subagentId, parentSubagentId, childDepth);
|
subagentId, parentSubagentId, childDepth);
|
||||||
}
|
}
|
||||||
|
return new SingleDelegation(result, childConversationId);
|
||||||
String response = result.toToolResponse(target.getName());
|
|
||||||
// Surface the child's session handle so the parent can follow up on this
|
|
||||||
// exact sub-agent (its conversation persists past this call) via
|
|
||||||
// send_to_subagent, instead of re-spawning a fresh, context-less child.
|
|
||||||
if (result.success() && childConversationId != null) {
|
|
||||||
response += "\n\n[session_id: " + childConversationId
|
|
||||||
+ " — to follow up with this sub-agent, call send_to_subagent(session_id, message)]";
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -347,12 +372,30 @@ public class DelegateAgentTool {
|
|||||||
* child's reply text, or an error string when the agent is missing/disabled.
|
* child's reply text, or an error string when the agent is missing/disabled.
|
||||||
*/
|
*/
|
||||||
public String delegateByAgentId(Long agentId, String task, ChatOrigin parentOrigin) {
|
public String delegateByAgentId(Long agentId, String task, ChatOrigin parentOrigin) {
|
||||||
|
ChildResult result = delegateByAgentIdStructured(agentId, task, parentOrigin);
|
||||||
|
return result.toToolResponse(result.agentName());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured variant of {@link #delegateByAgentId}: runs the same isolated
|
||||||
|
* child execution but returns the full {@link ChildResult} instead of a
|
||||||
|
* formatted string. Used by per-step plan delegation so the plan graph can
|
||||||
|
* branch on {@code success()} / {@code isBlank()} / {@code outcome()} and
|
||||||
|
* read token usage, rather than pattern-matching an error prefix out of a
|
||||||
|
* string. Never throws — agent-resolution and depth-guard failures come
|
||||||
|
* back as {@code outcome="error"} results.
|
||||||
|
*/
|
||||||
|
public ChildResult delegateByAgentIdStructured(Long agentId, String task, ChatOrigin parentOrigin) {
|
||||||
if (agentId == null) {
|
if (agentId == null) {
|
||||||
return "[错误] 未指定委派 Agent。";
|
return ChildResult.ofError(0, "?", "未指定委派 Agent。");
|
||||||
}
|
}
|
||||||
AgentEntity target = agentMapper.selectById(agentId);
|
AgentEntity target = agentMapper.selectById(agentId);
|
||||||
if (target == null || !Boolean.TRUE.equals(target.getEnabled())) {
|
if (target == null || !Boolean.TRUE.equals(target.getEnabled())) {
|
||||||
return "[错误] 未找到 id=" + agentId + " 的已启用 Agent。";
|
return ChildResult.ofError(0, "id=" + agentId, "未找到 id=" + agentId + " 的已启用 Agent。");
|
||||||
|
}
|
||||||
|
if (DelegationContext.currentDepth() >= MAX_DELEGATION_DEPTH) {
|
||||||
|
return ChildResult.ofError(0, target.getName(),
|
||||||
|
"委派层级已达上限(" + MAX_DELEGATION_DEPTH + " 层)");
|
||||||
}
|
}
|
||||||
ChatOrigin origin = parentOrigin != null ? parentOrigin : ChatOrigin.EMPTY;
|
ChatOrigin origin = parentOrigin != null ? parentOrigin : ChatOrigin.EMPTY;
|
||||||
ToolContext ctx = origin.toToolContext();
|
ToolContext ctx = origin.toToolContext();
|
||||||
@ -372,7 +415,7 @@ public class DelegateAgentTool {
|
|||||||
DelegationContext.enter(parentConvId, Set.of(), parentConvId, null, 0);
|
DelegationContext.enter(parentConvId, Set.of(), parentConvId, null, 0);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return delegateToAgent(target.getName(), task, false, ctx);
|
return executeSingleDelegation(target, task, false, ctx).result();
|
||||||
} finally {
|
} finally {
|
||||||
if (seedContext) {
|
if (seedContext) {
|
||||||
DelegationContext.exit();
|
DelegationContext.exit();
|
||||||
@ -543,6 +586,8 @@ public class DelegateAgentTool {
|
|||||||
payload.put("trimmedLength", r.trimmedLength);
|
payload.put("trimmedLength", r.trimmedLength);
|
||||||
payload.put("blank", r.isBlank());
|
payload.put("blank", r.isBlank());
|
||||||
payload.put("durationMs", r.durationMs);
|
payload.put("durationMs", r.durationMs);
|
||||||
|
payload.put("promptTokens", r.promptTokens);
|
||||||
|
payload.put("completionTokens", r.completionTokens);
|
||||||
payload.put("resultPreview", r.success
|
payload.put("resultPreview", r.success
|
||||||
? truncate(r.result, 400)
|
? truncate(r.result, 400)
|
||||||
: (r.error != null ? r.error : "error"));
|
: (r.error != null ? r.error : "error"));
|
||||||
@ -637,6 +682,8 @@ public class DelegateAgentTool {
|
|||||||
m.put("trimmedLength", r.trimmedLength);
|
m.put("trimmedLength", r.trimmedLength);
|
||||||
m.put("blank", r.isBlank());
|
m.put("blank", r.isBlank());
|
||||||
m.put("durationMs", r.durationMs);
|
m.put("durationMs", r.durationMs);
|
||||||
|
m.put("promptTokens", r.promptTokens);
|
||||||
|
m.put("completionTokens", r.completionTokens);
|
||||||
// childConversationId + subagentId for stable frontend tree lookup
|
// childConversationId + subagentId for stable frontend tree lookup
|
||||||
prepared.stream()
|
prepared.stream()
|
||||||
.filter(p -> p.index == r.taskIndex)
|
.filter(p -> p.index == r.taskIndex)
|
||||||
@ -666,6 +713,8 @@ public class DelegateAgentTool {
|
|||||||
long timeoutCount = results.stream().filter(r -> "timeout".equals(r.outcome)).count();
|
long timeoutCount = results.stream().filter(r -> "timeout".equals(r.outcome)).count();
|
||||||
long cancelledCount = results.stream().filter(r -> "cancelled".equals(r.outcome)).count();
|
long cancelledCount = results.stream().filter(r -> "cancelled".equals(r.outcome)).count();
|
||||||
long errorCount = results.stream().filter(r -> "error".equals(r.outcome)).count();
|
long errorCount = results.stream().filter(r -> "error".equals(r.outcome)).count();
|
||||||
|
long tokensInTotal = results.stream().mapToLong(r -> r.promptTokens).sum();
|
||||||
|
long tokensOutTotal = results.stream().mapToLong(r -> r.completionTokens).sum();
|
||||||
|
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
|
|
||||||
@ -680,6 +729,8 @@ public class DelegateAgentTool {
|
|||||||
.append(" cancelled=").append(cancelledCount)
|
.append(" cancelled=").append(cancelledCount)
|
||||||
.append(" error=").append(errorCount)
|
.append(" error=").append(errorCount)
|
||||||
.append(" durationMs=").append(totalDurationMs)
|
.append(" durationMs=").append(totalDurationMs)
|
||||||
|
.append(" tokensIn=").append(tokensInTotal)
|
||||||
|
.append(" tokensOut=").append(tokensOutTotal)
|
||||||
.append("\n\n");
|
.append("\n\n");
|
||||||
|
|
||||||
// Important: this result is from the current execution. Any timeout entries in the
|
// Important: this result is from the current execution. Any timeout entries in the
|
||||||
@ -700,6 +751,8 @@ public class DelegateAgentTool {
|
|||||||
.append(" | contentLength=").append(r.trimmedLength).append("chars")
|
.append(" | contentLength=").append(r.trimmedLength).append("chars")
|
||||||
.append(" | rawLength=").append(r.rawLength).append("chars")
|
.append(" | rawLength=").append(r.rawLength).append("chars")
|
||||||
.append(" | duration=").append(r.durationMs / 1000).append("s")
|
.append(" | duration=").append(r.durationMs / 1000).append("s")
|
||||||
|
.append(" | tokensIn=").append(r.promptTokens)
|
||||||
|
.append(" | tokensOut=").append(r.completionTokens)
|
||||||
.append("\n\n");
|
.append("\n\n");
|
||||||
|
|
||||||
switch (r.outcome) {
|
switch (r.outcome) {
|
||||||
@ -1048,11 +1101,17 @@ public class DelegateAgentTool {
|
|||||||
ChatOrigin childOrigin = (parentOrigin != null ? parentOrigin : ChatOrigin.EMPTY)
|
ChatOrigin childOrigin = (parentOrigin != null ? parentOrigin : ChatOrigin.EMPTY)
|
||||||
.withAgent(target.getId())
|
.withAgent(target.getId())
|
||||||
.withConversationId(childConversationId);
|
.withConversationId(childConversationId);
|
||||||
String rawResult = agentService.chat(target.getId(), task, childConversationId, childOrigin);
|
// chatWithUsage runs the same StateGraph as chat() (so the child
|
||||||
|
// message is persisted identically) but also surfaces the child's
|
||||||
|
// token usage from the graph's _usage_final event, so the parent can
|
||||||
|
// see what each sub-agent cost.
|
||||||
|
ChatResult chatResult = agentService.chatWithUsage(
|
||||||
|
target.getId(), task, childConversationId, childOrigin);
|
||||||
long durationMs = System.currentTimeMillis() - startTime;
|
long durationMs = System.currentTimeMillis() - startTime;
|
||||||
|
String rawResult = chatResult.content();
|
||||||
// Measure lengths before truncation so ChildResult carries accurate metadata.
|
// Measure lengths before truncation so ChildResult carries accurate metadata.
|
||||||
return ChildResult.ofSuccess(taskIndex, target.getName(), rawResult, durationMs,
|
return ChildResult.ofSuccess(taskIndex, target.getName(), rawResult, durationMs,
|
||||||
MAX_RESULT_LENGTH);
|
MAX_RESULT_LENGTH, chatResult.promptTokens(), chatResult.completionTokens());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Child agent failed: taskIndex={}, agent={}, error={}",
|
log.error("Child agent failed: taskIndex={}, agent={}, error={}",
|
||||||
taskIndex, target.getName(), e.getMessage());
|
taskIndex, target.getName(), e.getMessage());
|
||||||
@ -1079,21 +1138,25 @@ public class DelegateAgentTool {
|
|||||||
* <p>{@code rawLength} and {@code trimmedLength} are measured before truncation and reflect the
|
* <p>{@code rawLength} and {@code trimmedLength} are measured before truncation and reflect the
|
||||||
* true content length.
|
* true content length.
|
||||||
*/
|
*/
|
||||||
private record ChildResult(
|
public record ChildResult(
|
||||||
int taskIndex, String agentName, boolean success,
|
int taskIndex, String agentName, boolean success,
|
||||||
String result, String error, long durationMs,
|
String result, String error, long durationMs,
|
||||||
/** "success" | "blank_success" | "timeout" | "error" */
|
/** "success" | "blank_success" | "timeout" | "cancelled" | "error" */
|
||||||
String outcome,
|
String outcome,
|
||||||
int rawLength, int trimmedLength) {
|
int rawLength, int trimmedLength,
|
||||||
|
/** Child token usage captured from the graph's _usage_final event;
|
||||||
|
* 0 for non-success outcomes (timeout / error / cancelled). */
|
||||||
|
int promptTokens, int completionTokens) {
|
||||||
|
|
||||||
/** Whether the child returned no usable content (blank_success). */
|
/** Whether the child returned no usable content (blank_success). */
|
||||||
boolean isBlank() { return "blank_success".equals(outcome); }
|
public boolean isBlank() { return "blank_success".equals(outcome); }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Factory for a successful child execution.
|
* Factory for a successful child execution.
|
||||||
* Measures lengths from the raw result before applying the truncation limit.
|
* Measures lengths from the raw result before applying the truncation limit.
|
||||||
*/
|
*/
|
||||||
static ChildResult ofSuccess(int idx, String name, String rawResult, long ms, int maxLen) {
|
static ChildResult ofSuccess(int idx, String name, String rawResult, long ms, int maxLen,
|
||||||
|
int promptTokens, int completionTokens) {
|
||||||
String safe = rawResult != null ? rawResult : "";
|
String safe = rawResult != null ? rawResult : "";
|
||||||
String trimmed = safe.trim();
|
String trimmed = safe.trim();
|
||||||
boolean blank = trimmed.isEmpty();
|
boolean blank = trimmed.isEmpty();
|
||||||
@ -1102,7 +1165,8 @@ public class DelegateAgentTool {
|
|||||||
truncate(safe, maxLen),
|
truncate(safe, maxLen),
|
||||||
null, ms,
|
null, ms,
|
||||||
blank ? "blank_success" : "success",
|
blank ? "blank_success" : "success",
|
||||||
safe.length(), trimmed.length());
|
safe.length(), trimmed.length(),
|
||||||
|
Math.max(0, promptTokens), Math.max(0, completionTokens));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1113,14 +1177,14 @@ public class DelegateAgentTool {
|
|||||||
String msg = err != null ? err : "Unknown error";
|
String msg = err != null ? err : "Unknown error";
|
||||||
boolean isTimeout = msg.contains("超时") || msg.toLowerCase().contains("timeout");
|
boolean isTimeout = msg.contains("超时") || msg.toLowerCase().contains("timeout");
|
||||||
return new ChildResult(idx, name, false, null, msg, 0,
|
return new ChildResult(idx, name, false, null, msg, 0,
|
||||||
isTimeout ? "timeout" : "error", 0, 0);
|
isTimeout ? "timeout" : "error", 0, 0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Factory for an explicit timeout (parallel window exceeded). */
|
/** Factory for an explicit timeout (parallel window exceeded). */
|
||||||
static ChildResult ofTimeout(int idx, String name, int timeoutSec) {
|
static ChildResult ofTimeout(int idx, String name, int timeoutSec) {
|
||||||
String msg = "超时 (" + timeoutSec + "s)";
|
String msg = "超时 (" + timeoutSec + "s)";
|
||||||
return new ChildResult(idx, name, false, null, msg, (long) timeoutSec * 1000L,
|
return new ChildResult(idx, name, false, null, msg, (long) timeoutSec * 1000L,
|
||||||
"timeout", 0, 0);
|
"timeout", 0, 0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1130,7 +1194,7 @@ public class DelegateAgentTool {
|
|||||||
*/
|
*/
|
||||||
static ChildResult ofCancelled(int idx, String name) {
|
static ChildResult ofCancelled(int idx, String name) {
|
||||||
return new ChildResult(idx, name, false, null,
|
return new ChildResult(idx, name, false, null,
|
||||||
"已取消(必需子任务失败,触发提前收束)", 0, "cancelled", 0, 0);
|
"已取消(必需子任务失败,触发提前收束)", 0, "cancelled", 0, 0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy shims — kept for callers that pre-date the factory methods
|
// Legacy shims — kept for callers that pre-date the factory methods
|
||||||
@ -1140,15 +1204,19 @@ public class DelegateAgentTool {
|
|||||||
String trimmed = safe.trim();
|
String trimmed = safe.trim();
|
||||||
boolean blank = trimmed.isEmpty();
|
boolean blank = trimmed.isEmpty();
|
||||||
return new ChildResult(idx, name, true, safe, null, ms,
|
return new ChildResult(idx, name, true, safe, null, ms,
|
||||||
blank ? "blank_success" : "success", safe.length(), trimmed.length());
|
blank ? "blank_success" : "success", safe.length(), trimmed.length(), 0, 0);
|
||||||
}
|
}
|
||||||
static ChildResult error(int idx, String name, String err) {
|
static ChildResult error(int idx, String name, String err) {
|
||||||
return ofError(idx, name, err);
|
return ofError(idx, name, err);
|
||||||
}
|
}
|
||||||
|
|
||||||
String toToolResponse(String agentName) {
|
String toToolResponse(String agentName) {
|
||||||
if (success) return "[Agent「" + agentName + "」的回复]\n\n" + (result != null ? result : "");
|
if (!success) return "[错误] Agent「" + agentName + "」执行失败: " + error;
|
||||||
return "[错误] Agent「" + agentName + "」执行失败: " + error;
|
String body = "[Agent「" + agentName + "」的回复]\n\n" + (result != null ? result : "");
|
||||||
|
if (promptTokens > 0 || completionTokens > 0) {
|
||||||
|
body += "\n\n[usage: tokensIn=" + promptTokens + " tokensOut=" + completionTokens + "]";
|
||||||
|
}
|
||||||
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String truncate(String text, int maxLength) {
|
private static String truncate(String text, int maxLength) {
|
||||||
@ -1372,6 +1440,8 @@ public class DelegateAgentTool {
|
|||||||
Map<String, Object> ev = delegationPayload(subagentId, parentSubagentId, depth, childConvId, agentName);
|
Map<String, Object> ev = delegationPayload(subagentId, parentSubagentId, depth, childConvId, agentName);
|
||||||
ev.put("success", result.success);
|
ev.put("success", result.success);
|
||||||
ev.put("durationMs", result.durationMs);
|
ev.put("durationMs", result.durationMs);
|
||||||
|
ev.put("promptTokens", result.promptTokens);
|
||||||
|
ev.put("completionTokens", result.completionTokens);
|
||||||
ev.put("resultPreview",
|
ev.put("resultPreview",
|
||||||
result.success ? truncate(result.result, 200) : (result.error != null ? result.error : ""));
|
result.success ? truncate(result.result, 200) : (result.error != null ? result.error : ""));
|
||||||
streamTracker.broadcastObject(rootConvId, "delegation_end", ev);
|
streamTracker.broadcastObject(rootConvId, "delegation_end", ev);
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import org.mockito.Mock;
|
|||||||
import org.mockito.Spy;
|
import org.mockito.Spy;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import vip.mate.agent.AgentService;
|
import vip.mate.agent.AgentService;
|
||||||
|
import vip.mate.agent.AgentService.ChatResult;
|
||||||
import vip.mate.agent.delegation.SubagentRegistry;
|
import vip.mate.agent.delegation.SubagentRegistry;
|
||||||
import vip.mate.agent.model.AgentEntity;
|
import vip.mate.agent.model.AgentEntity;
|
||||||
import vip.mate.agent.repository.AgentMapper;
|
import vip.mate.agent.repository.AgentMapper;
|
||||||
@ -180,9 +181,9 @@ class DelegateAgentToolTest {
|
|||||||
// the test thread returns immediately. The orphan keeps sleeping on a
|
// the test thread returns immediately. The orphan keeps sleeping on a
|
||||||
// virtual thread until JVM teardown — that's the same behavior as
|
// virtual thread until JVM teardown — that's the same behavior as
|
||||||
// production (cancel is best-effort).
|
// production (cancel is best-effort).
|
||||||
when(agentService.chat(anyLong(), anyString(), anyString(), any())).thenAnswer(invocation -> {
|
when(agentService.chatWithUsage(anyLong(), anyString(), anyString(), any())).thenAnswer(invocation -> {
|
||||||
Thread.sleep(10_000);
|
Thread.sleep(10_000);
|
||||||
return "should not reach here";
|
return ChatResult.contentOnly("should not reach here");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set a conversationId so resolveParentConversationId works
|
// Set a conversationId so resolveParentConversationId works
|
||||||
@ -239,13 +240,13 @@ class DelegateAgentToolTest {
|
|||||||
when(streamTracker.isRunning(any())).thenReturn(false);
|
when(streamTracker.isRunning(any())).thenReturn(false);
|
||||||
|
|
||||||
// FastAgent completes immediately
|
// FastAgent completes immediately
|
||||||
when(agentService.chat(eq(10L), anyString(), anyString(), any()))
|
when(agentService.chatWithUsage(eq(10L), anyString(), anyString(), any()))
|
||||||
.thenReturn("Fast result completed successfully");
|
.thenReturn(ChatResult.contentOnly("Fast result completed successfully"));
|
||||||
|
|
||||||
// SlowAgent blocks longer than the (test-overridden) 3 s budget.
|
// SlowAgent blocks longer than the (test-overridden) 3 s budget.
|
||||||
when(agentService.chat(eq(11L), anyString(), anyString(), any())).thenAnswer(invocation -> {
|
when(agentService.chatWithUsage(eq(11L), anyString(), anyString(), any())).thenAnswer(invocation -> {
|
||||||
Thread.sleep(10_000);
|
Thread.sleep(10_000);
|
||||||
return "should not reach here";
|
return ChatResult.contentOnly("should not reach here");
|
||||||
});
|
});
|
||||||
|
|
||||||
ToolExecutionContext.set("parent-mixed", "admin");
|
ToolExecutionContext.set("parent-mixed", "admin");
|
||||||
@ -286,12 +287,12 @@ class DelegateAgentToolTest {
|
|||||||
.thenReturn(slowAgent);
|
.thenReturn(slowAgent);
|
||||||
when(streamTracker.isRunning(any())).thenReturn(false);
|
when(streamTracker.isRunning(any())).thenReturn(false);
|
||||||
// Required FailAgent errors immediately → arms fail-fast.
|
// Required FailAgent errors immediately → arms fail-fast.
|
||||||
when(agentService.chat(eq(20L), anyString(), anyString(), any()))
|
when(agentService.chatWithUsage(eq(20L), anyString(), anyString(), any()))
|
||||||
.thenThrow(new RuntimeException("boom"));
|
.thenThrow(new RuntimeException("boom"));
|
||||||
// SlowAgent would block well past the 3 s test budget; fail-fast cancels it.
|
// SlowAgent would block well past the 3 s test budget; fail-fast cancels it.
|
||||||
when(agentService.chat(eq(21L), anyString(), anyString(), any())).thenAnswer(inv -> {
|
when(agentService.chatWithUsage(eq(21L), anyString(), anyString(), any())).thenAnswer(inv -> {
|
||||||
Thread.sleep(10_000);
|
Thread.sleep(10_000);
|
||||||
return "unreachable";
|
return ChatResult.contentOnly("unreachable");
|
||||||
});
|
});
|
||||||
|
|
||||||
ToolExecutionContext.set("parent-ff", "admin");
|
ToolExecutionContext.set("parent-ff", "admin");
|
||||||
@ -325,10 +326,10 @@ class DelegateAgentToolTest {
|
|||||||
.thenReturn(optAgent)
|
.thenReturn(optAgent)
|
||||||
.thenReturn(okAgent);
|
.thenReturn(okAgent);
|
||||||
when(streamTracker.isRunning(any())).thenReturn(false);
|
when(streamTracker.isRunning(any())).thenReturn(false);
|
||||||
when(agentService.chat(eq(30L), anyString(), anyString(), any()))
|
when(agentService.chatWithUsage(eq(30L), anyString(), anyString(), any()))
|
||||||
.thenThrow(new RuntimeException("opt boom"));
|
.thenThrow(new RuntimeException("opt boom"));
|
||||||
when(agentService.chat(eq(31L), anyString(), anyString(), any()))
|
when(agentService.chatWithUsage(eq(31L), anyString(), anyString(), any()))
|
||||||
.thenReturn("ok result done");
|
.thenReturn(ChatResult.contentOnly("ok result done"));
|
||||||
|
|
||||||
ToolExecutionContext.set("parent-opt", "admin");
|
ToolExecutionContext.set("parent-opt", "admin");
|
||||||
String json = "[{\"agentName\":\"OptAgent\",\"task\":\"a\",\"optional\":true},"
|
String json = "[{\"agentName\":\"OptAgent\",\"task\":\"a\",\"optional\":true},"
|
||||||
@ -354,9 +355,9 @@ class DelegateAgentToolTest {
|
|||||||
when(agentMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(longAgent);
|
when(agentMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(longAgent);
|
||||||
when(streamTracker.isRunning(any())).thenReturn(false);
|
when(streamTracker.isRunning(any())).thenReturn(false);
|
||||||
// Sleeps 4 s — beyond the 3 s test budget, but within the 6 s override.
|
// Sleeps 4 s — beyond the 3 s test budget, but within the 6 s override.
|
||||||
when(agentService.chat(eq(40L), anyString(), anyString(), any())).thenAnswer(inv -> {
|
when(agentService.chatWithUsage(eq(40L), anyString(), anyString(), any())).thenAnswer(inv -> {
|
||||||
Thread.sleep(4_000);
|
Thread.sleep(4_000);
|
||||||
return "long task finished ok";
|
return ChatResult.contentOnly("long task finished ok");
|
||||||
});
|
});
|
||||||
|
|
||||||
ToolExecutionContext.set("parent-to", "admin");
|
ToolExecutionContext.set("parent-to", "admin");
|
||||||
@ -368,4 +369,60 @@ class DelegateAgentToolTest {
|
|||||||
"long task should complete within the widened budget: " + result);
|
"long task should complete within the widened budget: " + result);
|
||||||
assertFalse(result.contains("timeout=1"), "should not time out with the override: " + result);
|
assertFalse(result.contains("timeout=1"), "should not time out with the override: " + result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== token usage surfacing =====
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("delegateToAgent surfaces the child's token usage in the reply")
|
||||||
|
void delegateToAgentSurfacesTokenUsage() {
|
||||||
|
AgentEntity agent = new AgentEntity();
|
||||||
|
agent.setId(50L);
|
||||||
|
agent.setName("Worker");
|
||||||
|
agent.setEnabled(true);
|
||||||
|
agent.setWorkspaceId(1L);
|
||||||
|
when(agentMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(agent);
|
||||||
|
when(streamTracker.isRunning(any())).thenReturn(false);
|
||||||
|
when(agentService.chatWithUsage(eq(50L), anyString(), anyString(), any()))
|
||||||
|
.thenReturn(new ChatResult("done with work", 120, 45, null, null));
|
||||||
|
|
||||||
|
ToolExecutionContext.set("parent-usage", "admin");
|
||||||
|
String result = delegateAgentTool.delegateToAgent("Worker", "do the thing", null, null);
|
||||||
|
|
||||||
|
assertTrue(result.contains("tokensIn=120"), "reply should surface prompt tokens: " + result);
|
||||||
|
assertTrue(result.contains("tokensOut=45"), "reply should surface completion tokens: " + result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("delegateParallel aggregates child token usage in the header and per-row lines")
|
||||||
|
void delegateParallelAggregatesTokenUsage() {
|
||||||
|
AgentEntity a = new AgentEntity();
|
||||||
|
a.setId(60L);
|
||||||
|
a.setName("AgentA");
|
||||||
|
a.setEnabled(true);
|
||||||
|
a.setWorkspaceId(1L);
|
||||||
|
AgentEntity b = new AgentEntity();
|
||||||
|
b.setId(61L);
|
||||||
|
b.setName("AgentB");
|
||||||
|
b.setEnabled(true);
|
||||||
|
b.setWorkspaceId(1L);
|
||||||
|
when(agentMapper.selectOne(any(LambdaQueryWrapper.class)))
|
||||||
|
.thenReturn(a)
|
||||||
|
.thenReturn(b);
|
||||||
|
when(streamTracker.isRunning(any())).thenReturn(false);
|
||||||
|
when(agentService.chatWithUsage(eq(60L), anyString(), anyString(), any()))
|
||||||
|
.thenReturn(new ChatResult("result A", 100, 30, null, null));
|
||||||
|
when(agentService.chatWithUsage(eq(61L), anyString(), anyString(), any()))
|
||||||
|
.thenReturn(new ChatResult("result B", 80, 20, null, null));
|
||||||
|
|
||||||
|
ToolExecutionContext.set("parent-usage-parallel", "admin");
|
||||||
|
String json = "[{\"agentName\":\"AgentA\",\"task\":\"a\"},{\"agentName\":\"AgentB\",\"task\":\"b\"}]";
|
||||||
|
String result = delegateAgentTool.delegateParallel(json, null);
|
||||||
|
|
||||||
|
// Machine header carries the batch totals (180 in, 50 out).
|
||||||
|
assertTrue(result.contains("tokensIn=180"), "header should aggregate prompt tokens: " + result);
|
||||||
|
assertTrue(result.contains("tokensOut=50"), "header should aggregate completion tokens: " + result);
|
||||||
|
// Per-row lines carry each child's own usage.
|
||||||
|
assertTrue(result.contains("tokensIn=100"), "row A should carry its prompt tokens: " + result);
|
||||||
|
assertTrue(result.contains("tokensIn=80"), "row B should carry its prompt tokens: " + result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import org.mockito.Mock;
|
|||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.mockito.Spy;
|
import org.mockito.Spy;
|
||||||
import vip.mate.agent.AgentService;
|
import vip.mate.agent.AgentService;
|
||||||
|
import vip.mate.agent.AgentService.ChatResult;
|
||||||
import vip.mate.agent.delegation.SubagentRegistry;
|
import vip.mate.agent.delegation.SubagentRegistry;
|
||||||
import vip.mate.agent.model.AgentEntity;
|
import vip.mate.agent.model.AgentEntity;
|
||||||
import vip.mate.agent.repository.AgentMapper;
|
import vip.mate.agent.repository.AgentMapper;
|
||||||
@ -108,14 +109,14 @@ class DelegateEventSequenceTest {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// During chat(), simulate the child broadcasting a tool_call_started event
|
// During chat(), simulate the child broadcasting a tool_call_started event
|
||||||
when(agentService.chat(eq(100L), eq("summarize the report"), anyString(), any()))
|
when(agentService.chatWithUsage(eq(100L), eq("summarize the report"), anyString(), any()))
|
||||||
.thenAnswer(invocation -> {
|
.thenAnswer(invocation -> {
|
||||||
// The relay listener should have been registered by now — fire it
|
// The relay listener should have been registered by now — fire it
|
||||||
BiConsumer<String, String> relay = relayRef.get();
|
BiConsumer<String, String> relay = relayRef.get();
|
||||||
assertNotNull(relay, "Relay should be registered before child chat starts");
|
assertNotNull(relay, "Relay should be registered before child chat starts");
|
||||||
relay.accept("tool_call_started", "{\"name\":\"searchWeb\"}");
|
relay.accept("tool_call_started", "{\"name\":\"searchWeb\"}");
|
||||||
relay.accept("tool_call_completed", "{\"name\":\"searchWeb\",\"success\":true}");
|
relay.accept("tool_call_completed", "{\"name\":\"searchWeb\",\"success\":true}");
|
||||||
return "The report shows growth of 15% YoY.";
|
return ChatResult.contentOnly("The report shows growth of 15% YoY.");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@ -170,8 +171,10 @@ class DelegateEventSequenceTest {
|
|||||||
when(streamTracker.addBatchedEventRelay(anyString(), anyString(), anyInt(), anyLong(), any()))
|
when(streamTracker.addBatchedEventRelay(anyString(), anyString(), anyInt(), anyLong(), any()))
|
||||||
.thenReturn(() -> {});
|
.thenReturn(() -> {});
|
||||||
|
|
||||||
when(agentService.chat(eq(101L), anyString(), anyString(), any())).thenReturn("Result A");
|
when(agentService.chatWithUsage(eq(101L), anyString(), anyString(), any()))
|
||||||
when(agentService.chat(eq(102L), anyString(), anyString(), any())).thenReturn("Result B");
|
.thenReturn(ChatResult.contentOnly("Result A"));
|
||||||
|
when(agentService.chatWithUsage(eq(102L), anyString(), anyString(), any()))
|
||||||
|
.thenReturn(ChatResult.contentOnly("Result B"));
|
||||||
|
|
||||||
String json = "[{\"agentName\":\"AgentA\",\"task\":\"task A\"},{\"agentName\":\"AgentB\",\"task\":\"task B\"}]";
|
String json = "[{\"agentName\":\"AgentA\",\"task\":\"task A\"},{\"agentName\":\"AgentB\",\"task\":\"task B\"}]";
|
||||||
|
|
||||||
@ -203,7 +206,8 @@ class DelegateEventSequenceTest {
|
|||||||
ToolExecutionContext.set("inactive-parent", "admin");
|
ToolExecutionContext.set("inactive-parent", "admin");
|
||||||
when(streamTracker.isRunning("inactive-parent")).thenReturn(false);
|
when(streamTracker.isRunning("inactive-parent")).thenReturn(false);
|
||||||
|
|
||||||
when(agentService.chat(eq(200L), anyString(), anyString(), any())).thenReturn("done");
|
when(agentService.chatWithUsage(eq(200L), anyString(), anyString(), any()))
|
||||||
|
.thenReturn(ChatResult.contentOnly("done"));
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
delegateAgentTool.delegateToAgent("QuietAgent", "quiet task", null, null);
|
delegateAgentTool.delegateToAgent("QuietAgent", "quiet task", null, null);
|
||||||
@ -235,7 +239,7 @@ class DelegateEventSequenceTest {
|
|||||||
return (Runnable) () -> {};
|
return (Runnable) () -> {};
|
||||||
});
|
});
|
||||||
|
|
||||||
when(agentService.chat(eq(300L), anyString(), anyString(), any()))
|
when(agentService.chatWithUsage(eq(300L), anyString(), anyString(), any()))
|
||||||
.thenAnswer(invocation -> {
|
.thenAnswer(invocation -> {
|
||||||
BiConsumer<String, String> relay = relayRef.get();
|
BiConsumer<String, String> relay = relayRef.get();
|
||||||
// These should produce delegation_progress:
|
// These should produce delegation_progress:
|
||||||
@ -244,7 +248,7 @@ class DelegateEventSequenceTest {
|
|||||||
// These should be ignored by the relay filter:
|
// These should be ignored by the relay filter:
|
||||||
relay.accept("heartbeat", "{}");
|
relay.accept("heartbeat", "{}");
|
||||||
relay.accept("token", "{\"text\":\"hello\"}");
|
relay.accept("token", "{\"text\":\"hello\"}");
|
||||||
return "filtered result";
|
return ChatResult.contentOnly("filtered result");
|
||||||
});
|
});
|
||||||
|
|
||||||
delegateAgentTool.delegateToAgent("FilterAgent", "filter task", null, null);
|
delegateAgentTool.delegateToAgent("FilterAgent", "filter task", null, null);
|
||||||
@ -294,18 +298,19 @@ class DelegateEventSequenceTest {
|
|||||||
// ToolExecutionContext to the Child's own conversation. Reproduce that so
|
// ToolExecutionContext to the Child's own conversation. Reproduce that so
|
||||||
// the grandchild's immediate parent resolves to childConv, while its
|
// the grandchild's immediate parent resolves to childConv, while its
|
||||||
// events must still target rootConv (carried via DelegationContext).
|
// events must still target rootConv (carried via DelegationContext).
|
||||||
when(agentService.chat(eq(100L), anyString(), anyString(), any()))
|
when(agentService.chatWithUsage(eq(100L), anyString(), anyString(), any()))
|
||||||
.thenAnswer(inv -> {
|
.thenAnswer(inv -> {
|
||||||
String childConv = inv.getArgument(2);
|
String childConv = inv.getArgument(2);
|
||||||
ToolExecutionContext.set(childConv, "admin");
|
ToolExecutionContext.set(childConv, "admin");
|
||||||
try {
|
try {
|
||||||
return delegateAgentTool.delegateToAgent("Grandchild", "gtask", null, null);
|
return ChatResult.contentOnly(
|
||||||
|
delegateAgentTool.delegateToAgent("Grandchild", "gtask", null, null));
|
||||||
} finally {
|
} finally {
|
||||||
ToolExecutionContext.set(rootConv, "admin");
|
ToolExecutionContext.set(rootConv, "admin");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
when(agentService.chat(eq(200L), anyString(), anyString(), any()))
|
when(agentService.chatWithUsage(eq(200L), anyString(), anyString(), any()))
|
||||||
.thenReturn("grandchild done");
|
.thenReturn(ChatResult.contentOnly("grandchild done"));
|
||||||
|
|
||||||
delegateAgentTool.delegateToAgent("Child", "ctask", null, null);
|
delegateAgentTool.delegateToAgent("Child", "ctask", null, null);
|
||||||
|
|
||||||
|
|||||||
@ -42,6 +42,13 @@ const progress = computed(() => {
|
|||||||
return n ? `${n} ${n === 1 ? 'tool' : 'tools'}` : ''
|
return n ? `${n} ${n === 1 ? 'tool' : 'tools'}` : ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Compact token cost, shown once the subagent finishes (e.g. "3.2k tok").
|
||||||
|
const tokenLabel = computed(() => {
|
||||||
|
const t = (props.node.promptTokens || 0) + (props.node.completionTokens || 0)
|
||||||
|
if (!t) return ''
|
||||||
|
return `${t >= 1000 ? (t / 1000).toFixed(1) + 'k' : t} tok`
|
||||||
|
})
|
||||||
|
|
||||||
function stepStatus(i: number): 'pending' | 'running' | 'completed' {
|
function stepStatus(i: number): 'pending' | 'running' | 'completed' {
|
||||||
const p = plan.value
|
const p = plan.value
|
||||||
if (!p) return 'pending'
|
if (!p) return 'pending'
|
||||||
@ -63,6 +70,7 @@ function stepStatus(i: number): 'pending' | 'running' | 'completed' {
|
|||||||
<el-icon class="deleg-node__icon" :size="11"><Connection /></el-icon>
|
<el-icon class="deleg-node__icon" :size="11"><Connection /></el-icon>
|
||||||
<span class="deleg-node__name">{{ node.agentName }}</span>
|
<span class="deleg-node__name">{{ node.agentName }}</span>
|
||||||
<span v-if="progress" class="deleg-node__badge">{{ progress }}</span>
|
<span v-if="progress" class="deleg-node__badge">{{ progress }}</span>
|
||||||
|
<span v-if="tokenLabel" class="deleg-node__badge deleg-node__badge--token">{{ tokenLabel }}</span>
|
||||||
<el-icon v-if="isStalled" class="deleg-node__stale" :title="$t('chat.subagentStalled')" :size="11"><WarningFilled /></el-icon>
|
<el-icon v-if="isStalled" class="deleg-node__stale" :title="$t('chat.subagentStalled')" :size="11"><WarningFilled /></el-icon>
|
||||||
<el-icon
|
<el-icon
|
||||||
v-if="hasBody"
|
v-if="hasBody"
|
||||||
@ -157,6 +165,10 @@ function stepStatus(i: number): 'pending' | 'running' | 'completed' {
|
|||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
line-height: 16px;
|
line-height: 16px;
|
||||||
}
|
}
|
||||||
|
.deleg-node__badge--token {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
.deleg-node__stale {
|
.deleg-node__stale {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: var(--mc-warning, #e6a23c);
|
color: var(--mc-warning, #e6a23c);
|
||||||
|
|||||||
@ -1072,14 +1072,25 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Mark a subagent (segment or nested node) complete by subagentId. */
|
/** Mark a subagent (segment or nested node) complete by subagentId. */
|
||||||
|
// Compact "(12s · 3.2k tok)" meta suffix for a completed delegation segment.
|
||||||
|
function delegMetaSuffix(durationMs?: number, promptTokens?: number, completionTokens?: number): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (durationMs) parts.push(`${Math.round(durationMs / 1000)}s`)
|
||||||
|
const tok = (promptTokens || 0) + (completionTokens || 0)
|
||||||
|
if (tok > 0) parts.push(`${tok >= 1000 ? (tok / 1000).toFixed(1) + 'k' : tok} tok`)
|
||||||
|
return parts.length ? ` (${parts.join(' · ')})` : ''
|
||||||
|
}
|
||||||
|
|
||||||
function markDelegComplete(segs: MessageSegment[], subagentId: string | undefined, childConvId: string | undefined,
|
function markDelegComplete(segs: MessageSegment[], subagentId: string | undefined, childConvId: string | undefined,
|
||||||
success: boolean, resultPreview?: string, durationMs?: number): boolean {
|
success: boolean, resultPreview?: string, durationMs?: number,
|
||||||
|
promptTokens?: number, completionTokens?: number): boolean {
|
||||||
const seg = findDelegSegment(segs, subagentId, childConvId)
|
const seg = findDelegSegment(segs, subagentId, childConvId)
|
||||||
if (seg) {
|
if (seg) {
|
||||||
seg.status = success ? 'completed' : 'error'
|
seg.status = success ? 'completed' : 'error'
|
||||||
seg.toolSuccess = success
|
seg.toolSuccess = success
|
||||||
if (resultPreview) seg.toolResult = resultPreview
|
if (resultPreview) seg.toolResult = resultPreview
|
||||||
if (durationMs) seg.toolArgs = (seg.toolArgs || '').trimEnd() + ` (${Math.round(durationMs / 1000)}s)`
|
const suffix = delegMetaSuffix(durationMs, promptTokens, completionTokens)
|
||||||
|
if (suffix) seg.toolArgs = (seg.toolArgs || '').trimEnd() + suffix
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if (subagentId) {
|
if (subagentId) {
|
||||||
@ -1089,6 +1100,8 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
node.status = success ? 'completed' : 'error'
|
node.status = success ? 'completed' : 'error'
|
||||||
if (resultPreview) node.result = resultPreview
|
if (resultPreview) node.result = resultPreview
|
||||||
if (durationMs) node.durationMs = durationMs
|
if (durationMs) node.durationMs = durationMs
|
||||||
|
if (promptTokens) node.promptTokens = promptTokens
|
||||||
|
if (completionTokens) node.completionTokens = completionTokens
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1232,7 +1245,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
if (isStaleEvent(data)) return
|
if (isStaleEvent(data)) return
|
||||||
if (!currentAssistantId.value) return
|
if (!currentAssistantId.value) return
|
||||||
markDelegComplete(currentSegments.value, data.subagentId, data.childConversationId,
|
markDelegComplete(currentSegments.value, data.subagentId, data.childConversationId,
|
||||||
!!data.success, data.resultPreview, data.durationMs)
|
!!data.success, data.resultPreview, data.durationMs, data.promptTokens, data.completionTokens)
|
||||||
flushSegmentsToMessage()
|
flushSegmentsToMessage()
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -1250,7 +1263,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
: !!(cr.subagentId && findNode(segs.flatMap(s => s.childTimeline?.children || []), cr.subagentId)?.status === 'running')
|
: !!(cr.subagentId && findNode(segs.flatMap(s => s.childTimeline?.children || []), cr.subagentId)?.status === 'running')
|
||||||
if (stillRunning) {
|
if (stillRunning) {
|
||||||
markDelegComplete(segs, cr.subagentId, cr.childConversationId, !!cr.success,
|
markDelegComplete(segs, cr.subagentId, cr.childConversationId, !!cr.success,
|
||||||
cr.error || undefined, cr.durationMs)
|
cr.error || undefined, cr.durationMs, cr.promptTokens, cr.completionTokens)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -1261,7 +1274,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
markDelegComplete(segs, data.subagentId, data.childConversationId,
|
markDelegComplete(segs, data.subagentId, data.childConversationId,
|
||||||
!!data.success, data.resultPreview, data.durationMs)
|
!!data.success, data.resultPreview, data.durationMs, data.promptTokens, data.completionTokens)
|
||||||
}
|
}
|
||||||
flushSegmentsToMessage()
|
flushSegmentsToMessage()
|
||||||
})
|
})
|
||||||
|
|||||||
@ -171,6 +171,9 @@ export interface DelegationNode {
|
|||||||
tools?: DelegationToolEntry[]
|
tools?: DelegationToolEntry[]
|
||||||
result?: string
|
result?: string
|
||||||
durationMs?: number
|
durationMs?: number
|
||||||
|
/** Child token usage relayed from delegation_end / delegation_child_complete. */
|
||||||
|
promptTokens?: number
|
||||||
|
completionTokens?: number
|
||||||
/** Heartbeat watchdog flagged this subagent as making no observable progress. */
|
/** Heartbeat watchdog flagged this subagent as making no observable progress. */
|
||||||
stale?: boolean
|
stale?: boolean
|
||||||
/** Spawned via fire-and-forget delegation: runs detached, result via task_output. */
|
/** Spawned via fire-and-forget delegation: runs detached, result via task_output. */
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user