Skip to content

[api][plan][runtime][python] Support registering an Agent as an in-process internal sub-agent - #1138

Merged
yunfengzhou-hub merged 13 commits into
apache:mainfrom
yunfengzhou-hub:subagent-framework-internal
Oct 10, 2026
Merged

yunfengzhou-hub merged 13 commits into
apache:mainfrom
yunfengzhou-hub:subagent-framework-internal

Conversation

@yunfengzhou-hub

@yunfengzhou-hub yunfengzhou-hub commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Linked issue: #1137

Purpose of change

Registering an Agent instance as an AGENT resource now runs it as an in-process internal sub-agent: the caller resolves it via ctx.get_resource(scope, AGENT), calls submit(ctx, prompt), and awaits a Result holding the child's accumulated output — reusing an existing agent as a child instead of hand-writing a DeferredSubagentSetup. The child runs its own action loop, isolated and durable, and may nest.

Runtime flow

  1. Compile. AgentPlan splits an AGENT resource into three exclusive cases — SubagentSetup, YAML descriptor, Agent instance. Only the Agent case compiles, via InternalSubagentCompilationHelper: the child plan is compiled once, shared across names registering the same child, with cycles and self-references rejected, and served by InternalSubagentProvider. A Python-authored child travels as a PythonSerializableResourceProvider.
  2. Materialize. The provider builds InternalSubagentSetup (Java extends BaseDeferredSubagentSetup, Python subclasses DeferredSubagentSetup) 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.
  3. Submit + bootstrap. 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 deferred SubagentFuture. On first resolve prepare registers the call status and sends one InternalSubagentCallEvent on the mailbox thread, outside the durable boundary so a replayed send carries identical attributes.
  4. Dispatch + suspend. The callable waits off the mailbox thread — a Java caller on a JDK 21 continuation, a Python coroutine at await. The operator matches the child's actions to the envelope and runs them scope-aware (ActionTaskContextManager binds the child's SubagentScope; child resources resolve against the child plan, including a nested sub-agent) with memory isolated for the whole call and a child ResourceCache inheriting the root's Python bridge and carrying the child plan JSON, so a Python-owned child resource materializes against its own plan.
  5. Accumulate + resume + recover. Child OutputEvents accumulate into the call status; on quiesce the operator completes its future and the caller resumes with SubagentResult.ok. findCallStatus recurses 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

  • Compile the child Agent rather than require a DeferredSubagentSetup subclass: that boilerplate is what this removes, and a real plan and action loop compose (nesting) where a single deferred callable cannot.
  • Opaque deferred handle, not a raw future. Awaiting must release the mailbox so the operator can dispatch the child; a CompletableFuture.get() would block the mailbox and deadlock.
  • Identity assigned at await, not minted at send, so failover re-derives it and the replayed envelope resolves the child's persisted state instead of re-running it.
  • Orchestration state in the setup, not the operator, keeping per-scope and per-key state off the operator's shared dispatch path.

Behavioral Semantics

Interaction decisions

Caller context On resolve
Java action, JDK 21 continuation Caller suspends on the continuation; the mailbox dispatches the child; caller resumes with the Result.
Java action, no stackful suspension (≤ JDK 17) prepare fails fast with IllegalStateException rather than blocking the mailbox and deadlocking.
Python async action await releases the mailbox; a Python-compiled child's lazily-built handle adopts the prepared namespace, so no-id submit mints deterministic ids.
Python synchronous action Holds the mailbox for its whole pemja call and cannot yield, so awaiting an internal sub-agent would deadlock; the caller action must be async.
Any caller, after failover Replayed bootstrap carries identical attributes; the child replays its recorded output and ids re-derive identically.

Behavioral contracts

  • An Agent registered as an AGENT resource is invocable in-process; awaiting submit(ctx, prompt) returns a Result whose result holds the child's accumulated OutputEvent payloads.
  • A child action that raises surfaces as a failed Result (success=false, error_message); the job continues.
  • A child may register and await its own sub-agents; nested envelopes resolve to the right depth.
  • The child is isolated: its own resource cache (inheriting the root's Python bridge), scope-aware context, and memory kept for the whole call with parent MemoryItems copied before exposure, so a child write never mutates the parent's field list; its events do not land on the parent context.
  • Successive calls under one record run independently under distinct identities.
  • A completed call is durable: after failover it resolves to the same identity and replays the child's recorded output.

Failure behavior

  • Resolve without stackful suspension (Java caller): raises IllegalStateException (fail-fast, no deadlock).
  • Python prepare with a context that is not an InternalSubagentCallFactory: raises NotImplementedError.
  • Bootstrap send fails, or await for an unregistered (sessionId, callId): raises IllegalStateException.
  • No-id submit on a handle never wired to the task lifecycle: raises RuntimeError ("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.
  • Child action raises: captured into SubagentResult.error(e); the caller's Result reports failure and the job proceeds.
  • Unresolved handles at action finish: the deferred base enforces resolution; a failed invocation skips cleanup because the run fails and replays.

Tests

Contract Coverage
Agent compiles to an internal provider; shared plan; cycles/self-reference rejected; other two AGENT cases unaffected childAgentCompilesIntoInternalProvider, sharedChildAgentCompilesToSinglePlan, cycleThroughRootIsRejected, selfReferenceIsRejected, nonSubagentAgentResourceIsRejected; Python test_agent_plan_subagent_resources.py mirrors all
Caller receives accumulated output callerReceivesTheChildsAccumulatedOutput; e2e test_python_internal_subagent_call_end_to_end
Child failure surfaces via Result childFailureSurfacesThroughTheResult; e2e test_python_internal_subagent_failure_surfaces_via_result
Nesting; scope matching nestedCallReachesTheGrandchild, scopeMatchingAppliesTypeAndConditionFilters; e2e test_python_internal_subagent_nested_call
Independent identities per call successiveCallsRunIndependentlyUnderDistinctIdentities
Fail-fast without continuations resolvingWithoutContinuationsFailsFast, continuationsAreAvailableOnTheTestClasspath
Durability / failover replay InternalSubagentRecoveryTest; replayOfCompletedActionEmitsPreparedAndReusedPair
Cross-language lifecycle and no-id namespace replay TaskLifecycleListenerNotificationTest; Python test_task_lifecycle_bridge.py
Child memory persists for the whole call (a later child action reads an earlier one's write) laterChildActionReadsValueWrittenByEarlierChildAction
Child field write does not mutate the parent's field list childFieldWriteDoesNotLeakIntoParentFieldList, childNestedWriteDoesNotLeakIntoParentObject
Nested child plan survives Java↔Python round-trip internalSubagentProviderSurvivesJsonRoundTrip, javaPlanWithInternalSubagentSnapshotIsStable; Python test_python_can_deserialize_java_plan_with_internal_subagent
Internal child's Python-owned resource materializes through the root bridge testInternalChildCacheMaterializesPythonOwnedResourcesThroughTheRootBridge; Python test_scoped_materialization_uses_the_child_plan_and_its_own_cache, test_close_closes_the_root_and_every_scoped_cache

Highest-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-java green (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 --java on 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
  • prepare splits a mailbox-thread bootstrap (registers InternalSubagentCallStatus, indexes the record key, registers the session owner on the shared context, sends the envelope) from an off-mailbox call/_call that waits on the status's response future and converts a child exception into SubagentResult.error.
  • awaitSubagentCall unwraps ExecutionException so the caller's Result carries the failing child action's exception, not future plumbing.
  • Cross-language no-id: ActionExecutionOperator registers the PythonTaskLifecycleListener when the root plan declares a Python-compiled sub-agent (hasPythonCompiledSubagent()), so notify_action_prepared reaches FlinkRunnerContext, which captures the prepared namespace and replays it onto the lazily-materialized handle via adopt_prepared_namespace; SubagentIdAllocator is scoped to one action execution so ordinals restart per action and replays re-derive the same ids.
  • Full roster: Java InternalSubagentCallTest/RecoveryTest/GuardTest/MatchTest, AgentPlanSubagentResourceTest, AgentPlanCrossLanguageTest, IsolatedCachedMemoryStoreTest, ResourceCacheTest, ActionTaskContextManagerTest, ContinuationSupportTest, TaskLifecycleListenerNotificationTest; Python test_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; e2e internal_subagent_test.py.
  • Change size: 57 files, +4447/−131 across api, plan, runtime (Java), and python.

API

No public signature changes. Agent.addResource(String, ResourceType, Object) is unchanged; for an AGENT resource it now also accepts an Agent instance (javadoc-documented widening, source-compatible) alongside the existing SubagentSetup and YAML descriptor forms, which are unaffected. Python submit(ctx, prompt) gains working no-id support for internal sub-agents; submit_with_identity remains the explicit-id hook. New plan and runtime classes are additive. A Java caller awaiting in-process requires JDK 21 (continuation); the Python caller path does not.

Documentation

  • doc-needed
  • doc-not-needed
  • doc-included

Was this patch authored or co-authored using generative AI tooling?

  • Yes
  • No

Generated-by: Qoder 1.29.0 (Qwen3.8-Max)

@yunfengzhou-hub yunfengzhou-hub linked an issue Sep 18, 2026 that may be closed by this pull request
2 tasks done
@yunfengzhou-hub
yunfengzhou-hub force-pushed the subagent-framework-internal branch from 90bdf8f to a0ac91d Compare September 18, 2026 04:00
@purushah

Copy link
Copy Markdown
Contributor

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:

  1. Where in-flight call state lives across a checkpoint. Right now it's only on the heap and rebuilt when the parent replays, but child tasks are checkpointed before the parent is re-enqueued. On restore the child can run first, find no status, and the job loops.
  2. Whether the parent should wait on a ContinuationActionExecutor thread. Children use the same pool, so enough concurrent calls on one subtask can starve it.
  3. What "done" means for a child with more than one action. Replay only re-sends the child's output events, so a chained child comes back with partial output after failover.

I have more detailed notes and am happy to share them once the direction is set.

@yunfengzhou-hub

Copy link
Copy Markdown
Contributor Author

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:

  1. Where in-flight call state lives across a checkpoint. Right now it's only on the heap and rebuilt when the parent replays, but child tasks are checkpointed before the parent is re-enqueued. On restore the child can run first, find no status, and the job loops.
  2. Whether the parent should wait on a ContinuationActionExecutor thread. Children use the same pool, so enough concurrent calls on one subtask can starve it.
  3. What "done" means for a child with more than one action. Replay only re-sends the child's output events, so a chained child comes back with partial output after failover.

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:

  • Without ActionState, an in-flight call tolerates recomputation and takes the final result.
  • With ActionState, the call is consistent but requires the request to be replayable; there is no reconcile mechanism, and anything needing reconcile belongs in durable execution.

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?

@pltbkd

pltbkd commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

I have a proposal for the recovery consistency model.

Given that:

  1. Internal sub-agent recovery can provide the same recovery strength as durable execution under deterministic replay.
  2. ActionState serves an action-level role analogous to reconciliation: one record is initialized before execution, updated as durable calls finish, and marked completed with the action's memory updates and emitted events when the action finishes.
  3. The deterministic attributes of InternalSubagentCallEvent, including the sub-agent identity, form the event-identity component of the ActionState key. Together with the same business key, sequence number, and action identity, replay can therefore locate the original ActionState entry after failover.

We do not need to restore child ActionTasks. We can retain only unfinished root callers and let them issue the calls again. The regenerated child tasks then reconcile against ActionState: an incomplete action reruns and reuses its persisted durable-call results, while a completed action skips its body and replays the framework-visible events needed to reconstruct the remaining action graph. Calls to a deeper-level sub-agent initiated by that completed action can be skipped because their results have already been consumed by the action. Without ActionState, the unfinished subtree is recomputed.

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 wenjin272 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@yunfengzhou-hub

Copy link
Copy Markdown
Contributor Author

I have a proposal for the recovery consistency model.

Given that:

  1. Internal sub-agent recovery can provide the same recovery strength as durable execution under deterministic replay.
  2. ActionState serves an action-level role analogous to reconciliation: one record is initialized before execution, updated as durable calls finish, and marked completed with the action's memory updates and emitted events when the action finishes.
  3. The deterministic attributes of InternalSubagentCallEvent, including the sub-agent identity, form the event-identity component of the ActionState key. Together with the same business key, sequence number, and action identity, replay can therefore locate the original ActionState entry after failover.

We do not need to restore child ActionTasks. We can retain only unfinished root callers and let them issue the calls again. The regenerated child tasks then reconcile against ActionState: an incomplete action reruns and reuses its persisted durable-call results, while a completed action skips its body and replays the framework-visible events needed to reconstruct the remaining action graph. Calls to a deeper-level sub-agent initiated by that completed action can be skipped because their results have already been consumed by the action. Without ActionState, the unfinished subtree is recomputed.

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?

@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 ActionState. It also closes @purushah 's concern #3 (partial output after failover) as a side effect — a yield now holds same-call and ordinary events until the action finishes, so ActionState captures them and replay reconstructs the full output instead of losing mid-yield events.

@github-actions github-actions Bot added the doc-needed Your PR changes impact docs. label Oct 8, 2026

@wenjin272 wenjin272 left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

yunfengzhou-hub and others added 10 commits October 10, 2026 17:14
… 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
yunfengzhou-hub and others added 3 commits October 10, 2026 17:23
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
@yunfengzhou-hub
yunfengzhou-hub force-pushed the subagent-framework-internal branch from 47bb236 to 15e3ccb Compare October 10, 2026 09:24

@wenjin272 wenjin272 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing the comments. LGTM

@yunfengzhou-hub
yunfengzhou-hub merged commit bd527d7 into apache:main Oct 10, 2026
28 checks passed
weiqingy added a commit to weiqingy/flink-agents that referenced this pull request Oct 11, 2026
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)
wenjin272 added a commit to wenjin272/flink-agents that referenced this pull request Oct 12, 2026
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>
wenjin272 added a commit to wenjin272/flink-agents that referenced this pull request Oct 15, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

doc-needed Your PR changes impact docs.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Support in-process internal sub-agent

4 participants