mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +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")
|
@vip.mate.tool.ConcurrencyUnsafe("internally fans out to its own thread pool; outer executor must not double-parallelize")
|
||||||
@Tool(description = """
|
@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. \
|
Each task runs concurrently in an independent child session. \
|
||||||
Use this when you have multiple independent sub-tasks that can run simultaneously. \
|
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(
|
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,
|
String tasksJson,
|
||||||
// RFC-063r §2.5 改动点 5: hidden from LLM, used to inherit ChatOrigin into children.
|
// RFC-063r §2.5 改动点 5: hidden from LLM, used to inherit ChatOrigin into children.
|
||||||
@Nullable ToolContext ctx) {
|
@Nullable ToolContext ctx) {
|
||||||
@ -433,9 +437,11 @@ public class DelegateAgentTool {
|
|||||||
|
|
||||||
// 2. Main thread: validate agents, create child conversations, register relays
|
// 2. Main thread: validate agents, create child conversations, register relays
|
||||||
record PreparedChild(int index, AgentEntity agent, String task, String childConvId,
|
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<PreparedChild> prepared = new ArrayList<>();
|
||||||
List<String> errors = 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++) {
|
for (int i = 0; i < tasks.size(); i++) {
|
||||||
Map<String, String> t = tasks.get(i);
|
Map<String, String> t = tasks.get(i);
|
||||||
@ -453,6 +459,14 @@ public class DelegateAgentTool {
|
|||||||
continue;
|
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 childConvId = createChildConv(agent, parentConversationId);
|
||||||
String subagentId = parentConversationId != null
|
String subagentId = parentConversationId != null
|
||||||
? subagentRegistry.register(parentConversationId, childConvId,
|
? subagentRegistry.register(parentConversationId, childConvId,
|
||||||
@ -462,7 +476,7 @@ public class DelegateAgentTool {
|
|||||||
? registerBatchedRelay(childConvId, rootConvFinal, agent.getName(),
|
? registerBatchedRelay(childConvId, rootConvFinal, agent.getName(),
|
||||||
subagentId, parentSubagentId, childDepth)
|
subagentId, parentSubagentId, childDepth)
|
||||||
: null;
|
: 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()) {
|
if (prepared.isEmpty()) {
|
||||||
@ -484,6 +498,14 @@ public class DelegateAgentTool {
|
|||||||
"children", childrenInfo));
|
"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
|
// 4. Fan out — execute children in parallel
|
||||||
long startTime = System.currentTimeMillis();
|
long startTime = System.currentTimeMillis();
|
||||||
Map<Integer, CompletableFuture<ChildResult>> futures = new LinkedHashMap<>();
|
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);
|
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<>();
|
List<ChildResult> results = new ArrayList<>();
|
||||||
try {
|
try {
|
||||||
CompletableFuture.allOf(futures.values().toArray(new CompletableFuture[0]))
|
CompletableFuture<Void> allDone = CompletableFuture.allOf(
|
||||||
.get(parallelTimeoutSeconds, TimeUnit.SECONDS);
|
futures.values().toArray(new CompletableFuture[0]));
|
||||||
|
CompletableFuture.anyOf(allDone, requiredFailure)
|
||||||
|
.get(effectiveTimeoutSeconds, TimeUnit.SECONDS);
|
||||||
} catch (TimeoutException e) {
|
} 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) {
|
} catch (Exception e) {
|
||||||
log.error("Parallel delegation error: {}", e.getMessage());
|
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
|
// Collect results — completed futures get their value; unfinished ones are cancelled and recorded as timeout
|
||||||
for (var entry : futures.entrySet()) {
|
for (var entry : futures.entrySet()) {
|
||||||
@ -565,8 +599,11 @@ public class DelegateAgentTool {
|
|||||||
streamTracker.requestStop(p.childConvId);
|
streamTracker.requestStop(p.childConvId);
|
||||||
}
|
}
|
||||||
f.cancel(true);
|
f.cancel(true);
|
||||||
// Use ofTimeout so outcome="timeout" is explicit and distinct from "error".
|
// Distinguish fail-fast cancellation from a genuine timeout so the
|
||||||
results.add(ChildResult.ofTimeout(idx, agentName, parallelTimeoutSeconds));
|
// 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 successCount = results.stream().filter(r -> r.success && !r.isBlank()).count();
|
||||||
long blankCount = results.stream().filter(ChildResult::isBlank).count();
|
long blankCount = results.stream().filter(ChildResult::isBlank).count();
|
||||||
long timeoutCount = results.stream().filter(r -> "timeout".equals(r.outcome)).count();
|
long timeoutCount = results.stream().filter(r -> "timeout".equals(r.outcome)).count();
|
||||||
|
long cancelledCount = results.stream().filter(r -> "cancelled".equals(r.outcome)).count();
|
||||||
long errorCount = results.stream().filter(r -> "error".equals(r.outcome)).count();
|
long errorCount = results.stream().filter(r -> "error".equals(r.outcome)).count();
|
||||||
|
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
@ -639,6 +677,7 @@ public class DelegateAgentTool {
|
|||||||
.append(" success=").append(successCount)
|
.append(" success=").append(successCount)
|
||||||
.append(" blank_success=").append(blankCount)
|
.append(" blank_success=").append(blankCount)
|
||||||
.append(" timeout=").append(timeoutCount)
|
.append(" timeout=").append(timeoutCount)
|
||||||
|
.append(" cancelled=").append(cancelledCount)
|
||||||
.append(" error=").append(errorCount)
|
.append(" error=").append(errorCount)
|
||||||
.append(" durationMs=").append(totalDurationMs)
|
.append(" durationMs=").append(totalDurationMs)
|
||||||
.append("\n\n");
|
.append("\n\n");
|
||||||
@ -673,7 +712,7 @@ public class DelegateAgentTool {
|
|||||||
.append(",trim 后 0 字符)。请勿将此误报为超时或失败——子 Agent 已正常完成,只是本次无输出。\n");
|
.append(",trim 后 0 字符)。请勿将此误报为超时或失败——子 Agent 已正常完成,只是本次无输出。\n");
|
||||||
}
|
}
|
||||||
case "timeout" ->
|
case "timeout" ->
|
||||||
sb.append("❌ 超时(").append(parallelTimeoutSeconds).append("s 内未返回)\n");
|
sb.append("❌ 超时(").append(effectiveTimeoutSeconds).append("s 内未返回)\n");
|
||||||
default ->
|
default ->
|
||||||
sb.append("❌ 失败:").append(r.error).append("\n");
|
sb.append("❌ 失败:").append(r.error).append("\n");
|
||||||
}
|
}
|
||||||
@ -1084,6 +1123,16 @@ public class DelegateAgentTool {
|
|||||||
"timeout", 0, 0);
|
"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
|
// Legacy shims — kept for callers that pre-date the factory methods
|
||||||
static ChildResult success(int idx, String name, String result, long ms) {
|
static ChildResult success(int idx, String name, String result, long ms) {
|
||||||
// result may already be truncated at call site — lengths will be approximate
|
// result may already be truncated at call site — lengths will be approximate
|
||||||
@ -1336,6 +1385,17 @@ public class DelegateAgentTool {
|
|||||||
return DelegationContext.parentConversationId();
|
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() {
|
private String availableAgentsHint() {
|
||||||
List<AgentEntity> agents = agentMapper.selectList(new LambdaQueryWrapper<AgentEntity>()
|
List<AgentEntity> agents = agentMapper.selectList(new LambdaQueryWrapper<AgentEntity>()
|
||||||
.eq(AgentEntity::getEnabled, true).select(AgentEntity::getName));
|
.eq(AgentEntity::getEnabled, true).select(AgentEntity::getName));
|
||||||
|
|||||||
@ -263,4 +263,109 @@ class DelegateAgentToolTest {
|
|||||||
assertTrue(result.contains("超时") || result.contains("✗"),
|
assertTrue(result.contains("超时") || result.contains("✗"),
|
||||||
"Should contain timeout indicator for SlowAgent: " + result);
|
"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