From 5f571e86a2f58588f09cb632c15eac6f30d6c297 Mon Sep 17 00:00:00 2001 From: matevip Date: Fri, 22 May 2026 13:44:01 +0800 Subject: [PATCH] feat(agent,ui): multi-level subagent delegation tree --- .../agent/delegation/SubagentController.java | 16 +- .../agent/delegation/SubagentHeartbeat.java | 8 +- .../agent/delegation/SubagentRegistry.java | 48 ++- .../agent/runtime/AgentRuntimeAggregator.java | 6 + .../mate/tool/builtin/DelegateAgentTool.java | 301 +++++++++------- .../mate/tool/builtin/DelegationContext.java | 37 +- .../tool/builtin/DelegateAsyncToolTest.java | 61 +++- .../builtin/DelegateEventSequenceTest.java | 88 +++++ mateclaw-ui/src/api/index.ts | 5 + .../components/chat/DelegationNodeView.vue | 223 ++++++++++++ .../src/components/chat/ToolCallSegment.vue | 26 +- .../src/components/live/LiveFocusPanel.vue | 15 +- mateclaw-ui/src/components/live/LivePanel.vue | 34 +- mateclaw-ui/src/composables/chat/useChat.ts | 332 +++++++++++------- mateclaw-ui/src/composables/chat/useStream.ts | 6 + mateclaw-ui/src/i18n/locales/en-US.ts | 2 + mateclaw-ui/src/i18n/locales/zh-CN.ts | 2 + mateclaw-ui/src/types/index.ts | 39 +- 18 files changed, 961 insertions(+), 288 deletions(-) create mode 100644 mateclaw-ui/src/components/chat/DelegationNodeView.vue diff --git a/mateclaw-server/src/main/java/vip/mate/agent/delegation/SubagentController.java b/mateclaw-server/src/main/java/vip/mate/agent/delegation/SubagentController.java index 5b2e65e5..d4a93852 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/delegation/SubagentController.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/delegation/SubagentController.java @@ -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> 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> snapshot = registry.snapshot(parentConversationId).stream() + List> 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()); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/delegation/SubagentHeartbeat.java b/mateclaw-server/src/main/java/vip/mate/agent/delegation/SubagentHeartbeat.java index 564de2c0..886ea19c 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/delegation/SubagentHeartbeat.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/delegation/SubagentHeartbeat.java @@ -89,10 +89,16 @@ public class SubagentHeartbeat { if (rec.status().compareAndSet("running", "stale")) { Map 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); } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/delegation/SubagentRegistry.java b/mateclaw-server/src/main/java/vip/mate/agent/delegation/SubagentRegistry.java index f21e8cb4..73202a3d 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/delegation/SubagentRegistry.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/delegation/SubagentRegistry.java @@ -53,7 +53,14 @@ public class SubagentRegistry { AtomicReference 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 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 immediate parent matches + * {@code parentConvId}. Filtering at the registry boundary prevents callers + * from accidentally surfacing other tenants' subagents in API responses. + * + *

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 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 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); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/AgentRuntimeAggregator.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/AgentRuntimeAggregator.java index 5c68bf7d..a7593330 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/runtime/AgentRuntimeAggregator.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/AgentRuntimeAggregator.java @@ -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(), diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java index 45fc2b16..cbfd9b4c 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java @@ -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 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> 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> childrenInfo = prepared.stream().map(p -> { + Map 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 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 payload = new java.util.LinkedHashMap<>(); + Map 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> childResults = results.stream().map(r -> { Map 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 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 spawnEvent = new LinkedHashMap<>(); + if (streamTracker.isRunning(rootConvAsync)) { + Map 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 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 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 delegationPayload(String subagentId, String parentSubagentId, int depth, + String childConvId, String childAgentName) { + Map 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.). * - *

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. + *

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 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 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 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() { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java index c0b81b10..c19deb36 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java @@ -17,8 +17,17 @@ public final class DelegationContext { /** * Snapshot of one delegation layer's state. + * + *

{@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 childDeniedTools) {} + private record Frame(String parentConversationId, Set childDeniedTools, + String rootConversationId, String currentSubagentId) {} private static final ThreadLocal> 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 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 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 */ diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncToolTest.java index ad035df6..32956b8f 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncToolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncToolTest.java @@ -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 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 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 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 { diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateEventSequenceTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateEventSequenceTest.java index bdf04eb2..f36cabe2 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateEventSequenceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateEventSequenceTest.java @@ -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 createdConvs = new java.util.ArrayList<>(); + List 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 convCap = ArgumentCaptor.forClass(String.class); + ArgumentCaptor evCap = ArgumentCaptor.forClass(String.class); + ArgumentCaptor payloadCap = ArgumentCaptor.forClass(Object.class); + verify(streamTracker, atLeast(4)).broadcastObject(convCap.capture(), evCap.capture(), payloadCap.capture()); + + Map childStart = null; + Map grandStart = null; + for (int i = 0; i < evCap.getAllValues().size(); i++) { + if (!"delegation_start".equals(evCap.getAllValues().get(i))) continue; + Map p = (Map) 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"); + } } diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 56bfc88d..a023ef00 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -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 diff --git a/mateclaw-ui/src/components/chat/DelegationNodeView.vue b/mateclaw-ui/src/components/chat/DelegationNodeView.vue new file mode 100644 index 00000000..cab4906e --- /dev/null +++ b/mateclaw-ui/src/components/chat/DelegationNodeView.vue @@ -0,0 +1,223 @@ + + + + + diff --git a/mateclaw-ui/src/components/chat/ToolCallSegment.vue b/mateclaw-ui/src/components/chat/ToolCallSegment.vue index 5b92ebc5..0cd9491d 100644 --- a/mateclaw-ui/src/components/chat/ToolCallSegment.vue +++ b/mateclaw-ui/src/components/chat/ToolCallSegment.vue @@ -1,8 +1,9 @@