mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(agent,ui): multi-level subagent delegation tree
This commit is contained in:
parent
8bd8a02cd0
commit
5f571e86a2
@ -131,11 +131,15 @@ public class SubagentController {
|
||||
}
|
||||
|
||||
/**
|
||||
* List the sub-agents currently active under {@code parentConversationId}.
|
||||
* The query parameter is mandatory: returning all subagents process-wide
|
||||
* would let any logged-in user enumerate other tenants' delegation trees.
|
||||
* List the sub-agents currently active in the delegation tree rooted at
|
||||
* {@code parentConversationId} — the user-facing conversation. Returns the
|
||||
* whole tree (direct children plus deeper descendants), so a multi-level
|
||||
* delegation is fully visible. The query parameter is mandatory: returning
|
||||
* all subagents process-wide would let any logged-in user enumerate other
|
||||
* tenants' delegation trees. Tenant isolation is enforced on this root
|
||||
* conversation, which the caller owns.
|
||||
*/
|
||||
@Operation(summary = "List active sub-agents under a parent conversation")
|
||||
@Operation(summary = "List active sub-agents in a conversation's delegation tree")
|
||||
@GetMapping("/active")
|
||||
@RequireGlobalAdmin
|
||||
public R<Map<String, Object>> listActive(@RequestParam(required = false) String parentConversationId,
|
||||
@ -147,7 +151,7 @@ public class SubagentController {
|
||||
if (!conversationService.isConversationOwner(parentConversationId, username)) {
|
||||
throw new MateClawException(403, "not the owner of conversation " + parentConversationId);
|
||||
}
|
||||
List<Map<String, Object>> snapshot = registry.snapshot(parentConversationId).stream()
|
||||
List<Map<String, Object>> snapshot = registry.snapshotTree(parentConversationId).stream()
|
||||
.map(this::toResponseDto)
|
||||
.toList();
|
||||
return R.ok(Map.of("subagents", snapshot));
|
||||
@ -167,6 +171,8 @@ public class SubagentController {
|
||||
dto.put("subagentId", rec.subagentId());
|
||||
dto.put("parentConversationId", rec.parentConversationId());
|
||||
dto.put("childConversationId", rec.childConversationId());
|
||||
dto.put("parentSubagentId", rec.parentSubagentId());
|
||||
dto.put("depth", rec.depth());
|
||||
dto.put("agentId", rec.agentId());
|
||||
dto.put("goal", rec.goal());
|
||||
dto.put("startedAt", rec.startedAt());
|
||||
|
||||
@ -89,10 +89,16 @@ public class SubagentHeartbeat {
|
||||
if (rec.status().compareAndSet("running", "stale")) {
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("subagentId", rec.subagentId());
|
||||
payload.put("parentSubagentId", rec.parentSubagentId());
|
||||
payload.put("depth", rec.depth());
|
||||
payload.put("cycles", sc);
|
||||
payload.put("lastTool", currentTool != null ? currentTool : "");
|
||||
payload.put("elapsedMs", System.currentTimeMillis() - rec.startedAt());
|
||||
streamTracker.broadcastObject(rec.parentConversationId(), "subagent_stale", payload);
|
||||
// Broadcast to the root (human-facing) conversation so the event
|
||||
// reaches the stream the user is watching at any tree depth.
|
||||
String target = rec.rootConversationId() != null
|
||||
? rec.rootConversationId() : rec.parentConversationId();
|
||||
streamTracker.broadcastObject(target, "subagent_stale", payload);
|
||||
log.info("[SubagentHeartbeat] subagent {} marked stale after {} idle cycles (limit={})",
|
||||
rec.subagentId(), sc, limit);
|
||||
}
|
||||
|
||||
@ -53,7 +53,14 @@ public class SubagentRegistry {
|
||||
AtomicReference<String> lastSeenTool,
|
||||
AtomicInteger staleCount,
|
||||
AtomicLong firstApiCallAt,
|
||||
Disposable disposable
|
||||
Disposable disposable,
|
||||
// Tree identity: parentSubagentId is null for first-level children
|
||||
// (spawned by the root agent); depth is 1 for first-level, 2 for a
|
||||
// grandchild, etc. rootConversationId is the human-facing stream the
|
||||
// whole tree reports into, used for UI-facing broadcasts at any depth.
|
||||
String parentSubagentId,
|
||||
int depth,
|
||||
String rootConversationId
|
||||
) {}
|
||||
|
||||
private final ConcurrentMap<String, SubagentRecord> active = new ConcurrentHashMap<>();
|
||||
@ -77,6 +84,16 @@ public class SubagentRegistry {
|
||||
* children spawn within the same millisecond.
|
||||
*/
|
||||
public String register(String parentConvId, String childConvId, Long agentId, String goal, Disposable d) {
|
||||
return register(parentConvId, childConvId, agentId, goal, d, null, 1, parentConvId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a sub-agent with full tree identity. {@code parentSubagentId} is
|
||||
* null for first-level children; {@code depth} is 1-based; {@code rootConvId}
|
||||
* is the human-facing conversation the whole tree reports into.
|
||||
*/
|
||||
public String register(String parentConvId, String childConvId, Long agentId, String goal,
|
||||
Disposable d, String parentSubagentId, int depth, String rootConvId) {
|
||||
String sid = "sa-" + System.currentTimeMillis() + "-" + nextHexSuffix();
|
||||
active.put(sid, new SubagentRecord(
|
||||
sid,
|
||||
@ -93,7 +110,10 @@ public class SubagentRegistry {
|
||||
new AtomicReference<>(null),
|
||||
new AtomicInteger(0),
|
||||
new AtomicLong(0),
|
||||
d));
|
||||
d,
|
||||
parentSubagentId,
|
||||
depth,
|
||||
rootConvId != null ? rootConvId : parentConvId));
|
||||
return sid;
|
||||
}
|
||||
|
||||
@ -120,9 +140,13 @@ public class SubagentRegistry {
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of all sub-agents whose parent matches {@code parentConvId}.
|
||||
* Filtering at the registry boundary prevents callers from accidentally
|
||||
* surfacing other tenants' subagents in API responses.
|
||||
* Snapshot of all sub-agents whose <em>immediate</em> parent matches
|
||||
* {@code parentConvId}. Filtering at the registry boundary prevents callers
|
||||
* from accidentally surfacing other tenants' subagents in API responses.
|
||||
*
|
||||
* <p>Note: this returns only direct children. To list a whole delegation
|
||||
* tree (including grandchildren whose immediate parent is a child
|
||||
* conversation), use {@link #snapshotTree(String)}.
|
||||
*/
|
||||
public List<SubagentRecord> snapshot(String parentConvId) {
|
||||
if (parentConvId == null) return List.of();
|
||||
@ -131,6 +155,20 @@ public class SubagentRegistry {
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of the entire delegation tree rooted at {@code rootConvId} — the
|
||||
* human-facing conversation. Every sub-agent at any depth carries the same
|
||||
* {@code rootConversationId}, so this returns direct children and all deeper
|
||||
* descendants. Tenant isolation must be enforced on {@code rootConvId} by
|
||||
* the caller (it is the conversation the user owns).
|
||||
*/
|
||||
public List<SubagentRecord> snapshotTree(String rootConvId) {
|
||||
if (rootConvId == null) return List.of();
|
||||
return active.values().stream()
|
||||
.filter(r -> rootConvId.equals(r.rootConversationId()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
public void unregister(String subagentId) {
|
||||
if (subagentId == null) return;
|
||||
active.remove(subagentId);
|
||||
|
||||
@ -84,6 +84,9 @@ public class AgentRuntimeAggregator {
|
||||
String subagentId,
|
||||
String parentConversationId,
|
||||
String childConversationId,
|
||||
String rootConversationId,
|
||||
String parentSubagentId,
|
||||
int depth,
|
||||
Long agentId,
|
||||
String agentName,
|
||||
String agentIcon,
|
||||
@ -186,6 +189,9 @@ public class AgentRuntimeAggregator {
|
||||
rec.subagentId(),
|
||||
rec.parentConversationId(),
|
||||
rec.childConversationId(),
|
||||
rec.rootConversationId(),
|
||||
rec.parentSubagentId(),
|
||||
rec.depth(),
|
||||
rec.agentId(),
|
||||
ag == null ? null : ag.getName(),
|
||||
ag == null ? null : ag.getIcon(),
|
||||
|
||||
@ -237,11 +237,19 @@ public class DelegateAgentTool {
|
||||
}
|
||||
|
||||
String parentConversationId = resolveParentConversationId();
|
||||
// Root (human-facing) conversation at the top of the delegation tree.
|
||||
// At depth 0 the immediate parent IS the root; deeper layers carry it
|
||||
// forward via DelegationContext so events reach the stream the user sees.
|
||||
String rootConversationId = DelegationContext.rootConversationId();
|
||||
if (rootConversationId == null) rootConversationId = parentConversationId;
|
||||
String parentSubagentId = DelegationContext.currentSubagentId();
|
||||
int childDepth = depth + 1;
|
||||
|
||||
// Spawn-pause: when the operator paused this conversation's tree
|
||||
// (via /api/v1/subagents/spawn-pause), short-circuit before creating
|
||||
// child state so no conversation rows / relays / registry entries leak.
|
||||
if (parentConversationId != null && subagentRegistry.isSpawnPaused(parentConversationId)) {
|
||||
// Spawn-pause: short-circuit before creating child state when either the
|
||||
// immediate parent or the root tree is paused, so no conversation rows /
|
||||
// relays / registry entries leak.
|
||||
if ((parentConversationId != null && subagentRegistry.isSpawnPaused(parentConversationId))
|
||||
|| (rootConversationId != null && subagentRegistry.isSpawnPaused(rootConversationId))) {
|
||||
return "[错误] Spawning paused for this conversation; resume via /api/v1/subagents/spawn-pause";
|
||||
}
|
||||
|
||||
@ -263,24 +271,28 @@ public class DelegateAgentTool {
|
||||
log.info("Agent delegation: depth={}, target={}({}), childConv={}, parentConv={}",
|
||||
depth + 1, target.getName(), target.getId(), childConversationId, parentConversationId);
|
||||
|
||||
// Broadcast delegation_start + register event relay to parent session
|
||||
boolean hasParent = parentConversationId != null && streamTracker.isRunning(parentConversationId);
|
||||
if (hasParent) {
|
||||
streamTracker.broadcastObject(parentConversationId, "delegation_start", Map.of(
|
||||
"childConversationId", childConversationId,
|
||||
"childAgentName", target.getName(),
|
||||
"task", truncate(task, 200)));
|
||||
}
|
||||
Runnable stopRelay = hasParent ? registerBatchedRelay(childConversationId, parentConversationId, target.getName()) : null;
|
||||
|
||||
// Register the live sub-agent so the operator UI / heartbeat watchdog
|
||||
// can observe it. Disposable is null in the synchronous single-task
|
||||
// path because the executor blocks on AgentService#chat directly —
|
||||
// there is no Flux subscription to dispose. Interrupts in this path
|
||||
// are best-effort (status flip; no underlying cancel).
|
||||
// Register the live sub-agent first so its stable id rides on every
|
||||
// event. Disposable is null in the synchronous single-task path because
|
||||
// the executor blocks on AgentService#chat directly — there is no Flux
|
||||
// subscription to dispose. Interrupts here are best-effort (status flip).
|
||||
String subagentId = parentConversationId != null
|
||||
? subagentRegistry.register(parentConversationId, childConversationId,
|
||||
target.getId(), task, null)
|
||||
target.getId(), task, null, parentSubagentId, childDepth, rootConversationId)
|
||||
: null;
|
||||
|
||||
// Broadcast to the ROOT conversation (not the immediate parent) so a
|
||||
// grandchild's progress reaches the stream the user is watching. Every
|
||||
// event carries subagentId/parentSubagentId/depth for tree rebuild.
|
||||
boolean hasRoot = rootConversationId != null && streamTracker.isRunning(rootConversationId);
|
||||
if (hasRoot) {
|
||||
Map<String, Object> startEvent = delegationPayload(subagentId, parentSubagentId, childDepth,
|
||||
childConversationId, target.getName());
|
||||
startEvent.put("task", truncate(task, 200));
|
||||
streamTracker.broadcastObject(rootConversationId, "delegation_start", startEvent);
|
||||
}
|
||||
Runnable stopRelay = hasRoot
|
||||
? registerBatchedRelay(childConversationId, rootConversationId, target.getName(),
|
||||
subagentId, parentSubagentId, childDepth)
|
||||
: null;
|
||||
|
||||
// Execute child agent — RFC-063r §2.5 改动点 5: inherit the parent
|
||||
@ -289,7 +301,8 @@ public class DelegateAgentTool {
|
||||
ChatOrigin parentOrigin = ChatOrigin.from(ctx);
|
||||
ChildResult result;
|
||||
try {
|
||||
result = runSingleChild(0, target, taskWithContext, parentConversationId, childConversationId, parentOrigin);
|
||||
result = runSingleChild(0, target, taskWithContext, parentConversationId, childConversationId,
|
||||
parentOrigin, rootConversationId, subagentId);
|
||||
} finally {
|
||||
// Cleanup relay + registry regardless of how the child returned
|
||||
// (success / exception / interruption) so we never leak entries.
|
||||
@ -303,8 +316,9 @@ public class DelegateAgentTool {
|
||||
subagentRegistry.unregister(subagentId);
|
||||
}
|
||||
}
|
||||
if (hasParent) {
|
||||
broadcastEnd(parentConversationId, childConversationId, target.getName(), result);
|
||||
if (hasRoot) {
|
||||
broadcastEnd(rootConversationId, childConversationId, target.getName(), result,
|
||||
subagentId, parentSubagentId, childDepth);
|
||||
}
|
||||
|
||||
return result.toToolResponse(target.getName());
|
||||
@ -345,15 +359,21 @@ public class DelegateAgentTool {
|
||||
}
|
||||
|
||||
String parentConversationId = resolveParentConversationId();
|
||||
String rootConversationId = DelegationContext.rootConversationId();
|
||||
if (rootConversationId == null) rootConversationId = parentConversationId;
|
||||
final String rootConvFinal = rootConversationId;
|
||||
final String parentSubagentId = DelegationContext.currentSubagentId();
|
||||
final int childDepth = depth + 1;
|
||||
|
||||
// Spawn-pause: short-circuit before allocating any per-child state so
|
||||
// we don't leak conversation rows / relays / registry entries when an
|
||||
// operator paused this conversation's tree.
|
||||
if (parentConversationId != null && subagentRegistry.isSpawnPaused(parentConversationId)) {
|
||||
// operator paused this conversation's tree (immediate parent or root).
|
||||
if ((parentConversationId != null && subagentRegistry.isSpawnPaused(parentConversationId))
|
||||
|| (rootConvFinal != null && subagentRegistry.isSpawnPaused(rootConvFinal))) {
|
||||
return "[错误] Spawning paused for this conversation; resume via /api/v1/subagents/spawn-pause";
|
||||
}
|
||||
|
||||
boolean hasParent = parentConversationId != null && streamTracker.isRunning(parentConversationId);
|
||||
boolean hasRoot = rootConvFinal != null && streamTracker.isRunning(rootConvFinal);
|
||||
|
||||
// 2. Main thread: validate agents, create child conversations, register relays
|
||||
record PreparedChild(int index, AgentEntity agent, String task, String childConvId,
|
||||
@ -378,12 +398,13 @@ public class DelegateAgentTool {
|
||||
}
|
||||
|
||||
String childConvId = createChildConv(agent, parentConversationId);
|
||||
Runnable stopRelay = hasParent
|
||||
? registerBatchedRelay(childConvId, parentConversationId, agent.getName())
|
||||
: null;
|
||||
String subagentId = parentConversationId != null
|
||||
? subagentRegistry.register(parentConversationId, childConvId,
|
||||
agent.getId(), task, null)
|
||||
agent.getId(), task, null, parentSubagentId, childDepth, rootConvFinal)
|
||||
: null;
|
||||
Runnable stopRelay = hasRoot
|
||||
? registerBatchedRelay(childConvId, rootConvFinal, agent.getName(),
|
||||
subagentId, parentSubagentId, childDepth)
|
||||
: null;
|
||||
prepared.add(new PreparedChild(i, agent, task, childConvId, stopRelay, subagentId));
|
||||
}
|
||||
@ -394,14 +415,15 @@ public class DelegateAgentTool {
|
||||
|
||||
log.info("Parallel delegation: {} tasks, parentConv={}", prepared.size(), parentConversationId);
|
||||
|
||||
// 3. Broadcast delegation_start (parallel mode)
|
||||
if (hasParent) {
|
||||
List<Map<String, String>> childrenInfo = prepared.stream().map(p -> Map.of(
|
||||
"childConversationId", p.childConvId,
|
||||
"childAgentName", p.agent.getName(),
|
||||
"task", truncate(p.task, 100)
|
||||
)).toList();
|
||||
streamTracker.broadcastObject(parentConversationId, "delegation_start", Map.of(
|
||||
// 3. Broadcast delegation_start (parallel mode) to the root conversation
|
||||
if (hasRoot) {
|
||||
List<Map<String, Object>> childrenInfo = prepared.stream().map(p -> {
|
||||
Map<String, Object> m = delegationPayload(p.subagentId, parentSubagentId, childDepth,
|
||||
p.childConvId, p.agent.getName());
|
||||
m.put("task", truncate(p.task, 100));
|
||||
return m;
|
||||
}).toList();
|
||||
streamTracker.broadcastObject(rootConvFinal, "delegation_start", Map.of(
|
||||
"parallel", true,
|
||||
"children", childrenInfo));
|
||||
}
|
||||
@ -417,7 +439,8 @@ public class DelegateAgentTool {
|
||||
ChatOrigin parentOriginParallel = ChatOrigin.from(ctx);
|
||||
for (PreparedChild p : prepared) {
|
||||
CompletableFuture<ChildResult> future = CompletableFuture.supplyAsync(
|
||||
() -> runSingleChild(p.index, p.agent, p.task, parentConversationId, p.childConvId, parentOriginParallel),
|
||||
() -> runSingleChild(p.index, p.agent, p.task, parentConversationId, p.childConvId,
|
||||
parentOriginParallel, rootConvFinal, p.subagentId),
|
||||
DELEGATION_EXECUTOR);
|
||||
|
||||
// Broadcast per-child completion as soon as each child finishes
|
||||
@ -426,18 +449,16 @@ public class DelegateAgentTool {
|
||||
// because the timeout result is already handled in the collection loop below and
|
||||
// emitting here first would race-replace the correct "timeout" error before delegation_end
|
||||
// has a chance to patch remaining running segments.
|
||||
if (hasParent) {
|
||||
final String parentConvIdFinal = parentConversationId;
|
||||
if (hasRoot) {
|
||||
future.whenComplete((result, ex) -> {
|
||||
if (ex instanceof java.util.concurrent.CancellationException) return;
|
||||
if (!streamTracker.isRunning(parentConvIdFinal)) return;
|
||||
if (!streamTracker.isRunning(rootConvFinal)) return;
|
||||
ChildResult r = (result != null) ? result
|
||||
: ChildResult.ofError(p.index, p.agent.getName(),
|
||||
ex != null ? ex.getMessage() : "Unknown error");
|
||||
Map<String, Object> payload = new java.util.LinkedHashMap<>();
|
||||
Map<String, Object> payload = delegationPayload(p.subagentId, parentSubagentId, childDepth,
|
||||
p.childConvId, r.agentName);
|
||||
payload.put("taskIndex", r.taskIndex);
|
||||
payload.put("childConversationId", p.childConvId);
|
||||
payload.put("childAgentName", r.agentName);
|
||||
payload.put("success", r.success);
|
||||
payload.put("outcome", r.outcome);
|
||||
payload.put("rawLength", r.rawLength);
|
||||
@ -447,7 +468,7 @@ public class DelegateAgentTool {
|
||||
payload.put("resultPreview", r.success
|
||||
? truncate(r.result, 400)
|
||||
: (r.error != null ? r.error : "error"));
|
||||
streamTracker.broadcastObject(parentConvIdFinal, "delegation_child_complete", payload);
|
||||
streamTracker.broadcastObject(rootConvFinal, "delegation_child_complete", payload);
|
||||
});
|
||||
}
|
||||
|
||||
@ -512,7 +533,7 @@ public class DelegateAgentTool {
|
||||
}
|
||||
|
||||
// 7. Broadcast delegation_end with per-child structured summary
|
||||
if (hasParent) {
|
||||
if (hasRoot) {
|
||||
List<Map<String, Object>> childResults = results.stream().map(r -> {
|
||||
Map<String, Object> m = new java.util.LinkedHashMap<>();
|
||||
m.put("taskIndex", r.taskIndex);
|
||||
@ -523,15 +544,18 @@ public class DelegateAgentTool {
|
||||
m.put("trimmedLength", r.trimmedLength);
|
||||
m.put("blank", r.isBlank());
|
||||
m.put("durationMs", r.durationMs);
|
||||
// childConversationId for stable frontend segment lookup
|
||||
// childConversationId + subagentId for stable frontend tree lookup
|
||||
prepared.stream()
|
||||
.filter(p -> p.index == r.taskIndex)
|
||||
.findFirst()
|
||||
.ifPresent(p -> m.put("childConversationId", p.childConvId));
|
||||
.ifPresent(p -> {
|
||||
m.put("childConversationId", p.childConvId);
|
||||
if (p.subagentId != null) m.put("subagentId", p.subagentId);
|
||||
});
|
||||
if (!r.success && r.error != null) m.put("error", r.error);
|
||||
return m;
|
||||
}).toList();
|
||||
streamTracker.broadcastObject(parentConversationId, "delegation_end", Map.of(
|
||||
streamTracker.broadcastObject(rootConvFinal, "delegation_end", Map.of(
|
||||
"parallel", true,
|
||||
"totalDurationMs", totalDurationMs,
|
||||
"success", results.stream().allMatch(r -> r.success),
|
||||
@ -641,7 +665,12 @@ public class DelegateAgentTool {
|
||||
if (parentConversationId == null || parentConversationId.isBlank()) {
|
||||
return errorJson("delegateAsync requires a parent conversation context");
|
||||
}
|
||||
if (subagentRegistry.isSpawnPaused(parentConversationId)) {
|
||||
String rootConversationId = DelegationContext.rootConversationId();
|
||||
if (rootConversationId == null) rootConversationId = parentConversationId;
|
||||
String parentSubagentId = DelegationContext.currentSubagentId();
|
||||
int childDepth = depth + 1;
|
||||
if (subagentRegistry.isSpawnPaused(parentConversationId)
|
||||
|| subagentRegistry.isSpawnPaused(rootConversationId)) {
|
||||
return errorJson("Spawning paused for this conversation; resume via /api/v1/subagents/spawn-pause");
|
||||
}
|
||||
|
||||
@ -657,25 +686,31 @@ public class DelegateAgentTool {
|
||||
|
||||
String childConversationId = createChildConv(target, parentConversationId);
|
||||
|
||||
// Register first so the subagentId + tree identity can be persisted into
|
||||
// the task payload; the registry is process-local, but the request_json
|
||||
// is the durable record that task_output authorizes against.
|
||||
String subagentId = subagentRegistry.register(parentConversationId, childConversationId,
|
||||
target.getId(), task, null, parentSubagentId, childDepth, rootConversationId);
|
||||
final String rootConvAsync = rootConversationId;
|
||||
|
||||
String requestJson;
|
||||
try {
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("parentConversationId", parentConversationId);
|
||||
payload.put("rootConversationId", rootConversationId);
|
||||
payload.put("childConversationId", childConversationId);
|
||||
payload.put("childAgentId", target.getId());
|
||||
payload.put("subagentId", subagentId);
|
||||
if (parentSubagentId != null) payload.put("parentSubagentId", parentSubagentId);
|
||||
payload.put("depth", childDepth);
|
||||
payload.put("task", truncate(task, ASYNC_TASK_REQUEST_MAX_CHARS));
|
||||
payload.put("label", safeLabel);
|
||||
requestJson = objectMapper.writeValueAsString(payload);
|
||||
} catch (Exception e) {
|
||||
subagentRegistry.unregister(subagentId);
|
||||
return errorJson("Failed to serialize task payload: " + e.getMessage());
|
||||
}
|
||||
|
||||
// Live observability handle — task_output never reads from it; the
|
||||
// persistent mate_async_task row is the source of truth for status,
|
||||
// result, and attribution.
|
||||
String subagentId = subagentRegistry.register(parentConversationId, childConversationId,
|
||||
target.getId(), task, null);
|
||||
|
||||
AsyncTaskEntity entity;
|
||||
try {
|
||||
entity = asyncTaskService.submitOneShot(
|
||||
@ -687,7 +722,8 @@ public class DelegateAgentTool {
|
||||
() -> {
|
||||
try {
|
||||
ChildResult childResult = runSingleChild(0, target, task,
|
||||
parentConversationId, childConversationId, parentOrigin);
|
||||
parentConversationId, childConversationId, parentOrigin,
|
||||
rootConvAsync, subagentId);
|
||||
return childResult.toToolResponse(target.getName());
|
||||
} finally {
|
||||
subagentRegistry.get(subagentId).ifPresent(rec -> {
|
||||
@ -713,14 +749,13 @@ public class DelegateAgentTool {
|
||||
entity.getTaskId(), target.getName(), target.getId(),
|
||||
childConversationId, parentConversationId);
|
||||
|
||||
if (streamTracker.isRunning(parentConversationId)) {
|
||||
Map<String, Object> spawnEvent = new LinkedHashMap<>();
|
||||
if (streamTracker.isRunning(rootConvAsync)) {
|
||||
Map<String, Object> spawnEvent = delegationPayload(subagentId, parentSubagentId, childDepth,
|
||||
childConversationId, target.getName());
|
||||
spawnEvent.put("taskId", entity.getTaskId());
|
||||
spawnEvent.put("childConversationId", childConversationId);
|
||||
spawnEvent.put("childAgentName", target.getName());
|
||||
spawnEvent.put("label", safeLabel);
|
||||
spawnEvent.put("task", truncate(task, 200));
|
||||
streamTracker.broadcastObject(parentConversationId, "delegation_async_spawned", spawnEvent);
|
||||
streamTracker.broadcastObject(rootConvAsync, "delegation_async_spawned", spawnEvent);
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
@ -781,11 +816,13 @@ public class DelegateAgentTool {
|
||||
// a follow-up that surfaces a stable per-channel / per-cron caller
|
||||
// identity into ChatOrigin.requesterId would close this gap.
|
||||
String taskParentConv;
|
||||
String taskRootConv;
|
||||
try {
|
||||
JsonNode req = entity.getRequestJson() == null
|
||||
? null
|
||||
: objectMapper.readTree(entity.getRequestJson());
|
||||
taskParentConv = req == null ? "" : req.path("parentConversationId").asText("");
|
||||
taskRootConv = req == null ? "" : req.path("rootConversationId").asText("");
|
||||
} catch (Exception e) {
|
||||
return errorJson("Failed to parse task payload: " + e.getMessage());
|
||||
}
|
||||
@ -793,9 +830,15 @@ public class DelegateAgentTool {
|
||||
ChatOrigin origin = ChatOrigin.from(ctx);
|
||||
String currentUser = origin != null ? origin.requesterId() : null;
|
||||
|
||||
if (taskParentConv.isEmpty()
|
||||
|| currentParentConv == null
|
||||
|| !taskParentConv.equals(currentParentConv)) {
|
||||
// Authorize the caller against EITHER the immediate spawn conversation or
|
||||
// the root of its delegation tree. The latter lets a root agent poll a
|
||||
// task that one of its (sub)children spawned: the child stamped its own
|
||||
// conversation as parentConversationId, but rootConversationId points
|
||||
// back at the user-facing conversation the root agent runs in.
|
||||
boolean convOk = currentParentConv != null
|
||||
&& ((!taskParentConv.isEmpty() && taskParentConv.equals(currentParentConv))
|
||||
|| (!taskRootConv.isEmpty() && taskRootConv.equals(currentParentConv)));
|
||||
if (!convOk) {
|
||||
return errorJson("Forbidden: task does not belong to current conversation");
|
||||
}
|
||||
if (entity.getCreatedBy() == null || currentUser == null
|
||||
@ -889,13 +932,17 @@ public class DelegateAgentTool {
|
||||
*/
|
||||
private ChildResult runSingleChild(int taskIndex, AgentEntity target, String task,
|
||||
String parentConversationId, String childConversationId,
|
||||
ChatOrigin parentOrigin) {
|
||||
ChatOrigin parentOrigin,
|
||||
String rootConversationId, String subagentId) {
|
||||
boolean relayChildEvents = parentConversationId != null && streamTracker.isRunning(parentConversationId);
|
||||
if (relayChildEvents) {
|
||||
streamTracker.register(childConversationId);
|
||||
streamTracker.incrementFlux(childConversationId);
|
||||
}
|
||||
DelegationContext.enter(parentConversationId, deniedToolsForChild());
|
||||
// Carry root conversation + this child's subagentId into the context so
|
||||
// a grandchild broadcasts to the root stream and tags this as its parent.
|
||||
DelegationContext.enter(parentConversationId, deniedToolsForChild(),
|
||||
rootConversationId, subagentId);
|
||||
try {
|
||||
long startTime = System.currentTimeMillis();
|
||||
// RFC-063r §2.5 改动点 5: inherit parent origin, swap agentId
|
||||
@ -1117,67 +1164,62 @@ public class DelegateAgentTool {
|
||||
return childConvId;
|
||||
}
|
||||
|
||||
/** Child event types that are relayed to the parent for the nested delegation timeline. */
|
||||
/** Child event types that are relayed to the root for the nested delegation timeline. */
|
||||
private static final Set<String> RELAYED_CHILD_EVENTS = Set.of(
|
||||
"tool_call_started", "tool_call_completed", "phase",
|
||||
"plan_created", "plan_step_started", "plan_step_completed");
|
||||
|
||||
private Runnable registerRelay(String childConvId, String parentConvId, String childAgentName) {
|
||||
return streamTracker.addEventRelay(childConvId, (eventName, jsonData) -> {
|
||||
if (RELAYED_CHILD_EVENTS.contains(eventName)) {
|
||||
try {
|
||||
// Parse jsonData into a plain Object so the frontend receives a proper
|
||||
// JSON object under "data", not a string containing serialized JSON.
|
||||
// If parsing fails (e.g. plain text payload), fall back to the raw string.
|
||||
Object parsedData;
|
||||
try {
|
||||
parsedData = objectMapper.readValue(jsonData, Object.class);
|
||||
} catch (Exception ignored) {
|
||||
parsedData = jsonData;
|
||||
}
|
||||
streamTracker.broadcastObject(parentConvId, "delegation_progress", Map.of(
|
||||
"childConversationId", childConvId,
|
||||
"childAgentName", childAgentName,
|
||||
"originalEvent", eventName,
|
||||
"data", parsedData));
|
||||
} catch (Exception e) {
|
||||
log.debug("Relay error: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
});
|
||||
/** Tree identity attached to every relayed delegation event. */
|
||||
private record RelayIdentity(String childConvId, String childAgentName,
|
||||
String subagentId, String parentSubagentId, int depth) {}
|
||||
|
||||
/**
|
||||
* Builds a delegation event payload carrying tree identity. A null
|
||||
* {@code parentSubagentId} (first-level child) is omitted rather than
|
||||
* inserted, since downstream consumers treat absence as "top of tree".
|
||||
*/
|
||||
private Map<String, Object> delegationPayload(String subagentId, String parentSubagentId, int depth,
|
||||
String childConvId, String childAgentName) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
if (subagentId != null) m.put("subagentId", subagentId);
|
||||
if (parentSubagentId != null) m.put("parentSubagentId", parentSubagentId);
|
||||
m.put("depth", depth);
|
||||
m.put("childConversationId", childConvId);
|
||||
m.put("childAgentName", childAgentName);
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a batched relay so a chatty child does not flood the parent
|
||||
* transcript with one tool-call event per LLM step. The streaming layer
|
||||
* batches {@code tool_call_started} / {@code tool_call_completed} into
|
||||
* envelopes (5 events / 500 ms) and flushes immediately on lifecycle
|
||||
* events ({@code subagent_*}, {@code error}, {@code phase}, etc.).
|
||||
* Registers a batched relay so a chatty child does not flood the transcript
|
||||
* with one tool-call event per LLM step. The streaming layer batches
|
||||
* {@code tool_call_started} / {@code tool_call_completed} into envelopes
|
||||
* (5 events / 500 ms) and flushes immediately on lifecycle events
|
||||
* ({@code subagent_*}, {@code error}, {@code phase}, etc.).
|
||||
*
|
||||
* <p>The wrapper keeps the on-the-wire shape identical to
|
||||
* {@link #registerRelay} so frontend consumers do not need to change
|
||||
* — both batched envelopes and pass-through events surface as
|
||||
* {@code delegation_progress} on the parent.
|
||||
* <p>Both batched envelopes and pass-through events surface as
|
||||
* {@code delegation_progress} on the {@code rootConvId} stream (the
|
||||
* human-facing conversation), tagged with subagentId/parentSubagentId/depth
|
||||
* so the frontend can rebuild the multi-level spawn tree.
|
||||
*/
|
||||
private Runnable registerBatchedRelay(String childConvId, String parentConvId, String childAgentName) {
|
||||
return streamTracker.addBatchedEventRelay(childConvId, parentConvId, 5, 500L,
|
||||
private Runnable registerBatchedRelay(String childConvId, String rootConvId, String childAgentName,
|
||||
String subagentId, String parentSubagentId, int depth) {
|
||||
RelayIdentity id = new RelayIdentity(childConvId, childAgentName, subagentId, parentSubagentId, depth);
|
||||
return streamTracker.addBatchedEventRelay(childConvId, rootConvId, 5, 500L,
|
||||
(eventName, jsonData) -> {
|
||||
// The batched relay delivers (1) pass-through events directly
|
||||
// (plan/phase/error) and (2) batched tool-calls as a
|
||||
// "delegation_batch" envelope. Unpack each form to a stream
|
||||
// of delegation_progress events on the parent so the frontend
|
||||
// only handles a single event shape (see useChat delegation_progress).
|
||||
// (1) pass-through events arrive directly (plan/phase/error);
|
||||
// (2) batched tool-calls arrive as a "delegation_batch"
|
||||
// envelope. Unpack both into delegation_progress events so the
|
||||
// frontend only handles a single event shape.
|
||||
if ("delegation_batch".equals(eventName)) {
|
||||
relayBatchEnvelope(jsonData, childConvId, parentConvId, childAgentName);
|
||||
relayBatchEnvelope(jsonData, rootConvId, id);
|
||||
} else if (RELAYED_CHILD_EVENTS.contains(eventName)) {
|
||||
relayChildEvent(eventName, jsonData, childConvId, parentConvId, childAgentName);
|
||||
relayChildEvent(eventName, jsonData, rootConvId, id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Forward one child event to the parent as a delegation_progress envelope. */
|
||||
private void relayChildEvent(String eventName, String jsonData,
|
||||
String childConvId, String parentConvId, String childAgentName) {
|
||||
/** Forward one child event to the root as a delegation_progress envelope. */
|
||||
private void relayChildEvent(String eventName, String jsonData, String rootConvId, RelayIdentity id) {
|
||||
try {
|
||||
Object parsedData;
|
||||
try {
|
||||
@ -1185,11 +1227,11 @@ public class DelegateAgentTool {
|
||||
} catch (Exception ignored) {
|
||||
parsedData = jsonData;
|
||||
}
|
||||
streamTracker.broadcastObject(parentConvId, "delegation_progress", Map.of(
|
||||
"childConversationId", childConvId,
|
||||
"childAgentName", childAgentName,
|
||||
"originalEvent", eventName,
|
||||
"data", parsedData));
|
||||
Map<String, Object> ev = delegationPayload(id.subagentId(), id.parentSubagentId(), id.depth(),
|
||||
id.childConvId(), id.childAgentName());
|
||||
ev.put("originalEvent", eventName);
|
||||
ev.put("data", parsedData);
|
||||
streamTracker.broadcastObject(rootConvId, "delegation_progress", ev);
|
||||
} catch (Exception e) {
|
||||
log.debug("Child event relay error: {}", e.getMessage());
|
||||
}
|
||||
@ -1197,8 +1239,7 @@ public class DelegateAgentTool {
|
||||
|
||||
/** Unpack a delegation_batch envelope and replay each entry as delegation_progress. */
|
||||
@SuppressWarnings("unchecked")
|
||||
private void relayBatchEnvelope(String envelopeJson, String childConvId,
|
||||
String parentConvId, String childAgentName) {
|
||||
private void relayBatchEnvelope(String envelopeJson, String rootConvId, RelayIdentity id) {
|
||||
try {
|
||||
Map<String, Object> envelope = objectMapper.readValue(envelopeJson, Map.class);
|
||||
Object eventsObj = envelope.get("events");
|
||||
@ -1212,23 +1253,21 @@ public class DelegateAgentTool {
|
||||
String payloadJson = payload == null
|
||||
? "{}"
|
||||
: (payload instanceof String s ? s : objectMapper.writeValueAsString(payload));
|
||||
relayChildEvent(name.toString(),
|
||||
payloadJson,
|
||||
childConvId, parentConvId, childAgentName);
|
||||
relayChildEvent(name.toString(), payloadJson, rootConvId, id);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Batch envelope relay error: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void broadcastEnd(String parentConvId, String childConvId, String agentName, ChildResult result) {
|
||||
streamTracker.broadcastObject(parentConvId, "delegation_end", Map.of(
|
||||
"childConversationId", childConvId,
|
||||
"childAgentName", agentName,
|
||||
"success", result.success,
|
||||
"durationMs", result.durationMs,
|
||||
"resultPreview", result.success ? truncate(result.result, 200) : (result.error != null ? result.error : "")
|
||||
));
|
||||
private void broadcastEnd(String rootConvId, String childConvId, String agentName, ChildResult result,
|
||||
String subagentId, String parentSubagentId, int depth) {
|
||||
Map<String, Object> ev = delegationPayload(subagentId, parentSubagentId, depth, childConvId, agentName);
|
||||
ev.put("success", result.success);
|
||||
ev.put("durationMs", result.durationMs);
|
||||
ev.put("resultPreview",
|
||||
result.success ? truncate(result.result, 200) : (result.error != null ? result.error : ""));
|
||||
streamTracker.broadcastObject(rootConvId, "delegation_end", ev);
|
||||
}
|
||||
|
||||
private String resolveParentConversationId() {
|
||||
|
||||
@ -17,8 +17,17 @@ public final class DelegationContext {
|
||||
|
||||
/**
|
||||
* Snapshot of one delegation layer's state.
|
||||
*
|
||||
* <p>{@code rootConversationId} is the human-facing conversation at the top
|
||||
* of the delegation tree — every layer carries it unchanged so that a
|
||||
* grandchild's progress events can be broadcast to the same stream the user
|
||||
* is watching, rather than to its immediate (machine-only) parent.
|
||||
* {@code currentSubagentId} is the id of the subagent running THIS layer; a
|
||||
* deeper child reads it as its own {@code parentSubagentId} to reconstruct
|
||||
* the spawn tree.
|
||||
*/
|
||||
private record Frame(String parentConversationId, Set<String> childDeniedTools) {}
|
||||
private record Frame(String parentConversationId, Set<String> childDeniedTools,
|
||||
String rootConversationId, String currentSubagentId) {}
|
||||
|
||||
private static final ThreadLocal<Deque<Frame>> STACK = ThreadLocal.withInitial(ArrayDeque::new);
|
||||
|
||||
@ -41,14 +50,36 @@ public final class DelegationContext {
|
||||
return top != null && top.childDeniedTools != null ? top.childDeniedTools : Set.of();
|
||||
}
|
||||
|
||||
/** Root (human-facing) conversation ID for the whole tree, or null at top level. */
|
||||
public static String rootConversationId() {
|
||||
Frame top = STACK.get().peek();
|
||||
return top != null ? top.rootConversationId : null;
|
||||
}
|
||||
|
||||
/** Subagent id of the layer currently executing, or null at top level. */
|
||||
public static String currentSubagentId() {
|
||||
Frame top = STACK.get().peek();
|
||||
return top != null ? top.currentSubagentId : null;
|
||||
}
|
||||
|
||||
/** Enter the next delegation layer (with parent conversation ID and child tool restrictions) */
|
||||
public static void enter(String parentConversationId, Set<String> deniedTools) {
|
||||
STACK.get().push(new Frame(parentConversationId, deniedTools));
|
||||
enter(parentConversationId, deniedTools, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter the next delegation layer carrying the full tree identity so deeper
|
||||
* children can broadcast to the root conversation and tag their parent.
|
||||
*/
|
||||
public static void enter(String parentConversationId, Set<String> deniedTools,
|
||||
String rootConversationId, String currentSubagentId) {
|
||||
STACK.get().push(new Frame(parentConversationId, deniedTools,
|
||||
rootConversationId, currentSubagentId));
|
||||
}
|
||||
|
||||
/** Enter the next delegation layer (backward-compatible overload) */
|
||||
public static void enter() {
|
||||
enter(null, null);
|
||||
enter(null, null, null, null);
|
||||
}
|
||||
|
||||
/** Exit the current delegation layer, restoring the previous layer's context */
|
||||
|
||||
@ -30,6 +30,7 @@ import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
@ -91,7 +92,8 @@ class DelegateAsyncToolTest {
|
||||
AgentEntity target = makeAgent(10L, "Researcher");
|
||||
when(agentMapper.selectOne(any())).thenReturn(target);
|
||||
when(subagentRegistry.isSpawnPaused("parent-conv-1")).thenReturn(false);
|
||||
when(subagentRegistry.register(anyString(), anyString(), anyLong(), anyString(), any()))
|
||||
when(subagentRegistry.register(anyString(), anyString(), anyLong(), anyString(), any(),
|
||||
any(), anyInt(), anyString()))
|
||||
.thenReturn("sa-1");
|
||||
|
||||
AsyncTaskEntity entity = new AsyncTaskEntity();
|
||||
@ -121,7 +123,8 @@ class DelegateAsyncToolTest {
|
||||
void delegateAsyncRequestJsonShape() throws Exception {
|
||||
AgentEntity target = makeAgent(10L, "Researcher");
|
||||
when(agentMapper.selectOne(any())).thenReturn(target);
|
||||
when(subagentRegistry.register(anyString(), anyString(), anyLong(), anyString(), any()))
|
||||
when(subagentRegistry.register(anyString(), anyString(), anyLong(), anyString(), any(),
|
||||
any(), anyInt(), anyString()))
|
||||
.thenReturn("sa-2");
|
||||
AsyncTaskEntity entity = new AsyncTaskEntity();
|
||||
entity.setTaskId("tid-200");
|
||||
@ -137,7 +140,14 @@ class DelegateAsyncToolTest {
|
||||
Map<String, Object> payload = objectMapper.readValue(jsonCaptor.getValue(), new TypeReference<>() {});
|
||||
assertThat(payload).containsEntry("parentConversationId", "parent-conv-1")
|
||||
.containsEntry("label", "myLabel")
|
||||
.containsEntry("task", "task body");
|
||||
.containsEntry("task", "task body")
|
||||
// Durable async identity — task_output's route-B authorization reads
|
||||
// these persisted fields (the registry is process-local), so lock them.
|
||||
.containsEntry("rootConversationId", "parent-conv-1")
|
||||
.containsEntry("subagentId", "sa-2")
|
||||
.containsEntry("depth", 1);
|
||||
// A top-level spawn has no parent subagent, so the key is omitted entirely.
|
||||
assertThat(payload).doesNotContainKey("parentSubagentId");
|
||||
assertThat(payload.get("childConversationId")).asString().startsWith("child-");
|
||||
assertThat(((Number) payload.get("childAgentId")).longValue()).isEqualTo(10L);
|
||||
}
|
||||
@ -147,7 +157,8 @@ class DelegateAsyncToolTest {
|
||||
void delegateAsyncConcurrencyCap() throws Exception {
|
||||
AgentEntity target = makeAgent(10L, "Researcher");
|
||||
when(agentMapper.selectOne(any())).thenReturn(target);
|
||||
when(subagentRegistry.register(anyString(), anyString(), anyLong(), anyString(), any()))
|
||||
when(subagentRegistry.register(anyString(), anyString(), anyLong(), anyString(), any(),
|
||||
any(), anyInt(), anyString()))
|
||||
.thenReturn("sa-cap");
|
||||
when(asyncTaskService.submitOneShot(anyString(), anyString(), any(), anyString(), anyString(), any()))
|
||||
.thenThrow(new IllegalStateException("已达到最大并行任务数(3),请等待现有任务完成"));
|
||||
@ -175,7 +186,8 @@ class DelegateAsyncToolTest {
|
||||
assertThat(parsed).containsEntry("error", true);
|
||||
}
|
||||
verify(asyncTaskService, never()).submitOneShot(any(), any(), any(), any(), any(), any());
|
||||
verify(subagentRegistry, never()).register(any(), any(), any(), any(), any());
|
||||
verify(subagentRegistry, never()).register(any(), any(), any(), any(), any(),
|
||||
any(), anyInt(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -201,7 +213,8 @@ class DelegateAsyncToolTest {
|
||||
assertThat(parsed).containsEntry("error", true);
|
||||
assertThat((String) parsed.get("message")).contains("paused");
|
||||
verify(asyncTaskService, never()).submitOneShot(any(), any(), any(), any(), any(), any());
|
||||
verify(subagentRegistry, never()).register(any(), any(), any(), any(), any());
|
||||
verify(subagentRegistry, never()).register(any(), any(), any(), any(), any(),
|
||||
any(), anyInt(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -246,6 +259,42 @@ class DelegateAsyncToolTest {
|
||||
assertThat(((Number) parsed.get("duration_ms")).longValue()).isGreaterThanOrEqualTo(0L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("taskOutput authorized via root conversation when caller is the root of a child-spawned task")
|
||||
void taskOutputAllowedViaRootConversation() throws Exception {
|
||||
// A (grand)child stamped its OWN conversation as parentConversationId, but
|
||||
// rootConversationId points back at the user-facing conversation the root
|
||||
// agent runs in. The root agent (caller) must be able to poll that task even
|
||||
// though it is not the immediate spawn conversation.
|
||||
AsyncTaskEntity entity = new AsyncTaskEntity();
|
||||
entity.setTaskId("tid-root");
|
||||
entity.setTaskType("agent_delegate");
|
||||
entity.setStatus("succeeded");
|
||||
entity.setCreatedBy("user-1");
|
||||
entity.setResultJson("deep result");
|
||||
entity.setProgress(100);
|
||||
entity.setCreateTime(LocalDateTime.now().minusSeconds(5));
|
||||
entity.setUpdateTime(LocalDateTime.now());
|
||||
Map<String, Object> req = new LinkedHashMap<>();
|
||||
req.put("parentConversationId", "child-conv-2"); // NOT the caller's conversation
|
||||
req.put("rootConversationId", "parent-conv-1"); // caller IS the root
|
||||
req.put("childConversationId", "child-conv-3");
|
||||
req.put("childAgentId", 10L);
|
||||
req.put("subagentId", "sa-deep");
|
||||
req.put("depth", 2);
|
||||
req.put("task", "deep task");
|
||||
req.put("label", "");
|
||||
entity.setRequestJson(objectMapper.writeValueAsString(req));
|
||||
when(asyncTaskService.findEntityByTaskId("tid-root")).thenReturn(entity);
|
||||
|
||||
// Caller runs in parent-conv-1: != taskParentConv but == taskRootConv → allowed.
|
||||
String result = tool.taskOutput("tid-root", false, null, makeCtx("user-1", "parent-conv-1"));
|
||||
Map<String, Object> parsed = objectMapper.readValue(result, new TypeReference<>() {});
|
||||
assertThat(parsed).doesNotContainKey("error");
|
||||
assertThat(parsed).containsEntry("status", "succeeded")
|
||||
.containsEntry("result", "deep result");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("taskOutput on failed task returns error message")
|
||||
void taskOutputFailed() throws Exception {
|
||||
|
||||
@ -24,6 +24,7 @@ import vip.mate.channel.web.ChatStreamTracker;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.BiConsumer;
|
||||
|
||||
@ -258,4 +259,91 @@ class DelegateEventSequenceTest {
|
||||
assertEquals(2, progressCount,
|
||||
"Should have exactly 2 delegation_progress events (tool_call_started + phase), got: " + events);
|
||||
}
|
||||
|
||||
// ===== Nested delegation: grandchild events route to root with tree identity =====
|
||||
|
||||
@Test
|
||||
@DisplayName("A child delegating a grandchild broadcasts to root with parentSubagentId + depth=2")
|
||||
@SuppressWarnings("unchecked")
|
||||
void nestedDelegationRoutesGrandchildToRootWithIdentity() {
|
||||
AgentEntity child = makeAgent(100L, "Child");
|
||||
AgentEntity grandchild = makeAgent(200L, "Grandchild");
|
||||
when(agentMapper.selectOne(any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(child) // root delegates Child
|
||||
.thenReturn(grandchild); // Child delegates Grandchild
|
||||
|
||||
String rootConv = "root-conv";
|
||||
ToolExecutionContext.set(rootConv, "admin");
|
||||
when(streamTracker.isRunning(rootConv)).thenReturn(true);
|
||||
when(streamTracker.addBatchedEventRelay(anyString(), anyString(), anyInt(), anyLong(), any()))
|
||||
.thenReturn(() -> {});
|
||||
|
||||
// Capture each created child conversation + its immediate parent so we can
|
||||
// assert the grandchild's immediate parent is the Child's conversation,
|
||||
// not the root — the createChildConversation(childConvId, ..., parent) call.
|
||||
List<String> createdConvs = new java.util.ArrayList<>();
|
||||
List<String> createdParents = new java.util.ArrayList<>();
|
||||
doAnswer(inv -> {
|
||||
createdConvs.add(inv.getArgument(0));
|
||||
createdParents.add(inv.getArgument(4));
|
||||
return null;
|
||||
}).when(conversationService).createChildConversation(
|
||||
anyString(), anyLong(), anyString(), anyLong(), anyString());
|
||||
|
||||
// When the Child runs, the real ToolExecutionExecutor would switch the
|
||||
// ToolExecutionContext to the Child's own conversation. Reproduce that so
|
||||
// the grandchild's immediate parent resolves to childConv, while its
|
||||
// events must still target rootConv (carried via DelegationContext).
|
||||
when(agentService.chat(eq(100L), anyString(), anyString(), any()))
|
||||
.thenAnswer(inv -> {
|
||||
String childConv = inv.getArgument(2);
|
||||
ToolExecutionContext.set(childConv, "admin");
|
||||
try {
|
||||
return delegateAgentTool.delegateToAgent("Grandchild", "gtask", null, null);
|
||||
} finally {
|
||||
ToolExecutionContext.set(rootConv, "admin");
|
||||
}
|
||||
});
|
||||
when(agentService.chat(eq(200L), anyString(), anyString(), any()))
|
||||
.thenReturn("grandchild done");
|
||||
|
||||
delegateAgentTool.delegateToAgent("Child", "ctask", null, null);
|
||||
|
||||
ArgumentCaptor<String> convCap = ArgumentCaptor.forClass(String.class);
|
||||
ArgumentCaptor<String> evCap = ArgumentCaptor.forClass(String.class);
|
||||
ArgumentCaptor<Object> payloadCap = ArgumentCaptor.forClass(Object.class);
|
||||
verify(streamTracker, atLeast(4)).broadcastObject(convCap.capture(), evCap.capture(), payloadCap.capture());
|
||||
|
||||
Map<String, Object> childStart = null;
|
||||
Map<String, Object> grandStart = null;
|
||||
for (int i = 0; i < evCap.getAllValues().size(); i++) {
|
||||
if (!"delegation_start".equals(evCap.getAllValues().get(i))) continue;
|
||||
Map<String, Object> p = (Map<String, Object>) payloadCap.getAllValues().get(i);
|
||||
// Every delegation_start — at any depth — targets the root conversation.
|
||||
assertEquals(rootConv, convCap.getAllValues().get(i),
|
||||
"delegation_start must target the root conversation");
|
||||
String name = String.valueOf(p.get("childAgentName"));
|
||||
if ("Child".equals(name)) childStart = p;
|
||||
else if ("Grandchild".equals(name)) grandStart = p;
|
||||
}
|
||||
assertNotNull(childStart, "child delegation_start present");
|
||||
assertNotNull(grandStart, "grandchild delegation_start present");
|
||||
|
||||
// depth-1 child: depth=1, no parentSubagentId.
|
||||
assertEquals(1, ((Number) childStart.get("depth")).intValue());
|
||||
assertNull(childStart.get("parentSubagentId"), "depth-1 child carries no parentSubagentId");
|
||||
|
||||
// depth-2 grandchild: depth=2, parented to the child's subagentId.
|
||||
assertEquals(2, ((Number) grandStart.get("depth")).intValue());
|
||||
assertNotNull(grandStart.get("parentSubagentId"), "grandchild must carry parentSubagentId");
|
||||
assertEquals(childStart.get("subagentId"), grandStart.get("parentSubagentId"),
|
||||
"grandchild's parentSubagentId must equal the child's subagentId");
|
||||
|
||||
// Two child conversations were created: [0] = Child (parent=root),
|
||||
// [1] = Grandchild (parent must be the Child's conversation, not root).
|
||||
assertEquals(2, createdConvs.size(), "Child + Grandchild conversations created");
|
||||
assertEquals(rootConv, createdParents.get(0), "Child's immediate parent is the root conversation");
|
||||
assertEquals(createdConvs.get(0), createdParents.get(1),
|
||||
"Grandchild's immediate parent must be the Child's conversation");
|
||||
}
|
||||
}
|
||||
|
||||
@ -306,6 +306,11 @@ export interface LiveSubagentCard {
|
||||
subagentId: string
|
||||
parentConversationId: string | null
|
||||
childConversationId: string | null
|
||||
rootConversationId: string | null
|
||||
/** subagentId of the immediate parent; null for first-level (depth-1) children. */
|
||||
parentSubagentId: string | null
|
||||
/** 1 for a first-level child, 2 for a grandchild, etc. */
|
||||
depth: number
|
||||
agentId: number | null
|
||||
agentName: string | null
|
||||
agentIcon: string | null
|
||||
|
||||
223
mateclaw-ui/src/components/chat/DelegationNodeView.vue
Normal file
223
mateclaw-ui/src/components/chat/DelegationNodeView.vue
Normal file
@ -0,0 +1,223 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Loading, Select, CloseBold, ArrowDown, Connection, WarningFilled, Clock } from '@element-plus/icons-vue'
|
||||
import { useToolLabel } from '@/composables/useToolLabel'
|
||||
import type { DelegationNode } from '@/types'
|
||||
|
||||
// Renders one subagent (depth >= 2) in the delegation tree, recursing into its
|
||||
// own children. The depth-1 child is rendered by ToolCallSegment, which mounts
|
||||
// this component for each grandchild.
|
||||
const props = defineProps<{ node: DelegationNode }>()
|
||||
|
||||
const { getToolLabel } = useToolLabel()
|
||||
|
||||
const expanded = ref(props.node.status === 'running')
|
||||
|
||||
const isRunning = computed(() => props.node.status === 'running')
|
||||
const isError = computed(() => props.node.status === 'error')
|
||||
const isSuccess = computed(() => props.node.status === 'completed')
|
||||
const isStalled = computed(() => isRunning.value && !!props.node.stale)
|
||||
// Fire-and-forget delegation: runs detached, result via task_output later.
|
||||
const isAsync = computed(() => !!props.node.async)
|
||||
|
||||
const plan = computed(() => props.node.plan)
|
||||
const tools = computed(() => props.node.tools || [])
|
||||
const children = computed(() => props.node.children || [])
|
||||
const resultPreview = computed(() => {
|
||||
const r = props.node.result || ''
|
||||
return r.length <= 600 ? r : r.slice(0, 600) + '\n... [truncated]'
|
||||
})
|
||||
|
||||
const hasBody = computed(() =>
|
||||
!!plan.value || tools.value.length > 0 || children.value.length > 0 || !!props.node.result
|
||||
)
|
||||
|
||||
const progress = computed(() => {
|
||||
const p = plan.value
|
||||
if (p?.steps?.length) {
|
||||
const done = p.stepResults?.filter(r => r?.status === 'completed').length || 0
|
||||
return `${done}/${p.steps.length}`
|
||||
}
|
||||
const n = tools.value.length
|
||||
return n ? `${n} ${n === 1 ? 'tool' : 'tools'}` : ''
|
||||
})
|
||||
|
||||
function stepStatus(i: number): 'pending' | 'running' | 'completed' {
|
||||
const p = plan.value
|
||||
if (!p) return 'pending'
|
||||
if (p.stepResults?.[i]?.status === 'completed') return 'completed'
|
||||
if (i === p.currentStep) return 'running'
|
||||
return 'pending'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="deleg-node" :class="{ 'is-running': isRunning, 'is-error': isError, 'is-success': isSuccess }">
|
||||
<div class="deleg-node__header" @click="hasBody ? (expanded = !expanded) : null">
|
||||
<span class="deleg-node__status">
|
||||
<el-icon v-if="isAsync" class="deleg-node__async" :title="$t('chat.subagentAsync')" :size="12"><Clock /></el-icon>
|
||||
<el-icon v-else-if="isRunning" class="is-loading" :size="12"><Loading /></el-icon>
|
||||
<el-icon v-else-if="isSuccess" :size="12"><Select /></el-icon>
|
||||
<el-icon v-else :size="12"><CloseBold /></el-icon>
|
||||
</span>
|
||||
<el-icon class="deleg-node__icon" :size="11"><Connection /></el-icon>
|
||||
<span class="deleg-node__name">{{ node.agentName }}</span>
|
||||
<span v-if="progress" class="deleg-node__badge">{{ progress }}</span>
|
||||
<el-icon v-if="isStalled" class="deleg-node__stale" :title="$t('chat.subagentStalled')" :size="11"><WarningFilled /></el-icon>
|
||||
<el-icon
|
||||
v-if="hasBody"
|
||||
class="deleg-node__arrow"
|
||||
:class="{ 'is-open': expanded }"
|
||||
:size="10"
|
||||
><ArrowDown /></el-icon>
|
||||
</div>
|
||||
|
||||
<Transition name="deleg-slide">
|
||||
<div v-if="expanded && hasBody" class="deleg-node__body">
|
||||
<!-- The subagent's own plan checklist -->
|
||||
<div v-if="plan" class="deleg-node__plan">
|
||||
<div
|
||||
v-for="(step, i) in plan.steps"
|
||||
:key="i"
|
||||
class="deleg-node__step"
|
||||
:class="`is-${stepStatus(i)}`"
|
||||
>
|
||||
<el-icon v-if="stepStatus(i) === 'running'" class="is-loading" :size="11"><Loading /></el-icon>
|
||||
<el-icon v-else-if="stepStatus(i) === 'completed'" :size="11"><Select /></el-icon>
|
||||
<span v-else class="deleg-node__dot"></span>
|
||||
<span class="deleg-node__step-text">{{ step }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tools the subagent called -->
|
||||
<div v-if="tools.length" class="deleg-node__tools">
|
||||
<div
|
||||
v-for="(t, i) in tools"
|
||||
:key="i"
|
||||
class="deleg-node__tool"
|
||||
:class="`is-${t.status}`"
|
||||
>
|
||||
<el-icon v-if="t.status === 'running'" class="is-loading" :size="11"><Loading /></el-icon>
|
||||
<el-icon v-else-if="t.status === 'completed'" :size="11"><Select /></el-icon>
|
||||
<el-icon v-else :size="11"><CloseBold /></el-icon>
|
||||
<span class="deleg-node__tool-name">{{ getToolLabel(t.name) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recurse into deeper subagents -->
|
||||
<DelegationNodeView v-for="c in children" :key="c.subagentId" :node="c" />
|
||||
|
||||
<!-- Final result preview -->
|
||||
<pre v-if="node.result" class="deleg-node__result">{{ resultPreview }}</pre>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.deleg-node {
|
||||
margin: 2px 0 2px 4px;
|
||||
padding-left: 6px;
|
||||
border-left: 2px solid var(--mc-border-light);
|
||||
}
|
||||
.deleg-node.is-running { border-left-color: var(--mc-primary); }
|
||||
.deleg-node.is-success { border-left-color: var(--mc-success); }
|
||||
.deleg-node.is-error { border-left-color: var(--mc-danger); }
|
||||
|
||||
.deleg-node__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.9;
|
||||
color: var(--mc-text-secondary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.deleg-node__status { display: flex; align-items: center; flex-shrink: 0; }
|
||||
.is-success .deleg-node__status { color: var(--mc-success); }
|
||||
.is-error .deleg-node__status { color: var(--mc-danger); }
|
||||
.is-running .deleg-node__status { color: var(--mc-primary); }
|
||||
|
||||
.deleg-node__icon { color: var(--mc-text-tertiary); flex-shrink: 0; }
|
||||
.deleg-node__name {
|
||||
font-weight: 500;
|
||||
color: var(--mc-text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 180px;
|
||||
}
|
||||
.deleg-node__badge {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: var(--mc-text-tertiary);
|
||||
background: var(--mc-bg-muted);
|
||||
border-radius: 8px;
|
||||
padding: 0 6px;
|
||||
line-height: 16px;
|
||||
}
|
||||
.deleg-node__stale {
|
||||
flex-shrink: 0;
|
||||
color: var(--mc-warning, #e6a23c);
|
||||
}
|
||||
.deleg-node__async {
|
||||
flex-shrink: 0;
|
||||
color: var(--mc-text-tertiary);
|
||||
}
|
||||
.deleg-node__arrow {
|
||||
flex-shrink: 0;
|
||||
color: var(--mc-text-tertiary);
|
||||
transition: transform 0.2s;
|
||||
margin-left: auto;
|
||||
}
|
||||
.deleg-node__arrow.is-open { transform: rotate(180deg); }
|
||||
|
||||
.deleg-node__body { padding: 2px 0 2px 4px; }
|
||||
.deleg-node__plan {
|
||||
margin-bottom: 4px;
|
||||
padding-left: 4px;
|
||||
border-left: 2px solid var(--mc-border-light);
|
||||
}
|
||||
.deleg-node__step,
|
||||
.deleg-node__tool {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: var(--mc-text-tertiary);
|
||||
}
|
||||
.deleg-node__step.is-running,
|
||||
.deleg-node__tool.is-running { color: var(--mc-primary); }
|
||||
.deleg-node__step.is-completed,
|
||||
.deleg-node__tool.is-completed { color: var(--mc-text-secondary); }
|
||||
.deleg-node__tool.is-error { color: var(--mc-danger); }
|
||||
.deleg-node__dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--mc-text-quaternary, #c0c0c0);
|
||||
flex-shrink: 0;
|
||||
margin: 0 3px;
|
||||
}
|
||||
.deleg-node__tools { padding-left: 6px; }
|
||||
.deleg-node__result {
|
||||
margin: 4px 0 0;
|
||||
padding: 6px 8px;
|
||||
background: var(--mc-bg-sunken);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--mc-border-light);
|
||||
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--mc-text-secondary);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.deleg-slide-enter-active, .deleg-slide-leave-active { transition: all 0.2s ease; }
|
||||
.deleg-slide-enter-from, .deleg-slide-leave-to { opacity: 0; transform: translateY(-3px); }
|
||||
</style>
|
||||
@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Loading, Select, CloseBold, ArrowDown, Document, Setting, Connection } from '@element-plus/icons-vue'
|
||||
import { Loading, Select, CloseBold, ArrowDown, Document, Setting, Connection, WarningFilled, Clock } from '@element-plus/icons-vue'
|
||||
import { useToolLabel } from '@/composables/useToolLabel'
|
||||
import type { MessageSegment } from '@/types'
|
||||
import DelegationNodeView from './DelegationNodeView.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
segment: MessageSegment
|
||||
@ -56,14 +57,20 @@ const isRead = computed(() => {
|
||||
const isSuccess = computed(() => props.segment.status === 'completed' && props.segment.toolSuccess !== false)
|
||||
const isError = computed(() => props.segment.status === 'error' || props.segment.toolSuccess === false)
|
||||
const isRunning = computed(() => props.segment.status === 'running')
|
||||
// A delegation flagged by the heartbeat watchdog as making no progress.
|
||||
const isStalled = computed(() => isDelegation.value && isRunning.value && !!props.segment.delegationStale)
|
||||
// Fire-and-forget delegation: runs detached, result comes via task_output later.
|
||||
// Takes visual priority over the running spinner so the row doesn't spin forever.
|
||||
const isAsync = computed(() => isDelegation.value && !!props.segment.delegationAsync)
|
||||
|
||||
// Nested subagent timeline relayed from the child conversation: the child's own
|
||||
// plan checklist + the tools it called. Only present on delegation segments.
|
||||
const childTimeline = computed(() => isDelegation.value ? props.segment.childTimeline : undefined)
|
||||
const childPlan = computed(() => childTimeline.value?.plan)
|
||||
const childTools = computed(() => childTimeline.value?.tools || [])
|
||||
const childNodes = computed(() => childTimeline.value?.children || [])
|
||||
const hasChildActivity = computed(() =>
|
||||
!!childPlan.value || childTools.value.length > 0
|
||||
!!childPlan.value || childTools.value.length > 0 || childNodes.value.length > 0
|
||||
)
|
||||
|
||||
// The body is expandable when there's any nested detail to show – either the
|
||||
@ -96,7 +103,8 @@ const childProgress = computed(() => {
|
||||
<div class="seg-tool" :class="{ 'is-running': isRunning, 'is-success': isSuccess, 'is-error': isError }">
|
||||
<div class="seg-tool__header" @click="hasBody ? (expanded = !expanded) : null">
|
||||
<span class="seg-tool__status">
|
||||
<el-icon v-if="isRunning" class="is-loading" :size="13"><Loading /></el-icon>
|
||||
<el-icon v-if="isAsync" class="seg-tool__async" :title="$t('chat.subagentAsync')" :size="13"><Clock /></el-icon>
|
||||
<el-icon v-else-if="isRunning" class="is-loading" :size="13"><Loading /></el-icon>
|
||||
<el-icon v-else-if="isSuccess" :size="13"><Select /></el-icon>
|
||||
<el-icon v-else :size="13"><CloseBold /></el-icon>
|
||||
</span>
|
||||
@ -107,6 +115,7 @@ const childProgress = computed(() => {
|
||||
</span>
|
||||
<span class="seg-tool__name">{{ displayName }}</span>
|
||||
<span v-if="isDelegation && childProgress" class="seg-tool__badge">{{ childProgress }}</span>
|
||||
<el-icon v-if="isStalled" class="seg-tool__stale" :title="$t('chat.subagentStalled')" :size="12"><WarningFilled /></el-icon>
|
||||
<span v-if="truncatedArgs" class="seg-tool__args">{{ truncatedArgs }}</span>
|
||||
<el-icon
|
||||
v-if="hasBody"
|
||||
@ -147,6 +156,9 @@ const childProgress = computed(() => {
|
||||
<span class="seg-child__tool-name">{{ getToolLabel(t.name) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grandchildren and deeper: the child agent's own delegations -->
|
||||
<DelegationNodeView v-for="c in childNodes" :key="c.subagentId" :node="c" />
|
||||
</div>
|
||||
<!-- Final tool/agent result preview -->
|
||||
<pre v-if="segment.toolResult">{{ resultPreview }}</pre>
|
||||
@ -254,6 +266,14 @@ const childProgress = computed(() => {
|
||||
padding: 0 6px;
|
||||
line-height: 16px;
|
||||
}
|
||||
.seg-tool__stale {
|
||||
flex-shrink: 0;
|
||||
color: var(--mc-warning, #e6a23c);
|
||||
}
|
||||
.seg-tool__async {
|
||||
flex-shrink: 0;
|
||||
color: var(--mc-text-tertiary);
|
||||
}
|
||||
|
||||
/* Nested subagent timeline */
|
||||
.seg-child {
|
||||
|
||||
@ -94,13 +94,20 @@
|
||||
<!-- Helpers (subagents) -->
|
||||
<div v-if="subagents.length > 0" class="focus-section">
|
||||
<div class="focus-section-title">{{ t('live.detail.helpers') }}</div>
|
||||
<div v-for="sub in subagents" :key="sub.subagentId" class="focus-sub-row">
|
||||
<div
|
||||
v-for="sub in subagents"
|
||||
:key="sub.subagentId"
|
||||
class="focus-sub-row"
|
||||
:style="{ marginLeft: (((sub.depth ?? 1) - 1) * 16) + 'px' }"
|
||||
>
|
||||
<div class="focus-sub-icon" :style="avatarBgStyle(sub)">
|
||||
<SkillIcon v-if="sub.agentIcon" :value="sub.agentIcon" :size="20" fallback="🤖" />
|
||||
<span v-else class="focus-sub-letter">{{ avatarLetter(sub) }}</span>
|
||||
</div>
|
||||
<div class="focus-sub-body">
|
||||
<div class="focus-sub-name">{{ sub.agentName || sub.subagentId }}</div>
|
||||
<div class="focus-sub-name">
|
||||
<span v-if="(sub.depth ?? 1) > 1" class="focus-sub-depth" aria-hidden="true">↳ </span>{{ sub.agentName || sub.subagentId }}
|
||||
</div>
|
||||
<div class="focus-sub-meta">
|
||||
{{ sub.lastTool || sub.currentPhase || sub.status }} · {{ formatAge(sub.ageMs) }}
|
||||
</div>
|
||||
@ -635,6 +642,10 @@ html.dark .focus-tile-warn {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.focus-sub-depth {
|
||||
color: var(--mc-text-tertiary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.focus-sub-meta {
|
||||
font-size: 11px;
|
||||
|
||||
@ -287,7 +287,39 @@ function progressFillStyle(run: LiveRunCard) {
|
||||
}
|
||||
|
||||
function childrenOf(run: LiveRunCard): LiveSubagentCard[] {
|
||||
return snapshot.value?.subagents.filter(s => s.parentConversationId === run.conversationId) ?? []
|
||||
// Group by the root conversation so the whole delegation tree shows under its
|
||||
// run — including grandchildren, whose immediate parent is a child
|
||||
// conversation, not this run. Fall back to parentConversationId for records
|
||||
// emitted before rootConversationId existed.
|
||||
const subs = (snapshot.value?.subagents ?? [])
|
||||
.filter(s => (s.rootConversationId ?? s.parentConversationId) === run.conversationId)
|
||||
|
||||
// Pre-order DFS by parentSubagentId so each child renders directly above its
|
||||
// own descendants, not after every same-depth sibling. A plain depth sort
|
||||
// attaches a grandchild under the wrong sibling once a run has >1 branch.
|
||||
const byParent = new Map<string | null, LiveSubagentCard[]>()
|
||||
for (const s of subs) {
|
||||
const key = s.parentSubagentId ?? null
|
||||
const bucket = byParent.get(key)
|
||||
if (bucket) bucket.push(s)
|
||||
else byParent.set(key, [s])
|
||||
}
|
||||
|
||||
const ordered: LiveSubagentCard[] = []
|
||||
const seen = new Set<string>()
|
||||
const walk = (parentId: string | null) => {
|
||||
for (const node of byParent.get(parentId) ?? []) {
|
||||
if (seen.has(node.subagentId)) continue // guard against cycles / duplicate ids
|
||||
seen.add(node.subagentId)
|
||||
ordered.push(node)
|
||||
walk(node.subagentId)
|
||||
}
|
||||
}
|
||||
walk(null) // depth-1 children (parentSubagentId null) are the subtree roots
|
||||
|
||||
// Append any orphan whose parent isn't in this set so it never disappears.
|
||||
for (const s of subs) if (!seen.has(s.subagentId)) ordered.push(s)
|
||||
return ordered
|
||||
}
|
||||
|
||||
function openDetail(run: LiveRunCard) {
|
||||
|
||||
@ -14,7 +14,7 @@ import { useMessages } from './useMessages'
|
||||
import { useStream } from './useStream'
|
||||
import { useMessageQueue } from './useMessageQueue'
|
||||
import { useGoalStore } from '@/stores/useGoalStore'
|
||||
import type { Message, MessageContentPart, MessageSegment, StreamPhase, HeartbeatData, QueuedMessage, PhaseEventData } from '@/types'
|
||||
import type { Message, MessageContentPart, MessageSegment, StreamPhase, HeartbeatData, QueuedMessage, PhaseEventData, DelegationNode, DelegationToolEntry, PlanMeta } from '@/types'
|
||||
import { classifyBackendError, type ChatErrorInfo } from '@/types/chatError'
|
||||
import { http } from '@/api'
|
||||
|
||||
@ -940,43 +940,159 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
||||
})
|
||||
|
||||
// ===== Agent delegation events =====
|
||||
// Delegations form a tree: a depth-1 child is a top-level tool_call segment
|
||||
// (keyed by its subagentId); depth-2+ subagents are DelegationNode entries
|
||||
// nested under an ancestor via parentSubagentId. Every delegation_* event
|
||||
// carries subagentId/parentSubagentId/depth so the flat event stream can be
|
||||
// reassembled into that tree on the frontend (see DelegationNodeView.vue).
|
||||
|
||||
type DelegContainer = { plan?: PlanMeta; tools?: DelegationToolEntry[]; children?: DelegationNode[] }
|
||||
|
||||
/** A depth-1 delegation segment, looked up by its subagentId (or childConversationId). */
|
||||
function findDelegSegment(segs: MessageSegment[], subagentId?: string, childConvId?: string): MessageSegment | undefined {
|
||||
if (subagentId) {
|
||||
const byId = segs.find(s => s.type === 'tool_call' && s.id === subagentId)
|
||||
if (byId) return byId
|
||||
}
|
||||
if (childConvId) return segs.find(s => s.type === 'tool_call' && s.id === childConvId)
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Recursively find a DelegationNode by subagentId. */
|
||||
function findNode(nodes: DelegationNode[] | undefined, subagentId: string): DelegationNode | undefined {
|
||||
if (!nodes) return undefined
|
||||
for (const n of nodes) {
|
||||
if (n.subagentId === subagentId) return n
|
||||
const deep = findNode(n.children, subagentId)
|
||||
if (deep) return deep
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function ensureTimeline(seg: MessageSegment): DelegContainer {
|
||||
const t = (seg.childTimeline ||= {})
|
||||
if (!t.tools) t.tools = []
|
||||
if (!t.children) t.children = []
|
||||
return t
|
||||
}
|
||||
|
||||
function ensureNodeContainer(node: DelegationNode): DelegContainer {
|
||||
if (!node.tools) node.tools = []
|
||||
if (!node.children) node.children = []
|
||||
return node
|
||||
}
|
||||
|
||||
/** Resolve the progress container (plan/tools/children) for a subagent at any depth. */
|
||||
function resolveContainer(segs: MessageSegment[], subagentId?: string, childConvId?: string): DelegContainer | undefined {
|
||||
const seg = findDelegSegment(segs, subagentId, childConvId)
|
||||
if (seg) return ensureTimeline(seg)
|
||||
if (subagentId) {
|
||||
for (const s of segs) {
|
||||
const node = findNode(s.childTimeline?.children, subagentId)
|
||||
if (node) return ensureNodeContainer(node)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Mark a subagent (segment or nested node) complete by subagentId. */
|
||||
function markDelegComplete(segs: MessageSegment[], subagentId: string | undefined, childConvId: string | undefined,
|
||||
success: boolean, resultPreview?: string, durationMs?: number): boolean {
|
||||
const seg = findDelegSegment(segs, subagentId, childConvId)
|
||||
if (seg) {
|
||||
seg.status = success ? 'completed' : 'error'
|
||||
seg.toolSuccess = success
|
||||
if (resultPreview) seg.toolResult = resultPreview
|
||||
if (durationMs) seg.toolArgs = (seg.toolArgs || '').trimEnd() + ` (${Math.round(durationMs / 1000)}s)`
|
||||
return true
|
||||
}
|
||||
if (subagentId) {
|
||||
for (const s of segs) {
|
||||
const node = findNode(s.childTimeline?.children, subagentId)
|
||||
if (node) {
|
||||
node.status = success ? 'completed' : 'error'
|
||||
if (resultPreview) node.result = resultPreview
|
||||
if (durationMs) node.durationMs = durationMs
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Create a depth-1 segment (top of tree) or a nested DelegationNode (deeper). */
|
||||
function addDelegation(segs: MessageSegment[], info: any, opts: { async?: boolean } = {}) {
|
||||
const subagentId: string | undefined = info.subagentId
|
||||
const parentSubagentId: string | undefined = info.parentSubagentId
|
||||
const agentName: string = info.childAgentName || 'Agent'
|
||||
const depth: number = info.depth || 1
|
||||
const task: string = info.task || ''
|
||||
|
||||
if (parentSubagentId) {
|
||||
// depth-2+: attach under the parent subagent's container.
|
||||
const parent = resolveContainer(segs, parentSubagentId)
|
||||
if (!parent) return
|
||||
if (!findNode(parent.children, subagentId || '')) {
|
||||
parent.children!.push({
|
||||
subagentId: subagentId || genSegId(),
|
||||
agentName, status: 'running', depth, task,
|
||||
tools: [], children: [],
|
||||
...(opts.async ? { async: true } : {})
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
// depth-1: a top-level segment, keyed by subagentId for stable lookup.
|
||||
// Dedup against SSE replay / a duplicated start re-creating the same segment.
|
||||
const segId = subagentId || info.childConversationId || genSegId()
|
||||
if (segs.some(s => s.type === 'tool_call' && s.id === segId)) return
|
||||
segs.push({
|
||||
id: segId,
|
||||
type: 'tool_call',
|
||||
status: 'running',
|
||||
toolName: `→ ${agentName}`,
|
||||
toolArgs: task,
|
||||
childTimeline: { tools: [], children: [] },
|
||||
timestamp: Date.now(),
|
||||
...(opts.async ? { delegationAsync: true } : {})
|
||||
})
|
||||
}
|
||||
|
||||
stream.on('delegation_start', (data) => {
|
||||
if (isStaleEvent(data)) return
|
||||
streamPhase.value = 'executing_tool'
|
||||
if (currentAssistantId.value) {
|
||||
const segs = currentSegments.value
|
||||
// Close any running thinking/content segment
|
||||
const runningSeg = segs.findLast((s: MessageSegment) => s.status === 'running')
|
||||
if (runningSeg) runningSeg.status = 'completed'
|
||||
if (!currentAssistantId.value) return
|
||||
const segs = currentSegments.value
|
||||
|
||||
if (data.parallel && Array.isArray(data.children)) {
|
||||
// Parallel mode: one segment per child. Use childConversationId as the segment ID
|
||||
// so downstream events (delegation_child_complete, delegation_progress) can look up
|
||||
// the correct row by stable ID instead of agent name — which is not unique when
|
||||
// two concurrent tasks go to the same agent.
|
||||
for (const child of data.children) {
|
||||
segs.push({
|
||||
id: child.childConversationId || genSegId(),
|
||||
type: 'tool_call',
|
||||
status: 'running',
|
||||
toolName: `→ ${child.childAgentName || 'Agent'}`,
|
||||
toolArgs: child.task || '',
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Single-task mode: same stable ID approach
|
||||
segs.push({
|
||||
id: data.childConversationId || genSegId(),
|
||||
type: 'tool_call',
|
||||
status: 'running',
|
||||
toolName: `→ ${data.childAgentName || 'Agent'}`,
|
||||
toolArgs: data.task || '',
|
||||
timestamp: Date.now()
|
||||
})
|
||||
if (data.parallel && Array.isArray(data.children)) {
|
||||
// Only close a running root-level content/thinking segment when these are
|
||||
// top-level (depth-1) delegations; nested ones don't touch the root timeline.
|
||||
const topLevel = data.children.some((c: any) => !c.parentSubagentId)
|
||||
if (topLevel) {
|
||||
const running = segs.findLast((s: MessageSegment) => s.status === 'running' && (s.type === 'thinking' || s.type === 'content'))
|
||||
if (running) running.status = 'completed'
|
||||
}
|
||||
flushSegmentsToMessage()
|
||||
for (const child of data.children) addDelegation(segs, child)
|
||||
} else {
|
||||
if (!data.parentSubagentId) {
|
||||
const running = segs.findLast((s: MessageSegment) => s.status === 'running' && (s.type === 'thinking' || s.type === 'content'))
|
||||
if (running) running.status = 'completed'
|
||||
}
|
||||
addDelegation(segs, data)
|
||||
}
|
||||
flushSegmentsToMessage()
|
||||
})
|
||||
|
||||
// Fire-and-forget delegation. The parent agent keeps running after spawning,
|
||||
// so unlike delegation_start we do NOT close the parent's running content/thinking
|
||||
// segment. The child runs detached and its result is fetched later via task_output,
|
||||
// so it is marked async and rendered as "running in background" rather than a
|
||||
// spinner that never resolves on this turn.
|
||||
stream.on('delegation_async_spawned', (data) => {
|
||||
if (isStaleEvent(data)) return
|
||||
if (!currentAssistantId.value) return
|
||||
addDelegation(currentSegments.value, data, { async: true })
|
||||
flushSegmentsToMessage()
|
||||
})
|
||||
|
||||
stream.on('delegation_progress', (data) => {
|
||||
@ -984,41 +1100,27 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
||||
if (!currentAssistantId.value) return
|
||||
const segs = currentSegments.value
|
||||
|
||||
// Primary lookup: by stable childConversationId (set as the segment ID at creation time).
|
||||
// Fallback: any running delegation segment (for older backends that don't send the field).
|
||||
const delegSeg = (data.childConversationId
|
||||
? segs.find((s: MessageSegment) => s.id === data.childConversationId)
|
||||
: undefined)
|
||||
|| segs.findLast((s: MessageSegment) =>
|
||||
s.type === 'tool_call' && s.status === 'running' && s.toolName?.startsWith('→'))
|
||||
const container = resolveContainer(segs, data.subagentId, data.childConversationId)
|
||||
if (!container) return
|
||||
if (!container.tools) container.tools = []
|
||||
|
||||
if (!delegSeg) return
|
||||
|
||||
// Normalize data.data: the backend relays the child event's JSON payload.
|
||||
// After the P2 fix it arrives as an object; be defensive for older backends.
|
||||
// Normalize data.data: the backend relays the child event's JSON payload as
|
||||
// an object; be defensive for older backends that sent a JSON string.
|
||||
const rawPayload = data.data
|
||||
const childData: Record<string, any> = rawPayload && typeof rawPayload === 'object'
|
||||
? rawPayload
|
||||
: (() => { try { return JSON.parse(String(rawPayload || '{}')) } catch { return {} } })()
|
||||
|
||||
// Build a structured child timeline on the delegation segment instead of
|
||||
// jamming tool names into toolArgs as text. The timeline holds the child
|
||||
// agent's own plan checklist + the tools it called, so the UI can render
|
||||
// a proper nested view (see ToolCallSegment.vue delegation branch).
|
||||
const timeline = (delegSeg.childTimeline ||= { tools: [] })
|
||||
if (!timeline.tools) timeline.tools = []
|
||||
|
||||
switch (data.originalEvent) {
|
||||
case 'tool_call_started': {
|
||||
const name = childData?.toolName || ''
|
||||
if (name) timeline.tools.push({ name, status: 'running' })
|
||||
if (name) container.tools.push({ name, status: 'running' })
|
||||
break
|
||||
}
|
||||
case 'tool_call_completed': {
|
||||
const name = childData?.toolName || ''
|
||||
const ok = childData?.success !== false
|
||||
// Match the most recent running entry with this name.
|
||||
const entry = [...timeline.tools].reverse()
|
||||
const entry = [...container.tools].reverse()
|
||||
.find(t => t.name === name && t.status === 'running')
|
||||
if (entry) entry.status = ok ? 'completed' : 'error'
|
||||
break
|
||||
@ -1026,21 +1128,21 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
||||
case 'plan_created': {
|
||||
const steps = childData?.steps
|
||||
if (Array.isArray(steps)) {
|
||||
timeline.plan = { planId: childData?.planId ?? '', steps, currentStep: 0, stepResults: [] }
|
||||
container.plan = { planId: childData?.planId ?? '', steps, currentStep: 0, stepResults: [] }
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'plan_step_started': {
|
||||
if (timeline.plan && typeof childData?.index === 'number') {
|
||||
timeline.plan.currentStep = childData.index
|
||||
if (container.plan && typeof childData?.index === 'number') {
|
||||
container.plan.currentStep = childData.index
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'plan_step_completed': {
|
||||
if (timeline.plan && typeof childData?.index === 'number') {
|
||||
const results = [...(timeline.plan.stepResults || [])]
|
||||
if (container.plan && typeof childData?.index === 'number') {
|
||||
const results = [...(container.plan.stepResults || [])]
|
||||
results[childData.index] = { result: childData.result ?? '', status: 'completed' }
|
||||
timeline.plan.stepResults = results
|
||||
container.plan.stepResults = results
|
||||
}
|
||||
break
|
||||
}
|
||||
@ -1049,90 +1151,62 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
||||
})
|
||||
|
||||
// Per-child completion: fires as soon as each individual child agent finishes,
|
||||
// before the overall delegation_end. Marks that child's segment done immediately
|
||||
// so the user sees incremental progress rather than a bulk update at the end.
|
||||
// before the overall delegation_end, so the user sees incremental progress.
|
||||
stream.on('delegation_child_complete', (data) => {
|
||||
if (isStaleEvent(data)) return
|
||||
if (!currentAssistantId.value) return
|
||||
const segs = currentSegments.value
|
||||
// Prefer childConversationId (stable) over agent name (non-unique)
|
||||
const delegSeg = (data.childConversationId
|
||||
? segs.find((s: MessageSegment) => s.id === data.childConversationId)
|
||||
: undefined)
|
||||
|| segs.findLast((s: MessageSegment) =>
|
||||
s.type === 'tool_call' && s.status === 'running' && s.toolName?.startsWith('→'))
|
||||
if (delegSeg) {
|
||||
delegSeg.status = data.success ? 'completed' : 'error'
|
||||
delegSeg.toolSuccess = data.success
|
||||
if (data.durationMs) {
|
||||
const durSec = Math.round(data.durationMs / 1000)
|
||||
delegSeg.toolArgs = (delegSeg.toolArgs || '').trimEnd() + ` (${durSec}s)`
|
||||
}
|
||||
// Write resultPreview for both success and failure so ToolCallSegment can show
|
||||
// an expand arrow with the child agent's actual output, not just a green/red dot.
|
||||
if (data.resultPreview) {
|
||||
delegSeg.toolResult = data.resultPreview
|
||||
}
|
||||
}
|
||||
markDelegComplete(currentSegments.value, data.subagentId, data.childConversationId,
|
||||
!!data.success, data.resultPreview, data.durationMs)
|
||||
flushSegmentsToMessage()
|
||||
})
|
||||
|
||||
stream.on('delegation_end', (data) => {
|
||||
if (isStaleEvent(data)) return
|
||||
if (currentAssistantId.value) {
|
||||
const segs = currentSegments.value
|
||||
if (data.parallel) {
|
||||
// Parallel mode: use per-child results if available (new backend),
|
||||
// fall back to aggregate success flag for older backends.
|
||||
if (Array.isArray(data.childResults) && data.childResults.length > 0) {
|
||||
for (const cr of data.childResults) {
|
||||
// Primary: stable childConversationId lookup. Fallback: agent name substring.
|
||||
const seg = (cr.childConversationId
|
||||
? segs.find((s: MessageSegment) => s.id === cr.childConversationId)
|
||||
: undefined)
|
||||
|| segs.findLast((s: MessageSegment) =>
|
||||
s.type === 'tool_call' && s.toolName?.includes(cr.agentName || ''))
|
||||
if (seg && seg.status === 'running') {
|
||||
// Segment not yet closed by delegation_child_complete (e.g. timed-out child).
|
||||
// Write whatever result info is available so ToolCallSegment can show content.
|
||||
seg.status = cr.success ? 'completed' : 'error'
|
||||
seg.toolSuccess = cr.success
|
||||
if (cr.durationMs) {
|
||||
const durSec = Math.round(cr.durationMs / 1000)
|
||||
seg.toolArgs = (seg.toolArgs || '').trimEnd() + ` (${durSec}s)`
|
||||
}
|
||||
// Show error reason for failures; for successes leave toolResult empty here
|
||||
// (delegation_child_complete already wrote the preview before we get to delegation_end).
|
||||
if (cr.error) {
|
||||
seg.toolResult = cr.error
|
||||
}
|
||||
}
|
||||
if (!currentAssistantId.value) return
|
||||
const segs = currentSegments.value
|
||||
if (data.parallel) {
|
||||
if (Array.isArray(data.childResults) && data.childResults.length > 0) {
|
||||
for (const cr of data.childResults) {
|
||||
// delegation_child_complete usually closed each child already; only
|
||||
// patch those still running (e.g. a timed-out child).
|
||||
const seg = findDelegSegment(segs, cr.subagentId, cr.childConversationId)
|
||||
const stillRunning = seg ? seg.status === 'running'
|
||||
: !!(cr.subagentId && findNode(segs.flatMap(s => s.childTimeline?.children || []), cr.subagentId)?.status === 'running')
|
||||
if (stillRunning) {
|
||||
markDelegComplete(segs, cr.subagentId, cr.childConversationId, !!cr.success,
|
||||
cr.error || undefined, cr.durationMs)
|
||||
}
|
||||
} else {
|
||||
// Legacy fallback: mark all remaining running delegation segments with overall status
|
||||
segs.filter((s: MessageSegment) =>
|
||||
s.type === 'tool_call' && s.status === 'running' && s.toolName?.startsWith('→'))
|
||||
.forEach((s: MessageSegment) => {
|
||||
s.status = data.success ? 'completed' : 'error'
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Single-task mode
|
||||
const delegSeg = segs.findLast((s: MessageSegment) =>
|
||||
// Legacy fallback: mark all remaining running top-level delegations.
|
||||
segs.filter((s: MessageSegment) =>
|
||||
s.type === 'tool_call' && s.status === 'running' && s.toolName?.startsWith('→'))
|
||||
if (delegSeg) {
|
||||
delegSeg.status = data.success ? 'completed' : 'error'
|
||||
delegSeg.toolSuccess = data.success
|
||||
if (data.resultPreview) {
|
||||
delegSeg.toolResult = data.resultPreview
|
||||
}
|
||||
if (data.durationMs) {
|
||||
delegSeg.toolArgs = (delegSeg.toolArgs || '').trimEnd() + ` (${Math.round(data.durationMs / 1000)}s)`
|
||||
}
|
||||
}
|
||||
.forEach((s: MessageSegment) => { s.status = data.success ? 'completed' : 'error' })
|
||||
}
|
||||
flushSegmentsToMessage()
|
||||
} else {
|
||||
markDelegComplete(segs, data.subagentId, data.childConversationId,
|
||||
!!data.success, data.resultPreview, data.durationMs)
|
||||
}
|
||||
flushSegmentsToMessage()
|
||||
})
|
||||
|
||||
// Heartbeat watchdog flagged a subagent as making no observable progress.
|
||||
// Mark the matching segment/node stale so the timeline can surface it; the
|
||||
// subagent stays "running" (stale ≠ finished — it may still recover).
|
||||
stream.on('subagent_stale', (data) => {
|
||||
if (isStaleEvent(data)) return
|
||||
if (!currentAssistantId.value || !data.subagentId) return
|
||||
const segs = currentSegments.value
|
||||
const seg = findDelegSegment(segs, data.subagentId)
|
||||
if (seg) {
|
||||
seg.delegationStale = true
|
||||
} else {
|
||||
for (const s of segs) {
|
||||
const node = findNode(s.childTimeline?.children, data.subagentId)
|
||||
if (node) { node.stale = true; break }
|
||||
}
|
||||
}
|
||||
flushSegmentsToMessage()
|
||||
})
|
||||
|
||||
// ===== Stream lifecycle (pre-token) events =====
|
||||
|
||||
@ -44,6 +44,12 @@ export type SSEEventType =
|
||||
| 'delegation_progress'
|
||||
| 'delegation_end'
|
||||
| 'delegation_child_complete'
|
||||
// Fire-and-forget delegation: a subagent spawned to run detached. Its result
|
||||
// is retrieved later via task_output, so it enters the timeline as a node
|
||||
// marked "running in background" rather than one that resolves this turn.
|
||||
| 'delegation_async_spawned'
|
||||
// Heartbeat watchdog flagged a sub-agent as making no observable progress
|
||||
| 'subagent_stale'
|
||||
// Persistent goal events (RFC 48) — emitted by GoalEvaluationNode
|
||||
| 'goal_evaluated'
|
||||
| 'goal_followup'
|
||||
|
||||
@ -90,6 +90,8 @@ export default {
|
||||
thinkingInProgress: 'Thinking...',
|
||||
stopped: 'Generation stopped',
|
||||
interrupted: 'Interrupted',
|
||||
subagentStalled: 'Subagent stalled — no progress',
|
||||
subagentAsync: 'Running in background — result via task_output',
|
||||
expandLines: 'Show more ({hidden} more lines)',
|
||||
collapse: 'Show less',
|
||||
failed: 'Generation failed',
|
||||
|
||||
@ -90,6 +90,8 @@ export default {
|
||||
thinkingInProgress: '思考中...',
|
||||
stopped: '已停止生成',
|
||||
interrupted: '已中断',
|
||||
subagentStalled: '子 Agent 无进展',
|
||||
subagentAsync: '后台运行中,结果稍后获取',
|
||||
expandLines: '展开(还有 {hidden} 行)',
|
||||
collapse: '收起',
|
||||
failed: '生成失败',
|
||||
|
||||
@ -127,6 +127,34 @@ export interface PlanMeta {
|
||||
stepResults?: { result: string; status: string }[]
|
||||
}
|
||||
|
||||
/** One tool the subagent called, shown in the nested delegation timeline. */
|
||||
export interface DelegationToolEntry {
|
||||
name: string
|
||||
status: 'running' | 'completed' | 'error'
|
||||
}
|
||||
|
||||
/**
|
||||
* A subagent at depth >= 2 in the delegation tree (a grandchild and deeper).
|
||||
* Built on the frontend from the flat delegation_* event stream, keyed by
|
||||
* subagentId and nested by parentSubagentId.
|
||||
*/
|
||||
export interface DelegationNode {
|
||||
subagentId: string
|
||||
agentName: string
|
||||
status: 'running' | 'completed' | 'error'
|
||||
depth: number
|
||||
task?: string
|
||||
plan?: PlanMeta
|
||||
tools?: DelegationToolEntry[]
|
||||
result?: string
|
||||
durationMs?: number
|
||||
/** Heartbeat watchdog flagged this subagent as making no observable progress. */
|
||||
stale?: boolean
|
||||
/** Spawned via fire-and-forget delegation: runs detached, result via task_output. */
|
||||
async?: boolean
|
||||
children: DelegationNode[]
|
||||
}
|
||||
|
||||
export interface PendingApprovalMeta {
|
||||
pendingId: string
|
||||
toolName: string
|
||||
@ -171,12 +199,19 @@ export interface MessageSegment {
|
||||
/**
|
||||
* For delegation segments (toolName starts with "→"): the subagent's own
|
||||
* activity, relayed from the child conversation. Renders as a nested timeline
|
||||
* (its plan checklist + the tools it called) instead of jammed text in toolArgs.
|
||||
* (its plan checklist + the tools it called + any grandchildren it delegated)
|
||||
* instead of jammed text in toolArgs. The depth-1 child is the segment itself;
|
||||
* `children` holds depth-2+ subagents as a recursive tree.
|
||||
*/
|
||||
childTimeline?: {
|
||||
plan?: PlanMeta
|
||||
tools?: { name: string; status: 'running' | 'completed' | 'error' }[]
|
||||
tools?: DelegationToolEntry[]
|
||||
children?: DelegationNode[]
|
||||
}
|
||||
/** For a delegation segment: heartbeat flagged the subagent as stalled (no progress). */
|
||||
delegationStale?: boolean
|
||||
/** For a delegation segment: spawned fire-and-forget, runs detached (result via task_output). */
|
||||
delegationAsync?: boolean
|
||||
/** 时间戳 */
|
||||
timestamp?: number
|
||||
/**
|
||||
|
||||
Loading…
Reference in New Issue
Block a user