Repository navigation
[api][plan][runtime][python] Support registering an Agent as an in-process internal sub-agent - #1138
Conversation
90bdf8f to
a0ac91d
Compare
|
Thanks for picking this up. Before we get into line-by-line review, could we open a discussion for the runtime model? This PR settles a few things #909 left open, and they affect most of the operator changes. The three I'd like agreement on:
I have more detailed notes and am happy to share them once the direction is set. |
@purushah Thanks for the comment. Worth aligning on these before this moves further. The preview does have some known persistence gaps that we're addressing. 1. In-flight state and recovery. We'd like to settle the principle first and get your read. A sub-agent call inherits durable execution's consistency model:
Mechanically, the parent's initiation and the child action's start each create a sub-agent call record, the later one linking to the earlier. On failover: without ActionState, in-flight calls are cancelled and re-run; with ActionState, the replayed action records serve as a reconciler for the call — when the parent has initiated but not completed, its request is matched against the persisted action records, completed actions are skipped and their results reused (including an already-produced OutputEvent, the sub-agent's response), and only the unfinished child actions re-run. Consistency therefore still rests on durable execution. I can write the full plan up separately if that's easier to react to. 2. Shared continuation executor. The call awaits on the async pool, so it does hold async threads; under heavy parallel fan-out where the children also need async capacity, this can block. We expect large fan-out to be rare, so the near-term guidance is to raise the async thread count, with auto-scaling as a later option. 3. Multi-action child "done". A call completes when all actions in its sub-agent scope quiesce — the same condition the main agent uses to finish a record: every output action is an OutputEvent and none is pending. Consistency follows point 1. We'd like to continue discussions in this PR or #1137, given that after #909 we have created an umbrella issue for sub-agent mechanism to better track discussions and development in each child issue. What do you think? |
a0ac91d to
cc54e97
Compare
cc54e97 to
5866831
Compare
5866831 to
b3acc18
Compare
|
I have a proposal for the recovery consistency model. Given that:
We do not need to restore child This makes the recovery strategy clear and simple. I have sent the code to @yunfengzhou-hub for review. Do you think this strategy is feasible? |
wenjin272
left a comment
There was a problem hiding this comment.
Thanks for implementing this and documenting the runtime flow! I left four inline comments on memory handling and cross-language resource support.
The in-flight recovery ordering, multi-action replay, and shared async-pool starvation concerns already raised by @purushah also remain on this head. I reproduced the two recovery cases and confirmed the shared-pool dependency in the code, so I have not duplicated those inline.
| LOG.warn( | ||
| "Subagent memory persist not yet supported; discarding {} cached entries.", | ||
| ownCache.size()); | ||
| ownCache.clear(); |
There was a problem hiding this comment.
Could we retain child memory for the lifetime of the sub-agent call? persistCache() is called after each action, but clearing the cache here discards writes before the next child action can read them. I reproduced this with action A writing a value and emitting an event to action B: B could no longer read the value.
There was a problem hiding this comment.
Fixed. The child's isolated memory now persists for the whole call instead of being cleared after each action, so a later child action reads what an earlier one wrote. laterChildActionReadsValueWrittenByEarlierChildAction reproduces your A-writes/B-reads case.
| gen.writeStringField("name", provider.getName()); | ||
| gen.writeStringField("type", provider.getType().getValue()); | ||
| gen.writeStringField("scope", provider.getScope()); | ||
| gen.writeObjectField("childPlan", provider.getChildPlan()); |
There was a problem hiding this comment.
Nesting an AgentPlan here exposes a missing final writeEndObject() in AgentPlanJsonSerializer: the provider type marker ends up inside childPlan, so the resulting JSON fails deserialization in both Java and Python. After correcting the nesting, Python still fails because its plan deserializer does not handle InternalSubagentProvider. Could we fix both sides and add Java/Python round-trip tests?
There was a problem hiding this comment.
Both sides fixed. Java adds the missing final writeEndObject() so the provider type marker no longer leaks into childPlan; Python's deserializer now converts InternalSubagentProvider into InternalSubagentSetup. Round-trip coverage: internalSubagentProviderSurvivesJsonRoundTrip (Java→Java), javaPlanWithInternalSubagentSnapshotIsStable (Java→snapshot), and test_python_can_deserialize_java_plan_with_internal_subagent (Java→Python).
| if (ownCache.containsKey(key)) { | ||
| return ownCache.get(key); | ||
| } | ||
| return parent.get(key); |
There was a problem hiding this comment.
Could we copy the mutable MemoryItem before exposing it to the child? MemoryObjectImpl.set() mutates the returned item's subKeys in place, so a child write changes the parent's field list even though the value stays in the child cache. I reproduced a parent getFields() NPE after the child added a field.
There was a problem hiding this comment.
Fixed. The parent MemoryItem is copied with an independent subKeys set before being exposed to the child scope, so a child field write no longer mutates the parent's field list. childFieldWriteDoesNotLeakIntoParentFieldList and childNestedWriteDoesNotLeakIntoParentObject cover it.
| new ResourceCache( | ||
| childPlan.getResourceProviders(), | ||
| userCodeClassLoader, | ||
| rootResourceCache)); |
There was a problem hiding this comment.
This child cache never receives a PythonActionExecutor or PythonResourceAdapter; the parent reference does not inherit those fields. Consequently, an internal child registering a Python-owned external sub-agent fails during eager materialization, even when the root Python runtime is initialized. Could we wire the bridge into child caches and add coverage for this nested resource case?
There was a problem hiding this comment.
Fixed. The child cache now inherits the Python bridge from the root (effectivePythonActionExecutor/effectivePythonResourceAdapter fall back to the parent) and carries the child plan JSON, so a Python-owned child resource materializes against its own plan through the single root runtime. Coverage: testInternalChildCacheMaterializesPythonOwnedResourcesThroughTheRootBridge (Java) and test_scoped_materialization_uses_the_child_plan_and_its_own_cache (Python).
@pltbkd Feasible — adopted and implemented(co-authored to you, now pushed to the PR). It follows your model faithfully: drop child tasks on resume, replay from the root caller, and reconcile completed actions against their persisted |
There was a problem hiding this comment.
Thanks for the follow-up fixes and regression tests! The original checkpoint-ordering, chained-replay, and cross-action memory reproducers now pass. I left five follow-up comments on remaining recovery, resource ownership, Python initialization, and memory isolation issues, including a concrete inline follow-up to the shared-pool starvation concern already raised by @purushah.
To clarify priorities:
- The failure on a second recovery and shared async-pool starvation are P1 issues that I think should be addressed before merging.
- The two cross-language resource issues (internal setups being overwritten during mixed registration, and Python dependencies being missed when they exist only in child plans) are P2, non-blocking follow-ups. These should not block this PR.
- The mutable memory-value isolation issue is also P2.
| private List<Event> completedActionOutputEvents( | ||
| ActionTask actionTask, ActionState actionState) { | ||
| if (!actionTask.isSubagentEvent()) { | ||
| return actionState.getOutputEvents(); |
There was a problem hiding this comment.
Could we also exclude sub-agent bootstraps when replaying a completed root action? After a child result is persisted and the parent fails, recovery still runs prepare(), but the cached result returns without yielding. The bootstrap then gets persisted among the parent's output events. On another recovery, this branch replays it without registering its call status, causing Missing subagent call status. I reproduced this with two successive recovery attempts.
There was a problem hiding this comment.
Fixed. The non-subagent branch of completedActionOutputEvents() now drops InternalSubagentCallEvent and replays only the graph-driving events, so a completed root caller no longer re-dispatches its finished child's bootstrap envelope — which registers no call status and caused the Missing subagent call status abort.
Added InternalSubagentRecoveryTest#replaysCompletedRootCallerWithoutRebootstrappingItsFinishedChildCall, an operator-level snapshot→restore test that seeds a completed root caller whose persisted outputs still hold the child bootstrap envelope, then restores and asserts the child is not re-dispatched.
| // cached as they are instead of being opened again here. | ||
| for (Map.Entry<String, Resource> handle : | ||
| pythonActionExecutor.eagerMaterialize(type).entrySet()) { | ||
| executor.eagerMaterialize(type, scopePlanJson).entrySet()) { |
There was a problem hiding this comment.
Java and Python disagree on resource ownership here: Java excludes internal child-plan providers from Python-owned resources, while Python's is_python_owned() still includes them. When internal and external Python sub-agents coexist, Python eager materialization returns both, and this loop replaces the existing Java InternalSubagentSetup with a PythonRuntimeResource. The subsequent internal call fails its type check. Could we align ownership classification and cover this mixed-registration case?
There was a problem hiding this comment.
Fixed. Python's is_python_owned() now excludes internal child-plan providers, matching Java's isPythonOwned, so a Python-compiled internal sub-agent stays Java-owned and eager materialization no longer replaces the InternalSubagentSetup with a PythonRuntimeResource. Added a mixed internal + external Python sub-agent registration case to test_resource_provider.py.
| childPlan.getResourceProviders(), | ||
| userCodeClassLoader, | ||
| rootResourceCache, | ||
| planJson)); |
There was a problem hiding this comment.
Inheriting the root bridge only works if the root initialized Python. PythonBridgeManager.open() still checks only the root plan's actions and resources. With a Java root whose child contains the only Python resource, no Python runtime is initialized, so child materialization still fails with "no Python runtime was initialized." Could we discover Python dependencies recursively across child plans? I reproduced this through the actual initialization and child-cache paths.
There was a problem hiding this comment.
Fixed. PythonBridgeManager.open() now detects Python across the whole plan tree: a cycle-guarded planTree() DFS feeds treeContainsPythonAction() / treeContainsPythonResource(), so a Java root whose only Python signal sits in a child plan still initializes the runtime. Mem0 detection stays root-only. Added PythonBridgeManagerTest#planTreeRecursesThroughNestedInternalSubagentChildPlans (detection) and #openStartsPythonWhenOnlyASubagentChildPlanBearsPython (open() actually enters the init branch on the child's signal).
| */ | ||
| MemoryItem(MemoryItem other) { | ||
| this.type = other.type; | ||
| this.value = other.value; |
There was a problem hiding this comment.
Copying subKeys fixes the field-list corruption, but value is still shared with the parent. I reproduced a child reading a parent-owned list, modifying it, and writing it back to the child memory: the parent's list also changed. Could we isolate mutable payloads as well, so lists and maps cannot bypass the child memory boundary?
There was a problem hiding this comment.
After discussion with @pltbkd , MemoryItem is now immutable and child scopes isolate by copy-on-write, so a mutable payload (list/map) read from the parent is copied into the child cache before any write and can no longer mutate the parent in place. This supersedes the earlier subKeys-only copy. Covered by MemoryObjectTest and IsolatedCachedMemoryStoreTest.
There was a problem hiding this comment.
Thanks for the update. I reran the mutable-list reproducer on 7dca9eb, and the parent value still changes. IsolatedCachedMemoryStore.get() returns the parent's item, and MemoryItem.getValue() exposes the same payload reference. Making that reference final does not make the list/map immutable:
List<String> items = (List<String>) child.get("items").getValue();
items.add("child"); // Already mutates the parent's list, before child.set().The copy-on-write change protects subKeys, but this path bypasses it. Could we also isolate mutable payloads and add a regression test asserting that modifying a list obtained through child memory leaves the parent's value unchanged?
There was a problem hiding this comment.
The deep copy of MemoryItem.getValue() is a little more complicated than expected, especially for external subagent. After discussion with @pltbkd , I think it might be better to isolate memory between parent and child for now, so I've just reverted changed related to memory sharing ability.
| + callId); | ||
| } | ||
| try { | ||
| return cs.getResponseFuture().get(); |
There was a problem hiding this comment.
Could we suspend the parent wait without occupying a worker in the shared async pool? awaitSubagentCall() blocks a thread that the child may also need for its durable async operations. With num-async-threads=1, a single parent awaiting a child that performs an async call can deadlock; larger pools hit the same condition with enough concurrent or nested calls. Increasing the thread count raises the threshold but does not remove this dependency.
There was a problem hiding this comment.
Agreed — this one is broader than a scoped fix: the parent's await holds a worker from the same bounded async pool its child needs, so num-async-threads=1 can self-deadlock and a larger pool only raises the threshold. I've filed #1213 to track suspending the wait without occupying a pool worker. I'd suggest keeping this PR to the four correctness fixes above and taking the threading change in #1213 — does that work for you?
07033e7 to
45d2c05
Compare
… internal sub-agent Registering an Agent instance -- rather than a SubagentSetup -- as an AGENT resource now compiles that child Agent into an in-process internal sub-agent. AgentPlan detects `value instanceof Agent`, compiles the child plan once through InternalSubagentCompilationHelper (child plans are reused across names and cycles are rejected) and serves it via InternalSubagentProvider; the runtime materializes it as InternalSubagentSetup. The caller drives the child directly with submit(ctx, prompt).await(): an InternalSubagentCallEvent is sent, the operator dispatches the child's actions in-process -- on a JDK 21 continuation for the Java caller path and through the await-only resolve for the Python path -- and the child's OutputEvents accumulate into the caller's Result. The child context is scope-aware, so a nested child resolves against the child plan and can itself register a grandchild. Calls are durable and failover-reproducible. Generated-by: Qoder Co-Authored-By: Qoder <noreply@qoder.com>
…gents Exercise the whole operator round-trip for a child Agent registered directly as an AGENT resource: the Python-compiled child plan is materialized into a Java InternalSubagentSetup, the call event is sent on the mailbox thread, the mailbox is released while the caller awaits, and the child's OutputEvents accumulate into the caller's Result. Covers the happy path, a failing child surfaced through the Result, and a nested grandchild resolved against the child plan. submit is called without identities, so the framework assigns the (session_id, call_id) pair deterministically from the executing action task and replays it identically after a failover. The Python caller path needs no JDK 21: a coroutine releases the mailbox by yielding at await. Generated-by: Qoder Co-Authored-By: Qoder <noreply@qoder.com>
…ask lifecycle A child Agent registered as an AGENT resource compiles to a PythonSerializableResourceProvider that the runtime treats as Java-owned, so registerSubagentSetups left pythonSetupRegistered false and never added the PythonTaskLifecycleListener. The caller-facing Python handle, built lazily during the action body, then never observed onActionPrepared, and a no-id submit(ctx, prompt) failed with "No prepared action task to assign sub-agent ids from". Register the bridge listener whenever the root plan declares a Python-compiled sub-agent (hasPythonCompiledSubagent), so the lifecycle also reaches child-scope actions. On the Python side, FlinkRunnerContext extracts the prepared namespace eagerly in notify_action_prepared/transferred (plain values, no pemja reference retained) and, in get_resource, wires a lazily materialized sub-agent handle as a listener and replays that namespace via the new BaseSubagentSetup.adopt_prepared_namespace. A no-id submit then mints deterministic (session_id, call_id) identities, replayed identically after a failover. Externally registered setups, already wired eagerly, are unaffected. Generated-by: Qoder Co-Authored-By: Qoder <noreply@qoder.com>
…caller Internal sub-agent recovery restored child call tasks from state alongside their root caller, so a replayed root re-sent its call event while the restored child ran again next to the regenerated copy. Rework recovery to replay from the root: on resume, drop every sub-agent child task and keep only root tasks, letting the replayed root regenerate the call tree. A completed child action reconciles against its persisted ActionState via completedActionOutputEvents, which re-emits same-call envelopes and terminal sub-agent results and skips different-call envelopes whose nested results the completed action already consumed. To keep ActionState faithful, an action yield now releases only the different-call bootstrap envelopes that must run ahead and holds ordinary and same-call events until the action finishes, since ActionState records pending events only at finish; the error path discards them instead. Document two invariants the child-drop relies on: a child task is persisted only while its root caller still awaits it, so dropping children never strands a key; and the parallel-without-coroutine engine runs only where continuations are unsupported (JDK < 21), where internal sub-agent resolution fails fast, so that path never recovers a child task. Generated-by: Qoder Co-authored-by: Gen Luo <86285274+pltbkd@users.noreply.github.com> Co-Authored-By: Qoder <noreply@qoder.com> AI-Model: Qoder Auto
Each internal sub-agent action ran against a fresh isolated memory view, and persistCache() discarded the child's writes with a warning, so a value one child action wrote was gone before the next action of the same call could read it. The isolated memory view now belongs to the sub-agent call status and is shared by every action of that call, so a value one action writes stays readable by the next, and persistCache() retains those writes in the scope instead of clearing them. The call status releases the view when the record finishes, so child writes never leak into the caller's durable state. Generated-by: Qoder (Qoder Auto) Co-Authored-By: Qoder <noreply@qoder.com> Co-authored-by: Gen Luo <86285274+pltbkd@users.noreply.github.com> AI-Model: Qoder Auto
…gents Nesting an AgentPlan inside an InternalSubagentProvider exposed a missing final writeEndObject() in AgentPlanJsonSerializer: the provider's type marker was written inside the child plan object, so the resulting JSON failed to deserialize in both Java and Python. Even after closing the object, Python still failed because its plan deserializer did not recognize InternalSubagentProvider. AgentPlanJsonSerializer now closes the nested child plan before the enclosing provider writes its own fields, and the Python plan deserializer converts an InternalSubagentProvider into Python's InternalSubagentSetup representation, so a plan that nests a child agent round-trips in both languages. Adds a Java cross-language snapshot and Java/Python round-trip coverage. Generated-by: Qoder (Qoder Auto) Co-Authored-By: Qoder <noreply@qoder.com> Co-authored-by: Gen Luo <86285274+pltbkd@users.noreply.github.com> AI-Model: Qoder Auto
… scope IsolatedCachedMemoryStore.get() returned the parent's MemoryItem directly on a read-through. MemoryObjectImpl.set() applies its field-list updates to the resolved item in place, so a child write mutated the parent's field list even though the value stayed in the child cache, leaving the parent listing a field it had no value for and causing a getFields() failure. The store now copies the parent's MemoryItem on read, so the child scope owns the item it exposes and its field-list updates stay confined to the child, leaving the parent's item unchanged. Generated-by: Qoder (Qoder Auto) Co-Authored-By: Qoder <noreply@qoder.com> Co-authored-by: Gen Luo <86285274+pltbkd@users.noreply.github.com> AI-Model: Qoder Auto
…d caches An internal sub-agent's child resource cache never received a PythonActionExecutor or PythonResourceAdapter, and the parent reference did not inherit those fields, so an internal child that registered a Python-owned resource failed during eager materialization even when the root Python runtime was initialized. The child cache now inherits the Python bridge from the root cache and carries the child plan JSON, so a Python-owned resource materializes against its own plan through the single Python runtime. getChildPlanJson() is the one source of the scope's plan JSON, keeping the eager (open) and lazy (call) paths on one Python scope cache so the resource is built exactly once. Also close the sub-agent scope resource caches when the runner context closes, so their Python-owned resources are released alongside the root cache's instead of leaking. Generated-by: Qoder (Qoder Auto) Co-Authored-By: Qoder <noreply@qoder.com> Co-authored-by: Gen Luo <86285274+pltbkd@users.noreply.github.com> AI-Model: Qoder Auto
… on replay completedActionOutputEvents() replayed a completed root caller's persisted output events unfiltered. When the root finished in the same dispatch that emitted its child bootstrap envelope, that InternalSubagentCallEvent was persisted among the root's outputs; a later restore replayed it, but the finished child call no longer registers a call status, so requireInternalCallStatus aborted the replay with "Missing subagent call status". A root caller owns no sub-agent call of its own, so every envelope it emitted bootstrapped a child call whose result the completed action already consumed and folded into its persisted outputs. The non-sub-agent branch now replays only the graph-driving events and drops those bootstrap envelopes, mirroring the different-call exclusion the sub-agent branch already applies. Generated-by: Qoder (Qoder Auto) Co-Authored-By: Qoder <noreply@qoder.com> Co-authored-by: Gen Luo <86285274+pltbkd@users.noreply.github.com> AI-Model: Qoder Auto
…py-on-write MemoryObjectImpl.set() and newObject() added a field name to the resolved parent item in place through getSubKeys().add(). MemoryStore.get() hands out the stored instance itself, which the state backend and any child store reading through to its parent may also hold, so an in-place structural change added the name to every other view of that item while the value stayed in the writer's own store, leaving field names that resolve to nothing. The internal sub-agent scope papered over this by defensively copying the parent item on every read in IsolatedCachedMemoryStore.get(). MemoryItem is now immutable: getSubKeys() returns a defensive copy and a structural change goes through withSubKey(), which rebuilds the item so it can be published with store.put() into the writer's own store. A sub-agent write therefore lands only in the child scope's cache and never mutates the parent's shared item, so the read-time copy is no longer needed and get() reads through to the parent directly. This replaces the copy-on-read workaround with copy-on-write and drops the shallow-copy constructor it relied on. Generated-by: Qoder (Qoder Auto) Co-Authored-By: Qoder <noreply@qoder.com> Co-authored-by: Gen Luo <86285274+pltbkd@users.noreply.github.com> AI-Model: Qoder Auto
PythonBridgeManager.open() decided whether to start the shared Python runtime by scanning only the root AgentPlan for a PythonFunction action or a Python-owned resource provider. It runs in ActionExecutionOperator.open() before sub-agent setups are registered, so it can only inspect plans statically, and an internal sub-agent's child plan is reachable solely through InternalSubagentProvider.getChildPlan(). A Java root with a Python-bearing child was therefore left uninitialized, and the child scope's eagerMaterialize would later fail its "no Python runtime was initialized" guard. open() now derives both signals from planTree(), which walks the plan and each internal sub-agent's child plan recursively (identity-guarded against a shared or cyclic child). Mem0 stays a root-only check because long-term memory is wired against the root plan, not a child scope. Generated-by: Qoder (Qoder Auto) Co-Authored-By: Qoder <noreply@qoder.com> Co-authored-by: Gen Luo <86285274+pltbkd@users.noreply.github.com> AI-Model: Qoder Auto
…ython_owned is_python_owned() returned True for any PythonSerializableResourceProvider, while Java ResourceProvider.isPythonOwned excludes one whose type is AGENT and whose serialized payload carries child_plan: a Python-compiled internal sub-agent executes natively on the Java side and is materialized there as an InternalSubagentSetup. The docstring already claimed it mirrors Java. flink_runner_context.eager_materialize keeps only the providers this returns True for, so the missing exclusion made Python hand back the internal sub-agent as well, and ResourceCache.eagerMaterialize then overwrote the Java-side InternalSubagentSetup with a PythonRuntimeResource. Mirror the Java child_plan exclusion so both runtimes agree on who owns the resource. Generated-by: Qoder (Qoder Auto) Co-Authored-By: Qoder <noreply@qoder.com> Co-authored-by: Gen Luo <86285274+pltbkd@users.noreply.github.com> AI-Model: Qoder Auto
Internal sub-agents no longer read through to the caller's persisted memory. The isolated store is now self-contained and ephemeral, keeping writes visible only within the same call. This also reverts the MemoryItem immutability mechanism that was introduced solely to support safe copy-on-write read-through. Co-Authored-By: Qoder <noreply@qoder.com> AI-Model: Qoder Auto AI-Contributed/Feature: 93/93 AI-Contributed/UT: 157/157
47bb236 to
15e3ccb
Compare
wenjin272
left a comment
There was a problem hiding this comment.
Thanks for addressing the comments. LGTM
Brings in apache#1214, apache#1208 and apache#1138. ChatModelInvoker's two new cancellation checks move to CancellationUtils, since main removed ModelRoutingResolver.isCancellation. ChatModelInvokerTest keeps this branch's tests plus main's provider cancellation test, and test_flink_runner_context_reconcilable.py takes both sides' imports. Generated-by: GitHub Copilot CLI 1.0.95 (Claude Opus 5.5)
Expose lazy action-owned chat calls as DurableFuture handles over the existing built-in chat/tool event loop. Keep invocation tracking in the runtime chat package and group per-action references in ChatContext. Privately route chat events while preserving ordinary event buffering and caller resource/memory scope. Persist terminal ChatResponseEvents through durable action state. Repeated awaits reuse the result; failed responses raise catchable chat errors while cancellation, persistence and event-delivery failures remain runtime failures. Java waits reuse the main branch's resolveAsync path without adding another durable slot. Support Python coroutine calls on JDK 11 and 21. Java awaits require JDK 21 continuations and reject unsupported runtimes before request dispatch. Document same-action use and the shared-pool limitation tracked by apache#1213; low-JDK Java internal calls and parallel chat composition remain out of scope. Place chat-call usage after the event-style example and focus the documentation on user-visible semantics rather than scheduler implementation details. Rebased onto main bd527d7, which includes internal sub-agent support (apache#1138). Related to apache#1086. Validation after rebase: - JDK 21 API/Plan/Runtime and MCP module tests: 2395 passed, 44 skipped. - JDK 21 focused chat/subagent/async/durable tests: 151 passed, 7 skipped. - JDK 11 focused tests: 126 passed, 24 skipped. - Python API/runtime tests: 860 passed, 14 skipped. - Rebuilt Flink 2.3 JARs and Python wheel; MiniCluster chat/subagent tests: 4 passed on JDK 21, and Python chat passed on JDK 11. - Spotless, Ruff, and git diff --check passed. Generated-by: Codex CLI 0.153.4 (GPT-5) Co-authored-by: Codex <codex@openai.com>
Expose lazy action-owned chat calls as DurableFuture handles over the existing built-in chat/tool event loop. Keep invocation tracking in the runtime chat package and group per-action references in ChatContext. Privately route chat events while preserving ordinary event buffering and caller resource/memory scope. Persist terminal ChatResponseEvents through durable action state. Repeated awaits reuse the result; failed responses raise catchable chat errors while cancellation, persistence and event-delivery failures remain runtime failures. Java waits reuse the main branch's resolveAsync path without adding another durable slot. Support Python coroutine calls on JDK 11 and 21. Java awaits require JDK 21 continuations and reject unsupported runtimes before request dispatch. Document same-action use and the shared-pool limitation tracked by apache#1213; low-JDK Java internal calls and parallel chat composition remain out of scope. Place chat-call usage after the event-style example and focus the documentation on user-visible semantics rather than scheduler implementation details. Rebased onto main bd527d7, which includes internal sub-agent support (apache#1138). Related to apache#1086. Validation after rebase: - JDK 21 API/Plan/Runtime and MCP module tests: 2395 passed, 44 skipped. - JDK 21 focused chat/subagent/async/durable tests: 151 passed, 7 skipped. - JDK 11 focused tests: 126 passed, 24 skipped. - Python API/runtime tests: 860 passed, 14 skipped. - Rebuilt Flink 2.3 JARs and Python wheel; MiniCluster chat/subagent tests: 4 passed on JDK 21, and Python chat passed on JDK 11. - Spotless, Ruff, and git diff --check passed. Generated-by: Codex CLI 0.153.4 (GPT-5) Co-authored-by: Codex <codex@openai.com>
Linked issue: #1137
Purpose of change
Registering an
Agentinstance as anAGENTresource now runs it as an in-process internal sub-agent: the caller resolves it viactx.get_resource(scope, AGENT), callssubmit(ctx, prompt), and awaits aResultholding the child's accumulated output — reusing an existing agent as a child instead of hand-writing aDeferredSubagentSetup. The child runs its own action loop, isolated and durable, and may nest.Runtime flow
AgentPlansplits anAGENTresource into three exclusive cases —SubagentSetup, YAML descriptor,Agentinstance. Only theAgentcase compiles, viaInternalSubagentCompilationHelper: the child plan is compiled once, shared across names registering the same child, with cycles and self-references rejected, and served byInternalSubagentProvider. A Python-authored child travels as aPythonSerializableResourceProvider.InternalSubagentSetup(Javaextends BaseDeferredSubagentSetup, Python subclassesDeferredSubagentSetup) holding child plan and scope. Per-call statuses, per-scope child caches, and the per-key session index live in the setup, not the operator.submit(ctx, prompt)assigns(session_id, call_id)from the executing task's namespace at await time, so a replay awaiting in the same order re-derives the same ids, and returns a deferredSubagentFuture. On first resolveprepareregisters the call status and sends oneInternalSubagentCallEventon the mailbox thread, outside the durable boundary so a replayed send carries identical attributes.await. The operator matches the child's actions to the envelope and runs them scope-aware (ActionTaskContextManagerbinds the child'sSubagentScope; child resources resolve against the child plan, including a nested sub-agent) with memory isolated for the whole call and a childResourceCacheinheriting the root's Python bridge and carrying the child plan JSON, so a Python-owned child resource materializes against its own plan.OutputEvents accumulate into the call status; on quiesce the operator completes its future and the caller resumes withSubagentResult.ok.findCallStatusrecurses through child caches so nested envelopes resolve depth-agnostically. On failover the replayed bootstrap reproduces the identity, so the child replays its recorded output;onRecordFinished(key)drops that key's sessions.Key decisions
Agentrather than require aDeferredSubagentSetupsubclass: that boilerplate is what this removes, and a real plan and action loop compose (nesting) where a single deferred callable cannot.CompletableFuture.get()would block the mailbox and deadlock.Behavioral Semantics
Interaction decisions
Result.preparefails fast withIllegalStateExceptionrather than blocking the mailbox and deadlocking.asyncactionawaitreleases the mailbox; a Python-compiled child's lazily-built handle adopts the prepared namespace, so no-idsubmitmints deterministic ids.async.Behavioral contracts
Agentregistered as anAGENTresource is invocable in-process; awaitingsubmit(ctx, prompt)returns aResultwhoseresultholds the child's accumulatedOutputEventpayloads.Result(success=false,error_message); the job continues.MemoryItems copied before exposure, so a child write never mutates the parent's field list; its events do not land on the parent context.Failure behavior
IllegalStateException(fail-fast, no deadlock).preparewith a context that is not anInternalSubagentCallFactory: raisesNotImplementedError.(sessionId, callId): raisesIllegalStateException.submiton a handle never wired to the task lifecycle: raisesRuntimeError("No prepared action task to assign sub-agent ids from"); the operator's Python-lifecycle gate keeps a Python caller driving a Python-compiled child from reaching it.SubagentResult.error(e); the caller'sResultreports failure and the job proceeds.Tests
Agentcompiles to an internal provider; shared plan; cycles/self-reference rejected; other twoAGENTcases unaffectedchildAgentCompilesIntoInternalProvider,sharedChildAgentCompilesToSinglePlan,cycleThroughRootIsRejected,selfReferenceIsRejected,nonSubagentAgentResourceIsRejected; Pythontest_agent_plan_subagent_resources.pymirrors allcallerReceivesTheChildsAccumulatedOutput; e2etest_python_internal_subagent_call_end_to_endResultchildFailureSurfacesThroughTheResult; e2etest_python_internal_subagent_failure_surfaces_via_resultnestedCallReachesTheGrandchild,scopeMatchingAppliesTypeAndConditionFilters; e2etest_python_internal_subagent_nested_callsuccessiveCallsRunIndependentlyUnderDistinctIdentitiesresolvingWithoutContinuationsFailsFast,continuationsAreAvailableOnTheTestClasspathInternalSubagentRecoveryTest;replayOfCompletedActionEmitsPreparedAndReusedPairTaskLifecycleListenerNotificationTest; Pythontest_task_lifecycle_bridge.pylaterChildActionReadsValueWrittenByEarlierChildActionchildFieldWriteDoesNotLeakIntoParentFieldList,childNestedWriteDoesNotLeakIntoParentObjectinternalSubagentProviderSurvivesJsonRoundTrip,javaPlanWithInternalSubagentSnapshotIsStable; Pythontest_python_can_deserialize_java_plan_with_internal_subagenttestInternalChildCacheMaterializesPythonOwnedResourcesThroughTheRootBridge; Pythontest_scoped_materialization_uses_the_child_plan_and_its_own_cache,test_close_closes_the_root_and_every_scoped_cacheHighest-risk paths — cross-language identity replay, mailbox suspension/deadlock, and cycle rejection — are pinned by dedicated Java and Python tests plus a real-Flink Python e2e (call, failure, nested); plan-compilation parity is asserted in both languages.
Verified: Java
ut-javagreen (internal sub-agent, plan, memory, lifecycle, continuation, resource-cache suites); Python non-integration suite green; internal sub-agent Python e2e green on a real Flink job;./tools/build.sh --javaon JDK 17.Not verified: live-cluster checkpoint/failover of an in-flight call (recovery is harness-level, not a real cluster restart); a Java caller awaiting end-to-end through the JDK 21 continuation on a live cluster; external-service e2e. Residual fork CI failures are pre-existing LLM-auth (HTTP 401) integration tests, unrelated to this change.
Implementation invariants, full test roster, and change size
preparesplits a mailbox-threadbootstrap(registersInternalSubagentCallStatus, indexes the record key, registers the session owner on the shared context, sends the envelope) from an off-mailboxcall/_callthat waits on the status's response future and converts a child exception intoSubagentResult.error.awaitSubagentCallunwrapsExecutionExceptionso the caller'sResultcarries the failing child action's exception, not future plumbing.ActionExecutionOperatorregisters thePythonTaskLifecycleListenerwhen the root plan declares a Python-compiled sub-agent (hasPythonCompiledSubagent()), sonotify_action_preparedreachesFlinkRunnerContext, which captures the prepared namespace and replays it onto the lazily-materialized handle viaadopt_prepared_namespace;SubagentIdAllocatoris scoped to one action execution so ordinals restart per action and replays re-derive the same ids.InternalSubagentCallTest/RecoveryTest/GuardTest/MatchTest,AgentPlanSubagentResourceTest,AgentPlanCrossLanguageTest,IsolatedCachedMemoryStoreTest,ResourceCacheTest,ActionTaskContextManagerTest,ContinuationSupportTest,TaskLifecycleListenerNotificationTest; Pythontest_agent_plan_subagent_resources.py,test_agent_plan_cross_language.py,test_eager_materialize.py,test_flink_runner_context_close.py,test_task_lifecycle_bridge.py; e2einternal_subagent_test.py.api,plan,runtime(Java), andpython.API
No public signature changes.
Agent.addResource(String, ResourceType, Object)is unchanged; for anAGENTresource it now also accepts anAgentinstance (javadoc-documented widening, source-compatible) alongside the existingSubagentSetupand YAML descriptor forms, which are unaffected. Pythonsubmit(ctx, prompt)gains working no-id support for internal sub-agents;submit_with_identityremains the explicit-id hook. Newplanandruntimeclasses are additive. A Java caller awaiting in-process requires JDK 21 (continuation); the Python caller path does not.Documentation
doc-neededdoc-not-neededdoc-includedWas this patch authored or co-authored using generative AI tooling?
Generated-by: Qoder 1.29.0 (Qwen3.8-Max)