mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
feat(agent): parallel delegation optional fail-fast and per-call timeout override
This commit is contained in:
parent
a94a756677
commit
903c7bd72e
@ -384,12 +384,16 @@ public class DelegateAgentTool {
|
||||
|
||||
@vip.mate.tool.ConcurrencyUnsafe("internally fans out to its own thread pool; outer executor must not double-parallelize")
|
||||
@Tool(description = """
|
||||
Delegate multiple tasks to different Agents in parallel (max 3). \
|
||||
Delegate multiple tasks to different Agents in parallel (max 8). \
|
||||
Each task runs concurrently in an independent child session. \
|
||||
Use this when you have multiple independent sub-tasks that can run simultaneously. \
|
||||
Input is a JSON array: [{"agentName":"Agent名称","task":"任务描述"}, ...]""")
|
||||
Input is a JSON array of objects with required "agentName" and "task", plus optional \
|
||||
"optional" (true = this task's failure must not abort the batch) and "timeout_seconds" \
|
||||
(widen the shared batch budget for a deliberately long task). When a required (non-optional) \
|
||||
task fails, remaining tasks are cancelled early instead of waiting out the full budget. \
|
||||
Example: [{"agentName":"X","task":"Y","optional":false,"timeout_seconds":120}, ...]""")
|
||||
public String delegateParallel(
|
||||
@ToolParam(description = "JSON array of tasks: [{\"agentName\":\"X\",\"task\":\"Y\"}, ...]")
|
||||
@ToolParam(description = "JSON array of tasks: [{\"agentName\":\"X\",\"task\":\"Y\",\"optional\":false,\"timeout_seconds\":120}, ...]")
|
||||
String tasksJson,
|
||||
// RFC-063r §2.5 改动点 5: hidden from LLM, used to inherit ChatOrigin into children.
|
||||
@Nullable ToolContext ctx) {
|
||||
@ -433,9 +437,11 @@ public class DelegateAgentTool {
|
||||
|
||||
// 2. Main thread: validate agents, create child conversations, register relays
|
||||
record PreparedChild(int index, AgentEntity agent, String task, String childConvId,
|
||||
Runnable stopRelay, String subagentId) {}
|
||||
Runnable stopRelay, String subagentId, boolean optional) {}
|
||||
List<PreparedChild> prepared = new ArrayList<>();
|
||||
List<String> errors = new ArrayList<>();
|
||||
// Highest per-task timeout override; widens the batch budget below.
|
||||
int maxTaskTimeoutSeconds = 0;
|
||||
|
||||
for (int i = 0; i < tasks.size(); i++) {
|
||||
Map<String, String> t = tasks.get(i);
|
||||
@ -453,6 +459,14 @@ public class DelegateAgentTool {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Optional tasks never trigger fail-fast; per-task timeout_seconds
|
||||
// (when present) widens the shared batch budget.
|
||||
boolean optional = "true".equalsIgnoreCase(t.get("optional"));
|
||||
Integer taskTimeout = parsePositiveIntOrNull(t.get("timeout_seconds"));
|
||||
if (taskTimeout != null) {
|
||||
maxTaskTimeoutSeconds = Math.max(maxTaskTimeoutSeconds, taskTimeout);
|
||||
}
|
||||
|
||||
String childConvId = createChildConv(agent, parentConversationId);
|
||||
String subagentId = parentConversationId != null
|
||||
? subagentRegistry.register(parentConversationId, childConvId,
|
||||
@ -462,7 +476,7 @@ public class DelegateAgentTool {
|
||||
? registerBatchedRelay(childConvId, rootConvFinal, agent.getName(),
|
||||
subagentId, parentSubagentId, childDepth)
|
||||
: null;
|
||||
prepared.add(new PreparedChild(i, agent, task, childConvId, stopRelay, subagentId));
|
||||
prepared.add(new PreparedChild(i, agent, task, childConvId, stopRelay, subagentId, optional));
|
||||
}
|
||||
|
||||
if (prepared.isEmpty()) {
|
||||
@ -484,6 +498,14 @@ public class DelegateAgentTool {
|
||||
"children", childrenInfo));
|
||||
}
|
||||
|
||||
// Batch budget: the global default, widened by the largest per-task
|
||||
// timeout_seconds override so a deliberately long child isn't cut off.
|
||||
final int effectiveTimeoutSeconds = Math.max(parallelTimeoutSeconds, maxTaskTimeoutSeconds);
|
||||
// Completes as soon as any REQUIRED child finishes unsuccessfully, so the
|
||||
// wait below can collapse early (fail-fast) instead of burning the full
|
||||
// budget while the parent already knows the batch can't succeed.
|
||||
CompletableFuture<Void> requiredFailure = new CompletableFuture<>();
|
||||
|
||||
// 4. Fan out — execute children in parallel
|
||||
long startTime = System.currentTimeMillis();
|
||||
Map<Integer, CompletableFuture<ChildResult>> futures = new LinkedHashMap<>();
|
||||
@ -528,19 +550,31 @@ public class DelegateAgentTool {
|
||||
});
|
||||
}
|
||||
|
||||
// Required children arm the fail-fast signal on unsuccessful completion.
|
||||
if (!p.optional()) {
|
||||
future.thenAccept(r -> {
|
||||
if (r != null && !r.success) {
|
||||
requiredFailure.complete(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
futures.put(p.index, future);
|
||||
}
|
||||
|
||||
// 5. Wait for all children (with timeout)
|
||||
// 5. Wait for all children, or bail out early when a required child fails.
|
||||
List<ChildResult> results = new ArrayList<>();
|
||||
try {
|
||||
CompletableFuture.allOf(futures.values().toArray(new CompletableFuture[0]))
|
||||
.get(parallelTimeoutSeconds, TimeUnit.SECONDS);
|
||||
CompletableFuture<Void> allDone = CompletableFuture.allOf(
|
||||
futures.values().toArray(new CompletableFuture[0]));
|
||||
CompletableFuture.anyOf(allDone, requiredFailure)
|
||||
.get(effectiveTimeoutSeconds, TimeUnit.SECONDS);
|
||||
} catch (TimeoutException e) {
|
||||
log.warn("Parallel delegation timed out ({}s), collecting completed results", parallelTimeoutSeconds);
|
||||
log.warn("Parallel delegation timed out ({}s), collecting completed results", effectiveTimeoutSeconds);
|
||||
} catch (Exception e) {
|
||||
log.error("Parallel delegation error: {}", e.getMessage());
|
||||
}
|
||||
boolean failFast = requiredFailure.isDone();
|
||||
|
||||
// Collect results — completed futures get their value; unfinished ones are cancelled and recorded as timeout
|
||||
for (var entry : futures.entrySet()) {
|
||||
@ -565,8 +599,11 @@ public class DelegateAgentTool {
|
||||
streamTracker.requestStop(p.childConvId);
|
||||
}
|
||||
f.cancel(true);
|
||||
// Use ofTimeout so outcome="timeout" is explicit and distinct from "error".
|
||||
results.add(ChildResult.ofTimeout(idx, agentName, parallelTimeoutSeconds));
|
||||
// Distinguish fail-fast cancellation from a genuine timeout so the
|
||||
// parent doesn't misread a cancelled sibling as a slow agent.
|
||||
results.add(failFast
|
||||
? ChildResult.ofCancelled(idx, agentName)
|
||||
: ChildResult.ofTimeout(idx, agentName, effectiveTimeoutSeconds));
|
||||
}
|
||||
}
|
||||
|
||||
@ -627,6 +664,7 @@ public class DelegateAgentTool {
|
||||
long successCount = results.stream().filter(r -> r.success && !r.isBlank()).count();
|
||||
long blankCount = results.stream().filter(ChildResult::isBlank).count();
|
||||
long timeoutCount = results.stream().filter(r -> "timeout".equals(r.outcome)).count();
|
||||
long cancelledCount = results.stream().filter(r -> "cancelled".equals(r.outcome)).count();
|
||||
long errorCount = results.stream().filter(r -> "error".equals(r.outcome)).count();
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
@ -639,6 +677,7 @@ public class DelegateAgentTool {
|
||||
.append(" success=").append(successCount)
|
||||
.append(" blank_success=").append(blankCount)
|
||||
.append(" timeout=").append(timeoutCount)
|
||||
.append(" cancelled=").append(cancelledCount)
|
||||
.append(" error=").append(errorCount)
|
||||
.append(" durationMs=").append(totalDurationMs)
|
||||
.append("\n\n");
|
||||
@ -673,7 +712,7 @@ public class DelegateAgentTool {
|
||||
.append(",trim 后 0 字符)。请勿将此误报为超时或失败——子 Agent 已正常完成,只是本次无输出。\n");
|
||||
}
|
||||
case "timeout" ->
|
||||
sb.append("❌ 超时(").append(parallelTimeoutSeconds).append("s 内未返回)\n");
|
||||
sb.append("❌ 超时(").append(effectiveTimeoutSeconds).append("s 内未返回)\n");
|
||||
default ->
|
||||
sb.append("❌ 失败:").append(r.error).append("\n");
|
||||
}
|
||||
@ -1084,6 +1123,16 @@ public class DelegateAgentTool {
|
||||
"timeout", 0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for a child cancelled by fail-fast — a required sibling failed,
|
||||
* so this still-running child was stopped before finishing. Distinct from
|
||||
* a timeout (it was not slow; the batch was abandoned).
|
||||
*/
|
||||
static ChildResult ofCancelled(int idx, String name) {
|
||||
return new ChildResult(idx, name, false, null,
|
||||
"已取消(必需子任务失败,触发提前收束)", 0, "cancelled", 0, 0);
|
||||
}
|
||||
|
||||
// Legacy shims — kept for callers that pre-date the factory methods
|
||||
static ChildResult success(int idx, String name, String result, long ms) {
|
||||
// result may already be truncated at call site — lengths will be approximate
|
||||
@ -1336,6 +1385,17 @@ public class DelegateAgentTool {
|
||||
return DelegationContext.parentConversationId();
|
||||
}
|
||||
|
||||
/** Parse a positive integer (seconds) or return null for blank / invalid / non-positive input. */
|
||||
private static Integer parsePositiveIntOrNull(String raw) {
|
||||
if (raw == null || raw.isBlank()) return null;
|
||||
try {
|
||||
int v = Integer.parseInt(raw.trim());
|
||||
return v > 0 ? v : null;
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String availableAgentsHint() {
|
||||
List<AgentEntity> agents = agentMapper.selectList(new LambdaQueryWrapper<AgentEntity>()
|
||||
.eq(AgentEntity::getEnabled, true).select(AgentEntity::getName));
|
||||
|
||||
@ -263,4 +263,109 @@ class DelegateAgentToolTest {
|
||||
assertTrue(result.contains("超时") || result.contains("✗"),
|
||||
"Should contain timeout indicator for SlowAgent: " + result);
|
||||
}
|
||||
|
||||
// ===== delegateParallel: fail-fast on required failure (RFC 05 Q3) =====
|
||||
|
||||
@Test
|
||||
@DisplayName("delegateParallel fails fast: required failure cancels a slow sibling")
|
||||
void delegateParallelRequiredFailureCancelsSibling() {
|
||||
AgentEntity failAgent = new AgentEntity();
|
||||
failAgent.setId(20L);
|
||||
failAgent.setName("FailAgent");
|
||||
failAgent.setEnabled(true);
|
||||
failAgent.setWorkspaceId(1L);
|
||||
|
||||
AgentEntity slowAgent = new AgentEntity();
|
||||
slowAgent.setId(21L);
|
||||
slowAgent.setName("SlowAgent");
|
||||
slowAgent.setEnabled(true);
|
||||
slowAgent.setWorkspaceId(1L);
|
||||
|
||||
when(agentMapper.selectOne(any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(failAgent)
|
||||
.thenReturn(slowAgent);
|
||||
when(streamTracker.isRunning(any())).thenReturn(false);
|
||||
// Required FailAgent errors immediately → arms fail-fast.
|
||||
when(agentService.chat(eq(20L), anyString(), anyString(), any()))
|
||||
.thenThrow(new RuntimeException("boom"));
|
||||
// SlowAgent would block well past the 3 s test budget; fail-fast cancels it.
|
||||
when(agentService.chat(eq(21L), anyString(), anyString(), any())).thenAnswer(inv -> {
|
||||
Thread.sleep(10_000);
|
||||
return "unreachable";
|
||||
});
|
||||
|
||||
ToolExecutionContext.set("parent-ff", "admin");
|
||||
String json = "[{\"agentName\":\"FailAgent\",\"task\":\"a\"},{\"agentName\":\"SlowAgent\",\"task\":\"b\"}]";
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
String result = delegateAgentTool.delegateParallel(json, null);
|
||||
long elapsed = System.currentTimeMillis() - start;
|
||||
|
||||
assertTrue(elapsed < 2500, "fail-fast should return well before the budget, took " + elapsed + "ms");
|
||||
assertTrue(result.contains("cancelled=1"), "slow sibling should be cancelled: " + result);
|
||||
assertTrue(result.contains("已取消"), "should label the cancelled sibling: " + result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("delegateParallel: optional task failure does not abort the batch")
|
||||
void delegateParallelOptionalFailureDoesNotAbort() {
|
||||
AgentEntity optAgent = new AgentEntity();
|
||||
optAgent.setId(30L);
|
||||
optAgent.setName("OptAgent");
|
||||
optAgent.setEnabled(true);
|
||||
optAgent.setWorkspaceId(1L);
|
||||
|
||||
AgentEntity okAgent = new AgentEntity();
|
||||
okAgent.setId(31L);
|
||||
okAgent.setName("OkAgent");
|
||||
okAgent.setEnabled(true);
|
||||
okAgent.setWorkspaceId(1L);
|
||||
|
||||
when(agentMapper.selectOne(any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(optAgent)
|
||||
.thenReturn(okAgent);
|
||||
when(streamTracker.isRunning(any())).thenReturn(false);
|
||||
when(agentService.chat(eq(30L), anyString(), anyString(), any()))
|
||||
.thenThrow(new RuntimeException("opt boom"));
|
||||
when(agentService.chat(eq(31L), anyString(), anyString(), any()))
|
||||
.thenReturn("ok result done");
|
||||
|
||||
ToolExecutionContext.set("parent-opt", "admin");
|
||||
String json = "[{\"agentName\":\"OptAgent\",\"task\":\"a\",\"optional\":true},"
|
||||
+ "{\"agentName\":\"OkAgent\",\"task\":\"b\"}]";
|
||||
String result = delegateAgentTool.delegateParallel(json, null);
|
||||
|
||||
// An optional failure must not cancel anything; the other task completes normally.
|
||||
assertTrue(result.contains("cancelled=0"), "no cancellation expected: " + result);
|
||||
assertTrue(result.contains("OkAgent"), "ok task should be reported: " + result);
|
||||
}
|
||||
|
||||
// ===== delegateParallel: per-task timeout override (RFC 05 Q2) =====
|
||||
|
||||
@Test
|
||||
@DisplayName("delegateParallel: per-task timeout_seconds widens the batch budget")
|
||||
void delegateParallelTimeoutOverrideWidensBudget() {
|
||||
AgentEntity longAgent = new AgentEntity();
|
||||
longAgent.setId(40L);
|
||||
longAgent.setName("LongAgent");
|
||||
longAgent.setEnabled(true);
|
||||
longAgent.setWorkspaceId(1L);
|
||||
|
||||
when(agentMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(longAgent);
|
||||
when(streamTracker.isRunning(any())).thenReturn(false);
|
||||
// Sleeps 4 s — beyond the 3 s test budget, but within the 6 s override.
|
||||
when(agentService.chat(eq(40L), anyString(), anyString(), any())).thenAnswer(inv -> {
|
||||
Thread.sleep(4_000);
|
||||
return "long task finished ok";
|
||||
});
|
||||
|
||||
ToolExecutionContext.set("parent-to", "admin");
|
||||
String json = "[{\"agentName\":\"LongAgent\",\"task\":\"a\",\"timeout_seconds\":6}]";
|
||||
String result = delegateAgentTool.delegateParallel(json, null);
|
||||
|
||||
// With the override the child finishes instead of timing out at 3 s.
|
||||
assertTrue(result.contains("long task finished ok") || result.contains("success=1"),
|
||||
"long task should complete within the widened budget: " + result);
|
||||
assertFalse(result.contains("timeout=1"), "should not time out with the override: " + result);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user