weiqingy commented on code in PR #926:
URL: https://github.com/apache/flink-agents/pull/926#discussion_r3661644118


##########
api/src/main/java/org/apache/flink/agents/api/agents/AgentExecutionOptions.java:
##########
@@ -42,9 +44,43 @@ public class AgentExecutionOptions {
     public static final ConfigOption<Boolean> CHAT_ASYNC =
             new ConfigOption<>("chat.async", Boolean.class, true);
 
+    /** Whether the built-in tool-call action runs each tool via durable async 
execution. */
     public static final ConfigOption<Boolean> TOOL_CALL_ASYNC =
             new ConfigOption<>("tool-call.async", Boolean.class, true);
 
+    /**
+     * Whether multiple tool calls from one {@code ToolRequestEvent} run as 
one parallel durable
+     * batch when {@link #TOOL_CALL_ASYNC} is also enabled (JDK 21+).
+     *
+     * <p>Default is {@code true}. A parallel batch raises the number of 
in-flight external calls;
+     * after failover, tools whose results were not yet persisted may be 
submitted again.
+     * Side-effecting tools should be idempotent or provide a {@code 
reconciler()}. Set to {@code
+     * false} to keep serial async or sync tool execution.
+     */
+    public static final ConfigOption<Boolean> TOOL_CALL_PARALLEL =
+            new ConfigOption<>("tool-call.parallel", Boolean.class, true);
+
+    /**
+     * Size of the dedicated thread pool used for tool-call async and parallel 
batch execution.
+     *
+     * <p>Separate from {@link #NUM_ASYNC_THREADS} so a large tool batch does 
not exhaust the global
+     * async pool.
+     */
+    public static final ConfigOption<Integer> TOOL_CALL_NUM_ASYNC_THREADS =
+            new ConfigOption<>(
+                    "tool-call.num-async-threads",
+                    Integer.class,
+                    Runtime.getRuntime().availableProcessors() * 2);
+
+    /**
+     * Overall timeout for one parallel tool-call batch.
+     *
+     * <p>Non-positive values disable the timeout. When the deadline elapses, 
unfinished slots are
+     * failed; slots that already completed keep their success or failure 
outcome.
+     */
+    public static final ConfigOption<Duration> TOOL_CALL_BATCH_TIMEOUT =
+            new ConfigOption<>("tool-call.batch.timeout", Duration.class, 
Duration.ofMillis(-1));

Review Comment:
   This is the repo's first `ConfigOption<Duration>`, and I do not think it can 
be set from any documented route.
   
   `AgentConfiguration.get()` dispatches `isAssignableFrom`, `String`, 
`Integer`, `Long`, `Float`, `Double`, `Boolean`, `isEnum()`, then `throw new 
ClassCastException`. There is no `Duration` branch, and YAML values arrive as 
`String` or `Integer`. So `getConfig().get(TOOL_CALL_BATCH_TIMEOUT)` at 
`JavaRunnerContextImpl.java:253` throws for any value a user sets, after `:119` 
has already persisted N PENDING slots, and the catch-all at 
`ToolCallAction.java:171` then reports every tool in the batch failed.
   
   The programmatic route clears `get()` but not plan serialization: 
`AgentPlan.writeObject` uses a bare `new ObjectMapper()` with no 
`JavaTimeModule`, and I reproduced `InvalidDefinitionException` on 
`java.time.Duration` against the pinned jackson-databind. On Python, 
`core_options.py:271` is `config_type=int`, so the documented `30s` raises 
`ValueError` mid-action.
   
   CI stays green because 
`JavaRunnerContextImplDurableExecuteAsyncTest.java:324-327` sets the option 
in-process, where `isAssignableFrom` short-circuits. And 
`check_java_python_config_options_parity.py:45` adds `"java.time.Duration": 
int` to the type table, so the harness now certifies exactly this pair.
   
   `ConfigurationUtils.convertValue` is already imported in 
`AgentConfiguration` for the enum branch and it handles `Duration`; retyping 
this option to `Long` millis would instead let the harness stay strict. Do you 
have a preference? Asking because the batch deadline is the only bound on a 
hung tool wedging the fan-in, and it currently ships both default-off and 
unsettable.



##########
runtime/src/test/java/org/apache/flink/agents/runtime/context/JavaRunnerContextImplDurableExecuteAsyncTest.java:
##########
@@ -240,12 +397,61 @@ public <T> T executeAsync(ContinuationContext context, 
Supplier<T> supplier) {
             return supplier.get();
         }
 
+        @Override
+        public <T> List<Outcome<T>> executeAllAsync(
+                ContinuationContext context,
+                List<Callable<T>> suppliers,
+                java.time.Duration timeout) {
+            executeAllAsyncCallCount++;
+            executeAllAsyncBatchSizes.add(suppliers.size());
+            if (useTimeoutCollection) {
+                return collectTimedOutOutcomes(suppliers);
+            }
+            List<Outcome<T>> outcomes = new 
java.util.ArrayList<>(suppliers.size());
+            for (Callable<T> supplier : suppliers) {
+                try {
+                    outcomes.add(Outcome.success(supplier.call()));
+                } catch (Exception e) {
+                    outcomes.add(Outcome.failure(e));
+                }
+            }
+            return outcomes;
+        }
+
+        private <T> List<Outcome<T>> collectTimedOutOutcomes(List<Callable<T>> 
suppliers) {

Review Comment:
   `testToolCallBatchExecutionIsActuallyParallel` covers the real java21 path 
well, fallback branch included, which matters because surefire runs the 
exploded `target/classes` and so never loads the `META-INF/versions/21` 
classes. The deadline leg looks like the remaining gap, and this stub is what 
makes it look covered.
   
   `collectTimedOutOutcomes` hard-codes "index 0 succeeds, everything else is a 
`TimeoutException`" and never reads the `timeout` argument. So in 
`testDurableExecuteAllAsyncTimeoutKeepsCompletedOutcomes` the 10 ms deadline 
set at `:324-327` and the `Thread.sleep(100)` in the second supplier are both 
inert, supplier 1 is never invoked, and its `getCallCount()` is never asserted. 
A deadline computed with the wrong sign, a `cancel` on the wrong future, or a 
barrier that never resolves would all still pass. What it does cover, a 
timed-out slot persisted FAILED with the cursor still advancing by 2, is worth 
keeping.
   
   Now that the e2e path exists, would a batch that overruns a short 
`tool-call.batch.timeout` be the cheapest way to get `getDeadlineNanos` and 
`collectBatchOutcomesOnTimeout` genuinely exercised? That may have to wait on 
the `Duration` conversion on `AgentExecutionOptions.java:82`, since setting the 
option is what trips the plan serializer.



##########
plan/src/main/java/org/apache/flink/agents/plan/actions/ToolCallAction.java:
##########
@@ -79,55 +103,160 @@ public static void processToolRequest(Event event, 
RunnerContext ctx) {
                 diagnosticError = e.getMessage();
             }
 
-            if (tool != null) {
-                try {
-                    // Framework-owned injected args must win over 
model-provided values so hidden
-                    // context such as tenant ids cannot be spoofed by a tool 
call payload.
-                    mergedArguments.putAll(resolveInjectedArguments(tool, 
ctx));
-                    ToolResponse response;
-                    final Tool toolRef = tool;
-                    final Map<String, Object> callArguments = mergedArguments;
-                    DurableCallable<ToolResponse> callable =
-                            new DurableCallable<>() {
-                                @Override
-                                public String getId() {
-                                    return "tool-call";
-                                }
-
-                                @Override
-                                public Class<ToolResponse> getResultClass() {
-                                    return ToolResponse.class;
-                                }
-
-                                @Override
-                                public ToolResponse call() throws Exception {
-                                    return toolRef.call(new 
ToolParameters(callArguments));
-                                }
-                            };
-                    response =
-                            toolCallAsync
-                                    ? ctx.durableExecuteAsync(callable)
-                                    : ctx.durableExecute(callable);
-                    success.put(id, response.isSuccess());
-                    responses.put(id, response);
-                    if (!response.isSuccess() && response.getError() != null) {
-                        error.put(id, response.getError());
-                    }
-                } catch (Exception e) {
-                    success.put(id, false);
-                    responses.put(
-                            id, ToolResponse.error(String.format("Tool %s 
execute failed.", name)));
-                    error.put(id, e.getMessage());
-                }
-            } else {
-                success.put(id, false);
-                responses.put(
-                        id, ToolResponse.error(String.format("Tool %s does not 
exist.", name)));
-                error.put(id, diagnosticError != null ? diagnosticError : 
"Tool does not exist.");
+            if (tool == null) {
+                recordInlineResponse(
+                        id,
+                        ToolResponse.error(String.format("Tool %s does not 
exist.", name)),
+                        diagnosticError != null ? diagnosticError : "Tool does 
not exist.",
+                        success,
+                        error,
+                        responses);
+                continue;
             }
+
+            try {
+                // Framework-owned injected args must win over model-provided 
values so hidden
+                // context such as tenant ids cannot be spoofed by a tool call 
payload.
+                mergedArguments.putAll(resolveInjectedArguments(tool, ctx));
+            } catch (Exception e) {
+                recordInlineResponse(
+                        id,
+                        ToolResponse.error(String.format("Tool %s execute 
failed.", name)),
+                        e.getMessage(),
+                        success,
+                        error,
+                        responses);
+                continue;
+            }
+
+            final Tool toolRef = tool;
+            final Map<String, Object> callArguments = mergedArguments;
+            DurableCallable<ToolResponse> callable =
+                    new DurableCallable<>() {
+                        @Override
+                        public String getId() {
+                            return "tool-call-" + id;
+                        }
+
+                        @Override
+                        public Class<ToolResponse> getResultClass() {
+                            return ToolResponse.class;
+                        }
+
+                        @Override
+                        public ToolResponse call() throws Exception {
+                            return toolRef.call(new 
ToolParameters(callArguments));
+                        }
+                    };
+            executions.add(new ToolCallExecution(id, name, callable));
+        }
+        return executions;
+    }
+
+    private static void executeParallel(
+            List<ToolCallExecution> executions,
+            RunnerContext ctx,
+            Map<String, Boolean> success,
+            Map<String, String> error,
+            Map<String, ToolResponse> responses) {
+        List<DurableCallable<ToolResponse>> callables = new 
ArrayList<>(executions.size());
+        for (ToolCallExecution execution : executions) {
+            callables.add(execution.callable);
+        }
+        try {
+            List<Outcome<ToolResponse>> outcomes = 
ctx.durableExecuteAllAsync(callables);
+            for (int i = 0; i < outcomes.size(); i++) {
+                recordOutcome(executions.get(i), outcomes.get(i), success, 
error, responses);
+            }
+        } catch (Exception e) {

Review Comment:
   Any throw out of `durableExecuteAllAsync` after partial progress marks every 
tool failed, including slots already durably finalized SUCCEEDED. The action is 
then marked completed, so those results never surface on a later run. Lost, not 
delayed.
   
   The serial path just below degrades per tool; this one is all-or-nothing. 
Routes in besides the `Duration` conversion on `AgentExecutionOptions.java:82`: 
a `JsonProcessingException` out of `finalizeExecutedOutcomes` 
(`JavaRunnerContextImpl.java:211`), or a deserialization failure in 
`readTerminalOutcomeAt`.
   
   Would recording the batch-level exception only against slots with no outcome 
yet work here, or is failing the whole set the intent?



##########
python/flink_agents/runtime/flink_runner_context.py:
##########
@@ -636,6 +721,110 @@ def wrapped_func(*a: Any, **kw: Any) -> Any:
 
         return wrapped_func
 
+    def _call_matches(
+        self, current: _PersistedCallResult, call: DurableCall, args_digest: 
str
+    ) -> bool:
+        return current.function_id == call.id and current.args_digest == 
args_digest
+
+    def _read_terminal_outcome(self, current: _PersistedCallResult) -> Outcome:
+        if current.exception_payload is not None:
+            return 
Outcome.failure(cloudpickle.loads(current.exception_payload))
+        if current.result_payload is None:
+            return Outcome.success(None)
+        return Outcome.success(cloudpickle.loads(current.result_payload))
+
+    def _callable_for_durable_call(self, call: DurableCall) -> Callable[[], 
Any]:
+        kwargs = call.kwargs or {}
+        return partial(call.func, *call.args, **kwargs)
+
+    def _prepare_batch_execution(self, calls: list[DurableCall]) -> 
_BatchExecutionPlan:
+        args_digest = ""

Review Comment:
   The batch keys slots on `("tool-call-<id>", "")` 
(`tool_call_action.py:134`). The Python single-call path keys on 
`(_compute_function_id(func), args_digest)` (`:482-483`), and since the 
callable is `tool.call`, `_compute_function_id` returns the same string for 
every tool. Java has no such split: `RunnerContextImpl.java:604-605` uses 
`getId()` plus `""` for the single-call state machine too.
   
   So a job that checkpoints mid-batch and restarts with 
`tool-call.parallel=false` replays cleanly on Java, while on Python 
`matchNextOrClearSubsequentCallResult` cannot match the persisted key, 
truncates the journal, and calls every completed tool again. Same flip that 
reaches the serial-path issue on `JavaRunnerContextImpl.java:193`.
   
   The per-call `functionId` rename was headed for its own issue, so not asking 
for it here. What caught my eye is that the batch path adopted it while the 
single-call path did not, so the two disagree inside Python today. Is 
documenting the flip as not recoverable across a restart the right stopgap 
until that lands?



##########
runtime/src/main/java/org/apache/flink/agents/runtime/context/JavaRunnerContextImpl.java:
##########
@@ -75,6 +102,159 @@ private <T> T durableExecuteAsyncWithReconcile(
                 callable, reconcileCallable, () -> 
executeAsyncCallable(callable));
     }
 
+    @Override
+    public <T> List<Outcome<T>> 
durableExecuteAllAsync(List<DurableCallable<T>> callables)
+            throws Exception {
+        if (callables.isEmpty()) {
+            return List.of();
+        }
+        if (durableExecutionContext == null) {
+            return executeAllWithoutDurableState(callables);
+        }
+
+        String argsDigest = "";
+        int base = durableExecutionContext.getCurrentCallIndex();
+        BatchExecutionPlan<T> plan = buildBatchExecutionPlan(callables, base, 
argsDigest);
+
+        reservePendingBatchIfNeeded(callables, argsDigest, plan);
+
+        List<Outcome<T>> executed = executeOutcomeSuppliers(plan.suppliers);
+        finalizeExecutedOutcomes(callables, base, argsDigest, plan, executed);
+
+        advanceCallIndexBy(callables.size());
+        return plan.outcomes;
+    }
+
+    private <T> BatchExecutionPlan<T> buildBatchExecutionPlan(
+            List<DurableCallable<T>> callables, int base, String argsDigest) 
throws Exception {
+        BatchExecutionPlan<T> plan = new 
BatchExecutionPlan<>(callables.size());
+        for (int i = 0; i < callables.size(); i++) {
+            DurableCallable<T> callable = callables.get(i);
+            CallResult current = getCallResultAt(base + i);
+            if (current == null) {
+                markReservationStart(plan, i);
+                addExecutableCall(plan, i, callable::call);
+                continue;
+            }
+            if (!current.matches(callable.getId(), argsDigest)) {
+                clearCallResultsFromAndPersist(base + i);
+                plan.needsReservation = true;
+                plan.executionStart = i;
+                addExecutableCall(plan, i, callable::call);
+                appendRemainingExecutions(callables, plan, i + 1);
+                break;
+            }
+            if (current.isPending()) {
+                Callable<T> reconcileCallable = callable.reconciler();
+                Callable<T> executionCallable =
+                        reconcileCallable != null ? reconcileCallable : 
callable::call;
+                addExecutableCall(plan, i, executionCallable);
+            } else {
+                plan.outcomes.add(
+                        readTerminalOutcomeAt(
+                                base + i, callable.getId(), argsDigest, 
callable.getResultClass()));
+            }
+        }
+        return plan;
+    }
+
+    private <T> void markReservationStart(BatchExecutionPlan<T> plan, int 
callIndex) {
+        plan.needsReservation = true;
+        if (plan.executionStart < 0) {
+            plan.executionStart = callIndex;
+        }
+    }
+
+    private <T> void addExecutableCall(
+            BatchExecutionPlan<T> plan, int callIndex, Callable<T> 
executionCallable) {
+        plan.outcomes.add(null);
+        plan.suppliers.add(executionCallable);
+        plan.executableCallIndexes.add(callIndex);
+    }
+
+    private <T> void appendRemainingExecutions(
+            List<DurableCallable<T>> callables, BatchExecutionPlan<T> plan, 
int startIndex) {
+        for (int i = startIndex; i < callables.size(); i++) {
+            DurableCallable<T> remaining = callables.get(i);
+            addExecutableCall(plan, i, remaining::call);
+        }
+    }
+
+    private <T> void reservePendingBatchIfNeeded(
+            List<DurableCallable<T>> callables, String argsDigest, 
BatchExecutionPlan<T> plan) {
+        if (!plan.needsReservation) {
+            return;
+        }
+        List<String> ids = new ArrayList<>();
+        for (DurableCallable<T> callable :
+                callables.subList(plan.executionStart, callables.size())) {
+            ids.add(callable.getId());
+        }
+        reservePendingBatch(ids, argsDigest);

Review Comment:
   Reserving PENDING for every call in the batch, non-reconcilable ones 
included, is the intended shape, and it carries the guarantee that such a slot 
is re-executed on recovery. The batch path honours that at `:147-151`. The 
serial path can read those same slots and does not.
   
   Before this PR the two never met. PENDING was written only by 
`durableExecuteWithReconcile` (`RunnerContextImpl.java:612`, `:618`) and the 
Python bridge's `_prepare_reconciler_execution`, both gated on a non-null 
`reconciler()`, and that method gates its own cache read on 
`!current.isPending()` at `:622`. The completion-only path never needed that 
gate, because non-reconcilable calls never wrote a PENDING slot. Now they do: 
`ToolCallAction.java:135` never overrides `reconciler()` and the interface 
default is `null`.
   
   `CallResult.matches` compares only `functionId` and `argsDigest` 
(`CallResult.java:160-162`), so a reserved slot reads as a cache hit at 
`RunnerContextImpl.java:757`, both payloads are null, and `tryGetCachedResult` 
throws NPE on `return Optional.of(null)` at `:575`. `ToolCallAction.java:192` 
catches it, so the tool is reported failed rather than re-executed.
   
   Two routes onto the serial path after a mid-batch crash, with the reserved 
slots still checkpointed. Setting `tool-call.parallel=false` and restarting is 
one. The other needs no config change: a trailing tool whose resource fails to 
resolve on the recovery run is dropped at `ToolCallAction.java:114`, 
`executions.size()` falls to 1, and the `> 1` guard at `:67` routes to serial.
   
   Treating a PENDING slot as a miss in `matchNextOrClearSubsequentCallResult` 
/ `tryGetCachedResult` would extend the same re-execute guarantee to that path. 
Would that be the right place for it, or is there a reason the serial read 
should keep seeing a hit?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to