-
Notifications
You must be signed in to change notification settings - Fork 150
[api][plan][runtime] Introduce AGENT resource type and sub-agent invocation API #938
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| /* | ||
| * Licensed to the Apache Software Foundation (ASF) under one | ||
| * or more contributor license agreements. See the NOTICE file | ||
| * distributed with this work for additional information | ||
| * regarding copyright ownership. The ASF licenses this file | ||
| * to you under the Apache License, Version 2.0 (the | ||
| * "License"); you may not use this file except in compliance | ||
| * with the License. You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
|
|
||
| package org.apache.flink.agents.api.subagent; | ||
|
|
||
| import org.apache.flink.agents.api.context.DurableCallable; | ||
|
|
||
| /** | ||
| * Convenience base for the {@link DurableCallable} returned by {@code asAsyncCallable}. | ||
| * | ||
| * <p>Keys the durable call by the framework-assigned identity as {@code sessionId#callId} (the | ||
| * {@link SubagentSetup} contract) and captures exceptions thrown by {@link #callInternal()} into | ||
| * {@link Result#error(Exception)}, so failures are reported through the result rather than thrown. | ||
| * Implementations only provide {@link #callInternal()}. | ||
| */ | ||
| public abstract class BaseSubagentCallable implements DurableCallable<Result> { | ||
|
|
||
| private final String sessionId; | ||
| private final String callId; | ||
|
|
||
| protected BaseSubagentCallable(String sessionId, String callId) { | ||
| this.sessionId = sessionId; | ||
| this.callId = callId; | ||
| } | ||
|
|
||
| @Override | ||
| public String getId() { | ||
| return sessionId + "#" + callId; | ||
| } | ||
|
|
||
| @Override | ||
| public Class<Result> getResultClass() { | ||
| return Result.class; | ||
| } | ||
|
|
||
| @Override | ||
| public final Result call() { | ||
| try { | ||
| return Result.ok(callInternal()); | ||
| } catch (Exception e) { | ||
| return Result.error(e); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Performs the invocation and returns the JSON-serializable payload. Thrown exceptions are | ||
| * captured into a failed {@link Result}. | ||
| */ | ||
| protected abstract Object callInternal() throws Exception; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| /* | ||
| * Licensed to the Apache Software Foundation (ASF) under one | ||
| * or more contributor license agreements. See the NOTICE file | ||
| * distributed with this work for additional information | ||
| * regarding copyright ownership. The ASF licenses this file | ||
| * to you under the Apache License, Version 2.0 (the | ||
| * "License"); you may not use this file except in compliance | ||
| * with the License. You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
|
|
||
| package org.apache.flink.agents.api.subagent; | ||
|
|
||
| import com.fasterxml.jackson.annotation.JsonCreator; | ||
| import com.fasterxml.jackson.annotation.JsonIgnore; | ||
| import com.fasterxml.jackson.annotation.JsonProperty; | ||
|
|
||
| import java.io.PrintWriter; | ||
| import java.io.Serializable; | ||
| import java.io.StringWriter; | ||
|
|
||
| /** | ||
| * Outcome of a {@link Subagent} call. | ||
| * | ||
| * <p>Sub-agent implementations should capture internal failures into a {@code Result} (via {@link | ||
| * #error}) instead of throwing, so callers can inspect {@link #isSuccess()} without try/catch. | ||
| * | ||
| * <p>The failure cause is carried as a serializable {@code errorMessage} — the full stack trace of | ||
| * the failure — rather than a live exception, so that a {@code Result} can be persisted through | ||
| * durable execution. | ||
| */ | ||
| public class Result implements Serializable { | ||
|
|
||
| private static final long serialVersionUID = 1L; | ||
|
|
||
| private final boolean success; | ||
| private final Object result; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I round-tripped a small POJO payload through this So Python does not diverge here. Its durable payload goes through What should
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for catching this — it's a real oversight. I'm currently working on the cross-language sub-agent invocation design and ran into the same issue there. The plan is to add One known limitation: for generic collection payloads like List, both recovery and cross-language paths leave result as ArrayList — getResult(List.class) only returns List, losing element types. A |
||
| private final String errorMessage; | ||
|
|
||
| @JsonCreator | ||
| public Result( | ||
| @JsonProperty("success") boolean success, | ||
| @JsonProperty("result") Object result, | ||
| @JsonProperty("errorMessage") String errorMessage) { | ||
| this.success = success; | ||
| this.result = result; | ||
| this.errorMessage = errorMessage; | ||
| } | ||
|
|
||
| /** Creates a successful result carrying the given value. */ | ||
| public static Result ok(Object result) { | ||
| return new Result(true, result, null); | ||
| } | ||
|
|
||
| /** Creates a failed result carrying the full stack trace of the given exception. */ | ||
| public static Result error(Exception exception) { | ||
| return new Result(false, null, exception == null ? null : stackTraceOf(exception)); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The durable layer sees a normal completion: The trace is also uncapped, and Worth capping what gets persisted, say the message plus the top N frames, and keeping the full trace to the log? And is collapsing a captured failure into a SUCCEEDED
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is by design. As discussed in #909, "sub-agent implementations should intercept internal exceptions and populate Result, without directly exposing them to the caller." The intent is to prevent sub-agent exceptions from directly affecting the caller while avoiding the need for every caller to write its own try-catch. When an exception occurs, the persisted Result carries the failure info; the caller always receives a failed Result rather than the exception itself, so Action behavior stays consistent between first run and replay. On durable-layer status (SUCCEEDED for failed sub-agent calls): This is deliberate. SUCCEEDED at the durable layer means "the durable call completed and returned a Result" — the sub-agent's success or failure is carried inside the Result payload ( On uncapped stack trace: The current text-based exception (full stack trace as If we were to switch to using Does the by-design approach (capturing into Result rather than throwing) seem reasonable, or do you prefer requiring callers to try-catch? |
||
| } | ||
|
|
||
| /** Creates a failed result carrying the given message. */ | ||
| public static Result error(String errorMessage) { | ||
| return new Result(false, null, errorMessage); | ||
| } | ||
|
|
||
| private static String stackTraceOf(Exception exception) { | ||
| StringWriter writer = new StringWriter(); | ||
| exception.printStackTrace(new PrintWriter(writer)); | ||
| return writer.toString(); | ||
| } | ||
|
|
||
| public boolean isSuccess() { | ||
| return success; | ||
| } | ||
|
|
||
| public Object getResult() { | ||
| return result; | ||
| } | ||
|
|
||
| public String getErrorMessage() { | ||
| return errorMessage; | ||
| } | ||
|
|
||
| /** | ||
| * Reconstructs an exception carrying the stored stack trace as its message, or null if this | ||
| * result is successful. | ||
| */ | ||
| @JsonIgnore | ||
| public Exception getException() { | ||
| return success ? null : new RuntimeException(errorMessage); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| /* | ||
| * Licensed to the Apache Software Foundation (ASF) under one | ||
| * or more contributor license agreements. See the NOTICE file | ||
| * distributed with this work for additional information | ||
| * regarding copyright ownership. The ASF licenses this file | ||
| * to you under the Apache License, Version 2.0 (the | ||
| * "License"); you may not use this file except in compliance | ||
| * with the License. You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
|
|
||
| package org.apache.flink.agents.api.subagent; | ||
|
|
||
| import org.apache.flink.agents.api.context.DurableCallable; | ||
| import org.apache.flink.agents.api.context.RunnerContext; | ||
|
|
||
| /** | ||
| * Caller-facing interface for all sub-agents (external and internal). | ||
| * | ||
| * <p>An invocation is identified by a {@code (sessionId, callId)} pair; the session groups a | ||
| * conversation across invocations. Both ids are assigned by the framework ({@link | ||
| * RunnerContext#nextSessionId()} and {@link RunnerContext#nextCallId(String)}): callers may supply | ||
| * a session id to continue a prior session but never supply a call id. | ||
| */ | ||
| public interface Subagent { | ||
|
|
||
| /** Synchronously invokes the sub-agent, creating a new session. */ | ||
| Result call(RunnerContext ctx, Object prompt) throws Exception; | ||
|
|
||
| /** Synchronously invokes the sub-agent continuing {@code sessionId}. */ | ||
| Result call(RunnerContext ctx, Object prompt, String sessionId) throws Exception; | ||
|
|
||
| /** Produces a deferred, durable callable for this sub-agent call, creating a new session. */ | ||
| DurableCallable<Result> asAsyncCallable(RunnerContext ctx, Object prompt); | ||
|
|
||
| /** | ||
| * Produces a deferred, durable callable for this sub-agent call continuing {@code sessionId}. | ||
| */ | ||
| DurableCallable<Result> asAsyncCallable(RunnerContext ctx, Object prompt, String sessionId); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| /* | ||
| * Licensed to the Apache Software Foundation (ASF) under one | ||
| * or more contributor license agreements. See the NOTICE file | ||
| * distributed with this work for additional information | ||
| * regarding copyright ownership. The ASF licenses this file | ||
| * to you under the Apache License, Version 2.0 (the | ||
| * "License"); you may not use this file except in compliance | ||
| * with the License. You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
|
|
||
| package org.apache.flink.agents.api.subagent; | ||
|
|
||
| import com.fasterxml.jackson.annotation.JsonIgnore; | ||
| import org.apache.flink.agents.api.context.DurableCallable; | ||
| import org.apache.flink.agents.api.context.RunnerContext; | ||
| import org.apache.flink.agents.api.resource.ResourceType; | ||
| import org.apache.flink.agents.api.resource.SerializableResource; | ||
|
|
||
| /** | ||
| * Base setup for an external sub-agent resource. Serialized into the agent plan as an {@code AGENT} | ||
| * resource. | ||
| * | ||
| * <p>Hosts the id-resolution chain behind {@link Subagent}: omitted ids are assigned via the | ||
| * context before an implementation is ever invoked, and {@link #call} runs the deferred callable | ||
| * through durable execution. Implementations only provide the terminal {@code asAsyncCallable}. | ||
| */ | ||
| public abstract class SubagentSetup extends SerializableResource implements Subagent { | ||
|
|
||
| @Override | ||
| @JsonIgnore | ||
| public ResourceType getResourceType() { | ||
| return ResourceType.AGENT; | ||
| } | ||
|
|
||
| @Override | ||
| public Result call(RunnerContext ctx, Object prompt) throws Exception { | ||
| return call(ctx, prompt, ctx.nextSessionId()); | ||
| } | ||
|
|
||
| @Override | ||
| public Result call(RunnerContext ctx, Object prompt, String sessionId) throws Exception { | ||
| return call(ctx, prompt, sessionId, ctx.nextCallId(sessionId)); | ||
| } | ||
|
|
||
| /** | ||
| * Synchronously invokes the sub-agent with already-assigned {@code sessionId} and {@code | ||
| * callId}, running the deferred callable through durable execution. Framework-facing: the ids | ||
| * are assigned by the shorter variants, never supplied by callers. | ||
| */ | ||
| public Result call(RunnerContext ctx, Object prompt, String sessionId, String callId) | ||
| throws Exception { | ||
| return ctx.durableExecuteAsync(asAsyncCallable(ctx, prompt, sessionId, callId)); | ||
| } | ||
|
|
||
| @Override | ||
| public DurableCallable<Result> asAsyncCallable(RunnerContext ctx, Object prompt) { | ||
| return asAsyncCallable(ctx, prompt, ctx.nextSessionId()); | ||
| } | ||
|
|
||
| @Override | ||
| public DurableCallable<Result> asAsyncCallable( | ||
| RunnerContext ctx, Object prompt, String sessionId) { | ||
| return asAsyncCallable(ctx, prompt, sessionId, ctx.nextCallId(sessionId)); | ||
| } | ||
|
|
||
| /** | ||
| * Produces the deferred, durable callable for one invocation; both ids are already assigned. | ||
| * The only method implementations must provide. Contract: the returned {@link | ||
| * DurableCallable#getId()} MUST be derived solely from the {@code (sessionId, callId)} pair. | ||
| */ | ||
| protected abstract DurableCallable<Result> asAsyncCallable( | ||
| RunnerContext ctx, Object prompt, String sessionId, String callId); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reading this from the perspective of someone writing an external integration against the new surface.
BaseSubagentCallableis the convenience base the API steers implementations to (:23-29, andsubagent.py:199-201says so explicitly). It does not overrideDurableCallable#reconciler(), so every sub-agent callable inherits thenulldefault atDurableCallable.java:74.durableExecuteselects the reconcile state machine only whenreconciler()is non-null (RunnerContextImpl.java:267-275), anddurableExecuteAsync, which is the pathSubagentSetup.calltakes, gates identically (JavaRunnerContextImpl.java:62-70). Either way sub-agent calls land ondurableExecuteCompletionOnly. On that pathappendPendingCallis never reached: its only callers are insidedurableExecuteWithReconcile(:555,:561). A crash between "external agent invoked" and "result persisted" therefore leaves no record at all, replay misses the cache, andcall()re-invokes the external agent.Grepping the new surface,
reconcildoes not appear anywhere underapi/.../subagent/, in the e2e tests, or in the runtime sub-agent tests. Python surfaces the field (subagent.py:82) and forwards it (:184), but wiresNoneand no test asserts the forwarding. The recovery tests seed only terminalCallResults (SubagentIdentityRecoveryTest.java:105-108,171-178), so the pending path has no sub-agent coverage on either side.Concretely: what does an integration author write today to get reconcile-before-resend, and is there anything on this surface that would tell them the option exists? One shape that would make the choice visible, in case it is useful:
BaseSubagentCallabletaking the reconciler as a constructor argument, so passingnullis something the author decided rather than a default they never saw.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
BaseSubagentCallable doesn't shield any DurableCallable convention — it inherits reconciler() as-is (default null) and only simplifies boilerplate (getId, getResultClass, call with exception capture). Without a reconciler, a crash between invocation and persistence causes replay to re-invoke the external agent — this is expected, identical to any DurableCallable, and typically fine for read-only or idempotent sub-agents. If side effects must not be replayed, the author should provide a reconciler per the existing DurableCallable contract.
The question is whether to surface this option more visibly. Here are three options:
Which do you prefer?