Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,10 @@ public class A2AAgentExecutor implements AgentExecutor {
private static final String INTERRUPT = "_interrupt";

/**
* Dead-time bound for waiting on the in-flight queue to drain before
* force-closing the stream. The wait returns as soon as the queue actually
* drains, so this only caps the worst case; it is set generously because under
* Dead-time bound for waiting on the in-flight queue to drain before closing
* the stream. The wait returns as soon as the queue actually drains; on timeout
* the queue stays open so a late interrupted status can still be delivered. The
* bound is set generously because under
* a high-latency Redis task store the event-bus processor persists each
* backed-up streaming event with a blocking round-trip, so a large backlog can
* take a while to clear.
Expand Down Expand Up @@ -278,22 +279,29 @@ private static void closeEventQueue(AgentEmitter emitter, String taskId) {
try {
Optional<org.a2aproject.sdk.server.events.EventQueue> queue = emitterEventQueue(emitter);
if (queue.isPresent()) {
org.a2aproject.sdk.server.events.EventQueue q = queue.get();
awaitInFlightDrained(q, taskId);
q.close(false, false);
closeWhenDrained(queue.get(), taskId, CLOSE_DRAIN_TIMEOUT_MS);
}
log.info("A2A eventQueue closed (INPUT_REQUIRED preserved) taskId={}", taskId);
} catch (ReflectiveOperationException | SecurityException e) {
log.warn("A2A closeEventQueue failed, falling back to complete() taskId={}", taskId, e);
emitter.complete();
}
}

static void closeWhenDrained(org.a2aproject.sdk.server.events.EventQueue queue, String taskId, long timeoutMs) {
if (awaitInFlightDrained(queue, taskId, timeoutMs)) {
queue.close(false, false);
log.info("A2A eventQueue closed (INPUT_REQUIRED preserved) taskId={}", taskId);
return;
}
log.warn("A2A eventQueue still has in-flight events after {}ms; leaving it open to preserve INPUT_REQUIRED "
+ "delivery taskId={}", timeoutMs, taskId);
}

private static void completeAndDrain(AgentEmitter emitter, String taskId) {
emitter.complete();
try {
Optional<org.a2aproject.sdk.server.events.EventQueue> queue = emitterEventQueue(emitter);
queue.ifPresent(q -> awaitInFlightDrained(q, taskId));
queue.ifPresent(q -> awaitInFlightDrained(q, taskId, CLOSE_DRAIN_TIMEOUT_MS));
log.info("A2A eventQueue drained after COMPLETED taskId={}", taskId);
} catch (ReflectiveOperationException | SecurityException e) {
log.debug("A2A completeAndDrain: eventQueue unavailable taskId={}", taskId, e);
Expand All @@ -308,7 +316,7 @@ private void failAndDrain(AgentEmitter emitter, A2AMessageContext msgCtx, Throwa
String taskId = msgCtx.getTaskId();
try {
Optional<org.a2aproject.sdk.server.events.EventQueue> queue = emitterEventQueue(emitter);
queue.ifPresent(q -> awaitInFlightDrained(q, taskId));
queue.ifPresent(q -> awaitInFlightDrained(q, taskId, CLOSE_DRAIN_TIMEOUT_MS));
log.info("A2A eventQueue drained after FAILED taskId={}", taskId);
} catch (ReflectiveOperationException | SecurityException e) {
log.debug("A2A failAndDrain: eventQueue unavailable taskId={}", taskId, e);
Expand All @@ -333,16 +341,20 @@ private static Optional<org.a2aproject.sdk.server.events.EventQueue> emitterEven
* child queue. {@code MainQueue.size()} only returns to zero after
* {@code distributeToChildren()} and the matching semaphore release, so this is
* the reliable "safe to close" signal. Returns early as soon as the queue
* drains and only blocks up to {@link #CLOSE_DRAIN_TIMEOUT_MS}; falls back to
* an immediate close if the topology or {@code size()} cannot be read
* reflectively.
* drains and only blocks up to the supplied timeout. A timeout or unavailable
* queue size is reported to the caller so it can avoid closing ahead of an
* in-flight interrupted status.
*
* @param childQueue
* the emitter's (child) event queue
* @param taskId
* the A2A task ID for logging
* @param timeoutMs
* maximum time to wait for in-flight events
* @return {@code true} when the queue drained before the timeout
*/
private static void awaitInFlightDrained(org.a2aproject.sdk.server.events.EventQueue childQueue, String taskId) {
private static boolean awaitInFlightDrained(org.a2aproject.sdk.server.events.EventQueue childQueue, String taskId,
long timeoutMs) {
Object sizeTarget = childQueue;
try {
var parentField = childQueue.getClass().getDeclaredField("parent");
Expand All @@ -354,24 +366,24 @@ private static void awaitInFlightDrained(org.a2aproject.sdk.server.events.EventQ
} catch (ReflectiveOperationException | SecurityException e) {
log.debug("A2A awaitInFlightDrained: no parent queue, polling child queue taskId={}", taskId);
}
long deadline = System.currentTimeMillis() + CLOSE_DRAIN_TIMEOUT_MS;
long deadline = System.currentTimeMillis() + timeoutMs;
try {
var sizeMethod = sizeTarget.getClass().getMethod("size");
sizeMethod.setAccessible(true);
while (System.currentTimeMillis() < deadline) {
Object size = sizeMethod.invoke(sizeTarget);
if (size instanceof Integer i && i <= 0) {
return;
return true;
}
Thread.sleep(CLOSE_DRAIN_POLL_MS);
}
log.warn("A2A awaitInFlightDrained timed out after {}ms, closing anyway taskId={}", CLOSE_DRAIN_TIMEOUT_MS,
taskId);
return false;
} catch (InterruptedException e) {
log.debug("A2A awaitInFlightDrained interrupted taskId={}", taskId);
} catch (ReflectiveOperationException | SecurityException e) {
log.debug("A2A awaitInFlightDrained: size() unavailable, closing immediately taskId={}", taskId);
log.debug("A2A awaitInFlightDrained: size() unavailable taskId={}", taskId);
}
return false;
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ public void save(Task task, boolean isOverwrite) {

if (isCritical || isWriteDue) {
delegate.save(task, isOverwrite);
lastWriteMs.put(id, now);
lastWriteMs.put(id, clock.getAsLong());
if (state != null && state.isFinal()) {
// Durable in the delegate now; drop the in-memory copy so the map stays
// bounded.
Expand Down Expand Up @@ -143,10 +143,9 @@ public void delete(String taskId) {
public ListTasksResult list(ListTasksParams params) {
// Flush any coalesced-but-unwritten state so the delegate's scan reflects
// in-flight tasks too.
long now = clock.getAsLong();
for (Map.Entry<String, Task> e : latest.entrySet()) {
delegate.save(e.getValue(), true);
lastWriteMs.put(e.getKey(), now);
lastWriteMs.put(e.getKey(), clock.getAsLong());
}
return delegate.list(params);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,15 @@ void syncCompletedPathWaitsForEnqueuedFinalEventToDrain() {
assertThat(queue.sizeCalls.get()).isPositive();
}

@Test
void inputRequiredQueueRemainsOpenWhenDrainTimesOut() {
NeverDrainingEventQueue queue = new NeverDrainingEventQueue();

A2AAgentExecutor.closeWhenDrained(queue, "task-1", 0L);

assertThat(queue.closeCalls.get()).isZero();
}

@Test
void failurePathEmitsFailedStatusWithBusinessError() {
ServeOrchestrator orchestrator = mock(ServeOrchestrator.class);
Expand Down Expand Up @@ -394,4 +403,18 @@ public void close(boolean isImmediate) {
public void close(boolean isImmediate, boolean shouldNotifyParent) {
}
}

private static final class NeverDrainingEventQueue extends CountingEventQueue {
private final AtomicInteger closeCalls = new AtomicInteger();

@Override
public int size() {
return 1;
}

@Override
public void close(boolean isImmediate, boolean shouldNotifyParent) {
closeCalls.incrementAndGet();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,17 @@ void writesAgainOnceThrottleWindowElapses() {
assertThat(delegate.data.get(ID).contextId()).isEqualTo("v1");
}

@Test
void measuresThrottleWindowAfterSlowDelegateWriteCompletes() {
delegate.saveDurationMs = INTERVAL_MS + 50L;

store.save(working("v0"), false);
store.save(working("v1"), false);

assertThat(delegate.saveCount).isEqualTo(1);
assertThat(store.get(ID).contextId()).isEqualTo("v1");
}

@Test
void finalStateAlwaysWritesThroughAndEvictsCache() {
store.save(working("v0"), false); // count 1
Expand Down Expand Up @@ -119,15 +130,18 @@ private static Task withState(String versionTag, TaskState state) {
* In-memory {@link TaskStore} that counts writes, standing in for the slow
* Redis delegate.
*/
private static final class CountingStore implements TaskStore {
private final class CountingStore implements TaskStore {
private final Map<String, Task> data = new HashMap<>();

private int saveCount;

private long saveDurationMs;

@Override
public void save(Task task, boolean isOverwrite) {
saveCount++;
data.put(task.id(), task);
now[0] += saveDurationMs;
}

@Override
Expand Down
4 changes: 2 additions & 2 deletions service/agent-service-demo/example/a2a/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,7 @@ curl -sS -N -X POST http://localhost:18090/a2a/ \
JSON
```

恢复后应进入 `TASK_STATE_COMPLETED`。Agent D 的结构化最终结果通过 `parts.data` 传递,逐层返回后的用户结果应包含 `claim_id`、`WF-STREAM-001`、`policy_status`、`OVER_LIMIT`、`approved` 和 `llm_report`,且不得包含未解开的 `workflow_final` AgentCore 信封
恢复后应进入 `TASK_STATE_COMPLETED`,保持原 `taskId`,且不得包含未解开的 `workflow_final` AgentCore 信封。Agent D 的结构化最终结果通过 `parts.data` 传递,但经过 Agent B 和 Agent A 的 ReAct 层后,最外层文本可能被模型重新表述;烟测不对该文本的字段名或格式做断言

### 场景 5:A -> B -> D 非流式人工审批与恢复

Expand Down Expand Up @@ -430,7 +430,7 @@ curl -sS -X POST http://localhost:18090/a2a/ \
JSON
```

恢复后应进入 `TASK_STATE_COMPLETED`,结果应包含 `claim_id`、`WF-NONSTREAM-001`、`policy_status`、`OVER_LIMIT`、`approved` 和 `llm_report`,并且 Agent B 不应因空工具结果重复调用 Agent D。
恢复后应进入 `TASK_STATE_COMPLETED` 并保持原 `taskId`,同时 Agent B 不应因空工具结果重复调用 Agent D。烟测不检查经过 ReAct 层后的最终文本字段或格式

### 场景 6:A -> B -> D 合规费用自动审批

Expand Down
6 changes: 2 additions & 4 deletions service/agent-service-demo/example/a2a/smoke-a2a.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -548,8 +548,7 @@ try {
$dStreamResponse2 = Invoke-Utf8TextRequest -Uri "$BaseUrlA/a2a/" -Body $dStreamBody2 `
-TimeoutSec $A2aRequestTimeoutSec
$dStreamResponse2 | Set-Content -LiteralPath (Join-Path $tmp "d-stream-response-2.txt") -Encoding utf8
$dStreamTask2 = Read-SseTask $dStreamResponse2 "TASK_STATE_COMPLETED" `
"policy_status" $dStreamClaim "llm_report"
$dStreamTask2 = Read-SseTask $dStreamResponse2 "TASK_STATE_COMPLETED"
$dStreamArtifacts = @(
foreach ($line in ($dStreamResponse2 -split "`r?`n")) {
if ($line.StartsWith("data:")) {
Expand Down Expand Up @@ -585,8 +584,7 @@ try {
-Body $dNonstreamBody2 -Accept "application/json" -TimeoutSec $A2aRequestTimeoutSec
$dNonstreamResponse2 = $dNonstreamRawResponse2 | ConvertFrom-Json
Save-JsonResponse $dNonstreamResponse2 "d-nonstream-response-2.json"
$dNonstreamTask2 = Read-SyncTask $dNonstreamResponse2 "TASK_STATE_COMPLETED" `
"policy_status" $dNonstreamClaim "llm_report"
$dNonstreamTask2 = Read-SyncTask $dNonstreamResponse2 "TASK_STATE_COMPLETED"
Assert-PlainTerminalArtifacts @($dNonstreamResponse2.result.task.artifacts) $dNonstreamRawResponse2
if ($dNonstreamTask2.TaskId -ne $dNonstreamTask1.TaskId) {
throw "Agent D non-streaming resume changed taskId from $($dNonstreamTask1.TaskId) to $($dNonstreamTask2.TaskId)"
Expand Down
6 changes: 2 additions & 4 deletions service/agent-service-demo/example/a2a/smoke-a2a.sh
Original file line number Diff line number Diff line change
Expand Up @@ -470,8 +470,7 @@ write_a2a_request "SendStreamingMessage" "d-stream-2" "$D_STREAM_CONTEXT" "$d_st
curl -sS -N --max-time "$A2A_REQUEST_TIMEOUT_SECONDS" -X POST "$BASE_URL_A/a2a/" \
-H 'Content-Type: application/json' \
-H 'Accept: text/event-stream' --data-binary "@$d_stream_request2" >"$d_stream_response2"
d_stream_resumed_task_id="$(assert_sse_task "$d_stream_response2" "TASK_STATE_COMPLETED" \
"policy_status" "$D_STREAM_CLAIM" "llm_report")"
d_stream_resumed_task_id="$(assert_sse_task "$d_stream_response2" "TASK_STATE_COMPLETED")"
assert_plain_terminal_artifacts "$d_stream_response2" "sse"
if [ "$d_stream_resumed_task_id" != "$d_stream_task_id" ]; then
fail "Agent D streaming resume changed taskId from $d_stream_task_id to $d_stream_resumed_task_id"
Expand Down Expand Up @@ -502,8 +501,7 @@ write_a2a_request "SendMessage" "d-nonstream-2" "$D_NONSTREAM_CONTEXT" "$d_nonst
curl -sS --max-time "$A2A_REQUEST_TIMEOUT_SECONDS" -X POST "$BASE_URL_A/a2a/" \
-H 'Content-Type: application/json' \
--data-binary "@$d_nonstream_request2" >"$d_nonstream_response2"
d_nonstream_resumed_task_id="$(assert_sync_task "$d_nonstream_response2" "TASK_STATE_COMPLETED" \
"policy_status" "$D_NONSTREAM_CLAIM" "llm_report")"
d_nonstream_resumed_task_id="$(assert_sync_task "$d_nonstream_response2" "TASK_STATE_COMPLETED")"
assert_plain_terminal_artifacts "$d_nonstream_response2" "sync"
if [ "$d_nonstream_resumed_task_id" != "$d_nonstream_task_id" ]; then
fail "Agent D non-streaming resume changed taskId from $d_nonstream_task_id to $d_nonstream_resumed_task_id"
Expand Down