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


##########
python/flink_agents/runtime/flink_runner_context.py:
##########
@@ -412,8 +485,9 @@ def _try_get_cached_result(
             A tuple of (is_hit, result_or_exception). If is_hit is True,
             the second element is the cached result or an exception to 
re-raise.
         """
-        function_id = _compute_function_id(func)
-        args_digest = _compute_args_digest(args, kwargs)
+        function_id, args_digest = _resolve_durable_identity(

Review Comment:
   The new PENDING branch seems to open a recovery gap on Python, and I would 
welcome a sanity check on the routing. `matchNextOrClearSubsequentCallResult` 
returns `null` for a matching PENDING slot without clearing it or advancing 
`currentCallIndex` (`RunnerContextImpl.java:762-771`). Java pre-checks PENDING 
in `durableExecuteCompletionOnly` at `:297-300` and finalizes in place, so it 
never lands there. Python's `_try_get_cached_result` reads that `null` as a 
plain miss (`:494`), re-executes, and `recordCallCompletion` appends 
(`RunnerContextImpl.java:813`): a two-call batch replayed serially becomes 
`[PENDING(f0), PENDING(f1), SUCCEEDED(f0), SUCCEEDED(f1)]`, and each later 
failover re-runs both tools and appends two more.
   
   It is new at this head: at `9169bb2` the batch reserved 
`"tool-call-<call_id>"`, which never matched the serial digest, so the mismatch 
branch cleared and re-ran cleanly. No config flip is needed either, since 
`len(executions)` dropping to 1 on an unresolvable tool routes a reserved batch 
to the serial path (`tool_call_action.py:69`, `:108-117`); flipping back to 
parallel caps the growth but leaves a stale tail. Java has the recovery test 
(`RunnerContextImplDurableExecuteTest.java:267-299`); would a Python 
counterpart make sense, given the parity ask in `AGENTS.md`? For the fix, the 
bridge could take Java's pre-check, or the matcher could clear the slot before 
returning the miss. Which fits the bridge contract better?



##########
docs/content/docs/operations/configuration.md:
##########
@@ -131,7 +131,10 @@ Here is the list of all built-in core configuration 
options.
 | `max-retries`             | 3                          | int                 
  | Number of retries when using `ErrorHandlingStrategy.RETRY`.                 
                                                                                
                                                                                
                    |
 | `retry-wait-interval`     | 1                          | int                 
  | Base wait interval in seconds between retries when using 
`ErrorHandlingStrategy.RETRY`. Uses exponential backoff: the actual wait time 
for the Nth retry is `retry-wait-interval * 2^(N-1)` seconds. For example, with 
default 1s, waits are 1s, 2s, 4s, etc. Retry count and total wait time are 
reported in `ChatResponseEvent` and recorded as metrics (`retryCount`, 
`retryWaitSec`) under the connection name. |
 | `chat.async`              | true                       | boolean             
  | Whether chat asynchronously for built-in chat action.                       
                                                                                
                                                                                
                    |
-| `tool-call.async`         | true                       | boolean             
  | Whether process tool call for built-in tool call action.                    
                                                                                
                                                                                
                    |
+| `tool-call.async`         | true                       | boolean             
  | Whether the built-in tool-call action runs each tool via durable async 
execution.                                                                      
                                                                                
                         |
+| `tool-call.parallel`      | true                       | boolean             
  | When `tool-call.async` is also true (JDK 21+), run multiple tool calls from 
one `ToolRequestEvent` as one parallel durable batch. Increases in-flight 
external calls; after failover, unfinished tools may be submitted again — 
side-effecting tools should be idempotent or provide a reconciler. Set to 
`false` for serial tool execution. |
+| `tool-call.num-async-threads` | os cpu count * 2       | int                 
  | Dedicated thread pool size for tool-call async / parallel batch execution. 
Separate from `num-async-threads` so a large tool batch does not exhaust the 
global async pool.                                                              
                      |
+| `tool-call.batch.timeout.ms` | -1 (disabled)              | long 
(milliseconds)   | Overall timeout for one parallel tool-call batch. 
Non-positive disables it. On timeout, completed slots keep their outcome and 
unfinished slots fail. Timeout cancellation is best-effort; external side 
effects from unfinished tool calls may still complete, so side-effecting tools 
should be idempotent or provide a reconciler. |

Review Comment:
   nit: two JDK wording points. The new row has no JDK caveat, though the 
option is inert below Java 21: the java11 fallback takes `timeout` and ignores 
it (`ContinuationActionExecutor.java:75-87`), and two of the five IT combos run 
Java 17. Conversely `:135` says `tool-call.parallel` needs JDK 21+, which does 
not hold for Python, whose batch runs on a `ThreadPoolExecutor` 
(`flink_runner_context.py:266`). Worth a clause on each?



##########
python/flink_agents/api/runner_context.py:
##########
@@ -24,12 +25,49 @@
 from flink_agents.api.metric_group import MetricGroup
 from flink_agents.api.resource import Resource, ResourceType
 
-__all__ = ["AsyncExecutionResult", "RunnerContext"]
+__all__ = ["AsyncExecutionResult", "DurableCall", "Outcome", "RunnerContext"]
 
 if TYPE_CHECKING:
     from flink_agents.api.memory_object import MemoryObject
 
 
+@dataclass(frozen=True)
+class DurableCall:
+    """A deterministic durable call entry for batch execution."""
+
+    id: str

Review Comment:
   `id` is required here but the Python runtime never reads it: 
`_durable_identity` (`flink_runner_context.py:751-752`) recomputes identity 
from `call.func/args/kwargs` at `:755`, `:821`, `:843`. Java treats the id as 
the journal identity (`RunnerContextImpl.java:293`), so a stable `id` with 
varying arguments keeps recovery on Java and loses it on Python. `DurableCall` 
is new, so it is cheap to settle now. Should `id` stay on the dataclass at all?
   
   Keeping it also pulls in the `plan` to `runtime` import 
(`tool_call_action.py:34`), the first production one in the repo, against the 
direction `AGENTS.md` states. Java declares the id locally 
(`ToolCallAction.java:45`). Where would you want that to live?



##########
python/flink_agents/runtime/tests/test_flink_runner_context_reconcilable.py:
##########
@@ -128,27 +170,78 @@ def clearCallResultsFromCurrentIndexAndPersist(self) -> 
None:
         self.operations.append("clear")
         self.call_results = self.call_results[: self.current_call_index]
 
+    def reservePendingBatch(
+        self, function_ids: list[str], args_digests: list[str]
+    ) -> None:
+        self.operations.append(f"reserve:{len(function_ids)}")
+        for function_id, digest in zip(function_ids, args_digests, 
strict=True):
+            self.call_results.append(
+                _StoredCallResult(
+                    function_id=function_id,
+                    args_digest=digest,
+                    status="PENDING",

Review Comment:
   The fake's `matchNextOrClearSubsequentCallResult` (`:103-116`) has no 
`status == "PENDING"` branch, so a PENDING match still returns `[True, None, 
None]`, the pre-fix behaviour changed at `RunnerContextImpl.java:762-771`. A 
Python regression test for the batch to serial case would pass here while 
production appends. Would it help to teach the fake the new contract first, so 
such a test could fail?



##########
python/flink_agents/api/runner_context.py:
##########
@@ -203,6 +241,7 @@ def durable_execute(
         func: Callable[[Any], Any],
         *args: Any,
         reconciler: Callable[[], Any] | None = None,
+        durable_id: str | None = None,

Review Comment:
   `durable_id` is on both public abstract signatures (`:244`, `:306`) and 
threaded through `flink_runner_context.py`, but nothing passes it, and 
`test_tool_call_action.py:361` asserts it is not passed. Java has no 
counterpart. Two things that made me pause while it sits unused: `reconciler`'s 
docstring marks itself reserved and not forwarded to `func` (`:285-286`) where 
`durable_id`'s (`:287-290`) does not, so a tool declaring its own `durable_id` 
would have it swallowed, and every third-party `RunnerContext` inherits the 
abstract signature. Is a follow-up expected to use it?



##########
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:
   Partly. The Python catch-all is gone (`tool_call_action.py:145-156`), but 
the Java one at `ToolCallAction.java:168-177` is unchanged, so the two 
languages now do different things with the same failure. Was leaving Java for 
the follow-up the intent? I answered your two-layer question in the review 
comment, since it bears on this.



-- 
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