GitHub user da-daken added a comment to the discussion: Parallel Tool Call 
Execution

hi @weiqingy @joeyutong Thanks for the sharp review — all three points were 
spot on. Here's how I've addressed each in the updated design.

---

### 1. Python parity

You're right that `asyncio.gather` won't work and that `__await__` currently 
fuses execute + record.

First, the three structural constraints on the Python side:

1. **Index state lives entirely in Java** — Python has no 
`DurableExecutionContext`, it only bridges call-index reads/writes through 
`_j_runner_context`, and today's bridge is "current index" only.
2. **`__await__` fuses execute + record** — 
`_DurableAsyncExecutionResult.__await__` calls `_record_call_completion` right 
after the thread pool finishes and does `currentCallIndex += 1`.
3. **No asyncio event loop, one `send(None)` per operator tick** — multiple 
independent `await`s can only advance serially, so you can't get concurrency 
within a single action.

What I changed:

- **First-class batch entry**: `ctx.durable_execute_all_async([...])` returns a 
single awaitable that does the fan-out internally — sidesteps `gather`'s "one 
await = one slot" assumption.
- **Unfuse execute from record**: a new 
`_DurableBatchAsyncExecutionResult.__await__` does submit-all → a single `while 
...: yield` loop over all futures → record in tool_call order. Recording no 
longer hangs off a single await; it's written at absolute indices when the 
batch settles.
- **Index-addressable primitives (Java side, shared by Java + Python)**: 
`reservePendingBatch` (reserve N PENDING at once), 
`getCallResultFieldsAt(index)`, `finalizeCallAt(index, ...)`, 
`advanceCallIndexBy(n)` on `DurableExecutionContext`. This is exactly the 
missing "absolute index" you called out — today's `appendPendingCall` only 
appends one at the current index (a second consecutive call throws), so 
`_record_call_completion` stops being the only recording path.
- **Aligned data model**: Python gets `DurableCall` (the `DurableCallable` 
equivalent, with `id` / `reconciler` and a reserved per-call `timeout`) and 
`Outcome` (three states, `.value` / `.error`); each tool uses a stable id 
`f"tool-call:{call_id}"` instead of every tool sharing the single 
`FunctionTool.call` id.
- **Recovery & reconciler**: the prepare phase scans the cache by absolute 
index (hits skip submission; `PENDING` with a reconciler goes through the 
reconciler) — same semantics as the Java three-phase flow.

### 2. Timeout fan-in

Good catch — the batch would otherwise hang on a stuck tool. Now:

- On the batch deadline, every unfinished slot is finalized via 
`finalizeCallAt(..., BatchTimeoutException)` so fan-in can always collect N 
results — no dangling `PENDING`, deterministic recovery.
- I kept per-call deadlines (v1 uses one batch value; per-tool timeout later 
just changes how the deadlines are computed — no strategy class needed).
- On the `cancel(true)` distinction: documented explicitly. It bounds the 
*wait*, not the *thread* — a tool that ignores interruption keeps its pool 
thread until it returns, temporarily shrinking `num-async-threads`. Guidance 
added: framework timeout is a backstop; tools that can block should use 
interruptible / self-timing clients. (Python is even blunter here — 
`Future.cancel()` can't stop a running thread at all, so that's flagged too.)

### 3. Return type

Fully agree — `List<T>` can't tell a thrown tool from a `null` result without 
reading slot state back. The batch API now returns per-call `Outcome<T>`:

- `SUCCEEDED` / `FAILED` / `TIMED_OUT`, with `value()` / `error()`. 
`success(null)` and `failure(e)` are distinguished by status, not by `null`.
- This lines up 1:1 with `ToolCallAction`'s `success` / `error` / `responses` 
maps — no more round-tripping through the store.
- Timeout stays faithful across recovery: it's persisted as `FAILED` carrying 
`BatchTimeoutException`, and rebuilt back into `TIMED_OUT` by exception type, 
so metrics don't lose it.

Roughly:

```java
public final class Outcome<T> {
    public enum Status { SUCCEEDED, FAILED, TIMED_OUT }

    public static <T> Outcome<T> success(T value);   // value may be null
    public static <T> Outcome<T> failure(Throwable e);
    public static <T> Outcome<T> timeout(BatchTimeoutException e);

    public boolean isSuccess();
    public boolean isFailure();
    public boolean isTimeout();
    public T value();          // success value (may be null)
    public Throwable error();  // failure / timeout exception
}

// Callers branch on status instead of guessing from null:
Outcome<ToolResponse> o = outcomes.get(i);
if (o.isSuccess()) {
    responses.put(id, o.value());
} else {                       // FAILED or TIMED_OUT
    error.put(id, describe(o.error()));
}
```

---

One extra thing that fell out of this: missing tools now occupy a response slot 
with a pre-set `FAILED` outcome (never submitted to the pool) so indices stay 
strictly aligned.

GitHub link: 
https://github.com/apache/flink-agents/discussions/855#discussioncomment-17622632

----
This is an automatically sent email for [email protected].
To unsubscribe, please send an email to: [email protected]

Reply via email to