Replies: 7 comments 14 replies
|
Thanks for the proposal. I understand the motivation for providing a unified invocation API for internal and external agents. However, I think the current From the user's perspective, an external agent is a black-box invocation target, so This is not only an internal implementation difference. To understand the behavior of Therefore, the proposal unifies the invocation shape, but does not fully unify the user-facing semantics. From this perspective, the shared abstraction seems closer to a
Under this model, the authoring experience remains different where it should be. For an external agent, its integration can provide a custom This would preserve a unified API for callers without exposing |
|
Hi @pltbkd, Thanks for putting together this comprehensive proposal! The introduction of A few thoughts and questions regarding design details, open items, and alignment with earlier roadmap discussions:
Looking forward to hearing your thoughts! |
|
Thanks for this proposal @pltbkd . I think treating agents as a first-class resource is a good direction. One thing I would like to understand better: for the external agent case specifically, what is the unique value of this design, compared with doing the same thing using the existing Tool or Action APIs? Today I can wrap a remote agent as a Tool. It registers, the LLM can call it, it runs under durable execution, and it takes a structured input. Or I can write an Action and call the remote directly. I just want to learn more here, not to question why we need this. For extenal agent proposal, let's use a concrete example to discuss. Say I call LangGraph agents owned by another team. I give one a metric dashboard link, it pulls logs, runs its own tools, and returns a diagnosis. Their protocol is not one single HTTP request, it is three steps (check below). You POST a message to a thread, it starts a background run and returns right away. Then you open an SSE stream to watch that thread, and the run keeps going even if your client disconnects. And you can GET the thread status any time, which returns running, completed, failed, or not_started. The thread id is given by the client, and it gives multi-turn continuity. So this remote is not a stateless function, it is a durable async job. It already exposes, on the server side, a similar lifecycle that @ofekron described. The thread id is a stable invocation id. The status endpoint gives real states. Completion is explicit, it is status completed, not the SSE socket closing. This matters for recovery. If Flink crashes after the call started, I do not need to run the remote agent again. I can use the same thread id to ask the server what happened. If it is still running, I reconnect to the stream. If it already finished, I read the saved result. So after a crash, recovery can be a query, not a re-run. This is where I am not sure the current design fits. The external agent endpoint splits submit and stream on purpose. The current design merges them back into one callable. So, on failover there is only one move, run the whole callable again. The server actually handles the easy case well. If I re-post while the run is still active, it short-circuits and just returns the status, and re-opening the stream re-attaches. The problem is the narrow window where the run already completed but Flink crashed before saving the result. On recovery the thread is no longer active, so a replay post is accepted and starts a new run, which the checkpointer appends as a new turn. That is duplicate work, and it corrupts a multi-turn thread. Deterministic SessionId does not help me here, because it maps to the thread id, and re-posting to that thread is not idempotent in this window. On recovery, the right behavior could be like this. Do not send the message again. First ask the server for the thread status. If it is still running, re-attach to the stream. If it already finished, just read the result. If it failed, report the error. This needs the call to be split into two parts, submit and probe. But the current interface is a single callable, so there is no place to express this split. To make it work, I would have to bypass the abstraction and handle the low level durable call myself. At that point I am basically writing it by hand with a Tool plus an Action, which is what I can already do today. And that brings me back to my first question. |
|
Hi @alnzng @weiqingy @ofekron @joeyutong I have created a PR for this framework: #938. I think it will help us ground the design discussion in concrete code, and I can land any conclusions we reach directly on the PR. The recent discussion made me think through the interface design once more. I'd like to share the reasoning behind the current BackgroundA key constraint is that For external sub-agents, the actual request (e.g., an HTTP call) must reside inside This led to the current interface: The concernI do find this interface unintuitive. Using Two alternativesOption A —
|
|
For an external sub-agent protocol, would it be useful for the reconciliation primitive to return the original persisted result artifact plus its canonical request hash and a verifier-discoverable signature/key reference—so recovery can prove ‘this is the same completed invocation’ before any resend? I’m especially curious whether you would treat that as an optional transport-neutral artifact profile rather than make it part of the core callable API. We have explored this only as a non-production deterministic fixture pattern, not as a Flink integration or production assurance. |
|
Thanks—yes, that is the boundary I was trying to isolate, and keeping it inside the reconciler makes sense. Our current implementation is not a Flink production integration, so I do not want to overstate it as one. The adjacent operational scenario is an external provider that may complete an invocation before the caller receives or persists the response. On recovery, the reconciler must determine whether the returned completed artifact belongs to the original invocation rather than merely trusting a matching call identity. The pattern we are testing binds a stable invocation ID and canonical request hash to the original persisted result artifact, then makes that binding independently checkable through a signature and discoverable key reference. A mismatch, missing original artifact, or ambiguous reconciliation fails closed before any resend. I agree this is probably best expressed first as a reusable reconciler component or profile. If useful, I can share a small synthetic example focused specifically on that boundary—without presenting it as a Flink integration or production assurance. |
Uh oh!
There was an error while loading. Please reload this page.
[Feature] Sub-agent Resource for Flink Agents
Motivation
As discussed in #660, there are scenarios where a Flink Agent needs to call external agents — for example, delegating a subtask to a remote LLM agent service or a third-party agent platform. Currently, users can only do this by manually writing remote calls inside actions, which means they miss out on durable execution, checkpoint recovery, and tool call integration.
Beyond external agent calls, several related scenarios have emerged:
These scenarios also create demand for internal sub-agents — the ability to register a Flink Agents
Agentas a sub-agent of anotherAgent, with proper context isolation and execution semantics.This proposal introduces
AGENTas a first-class resource type in Flink Agents, supporting both internal and external sub-agents with a unified API.Goals
AGENTresource type: Sub-agents can be registered viaaddResource(name, AGENT, ...)and accessed viactx.getResource(name, AGENT), usable by both Actions and ChatModel.The proposed changes have been initially validated with a POC (mainly on java api). The overall design still has room for discussion — feedback and suggestions are welcome.
Proposed Interface
1. Resource Type
2. Subagent Interface
Subagentis the common interface for all sub-agents. Both external (SubagentSetup) and internal sub-agents implement it.SubagentSetupis the abstract base for external sub-agents, implementingSubagent:3. Registration
Internal and external sub-agents are registered identically, only via
addResource, not via annotation:4. Calling
5. Implementing External Sub-agent
Execution Process
1. Compilation
Both internal and external sub-agents compile to
SerializableResourceProviderin the plan JSON, ensuring cross-language interoperability:Both paths produce
SerializableResourceProvider, resulting in a uniform plan JSON representation with no runtime difference.Additionally, the compiler registers built-in sub-agents: if
CHAT_MODELis available,ReActAgentis automatically registered as"general-purpose". This registration runs after all user-declared resources are extracted, ensuring decorator-declaredCHAT_MODELis ready.2. Execution Model
The default
call()implementation executes theDurableCallablereturned byasAsyncCallableviadurableExecuteAsync, leveraging the durable execution mechanism for state recording and recovery.Parallel calls: users manually split — call
asAsyncCallablefor each sub-agent, then pass allDurableCallables todurableExecuteAllAsync(to be introduced in an upcoming discussion).call()returnsResult; sub-agent implementations should intercept internal exceptions and populate Result, without directly exposing them to the caller.tool_call_actionconstructs success/errorToolResponseEventbased on Result, without try/catch.3. ChatModel Integration
Sub-agents are exposed to the LLM as tools (Subagent as Tool pattern).
tool_call_actionchecks resource type — TOOL resources go totool.call(), AGENT resources go tosubagent.call(ctx, prompt).ToolResponseEvent.LLM APIs (OpenAI, Ollama, etc.) only have tool/function call concepts — there is no "agent call". Sub-agents are exposed as tools with a single
promptparameter. Thetool_call_actionalready holdsctxas an action parameter, which is passed directly tosubagent.call(ctx, prompt).Internal Sub-agent Design Preview
Internal sub-agent compilation — registering
Agentinstances as sub-agents — will be discussed in a future proposal. The resource model and calling API are already designed to accommodate it.1. Compilation
The sub-agent's
Agentis compiled into an independentAgentPlan(childPlan), wrapped asInternalSubagentSetup(childPlan, scope). The child plan inherits the parent plan'sAgentConfiguration. Circular references are detected at compile time.scopeis the sub-agent's resource name, used for event routing.2. Invocation
Calling a sub-agent emits a
SubagentCallEvent. During async execution, the coroutine yields, and the AEO processes the event in a subsequent mailbox cycle, scheduling the sub-agent's internal actions. Only the event sending timing is modified forSubagentCallEventto support this execution model —asAsyncCallablesendsSubagentCallEventon the mailbox thread, anddurableExecuteAsyncblocks on the worker thread waiting for the result.3. Scope-based Execution
SubagentCallEventis converted toInputEventwithin the sub-agent's scheduling scope. Intermediate events are dispatched only within that scope — they do not leak to the parent agent or other sub-agents. The resultOutputEventis intercepted and returned to the caller. When all pending events are processed and no actions are running (quiesce), the call completes automatically.4. Isolation
SubagentRunnerContextprovides isolated context:AgentPlan's definitions, fully independent from parent.AgentConfiguration.Nested sub-agents are supported recursively via parent resource cache fallback.
5. Fault Tolerance
Design Discussion
1. Internal vs External Capability Parity
External and internal sub-agents provide identical user-facing API (registration,
call(),asAsyncCallable()). However, the underlying implementations differ significantly — external sub-agents are black-box calls (HTTP/gRPC), while internal sub-agents are framework-internal plan nesting execution.SubagentRunnerContextdurableExecuteAllAsyncdurableExecuteAsyncworker pool2. Introducing
SubagentCompatibleMarkerWhen an
Agentis registered as a sub-agent, it runs in an isolatedSubagentRunnerContext, meaning certain capabilities are restricted, such as the inability to write to memory. Not all Agents can correctly accommodate these constraints.To ensure registration correctness, we propose a
SubagentCompatiblemarker (interface / annotation / yaml field) that an Agent declares to indicate it is safe to run as a sub-agent. The framework validates this marker at compile time: when an unmarkedAgentis registered as a sub-agent, compilation fails fast.This provides an explicit contract: Agent authors must evaluate and declare compatibility with sub-agent constraints (e.g., no dependency on writing to parent memory, no reliance on cross-scope events), avoiding hard-to-debug behavioral anomalies at runtime.
We need to evaluate what capability restrictions sub-agents have, to confirm whether this marker is necessary.
To Be Refined
call()currently does not provide timeout or cancellation. Further investigation of requirements is needed to refine the design.tool_call_actiondispatch chain, need further clarification.SubagentSetupimplementation helpers for gRPC, A2A (Agent-to-Agent) protocol, etc., to reduce the cost of integrating external agents.All reactions