GitHub user pltbkd created a discussion: [Feature] Sub-agent Resource for Flink 
Agents - Framework part

# [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:

- **Parallel exploration**: Multiple agents working in different directions 
simultaneously, each with its own reasoning loop and tools.
- **Independent review**: An agent providing an independent perspective — e.g., 
a reviewer agent that evaluates code without being influenced by the main 
agent's context.
- **Reusing agents as sub-links**: Composing an agent defined by another team 
as a building block in a larger agent pipeline.

These scenarios also create demand for **internal sub-agents** — the ability to 
register a Flink Agents `Agent` as a sub-agent of another `Agent`, with proper 
context isolation and execution semantics.

This proposal introduces `AGENT` as a first-class resource type in Flink 
Agents, supporting both internal and external sub-agents with a unified API.

## Goals

- **Introduce `AGENT` resource type**: Sub-agents can be registered via 
`addResource(name, AGENT, ...)` and accessed via `ctx.getResource(name, 
AGENT)`, usable by both Actions and ChatModel.
- **Support for external, internal, and Remote Agents**: Supports custom 
external agents, supports qualified internal agents, and will later implement 
remote parallel execution of internal agents based on RpcOperator.
- **Context isolation, DurableExecute, and failover recovery**: Both internal 
and external sub-agents benefit from durable execution with automatic failover 
recovery. Internal agents introduce scope-level isolation.

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

```java
public enum ResourceType {
    CHAT_MODEL, CHAT_MODEL_CONNECTION, TOOL, EMBEDDING_MODEL,
    VECTOR_STORE, PROMPT, MCP_SERVER, SKILLS,
    AGENT  // Sub-agent resource
}
```

### 2. Subagent Interface

`Subagent` is the common interface for all sub-agents. Both external 
(`SubagentSetup`) and internal sub-agents implement it.

```java
public class Result {
    boolean success;        // success or failure
    Object result;       // type depends on agent implementation; built-in 
agents return List<Object>
    Exception exception; // present when success=false
}

public interface Subagent {
    default Result call(RunnerContext ctx, Object prompt) {
        return call(ctx, prompt, ctx.nextSessionId());
    }

    default Result call(RunnerContext ctx, Object prompt, String sessionId) {
        return ctx.durableExecuteAsync(asAsyncCallable(ctx, prompt, sessionId));
    }

    default DurableCallable<Result> asAsyncCallable(RunnerContext ctx, Object 
prompt) {
        return asAsyncCallable(ctx, prompt, ctx.nextSessionId());
    }

    DurableCallable<Result> asAsyncCallable(RunnerContext ctx, Object prompt, 
String sessionId);
}
```

```python
class Result:
    success: bool
    result: Any 
    exception: Optional[Exception]

class Subagent(Protocol):
    def call(self, ctx: RunnerContext, prompt: Any, session_id: Optional[str] = 
None) -> Result:
        if session_id is None:
            session_id = ctx.next_session_id()
        return ctx.durable_execute_async(
            self.as_async_callable(ctx, prompt, session_id))

    def as_async_callable(self, ctx: RunnerContext, prompt: Any, session_id: 
Optional[str] = None) -> DurableCallable[Result]:
        if session_id is None:
            session_id = ctx.next_session_id()
        ...
```

`SubagentSetup` is the abstract base for external sub-agents, implementing 
`Subagent`:

```java
public abstract class SubagentSetup extends SerializableResource implements 
Subagent {

    @Override
    @JsonIgnore
    public ResourceType getResourceType() { return ResourceType.AGENT; }
}
```

```python
class SubagentSetup(SerializableResource, Subagent):
    def get_resource_type(self) -> ResourceType:
        return ResourceType.AGENT
```

### 3. Registration

Internal and external sub-agents are registered identically, only via 
`addResource`, not via annotation:

```java
// Internal sub-agent
addResource("reviewer", ResourceType.AGENT, new ReviewerAgent());

// External sub-agent
addResource("summarizer", ResourceType.AGENT, new HttpSubagentSetup(endpoint));
```

```python
# Internal
self.add_resource("reviewer", ResourceType.AGENT, ReviewerAgent())

# External
self.add_resource("summarizer", ResourceType.AGENT, HttpSubagentSetup(endpoint))
```

```yaml
agents:
  - name: reviewer-agent
    module: com.example
    clazz: ReviewerAgent

  - name: root-agent
    module: com.example
    clazz: RootAgent
    subagents:
      - name: reviewer
        agent_ref: reviewer-agent          # internal: reference to another 
agent definition
      - name: summarizer
        target_module: com.example          # external: reference to 
SubagentSetup subclass
        target_clazz: HttpSubagentSetup
        arguments:
          endpoint: "http://summarizer:8080";
```

### 4. Calling

```java
// Synchronous
Subagent subagent = ctx.getResource("reviewer", ResourceType.AGENT);
Result result = subagent.call(ctx, prompt);

// Parallel
DurableCallable<Result> c1 = subagent1.asAsyncCallable(ctx, prompt1);
DurableCallable<Result> c2 = subagent2.asAsyncCallable(ctx, prompt2);
List<Result> results = ctx.durableExecuteAllAsync(c1, c2);
```

```python
# Synchronous
subagent: Subagent = ctx.get_resource("reviewer", ResourceType.AGENT)
result: Result = subagent.call(ctx, prompt)

# Parallel
c1 = subagent1.as_async_callable(ctx, prompt1)
c2 = subagent2.as_async_callable(ctx, prompt2)
results: list[Result] = await ctx.durable_execute_all_async(c1, c2)
```

### 5. Implementing External Sub-agent

```java
public class HttpSubagentSetup extends SubagentSetup {
    private final String endpointUrl;

    public HttpSubagentSetup(String endpointUrl) {
        this.endpointUrl = endpointUrl;
    }

    @Override
    public DurableCallable<Result> asAsyncCallable(RunnerContext ctx, Object 
prompt, String sessionId) {
        return () -> {
            List<Object> output = httpClient.post(endpointUrl,
                    Map.of("prompt", prompt, "session", sessionId));
            return new Result(true, output, null);
        };
    }
}
```

```python
class HttpSubagentSetup(SubagentSetup):
    def __init__(self, endpoint: str):
        self._endpoint = endpoint

    def as_async_callable(self, ctx: RunnerContext, prompt: Any, session_id: 
str) -> DurableCallable[Result]:
        def _call() -> Result:
            output = httpx.post(self._endpoint, json={"prompt": prompt, 
"session": session_id})
            return Result(success=True, result=output, exception=None)
        return DurableCallable(_call)
```

---

## Execution Process

### 1. Compilation

Both internal and external sub-agents compile to `SerializableResourceProvider` 
in the plan JSON, ensuring cross-language interoperability:

```
AGENT resource processing
  │
  ├── SubagentSetup (external)
  │     → SerializableResourceProvider
  │
  └── Agent (internal)
        → Compiled into child AgentPlan
          → InternalSubagentSetup → SerializableResourceProvider
```

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_MODEL` is 
available, `ReActAgent` is automatically registered as `"general-purpose"`. 
This registration runs after all user-declared resources are extracted, 
ensuring decorator-declared `CHAT_MODEL` is ready.

### 2. Execution Model

The default `call()` implementation executes the `DurableCallable` returned by 
`asAsyncCallable` via `durableExecuteAsync`, leveraging the durable execution 
mechanism for state recording and recovery.

**Parallel calls**: users manually split — call `asAsyncCallable` for each 
sub-agent, then pass all `DurableCallable`s to `durableExecuteAllAsync` (to be 
introduced in an upcoming discussion).

`call()` returns `Result`; sub-agent implementations should intercept internal 
exceptions and populate Result, without directly exposing them to the caller. 
`tool_call_action` constructs success/error `ToolResponseEvent` based on 
Result, without try/catch.

### 3. ChatModel Integration

Sub-agents are exposed to the LLM as tools (Subagent as Tool pattern). 

- **Prompt injection**: Available AGENT resources are listed in the system 
prompt alongside tool descriptions, letting the LLM know which sub-agents it 
can delegate to.
- **Dispatch**: `tool_call_action` checks resource type — TOOL resources go to 
`tool.call()`, AGENT resources go to `subagent.call(ctx, prompt)`.
- **Result**: Returned via existing `ToolResponseEvent`.

```
LLM response → tool_calls → ToolRequestEvent → tool_call_action
  │
  ├── name matches TOOL resource → tool.call(**kwargs)
  └── name matches AGENT resource → subagent.call(ctx, prompt)
  │
  └── ToolResponseEvent → back to LLM
```

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 `prompt` 
parameter. The `tool_call_action` already holds `ctx` as an action parameter, 
which is passed directly to `subagent.call(ctx, prompt)`.

---

## Internal Sub-agent Design Preview

Internal sub-agent compilation — registering `Agent` instances 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 `Agent` is compiled into an independent `AgentPlan` 
(childPlan), wrapped as `InternalSubagentSetup(childPlan, scope)`. The child 
plan inherits the parent plan's `AgentConfiguration`. Circular references are 
detected at compile time. `scope` is 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 for `SubagentCallEvent` to support this execution model — 
`asAsyncCallable` sends `SubagentCallEvent` on the mailbox thread, and 
`durableExecuteAsync` blocks on the worker thread waiting for the result.

### 3. Scope-based Execution

`SubagentCallEvent` is converted to `InputEvent` within 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 result 
`OutputEvent` is intercepted and returned to the caller. When all pending 
events are processed and no actions are running (quiesce), the call completes 
automatically.

### 4. Isolation

`SubagentRunnerContext` provides isolated context:

- **Actions and events**: Sub-agent uses its own `AgentPlan`'s definitions, 
fully independent from parent.
- **Memory**: Read-only access to parent memory; Sensory memory is allowed but 
not written back to parent scope memory.
- **Resources**: Uses own registered resources first, falls back to parent 
(parent resource cache inheritance).
- **Config**: Inherits parent plan's `AgentConfiguration`.

Nested sub-agents are supported recursively via parent resource cache fallback.

### 5. Fault Tolerance

- **Completed calls**: Intercepted directly by DurableExecute mechanism; 
failover hits cached result.
- **Incomplete calls**: Supports ActionState; after failover, deterministic 
SessionId associates the new sub-agent call with recovered Actions, enabling 
intermediate state recovery rather than full re-execution.

---

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

| Capability | External Sub-agent | Internal Sub-agent |
|---|---|---|
| **Context isolation** | Managed by external system via deterministic 
SessionId | Framework creates isolated `SubagentRunnerContext` |
| **Memory** | Maintained by external system | Read-only parent access, 
write-isolated, not persisted |
| **State recovery** | DurableExecute persists call records | Same as external; 
additionally, deterministic SessionId links call chain to sub-agent internal 
action ActionState, supporting intermediate state recovery |
| **Parallel execution** | Multiple agents in parallel via 
`durableExecuteAllAsync` | One action at a time per ActionExecutionOperator, 
but coroutine-based async partial parallelism |
| **Backpressure** | Via `durableExecuteAsync` worker pool | Same as external; 
needs to consider subagent nested call deadlock issues |

### 2. Introducing `SubagentCompatible` Marker

When an `Agent` is registered as a sub-agent, it runs in an isolated 
`SubagentRunnerContext`, 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 `SubagentCompatible` marker 
(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 unmarked `Agent` is 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

- **Timeout / cancellation semantics**: `call()` currently does not provide 
timeout or cancellation. Further investigation of requirements is needed to 
refine the design.
- **Sub-agent prompt injection and dispatch chain for ChatModel**: The prompt 
injection format for exposing sub-agents as tools to the LLM, and the 
`tool_call_action` dispatch chain, need further clarification.
- **External agent support framework**: Consider providing `SubagentSetup` 
implementation helpers for gRPC, A2A (Agent-to-Agent) protocol, etc., to reduce 
the cost of integrating external agents.
- **RpcOperator-based RemoteAgent design**: The remote execution approach via 
RpcOperator (FLIP-577) on a separate ActionExecutionOperator is yet to be 
designed.


GitHub link: https://github.com/apache/flink-agents/discussions/909

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

Reply via email to